刚带团队那会儿,我最怕听到一句话:"这个模块,我们先做个面向对象分析。"因为经验告诉我,说完这句话之后,很多人接下来干的事依然是:画页面原型、设计数据库表、然后照着表堆Controller、Service、DAO。真正把面向对象分析(OOA)和面向对象设计(OOD)当成两件不同事情来做,并且做对的人,其实不多。
有一次评审订单模块的设计文档,我看到类图上密密麻麻十几个类,名字分别是OrderController、OrderService、OrderDao、OrderVO、OrderDTO……唯独找不到一个能独立承载业务规则的Order类。这是非常典型的"数据库表驱动的过程式设计",只不过套了一层面向对象的外壳。为了让这类问题少一点,我决定把OOA和OOD的边界、步骤、收益,以及这些年做领域建模的体会,系统地梳理一遍。
这篇文章适合谁?适合那些已经会写面向对象代码,但想搞清楚"类到底怎么设计出来"的初中级开发者;也适合带项目、做技术评审,需要判断"这个分析模型到底好不好"的技术负责人。我不会只给结论,会把每一步背后"为什么这么做"也讲清楚。
1. OOA与OOD的分界线:差一个字母,差一个思考维度
1.1 多数团队的默认状态:分析和设计混着做
先说一个观察。很多项目把"需求评审"当成分析,把"数据库设计"当成设计,然后直接进入编码。这种模式不是不能交付,我见过不少团队靠这套流程也把项目做出来了。但代价是:业务规则散落在各个Service方法里,改动一个业务逻辑要牵连十几个文件,测试难以覆盖,新人接手成本极高。
为什么?因为"业务到底要做什么"和"技术上怎么做"这两件事被揉在一起了。需求变动时,你搞不清楚到底该改业务模型还是改技术实现;技术选型变化时,你又怕牵连业务逻辑。OOA和OOD的核心价值,就是强制把这两个问题分开回答。
1.2 一句话概括两者的差异
OOA回答的是"What":系统要支持的业务流程、业务规则是什么,问题域里有哪些关键概念,概念之间是什么关系。OOD回答的是"How":在给定的技术栈、性能要求、团队结构下,类、接口、模块、数据到底怎么组织。
我用一个生活化的类比。帮朋友安排一次旅行:分析阶段是搞清楚他要去哪里、想玩什么、预算是多少、有没有特别忌口;设计阶段是决定坐高铁还是飞机、住哪家酒店、每天行程怎么排、行李带什么。分析错了,设计得再漂亮,旅行的目的地都是错的。设计错了,目的地再正确,效率、舒适度也会出问题。
两者具体差异见下表:
| 维度 | OOA | OOD |
|---|---|---|
| 关注点 | 问题域、业务概念 | 解决方案域、实现机制 |
| 核心产物 | 问题域模型、分析类图、用例描述 | 设计类图、接口定义、架构分层 |
| 是否依赖技术栈 | 不依赖,换语言换框架不影响 | 依赖,和具体技术选型强相关 |
| 典型提问 | 系统要做什么 | 系统怎么做 |
| 主要变更驱动 | 需求、业务规则调整 | 技术选型、性能、扩展性、成本 |
1.3 分界线画清楚,本身就是工程风险控制手段
这条分界线不是学术洁癖,而是实打实的风险控制。
分析阶段如果就开始争论"这个字段该用varchar还是text""这个列表要不要走缓存",注意力会被实现细节带走,业务规则的完整性很容易漏掉。反过来说,设计阶段如果还停留在业务概念层面,不考虑接口边界、依赖方向、并发访问,写出来的代码就是一盘散沙。把两个阶段的思考维度分开,意味着在正确的阶段只讨论正确的问题,减少无意义的扯皮,也让评审会更有重点。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. OOA的完整推进路径:从业务用例走到问题域模型
2.1 第一步,识别对象:从用例文本里捞"名词"
OOA的起点不是数据库,而是用例(Use Case),或者说用户故事/业务场景描述。具体操作很简单:把用例文本里的名词圈出来,然后逐个过滤。
我拿一个咖啡店线上点单系统举例。假设有一条用例:
顾客浏览菜单,选择咖啡,加入购物车,提交订单,在线支付,等待制作完成后取餐。
把名词圈出来:顾客、菜单、咖啡、购物车、订单、支付、制作、取餐。然后逐个评估:
- 顾客:系统需要跟踪其身份、历史订单,有状态、有行为,是对象。
- 菜单:需要支持展示、维护,可能是对象,但更像一组配置数据,先挂起待评估。
- 咖啡:有种类、价格、配方,是业务核心概念,毫无疑问是对象。
- 购物车:是临时状态,是"进行中的订单",可以合并到订单建模,也可以单独建模。我倾向单独建,因为购物车和已提交订单的生命周期完全不同。
- 订单:核心对象,必须有。
- 支付:可以提取为"支付记录"对象,但支付方式(微信/支付宝/现金)更适合做属性还是类,先记下待定。
- 制作、取餐:这是流程/状态,不是对象,应该作为Order状态的一部分,或者由调度服务负责,不能直接建模成类。
判断一个候选对象是不是合格对象,我有四条标准:
- 它是否有多个实例?系统中单例且永远不会变的概念,一般不建模成对象。
- 它是否有一组内聚的状态和行为?光有状态没有行为的是数据结构,不是对象。
- 业务规则是否需要追踪它的生命周期?比如订单从创建、支付、制作到取餐,状态流转是核心规则。
- 去掉它之后,模型是否还能表达同样的业务语义?如果能,说明它只是另一个对象的属性。
2.2 第二步,识别属性和服务:先定义职责,再定义形态
对象确认之后,给每个对象补属性和服务。属性的识别原则是:只保留那些描述对象固有状态、且业务确实需要使用的信息。
以Order为例,订单号、下单时间、订单状态、总金额、所属顾客,是核心属性。但"顾客下单时用的IP地址""订单来源页面"如果不影响任何业务规则,就不该塞进核心订单对象,否则就是给它增加无意义的认知负担。
服务的识别原则是反推:哪些业务动作必须由这个对象配合完成?比如"订单添加一个条目",就应该由Order自身提供addOrderItem方法,而不是让Controller拿到OrderItem再往里塞。因为"添加条目时需要重新计算金额""超过一定数量需要校验"这类规则,封装在Order内部才安全,漏掉任何一个,外部代码都可能在不知情的情况下绕过校验。
这里我要特别强调:OOA阶段的"服务"只定义操作名称和职责说明,不急于设计方法参数列表、返回值、异常处理。这些是实现细节,留给OOD。分析阶段过早陷入参数设计,就像旅行还没定目的地就开始查航班,纯属浪费时间。
2.3 第三步,识别关系:一般-特殊、整体-部分、关联
对象不是孤立的,OOA需要把对象之间的关系画出来,主要有三类:
一般-特殊关系(继承/泛化):咖啡产品类目下,有美式、拿铁、卡布奇诺。继承关系要非常谨慎,我的经验法则是:如果未来很可能出现新的子类类型、且各子类行为差异明显,才考虑继承;如果所谓"子类"只是配置参数不同,比如美式和拿铁只是咖啡基底和牛奶比例不同,就应该用属性加枚举/配置去表达,而不是建一堆子类。
整体-部分关系(组合/聚合):订单由订单项组成,购物车包含咖啡。组合关系要问:子对象离开父对象是否还有独立意义?订单项离开订单基本没有意义,所以是强组合;咖啡离开购物车依然存在,所以购物车和咖啡是聚合,不是组合。组合和聚合在代码层面很相似,但生命周期语义完全不同,分析阶段就要定清楚,否则OOD阶段会非常纠结。
关联关系:顾客发起订单、员工处理订单、咖啡属于某个分类,这些都是关联。关联方向尽量保持单方向,双向关联会带来强耦合,实际编码时非常容易出错。
2.4 第四步,用主题控制模型复杂度的上限
当类超过20个,模型会变得很难读。OOA里有一个"主题"概念,就是把相关对象粘成一包,让读者先看大图,再展开细节。咖啡店系统的主题可以是:客户管理、菜单管理、订单处理、支付结算、库存管理。注意,主题既不是代码里的包,也不是模块,它是分析层面的分组,目的是让模型可读,OOD阶段再映射成真正的包结构。
2.5 OOA阶段的产物到底长什么样
最终输出物是:分析类图、用例描述、关键业务规则清单。分析类图里出现的类,基本都应该是业务概念——顾客、订单、咖啡、支付记录,绝对不该出现Controller、Service、Repository、DTO、Entity这些技术词汇。如果一张分析类图里出现了"订单服务""订单Dao"这种名字,说明分析已经被设计污染了,需要打回去重做。
3. OOD的核心决策:在分析模型上叠加可运行的方案
3.1 职责分配:让每个类"只有一个理由改变"
如果说OOA的核心是"找对象",那OOD的核心就是"分职责"。职责分配的质量,直接决定代码改起来痛不痛苦。
最典型的反例:Order类既负责业务规则,又负责数据库保存。结果任何改动——无论是业务规则变了,还是数据库字段变了,都得动Order类。业务人员要求改一条折扣规则,会波及嵌套在SQL逻辑里的字段拼接;DBA要求加个索引,又要动Order的持久化方法。这个类有几百行,谁改谁胆战心惊。
改进思路是:Order只保留业务状态和业务行为,持久化交给专门的Repository/DAO,两者之间通过接口依赖。判断职责是否过高的标准可以这样用:如果这个类被删掉,相关的业务规则是否至少有某个找不到家?反过来问:这个类是不是既处理业务规则、又处理持久化、还处理日志甚至权限。任何一个"又",都是拆分信号。
3.2 依赖方向:让抽象不依赖细节
依赖倒置原则听起来高深,实践中最常见的落地方式就是"面向接口编程,不要直接依赖具体实现"。
以通知功能为例。订单支付成功后要通知用户。如果OrderService直接依赖SmsNotifier这个具体类,将来要加微信通知、应用内通知,势必要改OrderService。如果改成依赖一个NotificationSender接口,构造函数里注入具体实现,OrderService的核心逻辑一行都不用改。
这个原则还会带来另外一个实实在在的好处:可测试性。单元测试中可以注入一个Mock的NotificationSender,断言"支付成功后确实调用了通知方法",而不需要真的发短信。没有接口抽象,测试时只能拿着真实实现硬跑,测一次发一条短信,谁受得了。
3.3 接口设计:面向调用方定义协议,而不是面向实现方
很多初学者做接口设计,思路是"我想提供什么方法就写什么方法"。更合理的顺序是:先思考调用方需要什么能力,把接口当成调用方与实现方的契约。
举个例子,支付模块要支持微信、支付宝、银行卡。如果站在实现方角度,接口很可能写成wechatPay()、alipayPay()、bankCardPay()。站在调用方角度,业务动作只有:发起支付、查询结果、退款。所以接口应该是pay(OrderId, Amount)、refund(OrderId, Reason)。底层到底走微信还是支付宝,由具体实现类决定,调用方根本不关心。
这一步做扎实了,后面引入新的支付方式就是新写一个实现类注册进去而已,调用方代码纹丝不动。这比任何"设计模式"的堆砌都有价值。
3.4 分层与模块化:让依赖关系有方向感
OOD阶段要把分析模型的类,安排到不同的层/包中。我常用的分层是四层:表现层(Controller/API)、应用层(应用服务,编排用例)、领域层(核心业务对象和规则)、基础设施层(数据库、消息、外部接口)。
依赖方向的核心约束是:表现层依赖应用层,应用层依赖领域层;基础设施层去实现领域层定义的接口,而不是反过来让领域层依赖基础设施层。这个方向的约束,是为了避免循环依赖,也是保证可测试性和可替换性的前提。
一个真实场景:很多项目把DTO和领域对象混着用,Controller层直接操作领域对象,应用层又操作DTO,最后依赖关系乱成一团。规范的做法是每层有边界:Controller层用DTO做输入输出,应用层负责转换,领域层只用领域对象。转换逻辑放在应用层或专门的Assembler里。
3.5 设计模式是常用工具,不是装饰品
设计模式本质上是OOD在特定重复场景下的成熟解法。策略模式适合"支付方式"这类算法族可替换的场景;工厂模式适合"根据类型创建不同对象"的场景;观察者模式适合"订单状态变更要通知多个订阅方"。都是好用的工具。
但我要泼一盆冷水:套模式前先问自己,这个"变化点"是真的会来,还是我脑补的?过度设计最常见的表现,就是为了"将来可能要扩展"而引入抽象,结果半年过去了,那个抽象从来没有第二个实现。我见过太多团队花两周设计抽象工厂,最后项目里一辈子只有一个工厂实现。模式是解决问题的,不是用来让代码看起来高端的。
4. 从分析模型到设计模型的映射:保留、拆分、合并、新增
4.1 分析类在OOD中的四种去向
OOA做完,拿到一组领域概念清晰的分析类;OOD开始,这组类要经历一轮"变形"。我把它们的去向总结为四类:
| 去向 | 适用情况 | 具体例子 |
|---|---|---|
| 原样保留 | 领域概念稳定,直接承担业务逻辑 | Order、Product |
| 拆分 | 一个分析类承担了过多职责 | 把Order拆成Order(业务)、OrderRepository(存储)、OrderAssembler(对象转换) |
| 合并 | 多个分析类面向同一实现抽象 | 各种支付方式合并为PaymentGateway接口加多个实现 |
| 新增 | 分析阶段不存在、纯技术支撑的类 | Controller、DAO、CacheManager、DTO |
关键原则是:技术类可以新增,但领域类不能被淹没。很多代码之所以"看不出面向对象",不是因为没有Controller和DAO,而是因为Order这个领域类被掏空了,所有业务逻辑都被抽到了OrderService里。再多的Controller、Service,也替代不了领域模型。
4.2 两个最常见的偏差
**偏差一:分析被技术手段污染。**需求还没稳定,就已经在画数据库表、讨论缓存淘汰策略、设计Kafka分区。结果业务模型没有巩固,技术方案也建立在不稳固的地基上。等业务方改需求,之前设计的表结构全作废,纯纯的返工。
**偏差二:分析模型只是装饰品。**团队把OOA的类图画得漂漂亮亮,然后就当PPT扔到文件夹里吃灰,设计阶段完全重写,分析模型的领域概念在代码里一个都找不到。表现就是:Order类没有任何业务方法,全是getter/setter,逻辑全堆在OrderService里。画图画得再认真,不落到代码里,等于没做。
4.3 迭代思维:OOA和OOD不是一次性的
有些人把OOA/OOD理解成瀑布式流程:先花两个月做分析,再花两个月做设计,最后花半年编码。现实中这套基本行不通,因为需求等不了那么久。
真实项目的节奏是:每一轮迭代开始时,针对本轮需求快速走一遍OOA,更新问题域模型;然后进入OOD,细化受影响的设计;编码后通过测试和业务反馈,再回头校正分析模型。分析模型不是静态文档,它是活的知识地图。模型落后于代码不可怕,可怕的是模型完全失效、没人维护、对后续开发没有指导意义。
4.4 让文档和代码保持同步的实操建议
我不建议维护"全量设计文档",那是给自己找麻烦,上线三个月后必然和代码脱节。比较可行的做法是只维护两个层次的图:
第一,体系结构图。反映包、层、关键组件之间的依赖关系,变化频率低,一般一两个季度更新一次。第二,关键用例的设计类图。只画受本轮改动影响的那几个类,和代码评审绑定,评审通过就同步更新。
另外,代码评审时加一条规则:改动是否与分析模型一致,类名是否仍是业务术语。我见过太多人把Order改成了OrderInfo,再改成OrderDetailInfo,最后代码里同时存在三个类,谁也不知道该用哪个。命名是模型的一部分,轻易改动意味着模型在崩坏。
5. 双O带来的真实优势,以及被人忽略的代价
5.1 收益一:应对需求变更的能力
OOA把业务规则内聚到对象本身,需求变化时,修改范围被限定在少数几个对象和方法里。咖啡店修改了"订单满三杯打九折"的规则,在OOD做得好的项目里,只需要改Order类的计算折扣方法,Controller、Repository、数据库都不用动。这是过程式代码很难做到的——那里可能需要从Controller到SQL层层修改,动一个规则心惊胆战。
这一点在需求频繁变化的业务系统里,是最大的护城河。
5.2 收益二:复用与可测试性
面向对象的复用,不是"把代码拷贝过来改成自己的",而是通过抽象接口和组合,让通用逻辑沉淀在稳定的抽象层,变化点留在实现层。OrderService依赖NotificationSender接口,那它天然可以被不同业务复用;测试时注入Mock实现,单测的隔离性和速度都远超启动整个Spring容器去测。
这些收益不是OOD阶段凭空变出来的,而是从OOA的建模质量开始的。分析阶段如果对象都没找对,设计阶段的抽象就是空中楼阁。
5.3 代价与边界:不是所有项目都需要完整OOA/OOD
完整的OOA/OOD是有成本的:需要和业务专家深度访谈、需要建模评审、需要维护模型文档。如果把控不好边界,很容易变成一个巨大而缓慢的"建模游戏"。
我自己的适用性判断标准很简单,看三点:
- 业务是否复杂到过程式代码难以维护?
- 系统生命周期是否足够长,值得前期的建模投入?
- 团队是否多人协作,需要通过统一模型对齐认知?
三点满足得越多,OOA/OOD的收益越大。反之,如果只是做一个验证想法的MVP原型,或者写一个跑完就扔的批处理脚本,完整建模就是过度投资。同样,在某些性能敏感的路径上,过细的对象切分带来的方法调用开销、内存分配开销如果不可接受,就该主动选择结构体加数据驱动的方式,而不是抱着"面向对象"四个字不放。工具是为人服务的,不是反过来。
5.4 几条值得记住的实操经验
我实际做项目这些年,有几条经验一直放在心里:
- 给类命名的时候,先想想这个类名会不会出现在业务专家的嘴里。如果业务专家都听不懂你在说什么类,它大概率不是领域类,而是某个技术实现类。
- 分析阶段持续的时间不要太长,一轮迭代的建模控制在几天内。一旦超过一周,需求早就变了,模型就变成了考古对象。
- 当发现"一个用例改动牵连十几个类"时,先不要急着重构,先回到OOA模型看职责分配是否本身就错了。很多"代码烂"的问题,源头是建模就错了。
- OOD的接口设计不要一开始就追求"完美抽象"。先让一个实现跑通,等第二个实现真的出现时再抽接口也不迟。过早抽象和过早优化一样有害,我见过太多为一个从未出现的需求准备的抽象,最后全是死代码。
5.5 一个总结性的收尾
最后再分享一个我自己踩过坑之后养成的习惯。每次代码评审,看到有人新增了一个类,我都会先问一句:"这个类是在描述业务规则,还是在支撑技术实现?"如果对方答不上来,说明这个类的定位很可疑——要么它根本不该存在,要么它的职责还没想清楚。
这个简单的问题,比任何复杂的规范都管用。OOA和OOD说到底,不是画图的流程,也不是让人看懂UML图的手段,而是让每一个类的存在都有理由、每一次改动都有确定影响范围的一种能力。把这个能力练扎实,比记住十个设计模式、二十个原则有用得多。
