
上周我翻一套测试机的上位机代码差点当场昏过去。三千多行的 Service 类几十个 if/else 嵌套Bin 判断和站点切换逻辑全用 int 常量和 switch 完成业务对象清一色是带 getter/setter 的“数据袋子”。干这行越久我越觉得像 93K 这种机台硬件电路被厂商封装得明明白白反而是我们自己的业务代码——TestProgram、DUT、Bin、Site 这一堆对象——被写成了没有灵魂的传参容器。今天我想认真聊聊怎么用 DDD领域驱动设计把这些业务对象重新“激活”让它们自己懂得测试规则而不是靠外围 Service 一层层指挥。这篇文章适合正在做 ATE 上位机、测试调度系统、良率分析平台的工程师也送给那些觉得“DDD 搞不懂”却又不甘心继续写面条代码的同行。1. 半导体测试机里的“业务对象”到底是什么鬼1.1 一台测试机在软件眼里长什么样三个真实的子域很多团队的代码混乱源头是根本没有分清自己到底在解决哪几个业务问题。半导体测试机的软件系统说大不大但涉及的业务其实横跨三个完全不同的“世界”第一个是测试执行子域。它负责 TestProgram 的运行、TestItem 的执行、Site 的调度、DUT 的测量结果回收。这里的关键对象是 TestProgram、TestItem、Site、DUT 和 MeasureResult。这个子域关心的是“这一轮测试跑没跑完、每一个 item 的判定是什么”。第二个是分选与良率子域。它负责 Bin 的划分、Lot 的判定、良率统计、Handler/Prober 的动作。这里的关键对象是 Bin、Yield、Lot、HandlerPort。这个子域关心的是“这一批货到底算好货还是坏货良率掉了该找哪个 Bin 的原因”。第三个是设备监控子域。它负责电源电压、温度、站点状态、自检周期、设备健康度。这里的关键对象是 PowerSupply、TemperatureSensor、SelfTestReport。它关心的是“机台现在能不能安全地继续跑”。这三个子域的服务对象不同、语言不同、变化节奏也不同。测试执行一天要跑几千轮分选良率的数据要落到数据库供统计分析设备监控要高频采集温度电压。把它们揉在一个代码库里用同一个模型去表达必然有人要妥协一妥协就是烂代码的开端。我见过一个很典型的现场一批 wafer 进测试器load board 上 128 个 DUT 同时并行测试每个 DUT 走同一份 TestProgram但不同 Site 的结果可能完全不同有 Fail 项要 rebin最后统计 Lot 良率。这个闭环里三个子域其实都在工作但传统代码里它们共享了一个巨大的“TestResult”类字段臃肿到没人敢动因为谁都不敢确认删掉哪个字段会不会影响别的模块。1.2 传统写法为什么“活”不起来一个让人窒息的实际案例我接手过一套维护了五年多的测试调度代码。它的业务对象长这样TesterDevice 类里有几十个字段比如int bin、boolean isPass、String siteName所有字段都是 public getter/setter没有任何行为。判断一个 Lot 是否通过的规则——“所有 Bin 低于 5 才算良品且温度超过 85 度自动重测一次”——被写成两层 if 嵌在 UI 按钮的点击事件里。你可以想象后面的连锁反应测试工程师拿着新规则来找产品经理产品经理来找开发开发打开那个点击事件找到那两行魔法数字改完发现站点切换逻辑也依赖同一个字段又不敢动最后只好再加一个临时变量在另一个回调里做二次补偿。这个系统的代码量不大但每一行都在“按脚本执行”而不是“按业务运转”。问题出在哪出在业务对象没有所有权。Bin 的判定规则应该是 Bin 这个对象自己说了算而不是由 Service 在外部替它作主状态机的迁移应该由 TestProgram 内部维护而不是靠 UI 层对某个 int 字段赋值。当业务规则无处安放它们就会像垃圾一样散落在系统的各个角落而 DDD 要解决的正是这个问题把规则放回它该待的位置。2. 建模之前先泼冷水DDD 不是改造代码是重构业务认知2.1 用测试机场景说清 DDD 四件事专治“DDD 搞不懂”很多同事一听到 DDD 就说搞不懂其实 DDD 的核心概念在测试机领域里全都能找到对应物只是没人拿它当例子。第一件事是限界上下文。它的意思是同一个词在不同业务场景里可以有不同的含义不要强行用一个类覆盖所有场景。比如“TestName”在测试执行上下文里它是标识一份测试程序的 key在良率报表上下文里它是历史数据的维度字段。这两者对修改频率、关联对象、生命周期的要求完全不同。你非要把它们拼成一个实体结果就是谁都迁就谁谁都不舒服。换个说法测试执行和良率统计是两个世界别硬塞一个模型。第二件事是聚合与聚合根。聚合是一组必须保持一致的对象集合聚合根是这组对象的入口。在一个 TestProgram 里TestItem 集合、PinMap 配置、Bin 定义必须一起加载、一起保存否则可能出现“测试项改了定义 Bin 的文件还是旧版本”这种数据不一致。所以 TestProgram 是聚合根TestItem 是它管理的实体而单个 DUT 的测量值只是传进来的参数不是聚合内的孩子。第三件事是领域服务。有些动作不属于任何单个对象而是跨越多个聚合的流程。比如“计算一个 Lot 的最终 Bin 分布”它要读取所有 Site 的测量结果再调用 Bin 规则最后生成报表。这件事放在哪个对象里都别扭那就单独放一个服务它只负责编排不持有状态。第四件事是领域事件。测试执行过程中会发生“Site 完成”“测试项失败”“温度越限”这些事它们是过去发生的领域关心它们的后果。比如 TestItemFailed 这个事件分选领域听到它可能决定 rebin设备监控领域听到它可能触发降温逻辑。事件让各个上下文之间解耦通过消息互相协作而不是直接调用对象方法。如果你还是觉得抽象我给你一个生活类比限界上下文就是公司里的财务部和销售部虽然都叫“客户”但财务关心回款账期销售关心成交概率你不可能让两个部门共用一张客户表还各自满意。聚合根就是一台汽车的发动机总成你要发动引擎只需要拧钥匙而不用自己动手处理每一个活塞的运动。领域服务是“年度审计”它不属于财务部也不属于销售部但它需要协调两边的数据。领域事件是“订单已发货”的短信销售部不用时刻盯着物流车只要听到通知就可以更新状态了。2.2 上下文地图你的测试机系统该拆成几个“世界”真正的建模不是从代码开始的而是先和测试工程师、设备维护工程师、生产管理的人坐在一起把系统里到底有哪些“业务边界”画出来。我平时会画一张极简的上下文地图直接把上面说的三个子域定义成三个上下文上下文核心对象主要职责对外接口测试执行TestProgram、TestItem、Site、MeasureResult运行测试、回收结果、维护站点状态启动程序、上报测试项结果分选与良率Bin、Lot、Yield、HandlerPort判等、分选、良率统计接收 Bin 决定、产出 Lot 报告设备监控PowerSupply、Temperature、SelfTestReport健康监测、温度保护、自动重测上报越限事件、控制电源这张表的作用不是给你画漂亮的架构图而是让团队意识到测试执行和良率统计不应该共享同一个 TestResult 模型。执行上下文里的 TestResult 是瞬时的、高并发的、关注单项判定良率上下文里的良率数据是历史的、聚合的、关注长期趋势。这两个模型的更新频率差一个数量级硬放一起缓存和锁都很难做。2.3 防腐层别让厂商 SDK 的“方言”污染你的领域做测试机软件绕不开厂商 SDK。93K 有 C 的 APIV9300 有自己的接口封装这些 SDK 类型设计得再优雅也是厂商的语言不是你的领域语言。防腐层的意思是在基础设施层和领域层之间加一道翻译SDK 的类永远不允许出现在领域层里。我见过最脏的代码是领域对象里直接持有 SDK 的函数句柄Service 在业务逻辑中调用 SDK 具体类的方法。这么做短平快但领域模型就被厂商绑架了——今天你用 93K明天你想支持另一家机台SDK 类型像牛皮癣一样贴在业务代码上换一家就要重写一遍业务逻辑。防腐层就是为领域层提供一套属于自己的端口比如TesterDriverPort底层是 93K 还是模拟器领域层完全不关心。3. 手把手实操让 TestProgram 这个老实体真正“活”过来3.1 为什么是六边形架构而不是三层架构很多人把 DDD 和画实体关系图划等号但真正让模型“活”起来的关键在架构。三层架构的问题在依赖方向UI 层依赖业务层业务层依赖数据层写着写着领域逻辑就被数据库反向绑架Repository 里的表结构成了事实上的模型。六边形架构正好相反它把领域层放在最中间端口定义在边界上数据库、UI、机台 SDK 都只是外部的适配器。用六边形架构之后最大的好处是领域规则可以在不连机台的情况下跑单元测试。我有一个团队同事曾经很怀疑这一点直到他看到一个完整的 TestProgram 领域测试跑了三百多个用例、每秒能执行上百个场景之后他再也不抱怨 DDD 影响进度了。测试机的业务规则比如“程序必须是 READY 状态才能绑定 Site”“F 类 Bin 必须禁止放行”这些是纯内存的逻辑根本不需要硬件参与。把它们从 Service 里抠出来放进领域层测试速度和测试深度完全不是一个量级。依赖方向是大问题。六边形架构要求依赖从外向内——适配器依赖端口端口依赖领域接口领域层不依赖任何外部框架。这个方向一旦定死很多所谓的“快捷设计”自然就没了你不可能在领域层里写一个new JdbcTemplate()因为你根本没有这个依赖。3.2 项目骨架长什么样一个可以直接抄的目录结构我在实际项目里用的目录结构很简单没有过度设计直接按“领域/应用/适配/基础设施”四层切src/main/java ├── domain │ ├── testplan │ │ ├── TestProgram.java │ │ ├── TestProgramState.java │ │ ├── TestItem.java │ │ ├── BinDefinition.java │ │ ├── SiteId.java │ │ ├── ProgramId.java │ │ └── exceptions │ ├── yield │ │ ├── Bin.java │ │ ├── Lot.java │ │ └── YieldCalculator.java │ └── events │ ├── TestItemPassed.java │ ├── TestItemFailed.java │ └── ProgramStarted.java ├── application │ ├── TestExecutionAppService.java │ └── YieldReportAppService.java ├── adapter │ ├── in │ │ ├── rest │ │ ├── commandline │ │ └── message │ └── out │ ├── persistence │ └── testerdriver └── infrastructure ├── tester │ ├── K93DriverAdapter.java │ └── V9300DriverAdapter.java └── db ├── MyBatisTestProgramRepository.java └── OutboxSender.java注意基础设施层和适配器层的区别。适配器是实现端口的地方比如实现TesterDriverPort的 93K 适配器基础设施是支撑这些适配器运转的底层机制比如数据库连接、消息队列。很多团队把这两层混在一起结果就是适配器里到处是 JDBC 代码测试还是跑不起来。3.3 重构核心业务对象从 getter/setter 到有行为的状态机下面这个例子是我在项目里真正写过的简化版。拿 TestProgram 开刀因为它是最典型的聚合根状态多、规则多、动不动就有工程师往里塞逻辑。public class TestProgram { private final ProgramId id; private String name; private PinMap pinMap; private ListTestItem items new ArrayList(); private ProgramState state ProgramState.DRAFT; private int maxSites; private final ListDomainEvent domainEvents new ArrayList(); public TestProgram(ProgramId id, String name, PinMap pinMap, int maxSites) { this.id id; this.name name; this.pinMap pinMap; this.maxSites maxSites; } public void bindSite(SiteId siteId) { if (state ! ProgramState.READY) { throw new IllegalStateException(只有 READY 状态的程序才能绑定站点); } if (!pinMap.isSiteSupported(siteId)) { throw new SiteNotSupportedException(siteId); } this.state ProgramState.BOUND; addEvent(new ProgramBound(id, siteId)); } public void start() { if (state ! ProgramState.BOUND) { throw new IllegalStateException(程序必须先绑定站点再启动); } this.state ProgramState.RUNNING; addEvent(new ProgramStarted(id)); } public void reportItemResult(SiteId siteId, TestItemId itemId, MeasuredValue value) { TestItem item findItem(itemId); TestResult result item.evaluate(value); if (result.isFail()) { addEvent(new TestItemFailed(id, siteId, itemId, result)); } else { addEvent(new TestItemPassed(id, siteId, itemId, result)); } } public boolean isBatchQualified() { return items.stream().noneMatch(TestItem::isHardFail); } private void addEvent(DomainEvent event) { domainEvents.add(event); } public ListDomainEvent getDomainEvents() { return List.copyOf(domainEvents); } }对比一下从前的写法区别很明显。旧代码里bindSite是个 Service 方法入参是 TestProgram 和 siteId中间写一堆 if 判断检查失败就在界面弹一个提示框新代码里这些规则全部收归对象自身检查失败直接抛异常后续由应用服务的异常处理器统一转成错误响应。领域对象自己知道自己能干什么、不能干什么这就算“活”了。还有一个极易被忽略的点聚合根必须维护事件列表。getDomainEvents()返回的是只读副本应用服务在save之后拿到这些事件统一发送。这样可以做到事件发送和状态落库在同一事务边界内避免“库写进去了事件没发出去”的尴尬。TestItem 的设计也是同理。它应该是不可变的值对象只负责求值不持有外部状态public class TestItem { private final TestItemId itemId; private final String name; private final MeasurementSpec spec; private final boolean hardFail; public TestItem(TestItemId itemId, String name, MeasurementSpec spec, boolean hardFail) { this.itemId itemId; this.name name; this.spec spec; this.hardFail hardFail; } public TestResult evaluate(MeasuredValue value) { return spec.evaluate(value); } public boolean isHardFail() { return hardFail; } }你可能已经注意到我在领域对象里没用 Lombok 的Data也拒绝给字段写无脑 setter。值对象一旦创建就不可变这是 DDD 的一条铁律应该有值语义的对象就别给它们身份证和户口本。3.4 应用服务和端口适配器把领域层“干净”地接到真实机台领域模型再活泼也需要一个瘦小的应用层来做编排和事务控制。下面是一个标准的应用服务写法Service Transactional public class TestExecutionAppService { private final TestProgramRepository repository; private final TesterDriverPort testerDriver; public void launchProgram(ProgramId programId, ListSiteId sites) { TestProgram program repository.find(programId); for (SiteId siteId : sites) { program.bindSite(siteId); } program.start(); repository.save(program); testerDriver.sendProgram(programId); } }应用服务只做三件事找对象、调方法、落库。它不承载业务规则规则都在 TestProgram 里。落库之后由领域事件驱动后续动作比如通知分选上下文开始 bin 判定。对外接机台这件事我用端口接口隔离public interface TesterDriverPort { void sendProgram(ProgramId id); SiteResult fetchSiteResult(SiteId siteId); }具体实现比如接 93K 的适配器只负责翻译 SDK 调用不做任何业务判断Component public class K93DriverAdapter implements TesterDriverPort { private final K93NativeApi api; Override public void sendProgram(ProgramId id) { K93ProgramHandle handle api.loadProgram(id.toString()); api.setSiteCount(handle, 128); api.execute(handle); } Override public SiteResult fetchSiteResult(SiteId siteId) { // 从厂商 SDK 读取数据翻译成领域层的 SiteResult return new SiteResult(siteId, api.getResult(siteId.toString())); } }看到没有适配器里全是 SDK 翻译没有“if (site 1)”这种业务逻辑。将来哪怕把 93K 换成模拟器、V9300 或者自研测试板领域层和应用层完全不需要动。这套东西的收益不是立竿见影的而是半年后当你真的需要支持第二家机台时你会发现原来那些担心全是多余的。4. 落地的坑与排查技巧实录4.1 贫血模型泛滥模型还是“死”的问题出在哪代码按 DDD 的目录结构写了领域对象却还是贫血的这是最常见的问题。我见过太多团队聚合根类里清一色的 getter/setter行为全留在 Service。为什么会这样第一个原因是 ORM 习惯。以前做三层架构实体类是由数据库表映射出来的字段和表列一一对应行为根本放不进去。到了 DDD 项目里很多同事还是从 DOData Object复制一份出来当领域对象结果自然长着 DO 的脸。第二个原因是序列化习惯。JSON 反序列化框架通常需要无参构造和 setter为了接口传输方便就把领域对象当成 DTO 用。于是领域对象肩膀上扛了“数据载体”的职责行为越来越薄。正确的做法是入参 DTO、出参 DTO、持久化数据都各自定义别让领域对象去迁就反序列化框架。哪怕多写几个转换类也值得。第三个原因是懒。给领域对象加行为意味着要想清楚状态一致性要写异常分支要写测试。而把逻辑放在 Service 里写起来确实快。但这里省的时间后面维护的时候十倍的赔回去。我经常跟团队说一句话你在 Model 里加一个isBatchQualified()这样的方法和使用它的 Service 之间隔着的是一次“规则还能不能被人看见”的斗争。4.2 聚合划分的经典错误把 DUT 当聚合根内存和锁一起失控新手建模时最容易把 DUT 当成聚合根理由是“每个 DUT 都是独立测试对象”。但在真实机台里128 个 Site 同时并行测试每个 DUT 的生命周期极短几秒一轮一轮结束就重置。如果把 DUT 当聚合根内存里同时存在成千上万个对象还都要做并发控制和状态追踪性能直接崩盘。正确做法是把 TestProgram 作为聚合根DUT 只是它执行时传入的一个参数。每个 Site 的实测结果属于瞬态数据不应该塞进聚合内部长期持有。结果数据应该是一个独立的聚合比如SiteRunResult它有自己的生命周期按批次批量落库。这样好处很明显TestProgram 聚合内只维护程序配置和当前状态无论多少个 DUT 在跑模型对象数量都是恒定的。你还可以把 DUT 相关的测量值与 bin 判定看成事件流结果来了发个事件统计侧自己聚合。这样分选上下文里的良率数据可以在自己的上下文里按自己的节奏更新不用跟着测试执行的高频节奏起舞。4.3 事件一致性、重试与幂等在实时机台面前做加法还是减法DDD 落地到实时性要求高的场景很多人会担心领域事件带来的性能损失。这个担心是合理的但解法不是放弃 DDD而是做取舍。先说一致性。用 Outbox 模式即领域事件先和业务数据一起写入本地数据库的 outbox 表再由一个独立发送器把 outbox 表里的记录逐步发到消息中间件。这样避免了“数据库写了、事件没发”的不一致也避免了分布式事务的复杂度。落地时候有个细节发送器必须做幂等因为消息可能重复投递。我通常会给每个事件带一个全局唯一的eventId消费方根据eventId做去重。再说性能。测试机一轮跑完128 个 Site 每个 Site 有几百个 item一瞬间产生上万个TestItemPassed/TestItemFailed事件。如果你让领域层逐条 post 事件到消息队列消息中间件直接被打懵。我的做法是领域聚合内先把事件收集到列表里应用服务按批次聚合比如合并成SiteTestCompleted这类粗粒度事件一次只发几个。这不是违背 DDD而是事件设计本身就该粗细结合。高频明细事件在领域内消化低频汇总事件对外广播这才是测试机现场需要的节奏。4.4 团队落地 DDD 的三条实用建议第一开事件风暴工作坊时别再叫一堆程序员空对空画图。我一般会请测试工程、生产管理、设备维护的同事一起参加带一叠便利贴先请他们讲“你日常最烦的一次测试异常处理流程”。场景故事出来之后领域事件和命令自然浮现。程序员负责记录和提问不要抢着给答案。第二第一个改造的子域别选“核心中的核心”。很多人一上来就重构测试执行那是自找麻烦。建议挑分选与良率子域它逻辑清晰、边界稳定、还经常被业务要求折腾做出来后能马上见到模型变化带来的灵活性红利团队信心一下就起来了。第三定期做“模型腐化检查”。每次代码评审我都会扫这几个味道看到switch (siteStatus)在领域层出现、看到聚合根的 getter 被外部频繁 set、看到代码里冒出if (status 2)这种魔法数立刻记一笔攒够了就约一场建模复盘。模型不是一次性画出来的是养出来的不腐化它它就会悄悄腐烂。5. 从代码洁癖到模型洁癖日常开发的检查清单5.1 我用来审查代码的 DDD 自检清单如果你也有洁癖以下几个问题是我每次提交代码前都会问自己的分享出来可以直接当成评审 checklist这个业务规则放在哪里了如果答案是“Service 里”立刻停下重新考虑要不要放进领域模型。聚合根之外有人能直接修改聚合内部集合吗如果领域模型对外暴露了可变的 List这就是个坏味道。某个操作是跨实体的流程吗如果是它是领域服务还是应用服务如果是应用服务它是否只是在做编排和事务我依赖的是端口接口还是具体实现领域层有没有出现厂商 SDK 的类型事件发出之后消费者能幂等处理吗有没有带全局唯一事件 ID数据库表结构变了领域模型需要跟着变吗如果答案是“需要”说明防腐层没做干净。这些问题是用来逼自己思考的不是用来罚款的。真的发现违规改就是了没必要搞什么“DDD 警察”文化那样只会让团队反感。5.2 反模式速查看到这些味道就该回建模会议反模式表现后果正确方向万能 TestResult所有上下文共用一个大类字段臃肿、改动互相牵连按上下文拆结果模型魔法数判状态if (status 2)满天飞规则不可见、改错位置状态机放进聚合根setter 泛滥领域对象全是 mutable任何地方都能改数据、一致性崩溃值对象不可变、状态迁移走方法SDK 渗透领域层直接调用厂商 API被厂商绑架、不可测试端口接口加防腐层Service 胖死业务规则全在 Service模型空心、测试困难行为下沉到领域层这张表不是纸上谈兵每一条都是我或者团队同事真实踩过的。尤其是“万能 TestResult”那条我们拆字段那几天虽然痛苦但拆完之后良率上下文再也不用跟着测试执行上下文熬夜改代码了那种痛快你试一次就懂。5.3 一个真实的迭代时间线从乱炖到领域模型的两个月去年我带着一个小组对旧的分选模块做了一次 DDD 改造时间线大概是这样第一周先没有写代码。我们和测试工程师、产线人员一起做了三轮事件风暴把“Bin 判定”“ReBin 流程”“Lot 放行”这几个核心流程的规则写满了三面白板。期间最大的收获是发现了一条被所有人忽略的业务规则Bin 7 的重测次数上限不是 3而是第一次失败后直接进入 Bin 10。这条规则以前散落在很多人的脑子里代码里压根没有。第二周到第五周我们重构了 Bin 和 Lot 两个聚合根把 Bin 判定逻辑封装成策略对象把 Lot 的放行决策改成由聚合根方法表达。这期间应用服务薄得让人不习惯但好处是测试突然变得好写了。第六周开始我们用了 Outbox 模式接领域事件让分选动作和良率事件分两路走。第八周支持了第二种机台的回传格式只需要新增一个适配器业务层一行没改。这个例子不是要证明我们有多厉害而是想告诉你DDD 带来的不是好看的目录结构而是当业务方第二次拿着新规则来找你时你不再需要满世界找那两行 if 在哪了。我在实际项目里最深的体会是DDD 的难点从来不在技术而在于你愿不愿意把业务规则一句一句从资深工程师嘴里问出来再一句一句安放到正确的模型里。代码洁癖走到最后本质上就是“模型洁癖”。如果你现在正被几千行的 Service 折磨别急着整体重写挑一个最让你头疼的业务对象从封装它的第一个行为开始。过程可能有点慢但当你看到那个对象终于能自己把 Bin 判定、状态流转、事件发布安排得明明白白的时候你会觉得这一切都值了。