ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

UML类图六种关系:依赖、关联、泛化、实现、聚合、组合判断指南

UML类图六种关系:依赖、关联、泛化、实现、聚合、组合判断指南 上周组内代码评审一个刚转正的同学在白板上画类图画到订单和订单项的时候停住了回头问我这条线到底该画实心菱形还是空心菱形我说你先别管菱形长什么样先回答我一个问题——把订单对象干掉之后订单项还活不活得下来他想了三秒说活不下来它本来就是订单的一部分。我说那就实心菱形组合收工。这事几乎每个写代码的人都会遇到。依赖、泛化、实现、关联、聚合、组合这六个词在 UML 类图里就是六条线但真正让人头疼的不是画线而是它们背后的语义边界。背口诀虚线箭头是依赖、实线是关联、空心三角是继承能应付考试一到真实项目就废了Spring 里注入的 Bean 到底算关联还是依赖两个 Service 互相调用要不要画成双向关联OneToMany加了cascade之后到底变成了哪种关系这些问题没有口诀能回答。这篇内容我想把六种关系一次性讲透不讲教科书定义讲判断方法。核心是三组对立临时与长期依赖 vs 关联、继承行为与继承契约泛化 vs 实现、生命周期绑定与不绑定组合 vs 聚合。每一组我都会给判断问题、代码对照和踩坑经验最后用一个订单域模型把六种关系全部落地一遍。适合刚接触 UML 的同学也适合写了几年代码但一直凭直觉画图的老手——尤其是那些在代码评审里被问住过的人。1. 六种关系到底在描述什么先问三个问题大多数人记不住这六种关系是因为把它们当成六个孤立的知识点去背。实际上它们是同一件事的六个刻度——两个类之间耦合的强度和形态。耦合这件事有两个维度一是持有时间是方法执行期间借一下还是对象活着就一直拿着二是生存依赖一方消失了另一方会不会跟着完蛋。把这两个维度想清楚六种关系自己就浮出来了。1.1 三组对立六个刻度我习惯把它们分成三组来记每组两个组内是强弱对立第一组是依赖与关联区别在于引用活多久。依赖是用一下就走方法执行完引用就没了关联是长期持有通常表现为类的字段。这组的判断问题很直白这个引用是方法里的局部变量、参数、返回值还是类的成员变量前者依赖后者关联。第二组是泛化与实现区别在于继承的是行为还是契约。泛化对应extends继承的是父类的字段和实现是 is-a 关系实现对应implements拿到的只是一组方法签名是 can-do 关系。这组的判断问题是父类型里有没有具体实现代码有就是泛化没有或只有签名就是实现。第三组是聚合与组合区别在于生命周期是否绑定。聚合是我有你但你可以不属于我组合是我没了你也活不成。这组的判断问题是把整体对象从内存里删掉部分对象还能被别的对象正常使用吗能聚合不能组合。这三组覆盖了六种关系。你会发现关联这组比较特殊它其实是一个可强化的基类聚合和组合在 UML 里都被归为关联的特例带菱形的关联只是多了整体-部分的语义。所以严格说六种关系不是平级的六个而是一条从弱到强的连续谱依赖 关联 聚合 组合泛化和实现另算一条谱。1.2 强弱排序和对照表把六种关系的核心特征整理成一张表我平时画图前会扫一眼能避免八成误判。关系语义UML 符号典型代码表现生命周期依赖A 使用 BB 变化会影响 A虚线 开放箭头指向被依赖方方法参数、局部变量、静态调用、返回值方法级用完即散关联A 知道 B长期持有引用实线可带箭头表示方向成员变量、构造器注入的接口对象级随 A 存活泛化A 是一种 B实线 空心三角指向父类class A extends B编译期确定不可变实现A 承诺能做到 B 声明的事虚线 空心三角指向接口class A implements B编译期确定可多实现聚合A 拥有 B但 B 可独立存在实线 空心菱形菱形在整体端成员集合元素由外部传入B 独立于 A组合A 由 B 组成B 依附于 A实线 实心菱形菱形在整体端成员集合A 内部创建和管理B 随 A 消亡这张表有个容易看漏的地方菱形永远画在整体那一端。很多人画反了把它画在部分那边图的语义就完全变了。记住一句话菱形是拥有者的标志谁拥有谁带菱形。注意UML 里聚合和组合都是关联的加强版所以它们的线是实线不是虚线。虚线一出现就说明这条关系是临时的不可能是聚合或组合。1.3 为什么非要分清分不清会付出什么代价有人可能觉得这是画图游戏代码照样跑。我举两个真实后果。第一个是测试成本。把组合误判成聚合意味着你以为订单项可以独立创建、独立注入于是写了一个OrderItem的工厂到处 new 出来塞给订单。等到要做级联校验订单总额必须等于明细之和的时候你会发现有订单项游离在订单之外谁都能改它的价格校验根本没法收口。反过来把聚合误判成组合你会强制在整体内部 new 出部分对象结果单元测试想替换一个假的产品对象都做不到只能跑集成测试。第二个是数据一致性问题。这一条在持久化层尤其明显。JPA 里OneToMany(cascade CascadeType.ALL, orphanRemoval true)表达的就是组合语义——删父删子、移除集合即删行而不带级联的OneToMany是聚合语义——删父不删子。选错了线上就是一堆孤儿数据或者误删事故。我见过一次生产事故就是因为聚合关系被写成了级联删除删掉一个分类顺手把分类下所有商品删了因为当初那位同学觉得分类和商品当然是一起的。所以这六种关系的本质不是画图规范而是你在代码里对所有权和生命周期做出的承诺。图画对了代码结构自然就对图画错了代码迟早会绕回来找你。2. 依赖与关联临时借用还是长期持有这组是最容易混的因为它俩在代码里长得有点像都是A 用到了 B。区别只在用多久和怎么拿到的。2.1 依赖方法执行期间的一次性借用依赖的定义是一个类的变化会影响到另一个类但两者之间没有长期的结构性关系。翻成代码就是引用只出现在方法作用域里。public class OrderSubmitService { private final OrderRepository repository; public OrderSubmitService(OrderRepository repository) { this.repository repository; } // NotificationSender 通过参数传入只在本次调用中存在 —— 依赖 public void submit(Order order, NotificationSender sender) { repository.save(order); String text MessageFormatter.format(order); // 静态调用 —— 依赖 sender.send(order.getCustomerId(), text); } }NotificationSender、MessageFormatter和OrderSubmitService之间就是依赖。判断依据很硬把OrderSubmitService的所有字段删掉它依然能编译通过因为这些引用都是方法级的。UML 里画一条从OrderSubmitService指向NotificationSender的虚线箭头箭头指向被依赖的那一方。依赖是最弱的关系也是最常见的。一个类只要调用了另一个类的方法不管是通过参数、局部变量还是静态方法都构成依赖。这也意味着依赖图往往非常密密到你没必要把每条都画出来——类图里通常只画那些值得注意的依赖比如跨模块调用、对外部系统的调用。2.2 关联字段级别的长期持有关联的判据只有一条引用是不是活过了方法调用的边界。只要它是个成员变量哪怕只是注入进来的接口也是关联。public class Customer { private final String id; // 长期持有订单引用 —— 关联一对多 private final ListOrder orders new ArrayList(); public Customer(String id) { this.id id; } public void addOrder(Order order) { this.orders.add(order); } public ListOrder getOrders() { return Collections.unmodifiableList(orders); } }Customer和Order是关联而且是有方向、有重数的关联一个客户对零到多个订单。UML 里用实线连接可以在两端标上重数1和0..*。关联还有几个变体值得知道。双向关联是指两个类互相持有引用比如Order里放一个CustomerCustomer里放一个ListOrder。自关联是类自己指向自己典型的是组织架构里的上下级或者菜单树的父子节点。限定关联是加了一个索引键的关联比如MapString, Order用订单号就能直接定位。public class Order { private final String orderNo; // 双向关联的另一端 private Customer customer; public Order(String orderNo, Customer customer) { this.orderNo orderNo; this.customer customer; } public Customer getCustomer() { return customer; } }2.3 把依赖改成关联会发生什么这个实验我建议你亲手做一次感受最直接。上面OrderSubmitService如果把NotificationSender从方法参数改成构造器注入的字段关系立刻从依赖升级为关联。变化体现在三个地方。第一是对象的创建责任转移了。原来调用方负责准备一个 sender现在 service 自己持有一个调用方只负责创建 service 的时候给一次。第二是状态可以跨方法累积了。如果 sender 内部有重试计数、限流窗口这类状态作为字段持有才能发挥作用作为参数每次都是新的就废了。第三是测试方式变了。依赖注入的字段可以在测试类里用 mock 替换但如果这个字段在构造器里被硬编码new出来你就得引入反射或者依赖注入框架才能替换。什么时候该把依赖升级成关联我的经验是三个信号需要跨方法复用同一个实例、这个对象持有需要维护的状态、需要对它做生命周期管理比如需要关闭、需要缓存。三个信号一个不占就老老实实保持依赖用参数传进去。反向操作把关联降级成依赖同样重要而且更常被忽略。一个类如果持有了十个字段有七个只是在一个方法里用了一下那就是过度关联。这种类通常难测试、启动慢、还容易形成循环依赖。我自己的习惯是当一个字段只被一个方法使用时就把它改成参数。这个规则帮我砍掉过不少臃肿的 Service。提示Spring 项目里的构造器注入从 UML 视角看是标准关联不是依赖。很多人因为感觉只是用来调一下就画成虚线这是最普遍的误判之一。3. 泛化与实现is-a 的两种形态这两个都画空心三角箭头都指向父类型区别只在线是实线还是虚线以及父类型里有没有实现代码。3.1 泛化继承行为也继承了约束泛化就是类继承extends。它表达的语义是子类是一种父类同时把父类的字段、方法、可见性约束一并继承过来。public abstract class PaymentChannel { // 子类继承的具体行为模板方法定义了支付流程骨架 public final PaymentResult pay(BigDecimal amount) { validate(amount); PaymentResult result doPay(amount); record(result); return result; } protected void validate(BigDecimal amount) { if (amount null || amount.signum() 0) { throw new IllegalArgumentException(金额必须为正数); } } // 留给子类实现的差异点 protected abstract PaymentResult doPay(BigDecimal amount); protected void record(PaymentResult result) { // 统一记账 } } public class AliPayChannel extends PaymentChannel { Override protected PaymentResult doPay(BigDecimal amount) { // 调用支付宝侧能力 return PaymentResult.success(); } }泛化最大的价值是共享实现最大的风险也是共享实现。父类里一个protected方法的改动可能悄无声息地破坏一堆子类的行为这就是经典的脆弱的基类问题。更麻烦的是protected字段——子类能直接改父类内部状态封装基本等于没有。我判断要不要用泛化的标准是里氏替换把子类对象塞进父类变量里所有依赖父类的代码都应该正常工作而且调用方不应该需要判断这到底是哪个子类。如果代码里到处是if (channel instanceof AliPayChannel)那这个泛化层级就建错了八成应该用组合加策略模式。3.2 实现只继承契约不继承实现实现对应implements拿到的是一组方法签名也就是一份契约。public interface Payable { PaymentResult pay(BigDecimal amount); default String channelName() { return getClass().getSimpleName(); } } public class WeChatPay implements Payable { Override public PaymentResult pay(BigDecimal amount) { return PaymentResult.success(); } }实现的关键特征是一个类可以实现多个接口而泛化只能单继承。这个差异不是语法细节而是设计自由度接口让你能横向描述这个类具备哪些能力而继承只能纵向描述这个类属于哪个家族。Java 8 引入default方法之后接口里也能有实现代码了这让实线还是虚线的判断多了一点模糊。我的判断标准是接口里只有少量默认方法作为便利工具主体还是签名那就还是实现如果接口里塞了大量默认方法甚至状态接口不能有实例字段但可以有静态常量那这个设计已经变味了可能该改成抽象类。3.3 面向接口编程到底该用在哪面向接口编程被说得太多反而容易滥用。我见过最夸张的一个项目每个类都有一个XxxService和一个XxxServiceImpl理由是以后可能要换实现。三年过去了没有任何一个接口有过第二个实现但每次加个方法要改两个文件翻代码要先跳一次接口再跳一次实现。我的标准是接口出现在有真实多态需求的地方或者出现在模块边界上。有真实多态需求指的是同一时刻存在两个以上的实现或者可以预见的短期内会出现。比如支付渠道、消息推送通道、存储后端本地、对象存储、规则引擎的判断器这些天然多实现接口是刚需。模块边界上的接口指的是跨团队或跨层调用时需要一个稳定的契约接口能隔离实现细节的变化比如领域层定义仓储接口、基础设施层提供实现这是依赖倒置的典型用法。除此之外的单一实现类抽接口基本是给自己加负担。测试需要 mock 也不是理由——现代 mock 框架可以直接对类做代理不必非得有接口。剩下的取舍可以看这张表判断维度优先选泛化抽象类优先选实现接口是否需要共享实现代码需要且代码量不小不需要或只需要极少量默认方法是否需要维护内部状态需要有受保护的字段不需要实现类自己管状态子类之间关系属于同一家族is-a 明确只共享某种能力can-do是否需要多继承不需要需要同时具备多种能力演进风险父类改动影响面大接口加方法默认会影响实现类需谨慎注意接口一旦发布就比抽象类更难改。给接口加一个抽象方法所有实现类都会编译失败抽象类加一个带默认实现的方法子类无感。所以对外发布的接口方法要尽量少而稳定。4. 聚合与组合生命周期是唯一裁判这组是六种关系里争议最多、也最容易画错的。很多资料用强和弱来区分但强弱太主观没有可操作性。我用一个可验证的问题整体对象不可达之后部分对象还能不能通过其他路径被访问到4.1 聚合拥有但不独占聚合的语义是我有一批东西但它们不属于我。典型例子是购物车和商品、球队和球员、班级和学生。删掉购物车商品还在数据库里好好的别人还能买。public class Cart { // 元素由外部创建外部也持有它们的引用 —— 聚合 private final ListProduct products new ArrayList(); public void add(Product product) { if (!products.contains(product)) { products.add(product); } } public void remove(Product product) { products.remove(product); } public BigDecimal total() { return products.stream() .map(Product::getPrice) .reduce(BigDecimal.ZERO, BigDecimal::add); } }注意几个特征Product对象是从外面传进来的Cart不负责创建它也不负责销毁它Cart被回收的时候那些Product对象依然被别的地方引用着不会被回收。这两个特征合起来就是聚合。4.2 组合同生共死组合的语义是我由这些东西构成它们只为我而存在。订单和订单项、Word 文档和段落、人体和心脏生理意义上。public class Order { private final String orderNo; // 只有一个入口能创建订单项且不提供外部替换 —— 组合 private final ListOrderItem items new ArrayList(); public Order(String orderNo, ListOrderItem rawItems) { this.orderNo orderNo; // 拷贝一份切断外部引用 this.items.addAll(rawItems); } public void addItem(String skuId, int quantity, BigDecimal unitPrice) { this.items.add(new OrderItem(skuId, quantity, unitPrice)); } public BigDecimal total() { return items.stream() .map(OrderItem::subtotal) .reduce(BigDecimal.ZERO, BigDecimal::add); } // 防御性拷贝防止外部拿到内部集合的引用 public ListOrderItem items() { return List.copyOf(items); } }组合的三个特征元素的创建被整体收口要么构造时传入后立即拷贝要么在整体内部new不提供修改集合的公开入口或者入口只做受限操作对外返回集合时做防御性拷贝。第三点是最容易被忽略的。如果items()直接return items;调用方拿到内部List的引用就能往里面随便塞订单项甚至可以把这个引用保存下来在订单对象已经被回收之后继续使用那些订单项。这时候组合语义就被破坏了——订单项实际上可以独立存活了。4.3 从内存和持久化两个角度看组合Java 没有 C 那种显式的析构函数所以同生共死不是语言保证的而是可达性保证的。订单对象不可达之后如果没有任何其他强引用指向它的订单项那订单项也随之不可达GC 会一起回收。但如果存在引用泄漏——比如某个监听器注册表里留了一个订单项的引用或者你把内部集合暴露出去过——那订单项就成了游离对象组合关系在实际运行时并不成立。所以判断组合不能只看代码写了什么还要看有没有第二条通往部分对象的引用路径。有第二条路径就是聚合甚至只是关联。持久化层面的对应关系更直观JPA 里两种写法的差别正好映射两种关系// 组合语义删订单订单项一起删从集合移除对应行也删 OneToMany(mappedBy order, cascade CascadeType.ALL, orphanRemoval true) private ListOrderItem items new ArrayList(); // 聚合语义删订单订单项保留只解除关联 ManyToMany JoinTable(name cart_product) private ListProduct products new ArrayList();cascade CascadeType.ALL加上orphanRemoval true是组合的标准配置去掉级联就是聚合。这个判断比看菱形实用得多——因为线可以画错但数据库里的行不会骗你。提示如果你不确定一个关系是聚合还是组合去问产品一个问题删掉 A 之后B 还要不要留在系统里给别人用答案就是答案。4.4 什么时候该主动选择聚合而不是组合组合听起来更紧密但紧不代表好。我遇到需要刻意降级为聚合的场景主要有两类。一类是部分对象有独立的业务价值。比如文章和标签一开始我做成了组合删文章时把没人用的标签一起清了。后来运营要求标签能跨文章复用、能做标签热度和聚合统计只好改成聚合标签独立管理文章只持有标签 ID 或引用。另一类是部分对象特别重创建成本高。比如报表和里面引用的数据源快照如果做成组合每生成一份报表就要完整拷贝一份快照内存直接爆。改成聚合多个报表共享同一个快照对象只在真正需要冻结数据的时候做一次深拷贝。反过来什么时候必须用组合当部分对象的生命周期严格从属于整体而且单独存在没有意义时。订单项离开了订单毫无价值文档段落离开了文档也没有意义一个 HTTP 请求的上下文离开了这次请求就是垃圾。这类对象做成组合能省掉一大堆孤儿数据的清理逻辑。5. 一个订单域模型把六种关系全用上前面分三组讲完了但真实项目里六种关系是混在一起出现的。我用一个精简的订单域模型串一遍代码可以直接跑。5.1 模型设计需求很朴素客户下订单订单由订单项组成订单项引用商品下单要走支付渠道支付渠道有多种实现库存服务负责扣减库存。把需求里的动词和名词翻译成关系关系对判断依据结论Order ← OrderItemOrderItem 只为 Order 存在随 Order 删除组合Cart → ProductProduct 独立存在可被多个购物车引用聚合Order → CustomerOrder 长期知道自己的客户关联Customer → Order客户持有一批订单关联与上一条构成双向AliPayChannel → PaymentChannel继承父类的支付模板流程泛化WeChatPayChannel → Payable实现支付能力接口实现OrderSubmitService → InventoryClient只在提交时调用一次依赖5.2 代码落地先定义能力接口和抽象父类这是实现和泛化的两种形态public interface Payable { PaymentResult pay(BigDecimal amount); } public abstract class PaymentChannel implements Payable { Override public final PaymentResult pay(BigDecimal amount) { validate(amount); PaymentResult result doPay(amount); afterPay(result); return result; } protected void validate(BigDecimal amount) { if (amount null || amount.signum() 0) { throw new IllegalArgumentException(金额必须为正数); } } protected abstract PaymentResult doPay(BigDecimal amount); protected void afterPay(PaymentResult result) { // 统一后置处理 } } public class AliPayChannel extends PaymentChannel { // 泛化 Override protected PaymentResult doPay(BigDecimal amount) { return PaymentResult.success(); } } public class MockPayChannel implements Payable { // 实现 Override public PaymentResult pay(BigDecimal amount) { return PaymentResult.success(); } }然后是组合、聚合、关联三种结构关系// 组合订单项由订单收口创建对外防御性拷贝 public class Order { private final String orderNo; private final ListOrderItem items new ArrayList(); private Customer customer; // 关联 public Order(String orderNo, Customer customer) { this.orderNo orderNo; this.customer customer; } public void addItem(OrderItem item) { this.items.add(item); } public BigDecimal total() { return items.stream() .map(OrderItem::subtotal) .reduce(BigDecimal.ZERO, BigDecimal::add); } public ListOrderItem items() { return List.copyOf(items); } } // 聚合商品独立存在购物车只持有引用 public class Cart { private final ListProduct products new ArrayList(); public void add(Product product) { products.add(product); } public ListProduct products() { return List.copyOf(products); } }最后是依赖只出现在方法签名里public class OrderSubmitService { private final Payable payable; // 关联长期持有支付能力 public OrderSubmitService(Payable payable) { this.payable payable; } // InventoryClient 只在本次调用中出现 —— 依赖 public void submit(Order order, InventoryClient inventoryClient) { inventoryClient.deduct(order.items()); PaymentResult result payable.pay(order.total()); if (!result.isSuccess()) { throw new IllegalStateException(支付失败 result.getMessage()); } } }跑一遍就能体会到六种关系不是画图时贴上去的标签而是写代码时做出的结构性决策谁持有谁、谁创建谁、谁的生命周期跟着谁这些决策写进代码画成 UML 只是把它们读出来。5.3 用读句子法反向校验画完图之后我有个校验习惯每一条关系用一句中文读出来看动词对不对。订单由订单项组成——组成且订单项不能独立存在组合。购物车包含商品——包含但商品能独立存在聚合。客户拥有订单——拥有长期持有关联。订单知道自己的客户——知道长期持有关联。支付宝渠道是一种支付渠道——是一种泛化。模拟渠道实现了支付能力——实现了接口实现。提交服务使用库存客户端——使用用完就走依赖。这个方法的妙处在于中文动词天然区分了语义强度。由……组成比包含重拥有比使用重。如果你读出来的句子是订单使用订单项那这个设计就有问题了——订单项被当成了临时资源但它明明应该是订单的组成部分。句子读不通图就是错的。提示不要把读句子当成形式主义。我见过一个项目把订单由订单项组成实现成了Order里存ListString orderItemIds然后每次用的时候现查。这就是典型的关系读对了实现走偏了组合的强一致性在中间层被抹掉了。6. 高频误判与排查速查表前面讲了原理这一节全是实战里踩出来的。我把见过的误判按出现频率排了个序。6.1 十个高频误判场景第一个把 Spring 注入的对象画成依赖。只要它是字段就是关联。依赖的判据是方法级引用不是我觉得它不重要。这个误判频率极高因为依赖注入让人产生我只是拿来用一下的心理错觉。第二个把所有 has-a 都画成组合。订单有客户也是 has-a但显然是关联不是组合。有集合字段不等于组合要看元素能不能独立存在。第三个聚合和组合只看菱形不看生命周期。菱形的空心实心只是结论的画法判断依据永远是整体没了部分还活不活。第四个DAO 和实体之间的关系画错。仓储和实体之间通常是依赖方法参数传实体或者泛化具体仓储继承基础仓储。把仓储和实体画成关联说明你在仓储里缓存了实体这个设计本身就该被质疑。第五个两个 Service 之间画成双向关联。大多数情况下它们应该通过事件或上层协调者解耦。如果确实需要互相调用先问问是不是职责划分出了问题。双向关联在 Service 层基本是坏味道。第六个集合字段一律当组合。集合只是容器装的是聚合还是组合取决于元素的所有权。ListOrderItem是组合ListProduct是聚合形态一样语义完全不同。第七个为了代码复用建泛化。这两个类有五个相同字段抽个父类吧——这是最常见的架构腐化起点。复用相同字段不等于 is-a 成立。这种情况优先考虑抽取成分组合或者用工具类。第八个箭头方向画反。依赖的箭头指向被依赖方泛化和实现的三角指向父类型。这个没有捷径画完检查一遍就好。有个记忆技巧箭头永远指向更强的一方——被依赖的更基础父类型更抽象。第九个DTO 和实体之间画关系。这两个之间既不是泛化也不是关联通常是转换关系用依赖表示转换方法接收实体返回 DTO。有些人图省事画成关联语义就完全错了。第十个把不同语境下的聚合混为一谈。这个单独说见下一节。6.2 排查速查表遇到拿不准的关系按这个顺序问自己问题是否这个引用是方法参数、局部变量或返回值吗依赖结束继续下一问这是继承吗父类型有实现代码就泛化只有签名就实现结束继续下一问是整体-部分结构吗继续下一问关联结束部分对象有第二条引用路径吗聚合继续下一问整体销毁后部分还有用吗聚合组合部分对象能脱离整体被创建和修改吗聚合组合顺序很重要先排除依赖再排除继承最后才在关联家族里细分。很多人一上来就纠结菱形其实前面两问就能砍掉大半关系。6.3 我在实际项目里踩过的几个坑第一个坑是双向关联导致的序列化死循环。Order持有CustomerCustomer持有ListOrder序列化成 JSON 的时候无限递归。解决办法是在一端加JsonIgnore或者干脆不暴露实体、改用 DTO 输出。后来我的原则是领域模型里允许双向关联但绝不能直接序列化实体。第二个坑是组合关系被外部破坏了。我在一个项目里为了图方便getItems()直接返回内部List结果有个定时任务往里面加了条目加完的条目不在任何事务里既没落库也没参与校验导致对账的时候账对不上。改成List.copyOf()之后问题消失。这个拷贝有性能成本但比起数据错误成本可以忽略。第三个坑是聚合被当成组合删数据。前面提过JPA 的级联配置选错删一个父对象连带删掉一批不该删的。后来我给自己定了个规矩任何cascade REMOVE和orphanRemoval true都要在代码评审里单独拿出来讨论一次因为它对应的是数据销毁不是普通配置。第四个坑是依赖关系过密导致的循环依赖。三个类互相通过方法参数传递看起来是依赖不是关联编译上不构成循环但运行时调用链绕成了一个圈排查问题极其痛苦。这种时候要看的是调用图不是类图。类图只能表达结构关系动态的调用关系得靠时序图。7. 同一个聚合在不同圈子里的三副面孔这六种关系里聚合这个词的污染最严重。同样一个词在数据、网络、业务、UML 四个语境里说的是完全不同的事。我见过有人在评审里说这里用聚合函数算一下另一个人理解成这里要用聚合关系建模聊了十分钟才反应过来不在一个频道上。数据库语境下的聚合指的是聚合函数和分组统计比如COUNT、SUM、AVG配合GROUP BY。它描述的是把多行压成一行的计算和对象之间的所有权毫无关系。用 CTE 做预聚合是常见的性能优化手法——先把明细压成小结果集再和大表关联避免全量扫描。这里的聚合是数据维度的收缩不是结构关系。网络和运维语境下的聚合指的是链路聚合把多条物理链路绑定成一条逻辑链路提升带宽和冗余。它描述的是多份资源对外表现为一份和 UML 聚合的整体拥有多个部分倒是有几分神似但关注点完全不同链路聚合关心的是传输能力和故障切换不关心生命周期归属。业务和内容语境下的聚合指的是把分散的信息源汇集到一起比如招聘信息聚合、天气数据聚合、音源聚合、多平台 token 聚合。这里的聚合是数据采集与归一化聚合方通常不拥有被聚合的数据只是做了整合展示。有趣的是业务聚合在 UML 里更接近依赖或者弱关联——聚合方拿到的是外部数据的一份镜像数据源消失它照样能跑只是内容变旧了。只有 UML 语境下的聚合讨论的是对象所有权和生命周期。判断依据永远是那句删掉整体部分还在不在。所以当有人跟你说这里做个聚合的时候先别急着画菱形先问一句你说的是哪个聚合。这个提问习惯帮我省下过不少无效沟通。我自己总结的一条经验是跨语境沟通时用行为描述代替术语。不说这是组合关系而说删订单的时候订单项要一起删不说这是聚合函数统计而说按天把明细压成一条汇总。术语省事但也容易造成假共识——双方都以为对方听懂了自己其实说的是两件事。
返回列表