软件工程方法学用处?
面向对象方法
面向对象方法是分析对象本身,在UML中有常用的表达方式,如用例图和类图。
类图
在教科书上,通常会介绍类图是由类名,属性·,方法和性质层组成的,太晦涩难懂了,我们直接从Java代码的角度来讲解这个概念。
public class User { private String nickname; // 昵称 private int level; // 等级 protected int points; // 积分 public String getNickname() { return nickname; } public void publish(Content c) { // 发布作品 } private void recalcLevel() { // 内部重算等级,外部不可见 } }比如有一个User类,类名也就是User,用户有昵称,等级和积分这么一些属性,方法名就是这个类能够干什么,那所谓的性质层就是对应访问修饰符,比如public是公有的,在类图中用➕表示,其他的见下面这个表格自行查阅对照。
符号 | 读作 | 对应 Java 关键字 |
| 公有 |
|
| 私有 |
|
| 保护 |
|
| 包内 | (默认/包级私有) |
代码对应的类图如下图所示
public class User { // ┌──────────────────┐ // │ User │ ← 类名 private String nickname; // │──────────────────│ private int level; // │ - nickname:String│ ← 属性名 protected int points; // │ - level:int │ (性质层 = 前面的符号) // │ # points:int │ public String getNickname() { ... } // │──────────────────│ public void publish(Content c) { ... } // │ + getNickname():String│ ← 方法名 private void recalcLevel() { ... } // │ + publish(c:Content) │ } // │ - recalcLevel() │ // └──────────────────┘类图中的关系
一般化关系
abstract class Content { // 爹:一般的 protected int likeCount; public void like() { likeCount++; } } class PixelPainting extends Content { // 儿子:特殊的 // 自动拥有 likeCount 和 like() }像泛化/继承就是一种一般化的关系,比如上述示例代码,“儿子”不需要重写“爸爸”的代码就能用它的代码。当然一般化关系也不止类与类之间的继承关系,接口与接口之间的继承关系也是可以的!如下面这个示例代码
interface Exportable { void exportPng(); } interface Printable extends Exportable { // ← 接口继承接口,也是泛化! void print(); // print() 之外,还自动拥有了 exportPng() 这个承诺 }一个接口可以继承多个接口,如下面示例代码
interface Multi extends Exportable, Shareable { } // 接口可以继承多个接口当然不止继承,接口实现也算是一种一般化关系,用书上的原话就是Java语言中一般化关系就是可以直接翻译成关键字extends和implements。
关联关系
class A { private B b; // ← 有这一行,就是关联。就这么简单 }关联是两个类之间的连接,可以是双向的也可以是单向的。
单项关联
class User { private String nickname; } class Content { private User author; // ← 关联:Content 长期持有 User }对应的图如下所示:
User ←──────── Content 实线箭头(可省略箭头只画实线) 1 *(一个作者可发多个作品)双向关联
class User { private List<Content> published; // ← 我发布的作品 } class Content { private User author; // ← 这作品的作者 }自关联
class User { private List<User> following; // 我关注的人(还是 User 类型!) private List<User> followers; // 关注我的人 }关联是用直线箭头表示的,这一点要记住!
依赖关系
// 关联:存成成员变量 —— 长期认识 class User { private Wallet wallet; // 钱包天天在,随时用 public void pay() { wallet.deduct(); } } // 依赖:只写在方法参数里 —— 临时借用 class User { public void publish(TagValidator v) { // 用完就还,不保存 v.check(this); } }关联是可以随时用的,而依赖是用完就还的那种
上面的截图是我自己的项目的部分代码截图,可以发现这种就是一种关联关系,用实线箭头表示,
而这则是一种依赖关系,用虚线箭头表示。
聚合关系
class Car { private String plate; // 车自己独立存在 public Car(String plate) { this.plate = plate; } } class Fleet { // 车队(整体) private List<Car> cars; // ★ 关键:Car 是外部 new 好"传进来"的 public Fleet(List<Car> cars) { this.cars = cars; } public void addCar(Car c) { cars.add(c); } } // 使用方: Car myCar = new Car("京A88888"); // 车在车队外就被创建了 Fleet fleet = new Fleet(List.of(myCar)); // 就算 fleet = null(车队解散),myCar 还好好的,还能加别的车队聚合关系讲的是一个类中有哪些可以用的成分组成,这个还得结合语义看看,虽然从Java语法角度来讲和关联关系无区别,但是可以从用法的角度来区分二者。
类图如下所示:
┌─────────┐ ┌─────────┐ │ Fleet │◇───────────│ Car │ └─────────┘ 1 * └─────────┘ ↑ 空心菱形 ◇,画在【整体】这一端菱形在整体这一侧
组合关系
组合关系相当于人和心脏之间的关系
class Heart { private int bpm; public void beat() { /* ... */ } } class Person { // 人(整体) // ★ 关键:Heart 是在自己内部 new 出来的 private Heart heart = new Heart(); public void exercise() { heart.beat(); } } // 外界拿不到这个 heart: // new Person().heart → 编译不过(除非提供 getter,那也只是读) // person 没了 → heart 也没人引用了 → 被垃圾回收,一起消失┌─────────┐ ┌─────────┐ │ Person │◆───────────│ Heart │ └─────────┘ 1 1 └─────────┘ ↑ 实心菱形 ◆,同样贴着【整体】这一端总结一下,一般化关系就是我们平时用的多的泛化与实现,关联关系最常见的例子就是平时自动注入一个类,依赖关系就是在传参数的时候用上这个类,聚合关系和组合关系得结合语境看看。
软件体系结构
| 组成 | 是什么 | 书上例子 | 你项目例子 |
|---|---|---|---|
| 组件 | 有独立功能、可重用的模块 | Spot 类(存坐标) | 登录模块、聊天模块 |
| 连接件 | 组件之间怎么交互 | Trans.move() 方法调用 | HTTP、WebSocket、JDBC |
| 实例 | 把上面俩串起来演示 | Test 类调 move() 改坐标 | 点赞→发消息→通知作者 |
简单来说组件是可以复用的软件结构单元,小到变量,函数,大到类,文件,程序,数据库,都是组件。
如果还是觉得很抽象,咱们就来看看Java示例代码吧。
// 组件 1:Spot(存坐标的零件) class Spot { int x, y; int getX() { return x; } int getY() { return y; } } // 组件 2:Trans(负责移动的零件) class Trans { // 连接件:通过方法调用使用 Spot void move(Spot s, int dx, int dy) { s.setX(s.getX() + dx); s.setY(s.getY() + dy); } } // 实例演示:Test 把两个组件连起来跑一遍 class Test { public static void main(String[] args) { Spot s = new Spot(); // 造组件 1 Trans t = new Trans(); // 造组件 2 t.move(s, 3, 5); // 连接件生效:坐标变了 System.out.println(s.getX()); // 输出 3 } }上述代码中,move是连接件,然后Test类中的main方法其实就是一种实例。
软件体系结构研究的内容
软件体系结构的研究内容 = 围绕"结构图"的 5 个问题: 怎么把图画出来?——体系结构描述(ADL) 人话:图纸得有规范画法。ADL 就是专门画这张图的"专业制图工具" (Acme、Wright、Rapide) 用哪种搭法?——体系结构风格 人话:前人总结的经典套路(管道-过滤器、C/S、分层……) 搭得好不好?——体系结构评估 人话:图纸画完先检查:性能行不行?好改不好改?别等楼盖塌了才发现 能不能照抄?——体系结构复用与演化 人话:好的搭法下个项目接着用;系统运行后要扩建,图纸怎么改 怎么照着盖?——基于体系结构的开发 人话:图纸 → 施工的全流程:分析→定结构→设计→编码,体系结构 贯穿软件生存周期用大白话来讲就是要研究怎么样才能让别人看得懂我的项目,所以写成一种规范的写法
构件:Vue 前端、SpringBoot 服务、MySQL、Redis
连接件:HTTP(前后端)、JDBC(后端→MySQL)
约束:前端不许直连数据库;每个请求必须带 token
比如上面这种写法
然后就是编写代码的时候采用什么风格,有B/S分层风格,有管道-过滤器风格。
结构风格研究完之后就研究评价方法
| 时刻 | 你做的事 | 研究内容 |
|---|---|---|
| 开工前 | 把“三块+怎么连+什么规矩”写成条文 | ① 描述 |
| 定套路 | 挑了 B/S + 分层 + 局部事件驱动 | ② 风格 |
| 动手前 | 自问“加直播要改几处” | ③ 评估 |
| 下个项目 | 骨架直接搬去二手书平台 | ④ 复用 |
| 写代码 | 每行代码照图填空 | ⑤ 开发 |
软件体系结构风格
和建筑风格一样,软件体系也有它的结构风格,
MVC
// Controller:只管接请求(C) @RestController class ArticleController { @GetMapping("/api/articles") Result list(int page) { return articleService.list(page); } } // Model:管数据和业务(M) @Service class ArticleService { public Result list(int page) { return Result.ok(articleMapper.selectPage(page)); // 读写数据 } } // View:管显示(V)—— 前端 Vue 渲染,后端只返回数据分层风格
Controller → Service → Mapper,一层只调相邻下一层: class ArticleController { private ArticleService service; // 只认识 Service } class ArticleService { private ArticleMapper mapper; // 只认识 Mapper } class ArticleMapper { /* 只管 SQL */ }C/S B/S架构
面向对象设计
问题域部分的设计
比如分析阶段可能是分析出这种模型
A B C 想同时继承 A 和 B \ / (比如:在职研究生,既是研究生,又是教师) \ / 但 Java 只许 extends 一个! C但是一个类不可能同时继承多个类【接口可以继承多个接口】
这种情况怎么解决?
class A { } class B extends A { } // B 先继承 A class C extends B { } // C 再继承 B —— 串成 A→B→C 一条链Java 不支持多重继承,设计中需将分析模型中的多重继承化为单重继承,方法包括:调整继承结构串成单链、抽取一般类消除重复属性、将其中一个继承关系改为组合(对象成员)等。
控制驱动部分设计
这个说大白话就是设计“发动机”——什么东西负责把流程跑起来,想清楚什么程序先跑什么程序后跑。
举个例子
// 被动类:不调它,它就静静躺着 @Service class ArticleService { public List<Article> list() { ... } // 没人调用 = 永远不执行 } // 主动类:系统一启动,它自己就转起来了 @Component class LikeCountSyncTask { // ← 主动类 @Scheduled(cron = "0 */10 * * * ?") // 每 10 分钟自己醒一次 public void syncRedisToDb() { ... } }component注解能够在系统启动的时候加载这个类,而service注解下的这个类需要手动调用才能够运行起来。
这两个类区别在于一个是主动类一个是被动类
| 被动类 | 主动类 | |
|---|---|---|
| 谁让它动 | 别人调用它才动 | 自己会主动跑起来 |
| 人话 | 像工具:你不按它,它不动 | 像工人:到点自己干活 |
| 你的项目 | ArticleService(被 Controller 调) | 定时任务、WebSocket 监听线程、消息消费者 |
经典软件体系结构风格
接下来我将详细介绍主程序-子程序,面向对象风格。
主程序-子程序风格
// 主程序:唯一的入口,控制整个流程 public class Main { public static void main(String[] args) { // ← 一切从这里开始 int a = 10, b = 5; System.out.println(minus(a, b)); // 按顺序喊子程序 System.out.println(print_msg("hello")); System.out.println(printx(999)); } } // 子程序:被喊到才动,是"被动类" static int minus(int a, int b) { return a - b; } static void print_msg(String s) { ... } static int printx(int x) { return x; }可以看到上面这个代码,连接件是调用-返回机制。
这种风格的核心思路就是分而治之:把一个大而复杂的程序系统,拆成主程序加一堆子程序,主程序负责调度,子程序负责干活。好处很直接——复杂任务被分解成许多容易处理的小任务,谁写哪个子程序分得清清楚楚,开发分工容易,出了问题也容易定位,维护起来相对省心。
但它的缺点同样明显,可以归纳成下面几条:
- 程序规模一大,开发就变慢:子程序越拆越多,主程序里的调用顺序越来越长,改一处要顺着调用链一路看下去。
- 测试困难:子程序之间靠参数和全局数据耦合,单独测一个子程序往往要先把它的上下游都准备好。
- 重用性差:子程序通常是为某个具体流程写的,换个场景就很难直接搬走。
- 数据安全差:大量使用全局变量和共享数据,谁都能改,一处改错全盘受影响。
- 难以开发大型软件和图形界面应用:界面需要事件驱动、随时响应,而这种风格是顺序调用、一条道走到黑,交互性天然吃亏。
- 数据结构一变就难维护:数据结构往往被多个子程序共享,改一个字段可能要连带改一大片子程序的参数和逻辑。
一句话理解:主程序-子程序风格适合流程固定、以计算和数据处理为主的中小型程序;一旦系统变大、需要频繁交互或数据结构经常变动,它的短板就会集中暴露,这也是后来面向对象风格、事件驱动风格等被提出的原因。
总结优点缺点,主程序子程序优点是化繁为简,任务分解;分工明确,便于开发,但是缺点也有,如果数据结构一变就很难维护,程序太长运行起来也消耗更长的时间资源,并且子程序重用性差,难以开发大型的软件和图形化界面。
面向的对象风格
/* ========== 1. 作者:自己的积分和粉丝自己管 ========== */ public class Author { private final String name; private int points; private List<String> fans = new ArrayList<>(); public Author(String name) { this.name = name; } // 行为住在对象里:加积分是"作者自己"的事 public void addPoints(int delta) { this.points += delta; } public void notifyFans(String message) { for (String fan : fans) { System.out.println("→ 通知粉丝 " + fan + ":" + message); } } public String getName() { return name; } public int getPoints() { return points; } public void addFan(String fan) { fans.add(fan); } }面向对象风格具有抽象,封装,继承和多态等特点。
// 这是一个抽象接口,定义了“发送消息”这个能力,但不管具体怎么发 public interface MessageSender { boolean sendMessage(String receiver, String content); } // 客户端代码只依赖抽象接口 public class NotificationService { // 依赖抽象,而不是具体的实现类 public void notifyUser(MessageSender sender, String user, String msg) { sender.sendMessage(user, msg); // 我不关心底层是发邮件还是发短信 } }public class RoomGroup { private Long groupId; // 私有属性,外界不能直接修改 private String groupName; // 私有属性 private int memberCount; // 私有属性 // 对外提供安全的访问接口 public String getGroupName() { return groupName; } public void setGroupName(String groupName) { // 封装了业务逻辑:名字不能为空,且长度有限制 if (groupName == null || groupName.trim().isEmpty() || groupName.length() > 50) { throw new IllegalArgumentException("群名称不合法"); } this.groupName = groupName; } // 成员数量只能增加或减少,不能随意修改数值 public void addMember() { this.memberCount++; } }抽象
// 三种作品:文章、像素画、游戏 Demo // 共同点:都有 id、作者、赞数,都要发布 // 不同点:怎么校验、怎么存、怎么通知——各不一样 abstract class Content { protected Long id; protected Author author; protected int likeCount; public abstract void validate(); // 只声明"要有校验",不说怎么校验 public abstract void store(); // 只声明"要能存",不说存哪 }这是体现出代码的抽象写法
封装
abstract class Content { private int likeCount; // 私有:外部不能直接改 public void like() { // 只开放这个入口 likeCount++; // 怎么加,规则由我定 } public int getLikeCount() { // 只读,不能写 return likeCount; } } // 外部代码: c.likeCount = 999; // ❌ 编译不过(private) c.like(); // ✅ 唯一合法方式写Java写的多的朋友肯定对这种写法不陌生吧!
继承
class Article extends Content { private String body; public void validate() { if (body.isEmpty()) throw new BizException("空文章"); } public void store() { articleMapper.insert(this); } // ↑ 只写了这两个,likeCount、like()、author 全是白拿的 } class PixelPainting extends Content { private int[][] grid; public void validate() { if (grid.length > 64) throw new BizException("画布太大"); } public void store() { ossService.upload(grid, "png"); } }多态
// 调用方:只认识 Content,完全不知道具体类型 public void publishAll(List<Content> works) { for (Content c : works) { c.validate(); // ← 多态在这里发生 c.store(); // ← 多态在这里发生 } } // 用法: List<Content> works = List.of( new Article(...), new PixelPainting(...), new GameDemo(...) ); publishAll(works); // 一行代码,三种行为各走各的用大白话概括,抽象是定义有哪些概念,封装则是把数据隐藏起来只暴露方法,继承是子类复用父类的东西只写差异,然后多态稍微抽象一点,你可以简单理解成
面向对象的优缺点如下所示
数据流风格
数据流风格分为批处理风格和管道过滤器风格。
批处理风格
public class TranscriptMain { public static void main(String[] args) throws IOException { File infile = new File("transcript.txt"); // 原始数据:一批学生记录 File outfile = new File("transcript_result.txt"); // 结果文件 List<String> lines = readLines(infile); // ① 整批读入 sortLines(lines); // ② 整批排序 writeLines(outfile, lines); // ③ 整批写出 } }这个是批处理风格,关注的是数据如何流动,而不是关注谁调用谁。
注意List<String> lines—— 它是整个文件全读进内存再处理。这就是批处理的“批”字:数据必须先攒齐,才能开工。
优点
缺点
管道-过滤器风格
书上说这种风格有很多优点,比如组件具有高内聚低耦合有点,可以复用,可以扩展,并且支持并行执行,但是不适合处理交互应用
如果还不懂可以看看下面这个图,管道相当于传送带,过滤器相当于一个一个工人,数据就是传送带上的物品
这个IMServer代码就能够很好的体现出我上面说的这种风格,可以看到管道pipeline,各种handler处理器,数据就是从处理器中通过。
管道过滤器优点有以下几点:首先是代码体现出高内聚低耦合的特点;然后管道过滤器分工明确;再者就是可以将整个系统行为看成多个过滤器的合成,比较简单;可复用性:管道过滤器可以复用,让开发工作方便起来了;可扩展性:就像IM服务器,可以扩展新增自定义的过滤器;可验证性:在管道-过滤器/批处理架构中,因为每个“过滤器(Handler)”或“处理步骤”都是高度独立、接口明确的,所以它的可验证性非常高。;支持并行执行
但是这种风格也有缺点:
隐式调用风格
显式调用是“点名做事”,隐式调用是“拉响警报”——拉警报的人只负责拉,救火的各自冲上来。代价是:你再也看不出来谁会响应,灵活性换来了可读性。
// 发布者:只管广播,不知道谁会收到 public void like(int contentId) { contentMapper.incrLike(contentId); // 干自己的活 eventBus.post(new LikedEvent(contentId)); // 广播:"点赞了!"——完事 } // 订阅者 1:收到事件,自己决定做什么(通知作者) @Subscribe public void onLiked(LikedEvent e) { pushService.notifyAuthor(e.getContentId()); } // 订阅者 2:也收到了,自己决定做什么(加积分) @Subscribe public void onLiked2(LikedEvent e) { authorService.addPoints(e.getContentId()); }层次风格
表示层(Vue 页面) ↓ 只能往下调 业务层(Service) ↓ 数据层(Mapper/DB)仓库风格
[组件A] [组件B] \ / [中央数据仓库] ← 所有人围着它转 / \ [组件C] [组件D]// 组件 A:只负责写数据进仓库,不知道谁会读 @Service public class LikeService { @Autowired private LikeMapper likeMapper; // 仓库(MySQL) public void like(Long contentId, Long userId) { likeMapper.insert(contentId, userId); // 写进中央仓库,完毕 // 它不知道有个"统计模块"会去读这张表 } } // 组件 B:只负责从仓库读,不知道数据谁写的 @Service public class RankService { @Autowired private LikeMapper likeMapper; public List<Content> hotList() { return likeMapper.selectByLikeCountDesc(); // 从同一个仓库读 } }这段代码也算是仓库风格!
解释器风格
// 规则是数据,不是代码 —— 改规则不用改代码 private static final List<String> WHITE_LIST = List.of( "/api/auth/**", ); public boolean preHandle(HttpServletRequest request, ...) { String uri = request.getRequestURI(); for (String pattern : WHITE_LIST) { if (MATCHER.match(pattern, uri)) { // ← 解释器:现场解释这条规则 return true; } } return false; }读规则,现场解释这种代码风格叫解释器风格。
反馈控制环风格
// 简化版自动限流:测当前 QPS,超阈值就限流,降下来再放开 @Component public class AdaptiveRateLimiter { private static final int THRESHOLD = 1000; // 目标:QPS 不超过 1000 private volatile boolean limited = false; @Scheduled(fixedRate = 1000) // ← 每秒采一次样(传感器) public void controlLoop() { int qps = Metrics.getCurrentQps(); // ① 测量 if (qps > THRESHOLD) { limited = true; // ② 超了 → 收紧 } else if (qps < THRESHOLD * 0.7) { limited = false; // ③ 降了 → 放开 } // 循环往复,这就是"反馈" } public boolean allowRequest() { return !limited; } }分布式软件体系结构风格
分布式系统是指基于网络环境,功能和数据分布在网络相连的多个计算机中,通过他们之间通信协作完成的各项功能的系统。
那为什么要有分布式软件体系结构呢?其实以前是集中式,也就是一台机器干所有事,这种风格风险很高,容易出现主机挂了的现象。
分布式体系结构风格
文件共享体系结构
P2P体系结构风格
2P(Peer-to-Peer)体系结构中,没有专用的服务器,每个节点既是客户端又是服务器(对等体),系统由对等节点组成,节点之间直接通信,通常为整个网络提供服务。
三层C/S体系结构风格
B/S体系结构风格
B/S C/S混合软件结构
中间件
总结
集中式(主机+终端) ↓ 加了文件服务器 文件共享(NAS,无并发控制) ↓ 客户端变胖 两层 C/S(胖客户端:快但不安全、难维护) ↓ 中间加一层业务层 三层 C/S(瘦客户端 + 应用服务器 + 数据库) ↓ 客户端换成浏览器 B/S(免安装、零维护) ← 你的 Vue + SpringBoot + MySQL 就是它 (旁支)P2P:没有中心,人人皆服务器 (贯穿始终)中间件:RPC / JDBC / ODBC / 消息队列 —— 屏蔽差异的胶水