ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

jMolecules与ArchUnit实战:DDD注解、架构规则与编译期织入

jMolecules与ArchUnit实战:DDD注解、架构规则与编译期织入

我第一次接触 jMolecules 的时候,第一反应是"这不就是给领域模型贴标签吗,能有多大用"。真正在一个中型项目里把它跑完一轮之后,观点变了。它解决的从来不是"怎么写出一个聚合根",而是怎么让领域模型在代码里表达出真实的架构意图,并且让这个意图可以被机器校验、被框架识别。这两件事,靠口头约定和评审是守不住的。

这篇内容围绕 jMolecules 这套注解体系展开,讲的是怎么用注解把 DDD 的战术模式落到 Java 代码里,怎么用 ArchUnit 把架构约束变成 CI 里会红的规则,怎么用编译期织入让业务代码不依赖具体的持久化框架。适合已经写过一些 DDD 代码、但总觉得"形似神不似"的同学,也适合正在纠结领域层要不要引入 JPA 注解的团队参考。全文基于常见的 1.x 版本实践展开,具体 API 签名以你实际引入的依赖源码为准。

1. jMolecules 到底在解决什么:把领域意图从框架里剥出来

1.1 一个典型困境:注解写满了,架构还是散的

先看一个特别常见的领域层写法。一个订单聚合根,类头上挂着@Entity,主键字段挂着@Id和@GeneratedValue,某个集合字段挂着@OneToMany(cascade = CascadeType.ALL),类底下还压着 Lombok 的@Data。代码能跑,数据库也能建表,但问题在于:这个类现在同时承担了三件事——它是领域概念的表达、是持久化映射的载体、还是序列化的数据容器。

三层职责压在一个类上,代价会在项目变大的时候一点点暴露出来。想换个存储实现,得把领域类的注解全翻一遍;想给领域层写纯单元测试,得先拉起一个 ORM 上下文;想校验"聚合之间不能直接持有对象引用"这条规矩,没有任何工具能在编译期或测试期帮你拦一下,全靠人眼。更麻烦的是新人进来,看到满屏的框架注解,根本分不清哪些字段是业务真正关心的,哪些只是为了凑映射。

这就是 jMolecules 想撬动的那个点。它的核心主张很朴素:领域模型应该用一套与框架无关的词汇来描述自己。你是聚合根就是聚合根,是值对象就是值对象,这个身份不该由@Entity来定义。至于这个语义最终怎么翻译成数据库表、怎么翻译成事件总线上的消息,那是基础设施层的事,交给集成模块去桥接。

所以它不是又一个 DDD 脚手架,也不是代码生成器,它更像一层语义中间层:往上,业务代码只依赖几行注解;往下,各种集成模块负责把这些注解"翻译"成具体框架需要的东西。翻译可以发生在编译期(字节码织入),也可以发生在测试期(ArchUnit 规则校验),还可以发生在运行期(Spring Data 的元注解识别)。

1.2 jMolecules 的三层分工

把这套东西拆开看,它其实由几个相对独立的部分组成,理解这个分工比记住注解名更重要。

模块作用位置主要职责
jmolecules-ddd源码层提供 DDD 战术模式的注解与少量类型接口
jmolecules-archunit测试层把注解语义转成可执行的架构规则
jmolecules-integrations编译期/运行期与持久化、事件、依赖注入等框架桥接
jmolecules-bytebuddy编译期字节码织入,自动补齐 equals/hashCode、映射注解等

这个分层带来的直接好处是依赖倒置得比较干净。领域模块的pom.xml里只需要一个jmolecules-ddd,它的传递依赖极少,几乎不会污染你的类路径。持久化相关的集成依赖放在基础设施模块里,领域模块对它一无所知。

我在实际项目里踩过一个相关的坑,值得提前说:jMolecules 同时提供了注解和类型接口两套表达方式,包名分别是org.jmolecules.ddd.annotation和org.jmolecules.ddd.types。比如聚合根,既可以写@AggregateRoot注解,也可以implements AggregateRoot<Order, OrderId>接口。这两者不是替代关系,语义上略有差别,混用的时候最容易出现导入错包的情况——IDE 自动补全一顺手,导进来的是注解包里的同名类,编译能过,但 ArchUnit 规则匹配不到,你会对着一堆"规则明明写了却不生效"发半天呆。我后来的做法是:在领域模块里统一用注解表达语义,类型接口只在确实需要泛型约束(比如仓储泛型参数)的地方使用,团队内写进代码规范。

2. 把战术模式翻译成注解:核心原语的语义边界

2.1 聚合根、实体、值对象:三者不能混着用

DDD 的战术模式里,最容易含糊的就是这三个概念。很多人写出来的"值对象"其实是个带 setter 的 POJO,写出来的"实体"其实没有身份标识,写出来的"聚合根"其实只是张表。jMolecules 的价值在于它逼你把这三个词选清楚,因为选完之后,规则会来检查你选得对不对。

聚合根(Aggregate Root)是一致性与事务的边界。它有全局唯一的标识,外部只能通过它来访问聚合内部的对象。在 jMolecules 里用@AggregateRoot标注,同时它的标识字段要用@Identity标出来,这样字节码织入才知道该基于哪个字段生成equals/hashCode。

实体(Entity)有身份、生命周期内可变,但它的身份只在聚合内部有意义。典型例子是订单里的订单行,每一行有自己的行号,但你不会拿行号去全局查一条订单行。用@Entity标注。

值对象(Value Object)没有身份,靠属性值判等,天然应该不可变。用@ValueObject标注。Java 17 之后首选record,因为它把不可变和值语义都帮你做了。

这三者的边界一旦在代码里显式化,很多设计问题会自动浮现。我见过一个很典型的反例:某团队的"金额"类被标成了@Entity,因为它有一个自增主键。这就是把持久化实现当成了领域语义——金额这种东西换了任何存储都不需要主键,它就是个值对象,主键是映射层的产物,不该反向污染模型。

2.2 仓储、领域事件、领域服务、模块的标注规矩

仓储(Repository)在 jMolecules 里用@Repository标注,约定是接口而非实现。这一条非常关键:仓储的抽象属于领域层,实现属于基础设施层。用接口表达,领域层就只依赖自己的抽象,具体的 JPA 实现、MyBatis 实现、内存实现可以随便换。ArchUnit 里有对应的规则可以强制"仓储必须是接口",CI 里直接拦住那些图省事写成类的做法。

领域事件(Domain Event)用@DomainEvent标注。事件对象应该是不可变的,字段全部 final,理想状态是用record。它表达的是"领域里发生了一件已经发生的事",所以命名用过去式,字段是事件的完整快照,不要塞聚合根引用进去——那样事件就变成了对可变状态的悬挂指针,序列化出去之后语义就变了。

领域服务(Domain Service)用@Service标注,指的是那种"跨多个聚合、又不适合塞进任何一个聚合"的无状态操作。要注意这个@Service是 jMolecules 的,不是依赖注入框架那个。它们的语义确实相近,但前者是架构语义,后者是容器语义,理论上可以叠加使用。

模块(Module)这块容易被忽略。jMolecules 提供了模块化表达的方式,可以用包级别的package-info.java声明,也可以在类型上标注。模块化的意义在于把"限界上下文"这个偏概念的东西,落成 ArchUnit 能检查的实实在在的边界。

2.3 容易被忽略的成员类注解:@Identity、@Member、@Reference

这三个注解是让整套体系"活起来"的关键,也是最容易漏掉的。

@Identity标在聚合根或实体的标识字段上。它的作用不止是"标记",更重要的是给字节码织入提供依据——织入器会根据这个字段生成正确的equals/hashCode,让两个从数据库里分别加载出来的同一个订单对象能判等。没有它,你就得自己手写,还得保证所有实体写法一致。

@Member用在值对象或实体的组成字段上,告诉织入器"这个字段参与值语义计算"。比如一个Money值对象里有金额和币种两个字段,都标上@Member,织入器生成equals的时候就会把它们都算进去。

@Reference用于跨聚合的引用。DDD 的一条硬规矩是聚合之间只通过标识引用,不持有对方的对象引用。这条规矩靠人守很难,靠规则守就容易多了——@Reference就是在代码里显式声明"我这里引用的是另一个聚合的 ID,不是一个对象"。ArchUnit 能据此检查你有没有偷偷把对方聚合根塞进来。

注解标在哪典型误用
@Identity聚合根/实体的标识字段标在值对象的普通字段上
@Member值对象的组成字段用在可变字段上,导致判等不稳定
@Reference跨聚合的引用字段引用的目标是聚合内部实体而非聚合根

3. 动手搭一个订单域:从依赖到可运行的骨架

3.1 依赖引入与包结构约定

先看依赖。领域模块只需要核心注解包,版本号以你实际使用的发布版为准:

<dependency> <groupId>org.jmolecules</groupId> <artifactId>jmolecules-ddd</artifactId> <version>1.10.0</version> </dependency>

测试模块里加架构规则依赖:

<dependency> <groupId>org.jmolecules.integrations</groupId> <artifactId>jmolecules-archunit</artifactId> <version>1.10.0</version> <scope>test</scope> </dependency>

包结构我建议按"一个聚合一个包"来切,而不是按"entity/service/repository"这种技术分层。前者天然对应限界上下文里的聚合边界,后者切完之后聚合内部的对象被打散到不同包里,模块化规则根本写不出来。

com.example.shop.order ├── Order.java // 聚合根 ├── OrderId.java // 标识(值对象) ├── OrderLine.java // 聚合内实体 ├── OrderStatus.java // 枚举 ├── OrderRepository.java // 仓储接口 └── events └── OrderPlaced.java // 领域事件

这个结构看起来平平无奇,但它让"哪些东西属于同一个聚合"在物理结构上一目了然。后面写 ArchUnit 规则的时候,你会感谢当初这个决定。

3.2 值对象与标识的写法

先写标识和值对象。用record是最省事的,不可变性、值语义、equals/hashCode全都是语言层面保证的:

package com.example.shop.order; import org.jmolecules.ddd.annotation.ValueObject; @ValueObject public record OrderId(String value) { public OrderId { if (value == null || value.isBlank()) { throw new IllegalArgumentException("订单号不能为空"); } } public static OrderId of(String value) { return new OrderId(value); } }

这里有几个细节值得展开。构造函数里的校验是必须的——值对象一旦创建出来就必须是合法的,这是它区别于普通 POJO 的核心特征。static of(...)工厂方法不是必须的,但强烈建议保留:jMolecules 的Identifier类型接口约定了一个of(...)形式的工厂,字节码织入在有需要的时候能顺着这个约定补齐创建逻辑;即使你不用接口,统一工厂方法也能让调用方的写法保持一致。

再看一个复合值对象:

@ValueObject public record Money(BigDecimal amount, String currency) { public Money { if (amount == null || amount.signum() < 0) { throw new IllegalArgumentException("金额不能为负"); } Objects.requireNonNull(currency, "币种不能为空"); } public Money add(Money other) { if (!currency.equals(other.currency)) { throw new IllegalArgumentException("币种不一致,无法相加"); } return new Money(amount.add(other.amount), currency); } }

add方法返回新对象而不是修改自身,这是值对象的标配写法。用 record 的时候这一点是强制的,因为字段都是 final,你想改也改不了。我个人的体会是,一旦用惯了 record 写值对象,就很难再回去了,因为你不用再担心某个方法不小心改了状态导致隔了三个调用栈之后出现莫名其妙的数据。

3.3 聚合根的内部一致性维护

聚合根的写法要点在于:所有对内部状态的修改都必须经过聚合根的方法,并且这些方法要维护业务不变式。

package com.example.shop.order; import org.jmolecules.ddd.annotation.AggregateRoot; import org.jmolecules.ddd.annotation.Identity; import java.util.ArrayList; import java.util.List; @AggregateRoot public class Order { @Identity private final OrderId id; private final String customerId; private final List<OrderLine> lines; private OrderStatus status; private Order(OrderId id, String customerId) { this.id = id; this.customerId = customerId; this.lines = new ArrayList<>(); this.status = OrderStatus.CREATED; } public static Order create(OrderId id, String customerId) { return new Order(id, customerId); } public void addLine(String sku, int quantity, Money unitPrice) { if (status != OrderStatus.CREATED) { throw new IllegalStateException("订单已提交,不能再修改"); } if (lines.stream().anyMatch(l -> l.sku().equals(sku))) { throw new IllegalArgumentException("同一 SKU 不能重复添加"); } lines.add(new OrderLine(sku, quantity, unitPrice)); } public void submit() { if (lines.isEmpty()) { throw new IllegalStateException("空订单不能提交"); } this.status = OrderStatus.SUBMITTED; } public OrderId getId() { return id; } public List<OrderLine> getLines() { return List.copyOf(lines); } public OrderStatus getStatus() { return status; } }

几点说明。构造函数私有、通过静态工厂创建,这样做的好处是创建路径唯一,所有的初始化逻辑都集中在一处。addLine里的两道校验就是聚合根存在的意义——如果把这些判断散到应用服务层,一旦有第二个调用方,就很容易漏掉其中一条。

getLines()返回List.copyOf(...)而不是直接返回内部列表,这是防御性拷贝。别小看这一行,我见过太多"聚合内部状态被外部偷偷改掉"的 bug,排查起来极其痛苦,因为从调用栈上看不出任何问题。

至于@Identity标在id字段上带来的实际收益:字节码织入之后,这个类的equals/hashCode会基于id生成,两个从不同查询里加载出来的同一订单对象能被正确判等,塞进Set也不会出现重复。手写这段逻辑当然可以,但一个项目里几十个实体,你能保证每一处都写对吗?我反正是保证不了。

3.4 仓储与领域事件的落点

仓储接口定义在领域层,实现放在基础设施层:

package com.example.shop.order; import org.jmolecules.ddd.annotation.Repository; import java.util.Optional; @Repository public interface OrderRepository { Optional<Order> findById(OrderId id); void save(Order order); void delete(Order order); }

接口里的方法签名要用领域语言,别出现selectByPrimaryKey这种。findById收的是OrderId值对象而不是String,这层类型安全是免费的,不用白不用。

领域事件:

package com.example.shop.order.events; import org.jmolecules.ddd.annotation.DomainEvent; import java.time.Instant; @DomainEvent public record OrderPlaced( String orderId, String customerId, Instant occurredAt ) {}

事件里存String类型的 orderId 而不是OrderId对象,这是我个人的偏好——事件要跨进程传输,用最原始的类型序列化最省心,也不容易在反序列化时踩到构造函数校验的坑。当然如果你的序列化方案能处理好值对象,直接用OrderId也没问题。

关于事件的发布时机,一个常见的做法是在应用服务层收集聚合根产生的领域事件,提交事务后再发布。不要在聚合根的方法里直接调用事件发布器,那样聚合就依赖了基础设施,前面辛苦建立的干净依赖关系一下就破了。

4. 让架构规则跑起来:ArchUnit 与 jMolecules 的组合拳

4.1 现成规则集的用法

注解写得再规范,没人检查也是白搭。jMolecules 的 ArchUnit 集成模块提供了一批开箱即用的规则,把注解语义翻译成了可执行的断言。典型用法是写一个架构测试类:

import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; import org.jmolecules.archunit.JMoleculesDddRules; @AnalyzeClasses(packages = "com.example.shop") class DddArchitectureTest { @ArchTest static final ArchRule ddd_rules = JMoleculesDddRules.all(); }

JMoleculesDddRules里包含的规则大致覆盖这几个方向:聚合之间的关系是否只通过标识、领域事件是否不可变、仓储是否声明为接口、值对象是否真的不可变、实体是否有标识。具体的方法名和覆盖范围会随版本演进,建议引入依赖后直接翻一下这个类的源码,比自己猜要快得多。

这里有个实践建议:先不要一次性把all()全打开。在一个存量的、注解覆盖不全的项目里直接上全套规则,CI 会红成一片,团队第一反应是把这堆测试删掉,那就适得其反了。我的做法是分三步走——第一步只开"仓储必须是接口"这种低风险高收益的规则,先把结构性的问题修掉;第二步开聚合引用相关的规则,这块需要改设计,周期长一点;第三步再开值对象不可变这类细粒度规则。

4.2 自己写规则的三种常见模式

现成规则不可能覆盖你的所有约定,自定义规则是必须的。我总结了三种最常用的模式。

第一种:依赖方向约束。这是最基础也最有价值的规则,拦住的是领域层反向依赖基础设施这种结构性错误。

@ArchTest static final ArchRule 聚合不应依赖仓储 = noClasses() .that().areAnnotatedWith(AggregateRoot.class) .should().dependOnClassesThat() .areAnnotatedWith(Repository.class);

第二种:命名与标注的一致性。这类规则用来防止"语义漂移"——比如有人写了个聚合根但忘了加注解,或者加注解的类名字一看就不对。

@ArchTest static final ArchRule 聚合根应位于聚合包根部 = classes() .that().areAnnotatedWith(AggregateRoot.class) .should().resideInAPackage("..order..") .orShould().resideInAPackage("..customer..") .orShould().resideInAPackage("..product..");

第三种:跨聚合引用的收紧。这是 DDD 落地里最容易破防的一条,也是最值得用规则守住的。

@ArchTest static final ArchRule 聚合之间不得持有对象引用 = noClasses() .that().areAnnotatedWith(AggregateRoot.class) .should().dependOnClassesThat() .areAnnotatedWith(AggregateRoot.class) .andShould().notDependOnClassesThat() .areAssignableTo(Object.class) .because("聚合之间只允许通过标识引用,不允许直接持有对方聚合根对象");

注意最后这条规则的写法有点绕,实际写的时候通常会配合@Reference注解做更精确的判定:允许依赖对方的值对象(比如CustomerId),但禁止依赖对方的聚合根类本身。这个粒度需要根据你的项目结构调整。

4.3 规则失败后的排查路径

ArchUnit 的失败信息有时候不太友好,尤其是涉及传递依赖的时候。分享一个我常用的排查顺序。

先看失败信息里给出的完整类路径,确认被"抓住"的到底是哪个类。很多时候问题不在被标注的那个类上,而在于它的某个字段的类型引起的传递依赖。比如你禁止聚合依赖仓储,结果某个聚合根里有个方法返回了List<OrderRepository>,这就是个隐藏的违例。

然后去确认注解有没有生效。前面提过注解包和接口包混用的问题,ArchUnit 按注解匹配的时候,导错包就直接匹配不上。我的习惯是在架构测试里加一条自检规则:

@ArchTest static final ArchRule 至少存在一个聚合根 = classes() .that().areAnnotatedWith(AggregateRoot.class) .should().haveSimpleNameEndingWith("Order") .allowEmptyShould(true);

用allowEmptyShould(true)配合一条宽泛的规则,如果匹配数量是零,说明注解根本没被扫到,问题在扫描路径或者注解导入上,而不是规则本身写错了。这一步能省掉大量自我怀疑的时间。

最后是性能问题。ArchUnit 的类扫描在大项目上并不便宜,几百个模块的话跑一次可能要好几分钟。可以配置缓存:

@AnalyzeClasses( packages = "com.example.shop", importOptions = ImportOption.DoNotIncludeTests.class, cacheMode = CacheMode.PER_CLASS )

CacheMode.PER_CLASS在本地开发时效果明显,CI 上一般用默认值就好。另外记得把测试类排除掉,否则测试里的 Mock 对象会被当成真实类参与分析,产生一堆莫名其妙的违例。

5. 编译期织入:ByteBuddy 把注解变成真实行为

5.1 织入插件配置

前面的注解本质上只是元数据,它们自身不产生任何运行时行为。真正让@Identity变成"自动生成 equals/hashCode"的,是编译期织入这个环节。

Maven 项目的配置大概长这样:

<plugin> <groupId>net.bytebuddy</groupId> <artifactId>byte-buddy-maven-plugin</artifactId> <version>1.14.9</version> <executions> <execution> <goals> <goal>transform</goal> </goals> </execution> </executions> <configuration> <transformations> <transformation>org.jmolecules.bytebuddy.JMoleculesPlugin</transformation> </transformations> </configuration> </plugin>

Gradle 项目的做法类似,用byteBuddy { transformation { plugin.set(...) } }配置。具体插件坐标和 transformation 类名以你所用版本为准,配置前建议先确认一下依赖包里到底有哪些插件类。

这里有个坑要提前说:这个插件的执行时机在compile之后、package之前,它处理的是编译产物的字节码。也就是说,你在 IDE 里直接点"运行"的时候,如果 IDE 没有走完整的 Maven/Gradle 生命周期,织入是没有发生的。源码里看起来一切正常,但equals行为跟 CI 上构建出来的包不一样。我在一个项目里被这个问题坑了整整半天,本地测试全绿,打包上线之后判等逻辑挂了。

5.2 织入到底改了什么

理解织入做了哪些事,比记住配置更重要。概括起来主要是三类改造。

第一类是补齐对象语义方法。带@Identity的类会得到基于标识字段的equals/hashCode;带@Member的类会得到基于成员字段的版本;带@ValueObject的类会得到更完整的toString。

这类改造的价值在于一致性。手写的话,你可能在这个类里基于 id 判等,在另一个类里基于全部字段判等,第三个类忘了写hashCode。这些问题单看代码都不明显,出问题的时候往往是在Set去重、Map查值、缓存 key 这些地方,现象诡异,排查成本极高。

第二类是注入框架注解。这是我觉得最有意思的一部分:jMolecules 可以把 JPA 的@Entity、@Embeddable、@Id这些注解,在字节码层面"补"到你的领域类上。也就是说,你的源码里一个 JPA 注解都没有,但字节码里该有的都有,ORM 框架照样能正确识别。

这一步的意义重大。领域层的源码从此对持久化框架完全无感,你可以拿同样的领域模型去适配不同的存储方案,也可以在纯单元测试里直接 new 一个聚合根,不需要任何 ORM 上下文。

第三类是方法生成。比如给值对象补of(...)工厂方法,给Identifier实现类补标准方法等。这部分用得相对少,但涉及的时候能省不少样板代码。

5.3 与持久化框架的桥接

织入完成之后,桥接就是水到渠成的事。以 JPA 为例,你需要在基础设施层提供一个仓储实现:

package com.example.shop.infrastructure.persistence; import org.springframework.stereotype.Component; import com.example.shop.order.*; @Component public class JpaOrderRepository implements OrderRepository { private final OrderEntityRepository entityRepository; public JpaOrderRepository(OrderEntityRepository entityRepository) { this.entityRepository = entityRepository; } @Override public Optional<Order> findById(OrderId id) { return entityRepository.findById(id.value()).map(this::toDomain); } @Override public void save(Order order) { entityRepository.save(toEntity(order)); } @Override public void delete(Order order) { entityRepository.deleteById(order.getId().value()); } private Order toDomain(OrderRecord record) { // 组装领域对象 } private OrderRecord toEntity(Order order) { // 转成持久化对象 } }

看到这里可能有人会问:既然字节码能自动注入 JPA 注解,为什么还要写这层转换?答案是取决于你的取舍。如果你选择让领域对象直接由 ORM 管理(也就是织入方案),那确实不需要转换层,Order类本身就是实体类,OrderRepository直接继承 Spring Data 的接口就行。但如果你对领域模型的纯净度要求很高(比如需要延迟加载、需要对聚合做复杂的重建逻辑),那加一层转换更稳妥。

两种方案我都用过。织入方案的代码量少得多,对中小项目非常合适;转换层方案更灵活,但样板代码会显著增加,需要配套的对象映射工具。选哪个主要看你团队对"领域层零框架依赖"这条底线的坚持程度。

Spring Data 的适配还有个细节:jMolecules 的@Repository注解在集成模块的支持下能被识别为仓储标记,从而让 Spring Data 的接口扫描正常工作。如果你的项目里已经有一堆继承JpaRepository的接口,迁移的时候可以逐步替换,不必一次性全改。

6. 上线前最容易翻车的几个点

6.1 record 与织入的冲突

这是我在实际项目里遇到的第一个真问题。用record写值对象非常舒服,但如果你同时想用字节码织入去生成equals/hashCode,会发现织不进去。原因是record的equals/hashCode是final的,字节码层面改不了。

好消息是这通常不构成问题——record自带的值语义本来就是正确的,你根本不需要织入。真正需要警惕的是混合场景:一个类用record写了,但又在里面标了@Identity,这时候你会以为"标识语义生效了",实际上并没有,record 的判等还是基于全部字段。

处理原则很简单:record 就老老实实做值对象,需要身份语义的类用普通类写。两者别混。我现在的习惯是,只要这个类需要被持久化框架管理、需要参与聚合的一致性维护,就不用 record;纯粹的数据载体、DTO、事件对象,一律用 record。

6.2 IDE 与 CI 的行为差异

前面提过一次,这里展开说。字节码织入带来的最大困扰就是"本地好使、打包不好使"或者反过来。

根源在于 IDE 的增量编译通常不触发完整的插件执行链。你改了代码,IDE 编译了,但没跑 ByteBuddy 插件,于是运行时的字节码和 CI 构建产物不一致。

几个应对办法。第一,把织入相关的行为验证放到集成测试里,而不是单元测试里,确保测试跑的是走完整构建流程的产物。第二,在团队里统一构建方式,本地验证重要改动时用命令行跑一次完整构建,别只信 IDE。第三,如果条件允许,用 IDE 的"委托构建给 Maven/Gradle"功能,让 IDE 复用构建工具的编译流程,虽然会慢一点,但一致性有保障。

我个人现在的做法是:涉及聚合根判等、领域事件序列化这类跟织入强相关的逻辑,一定要在 CI 上跑集成测试覆盖,本地跑通不算数。

6.3 规则集失控与性能

架构规则的价值在于长期守护,但如果规则写得太多太细,维护成本会反噬。

我见过一个项目最后积累了两百多条 ArchUnit 规则,跑一次架构测试要八分钟,开发者提交前根本不愿意本地跑,全靠 CI 兜底,反馈周期长得离谱。而且规则之间互相冲突的情况时有发生,改一条挂三条。

控制规模的经验是:规则要跟着架构决策走,而不是跟着代码现状走。每加一条规则,先问一句"这条规则守住的是哪个设计决策?如果它不存在,会发生什么坏事?"答不上来的规则就删掉。另外善用.because(...)把决策原因写清楚,半年后回头看的时候你会感谢自己。

性能上还有个小技巧:把架构测试按关注点拆成多个测试类,用@AnalyzeClasses的包范围做区分,这样改某个模块的时候只需要跑对应那部分规则。整体上比一个巨型测试类要快,失败信息也更容易定位。

6.4 版本对齐与依赖管理

最后一个务实的问题。jMolecules 的各个模块(核心注解、ArchUnit 集成、ByteBuddy 集成、各框架适配)版本需要保持一致,混用不同版本很容易出现"注解在但规则不认"或者"插件跑但什么都不做"的情况。

建议在父 POM 里用dependencyManagement统一管理版本:

<properties> <jmolecules.version>1.10.0</jmolecules.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.jmolecules</groupId> <artifactId>jmolecules-bom</artifactId> <version>${jmolecules.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement>

如果项目里同时有多个模块依赖不同版本,用mvn dependency:tree查一下有没有版本冲突,这个排查动作在引入新集成模块的时候一定要做一次。我遇到过一次 ArchUnit 规则完全不生效的情况,最后查出来是传递依赖带进来了一个旧版本的注解包,类加载的时候优先命中了旧版本,注解类型对不上,规则自然匹配不到。这类问题靠读代码是发现不了的,只能靠依赖树。

引入 jMolecules 这件事,我个人最大的体会是:它的收益不在写代码的那几天,而在项目跑到第二年的时候。前期的注解加得规不规范,当时看不出差别;等到要换存储、要拆分模块、要新人接手的时候,那些能用机器校验的架构约束,价值才会真正显现出来。所以如果准备上,建议一开始就把注解规范和架构测试一起立起来,别只加注解不写规则——那样的话,这套东西就真的只是给类贴了几个标签而已。

返回列表