ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于DDD的API接口优雅重构实践:从上帝接口到业务语言

基于DDD的API接口优雅重构实践:从上帝接口到业务语言 重构API这件事我干了快十年见过太多服务从两三个接口膨胀到一两百个最后变成谁都不敢改、改了必出事故的雷区。去年帮一个创业团队做技术评审他们有一个下单接口参数是一坨三百多行的JSON里面既有用户信息、商品列表又有优惠券、配送方式和发票抬头调用方为了凑齐这个对象得先连续请求五个查询接口。项目上线两年接口一百多个但没有一个接口能独立说清楚它到底代表一次什么业务动作。这种局面不是靠多写文档、多点监控就能救回来的。我当时给的建议是暂停新增功能用领域驱动设计的视角把API按业务能力重新拆一遍、重新塑形一次。这篇文章就是把这套基于DDD的API接口优雅重构实践完整复盘出来适合那些正在被复杂单体接口或散乱微服务折磨的后端团队参考。1. 重构之前先给现有API做一次痛感体检很多团队一谈重构就想直接动手改这是最大的误区。API重构本质上是对业务认知的一次重构如果连现在的接口到底痛在哪、为什么痛都没盘清楚改完大概率是换一种方式继续乱。所以我每次进场的第一步都是给现有API做一次系统性体检把病态接口一个一个揪出来。1.1 我见过的五个病态接口典型症状第一个症状是贫血模型。实体类基本退化成数据库表的投影只有getter和setter业务逻辑全堆在Service层里。表现出来就是接口返回的全是数据结构没有任何业务规则的味道。前端拿到的不是一个订单而是一堆字段拼起来的对象业务规则散落在Controller、Service甚至前端JS里改一个校验逻辑要动好几个地方。第二个症状是上帝接口。一个查询接口返回一百多个字段调用方每次只取其中五个剩下的数据白白浪费带宽和序列化时间。这种接口往往是因为最初图省事把多个查询场景的需求合并到一个大接口里后续所有新需求都往里塞字段。第三个症状是CRUD打天下。整个系统的接口看起来只有save、update、delete、list四种形状业务动作被压扁成数据操作。下单是POST /order/save提交退款申请也是POST /order/save只是内部用type字段区分加上一个typerefund_apply。这种接口短期开发快但业务语义被完全抹掉了。第四个症状是状态码失语。明明HTTP返回的是200响应体里code5001代表业务失败调用方要解析两层才能判断成败。更麻烦的是不同的接口团队对code的语义理解还不一致这个服务里code2003是库存不足另一个服务里code2003变成参数错误。第五个症状是服务间的数据库式调用。服务A需要订单数据不去调用服务A的定义好的查询接口而是直接批量拉取订单接口的原始数据在本地做过滤和join。这种调用模式会把接口性能拖垮还会让接口签名被调用方的临时需求绑架。1.2 用调用关系图圈出高优先级重构点诊断不能只靠感觉要有数据支撑。我的做法是从网关日志或者链路追踪系统里把近一个月的API调用记录拉出来统计出每个接口的调用方、调用频率、平均耗时、失败率以及响应体大小。然后重点标出几类异常调用量极高但每次都只用一两个字段的接口失败率波动明显且错误码集中在业务层的接口还有那些明显是为了支持另一个服务的内部逻辑而设计的接口。画调用关系图这步非常关键。把服务名画成节点接口调用画成连接边你会很快发现一些奇怪结构比如一个底层服务被十几个上层服务直接调用形成典型的上帝服务星型结构或者两个服务之间存在循环调用A调B的接口B又调A的接口这种结构基本可以断定业务边界切错了。把这些问题点汇总成痛感Top 10清单这就是重构的切入点。注意不是痛感最严重的优先而是改动范围可控、业务价值高的优先否则第一刀容易切到自己。1.3 越改越乱的根源业务语言没有统一体检做到最后你会发现一个更根本的问题大家的业务词汇表根本没有对齐。同一个客户在订单团队眼里是下单人在账户团队眼里是账号主体在风控团队眼里是风险实体。同一份订单数据交易上下文关注的是金额和状态流转物流上下文关注的是收货地址和包裹拆分财务上下文关注的是发票和结算周期。领域驱动设计里有个概念叫统一语言意思是团队内部、团队之间、系统之间对业务术语要有唯一、无歧义的定义。API层的混乱本质上是业务语言失焦的投影。接口命名一会叫createOrder一会叫submitOrder一会叫placeOrder调用方根本分不清区别。所以在重构任何代码之前先花时间把名词表、动词表理清楚这是DDD重构的第一块基石。2. 限界上下文把按功能分组的API重切成按业务能力划分体检完接下来是DDD重构中最核心也最容易被做错的一步确定限界上下文。你去看很多失败的DDD项目问题几乎都出在这——上下文切成了一堆听起来很高大上但没有实际业务边界的领域接口重切了却没有本质变化。真正的边界应该是从业务事件和业务语言里长出来的它不是拍脑袋切出来的。2.1 事件风暴与术语分析边界不是拍脑袋切出来的我通常用事件风暴来启动边界识别。准备一面足够大的墙把业务相关方都叫上——产品、运营、技术、客服最好都来。然后按时间顺序贴便签橙色表示命令用户发起的行为黄色表示事件系统内已经发生的事实蓝色表示读模型界面要展示的数据粉色表示外部依赖绿色表示业务规则。当整个业务过程被贴出来之后有一个现象一定会出现同一个名词在不同位置的定义开始打架。比如订单已创建这个事件订单团队认为订单创建后库存就锁定物流团队认为订单创建只是生成了一条记录拍照的时候物流还没开始。这种分歧点就是天然的业务边界。两边对订单这个词的语义、生命周期理解不一致它们就应该属于不同的限界上下文各自维护各自的订单模型。术语分析法也能辅助验证。把系统里所有业务词汇列出来逐个问三个问题这个词在所有场景里的含义是否一致不同场景对同一个词的操作是否冲突不同的词是否有可能是同一个概念只要有一个问题答案是否定的你就找到了至少两个上下文。2.2 康威定律对API分组的意义伊恩·康威在1968年有一个经典论断设计系统的组织其沟通结构会镜像地反映在系统架构上。放到API设计里就是说API的边界划分应该跟着组织结构和团队协作关系走。如果你的团队已经按业务线拆成了交易组、支付组、履约组那API就应该按这些业务能力来分组而不是强行按技术分层controller、service、dao来组织。我见过一个反例公司组织上已经有独立的支付团队但支付API还是挂在订单服务的路径下支付团队要改接口必须和订单团队排期结果每次改支付回调都要跨团队扯皮。后来把支付上下文独立出来API路径改成/payment/团队边界和接口边界对齐效率立刻上来了。这里想强调一点康威定律不是玄学它是在提醒你API结构设计如果不尊重组织沟通结构最后一定会被组织沟通结构反噬。2.3 一个订单服务切成五个上下文的完整过程拿最常见的订单场景举例。假设现在有一个万能的订单服务接口包括创建订单、修改订单、购物车、库存预占、支付回调、物流单生成、发票申请。第一步拉出这个系统里的主要业务事件购物车已更新、订单已提交、库存已锁定、支付成功、订单已发货、发票已开具。第二步看这些事件分别围绕什么业务对象在转哪些事件之间的关系是高内聚的哪些是低频的。以我的经验这种场景通常能切出这几个上下文购物车上下文关心选品、数量、价格试算不关心库存能不能扣掉。交易上下文关心订单的创建、状态流转、优惠计算是订单这个业务对象的法定拥有者。支付上下文关心支付单、支付回调、对账它的订单只是支付单上的关联信息。库存上下文关心库存的预占、释放、扣减根本不关心订单的详细商品信息。履约上下文关心出库、物流单、签收它需要的只是订单的履约信息。你会发现同一个订单数据被五个团队各自维护了一份带不同属性的版本。这不是数据冗余而是不同上下文对订单的不同关注点。交易上下文里的订单有订单项、金额、优惠履约上下文里的订单可能只需要订单号和收货地址。它们是不同的模型不应该共享同一个数据库表更不应该共享同一个大而全的API资源。2.4 重切后的接口清单长什么样边界切完之后接口清单要按业务命令和业务事件重新设计。这里有一个很实用的判断标准接口名应该是动词业务对象而且动词要来自业务语言不是技术语言。举个例子。重构前是这种POST /order/save POST /order/update POST /order/delete POST /order/list重构后变成POST /trading/orders 提交订单 GET /trading/orders/{id} 查询订单详情交易视角 DELETE /trading/orders/{id} 取消订单未支付状态 POST /payments 创建支付单 POST /payments/{id}/callback 支付回调 POST /inventory/reservations 创建库存预占单 POST /fulfillments/orders 创建履约单注意同样是DELETE /trading/orders/{id}它不是简单删除一条数据而是执行取消订单这个业务动作内部要校验订单状态是否允许取消、计算退款金额、触发取消通知事件。这才是API表达业务意图的正确方式。当你看到接口名把业务动作讲清楚了调用方的代码就不再是一堆数据搬运而是一段看得懂的业务故事。3. 战术设计落地聚合、防腐层与API资源模型对齐边界划好了接下来是战术设计也就是在代码层面把DDD的聚合、仓库、应用服务、领域服务这些元件真正落地。很多团队折在这一步因为他们把DDD做成了一堆Service加一个Repository就叫DDD实际上战术设计对API设计的影响远不止分层它直接决定了你的API资源模型长什么样。3.1 聚合根不是数据库表API资源模型跟着聚合走聚合是DDD中最容易被误读的概念。简单来说聚合是一组必须保持数据一致性的业务对象集合聚合根是外部访问这个集合的唯一入口。聚合边界就是事务边界聚合内部要么全部成功要么全部回滚。这给API设计的启示是接口暴露的资源应该对应聚合根而不是数据库表。还是拿订单举例一个订单聚合通常包含订单主记录、订单项、收货地址。如果按照数据库表设计API你会做出订单项的独立增删改查接口调用方为了改一个订单要调五六个接口中途任何一个失败数据就处于残缺状态这就是碎片化API的来源。重构之后API只暴露订单聚合根级别的操作RestController RequestMapping(/trading/orders) public class TradingOrderController { private final OrderApplicationService orderAppService; PostMapping public PlaceOrderResponse placeOrder(RequestBody PlaceOrderCommand command) { return orderAppService.placeOrder(command); } PostMapping(/{orderId}/line-items) public void addLineItem(PathVariable String orderId, RequestBody AddLineItemCommand command) { orderAppService.addLineItem(orderId, command); } }调用方要么通过下单一次完成要么通过向聚合添加商品项的方式操作但每一步都经过聚合根校验保证订单的不变式。判断子资源该不该有独立接口有个简单的标准这个东西离开聚合根还有没有独立业务含义订单项单独存在没有任何意义它永远属于某个订单所以不该有独立的POST /order-items接口。3.2 应用服务、领域服务、基础设施服务的三层边界战术设计的经典分层是接口层、应用层、领域层、基础设施层。这也是我见过被破坏得最多的部分。一个典型的重构前贫血Service长这样public class OrderService { public void saveOrder(OrderDTO dto) { if (dto.getStockCount() 100) { throw new BizException(商品限购); } // 五十行业务规则 // 手动写SQL直接操作数据库 // 再调支付接口 } }重构后正确的做法是应用服务只做用例编排领域服务表达跨聚合的业务规则聚合内的方法保证业务不变式基础设施层负责真正的持久化和技术细节。应用服务这部分是API的直接入口它接收Command对象不接收领域实体更不直接操作数据库。public class OrderApplicationService { private final OrderRepository orderRepository; private final InventoryClient inventoryClient; private final PaymentClient paymentClient; Transactional public PlaceOrderResponse placeOrder(PlaceOrderCommand command) { OrderId orderId orderRepository.nextId(); Order order Order.create(orderId, command.getCustomerId(), command.getLines(), command.getShippingAddress()); orderRepository.save(order); inventoryClient.reserve(orderId, command.getInventoryReservations()); return PlaceOrderResponse.from(order); } }领域层里Order聚合有自己的create方法里面执行限购校验、金额计算、状态初始化这些规则封装在聚合内部不泄漏到Service里。你可能会发现应用服务还是有点薄但它不需要再写业务规则了这就是充血模型和贫血模型的本质区别。接口层看到的东西始终是Command和Response DTO领域对象永远不直接暴露给外部。3.3 防腐层隔离第三方API对领域模型的语义污染业务系统天天要对接外部API第三方支付、短信服务商、大模型开放平台、设备管理服务商、行情数据服务这些五花八门的外部系统都有自己的数据模型和命名习惯。如果让外部模型直接穿透进你的领域层领域模型很快会被外部语义污染这也是很多系统重构后仍然别扭的原因之一。DDD给出的方案是防腐层。防腐层位于领域层和外部系统之间外部API的调用被封装在一个Adapter里领域层只依赖自己定义的端口Port不依赖任何外部SDK。// 定义在领域层 public interface PaymentGatewayPort { PaymentResult capture(PaymentIntent intent); RefundResult refund(RefundRequest request); } // 实现在基础设施层 Service public class PaymentGatewayAdapter implements PaymentGatewayPort { private final ExternalPaymentClient client; Override public PaymentResult capture(PaymentIntent intent) { ExternalPaymentRequest req toExternalRequest(intent); ExternalPaymentResponse resp client.capture(req); return toDomainResult(resp); } private ExternalPaymentRequest toExternalRequest(PaymentIntent intent) { // 领域对象转外部DTO做名称、字段、单位的转换 } }这样做的价值体现在几处第一领域层对外部系统的存在一无所知业务代码里不会出现第三方SDK的类型和方法调用第二外部系统升级、换供应商时只需要改Adapter里的一小段代码第三领域模型的语义保持纯净外部API里那些跟业务无关的字段、奇怪的命名习惯、不一致的单位换算全被挡在防腐层外面。如果你的API要被大量下游系统消费也把你自己理解成开放主机服务对外提供一份稳定的发布语言也就是公开DTO的无歧义契约而不是直接暴露内部领域对象。3.4 写命令与读查询分离DTO放在哪里才不出圈我特别建议在API重构时引入命令查询分离的思路。读操作和写操作在业务复杂度、性能优化方向上有本质差异写操作关心数据一致性、状态流转读操作关心响应速度、字段裁剪。把两者混在同一个接口模型里往往导致谁都满足不好。重构后写接口接收的是命令对象。命令对象表达用户意图字段一般是不可变的配有业务语义的校验逻辑。读接口返回的是查询Result按视图需求裁剪字段不直接返回领域实体。这里有一个重要的规范Command 和 QueryResult之间的DTO转换只能在应用服务层和接口层进行不允许把领域实体、领域服务方法暴露给Controller层。我还遇到过一种情况团队把QueryResult设计得跟数据库表字段一样命名直接叫order_entity这等于把领域模型泄漏出去了。正确做法是按调用方的使用场景来定义响应结构比如订单详情接口返回OrderDetailView里面包含订单基础信息和订单项列表但不会出现status_code这种技术字段。DTO的命名也要像业务语言一样说话不是一个笼统的ResponseBean。4. 真正让重构优雅的脏活兼容、版本、事务与验证说句实话DDD的建模和分层很多书上都写了但真正决定一个重构项目生死的是另外一堆脏活老接口怎么退场、新老版本怎么共存、跨服务的数据一致性怎么保证、重构后怎么证明没把业务改坏。这些步骤不做再漂亮的模型都只是PPT。4.1 老接口的退场仪式适配映射与灰度迁移不要追求大爆炸式重构也就是不要在某个凌晨把所有接口一次性切到新系统。业界成熟的模式是绞杀者模式让新API和旧API在一段时间内共存旧API的流量逐步被新API蚕食最后旧系统像被绞杀藤缠住的树一样自然枯萎。具体做法在网关层或者Controller适配层把旧请求映射到新的应用服务方法。比如旧接口POST /order/save入参结构很乱可以在适配器里把它转换成PlaceOrderCommand再调用新链路。这个适配器就是临时的等所有调用方切到新接口后再整体删除。注意删接口要定一个明确的退场日期不能无限期拖下去。我见过很多团队说先留着适配层看看情况结果适配层一留就是三年成了新的历史包袱。灰度迁移可以按调用方划分先把对业务影响最小的内部调用方切过去跑一两个迭代没有问题再切外部重要客户最后切剩余的零散流量。有条件的话可以在核心接口上做金丝雀发布让少量线上流量先走新链路比较错误率和耗时稳定后再放全量。这些操作分开看都不难难的是坚持执行。4.2 版本策略别在一开始就搞成贵族式设计新老接口共存期版本策略一定会被摆上台面。常见三种URL版本、Header版本、媒体类型版本各有各的适用场景。URL版本最简单。POST /v2/trading/orders和POST /v1/order/save一眼就能看出来调的是哪个版本日志排查、网关路由、客户端升级都很直接。缺点是版本的语义混进了资源路径里严格来说不够RESTful但对绝大多数业务团队来说这是性价比最高的选择。Header版本的做法是URL保持不变在请求Header里传Accept: application/vnd.trading.apijson;version2。这样URL干净但问题也明显调试工具里要额外设置Header网关日志要打印才能定位问题调用方也很容易忘记传版本。我一般不推荐团队在第一版重构时使用。媒体类型版本是RESTful规范的推荐方式本质上和Header版本一脉相承但需要客户端和服务端对媒体类型解析有非常严格的约定。如果你的API只面向自己团队的几个内部服务搞这套属于过度设计。我的结论很简单内部系统用URL版本公共服务、大量外部SaaS客户优先考虑媒体类型版本。版本号从2开始不要从1.1、1.2这种小版本开始否则会陷入无穷无尽的版本泥潭。4.3 聚合边界给分布式事务出的减法题微服务化之后原来单体应用里的一个事务往往被拆到多个服务里分布式事务成为重构路上绕不开的坎。很多团队第一反应是引入分布式事务框架但这往往是灾难的开始。DDD分析出的聚合边界其实就已经告诉你事务一致性边界应该怎么画了。聚合内部需要强一致用本地事务就够了跨聚合的协作默认应该走最终一致性。比如下单流程订单聚合和库存聚合如果不在同一个服务里不要试图用一个分布式事务锁住两边。正确的做法是订单服务在本地事务里创建订单并发一个订单已提交事件库存服务订阅这个事件后执行库存预占预占失败再通过补偿动作取消订单。整个过程有短暂的不一致窗口但通过事件和补偿机制最终达到一致。我这里想讲一次真实的教训。之前带团队重构一个下单链路因为感觉分布式事务很高级就引入了全局事务框架结果每次下单都要锁库存表高并发下单时大量请求等待锁P99从80毫秒涨到2秒多还时不时出现悬空事务导致数据对不上。后来我们重新看业务发现下单和预占库存这两个动作其实是强关联的就把它们放进了同一个服务、同一个本地事务里支付走完全最终一致。复杂度骤降性能问题也消失了。所以能用本地事务解决的不要引入分布式事务能靠事件Saga补偿解决的不要硬上强一致方案。重构不是炫技是在满足业务正确性的前提下找最简单的路径。4.4 契约测试与影子流量把重构风险关进笼子重构最大的不确定性不是代码写不出来而是改了之后有没有悄悄破坏调用方的假设。比如订单详情接口原来返回的total字段是商品总额重构后语义变成了含税总额调用方没发现财务对账就全错了。为了锁住这类风险推荐两个很实用的手段。第一是消费者驱动契约测试。让每个下游消费者把自己对接口的期望写成契约每次构建时服务提供方都要运行一遍这些契约测试一旦破坏了任何一个消费者的约定CI直接报红。相当于把不能破坏调用方从口头约定变成了自动化的质量红线。契约测试的粒度要比接口联调测试细很多它关注的不是业务流程跑不跑得通而是具体的字段、类型、枚举值、状态码这些细节。第二是影子流量回放。把线上真实请求记录下来同时打到旧接口和新接口上比对两者的响应。这里要注意不是所有接口都能简单做响应body的字符串比对更有效的是做字段级的语义比对。比如订单状态字段旧接口返回1表示已支付新接口返回paid如果业务含义等价也算通过。影子流量能在你正式上线之前就发现大量隐藏的不兼容问题。再加上常规的新旧接口并存期对账每天跑定时任务用新老两条链路处理同一批数据比对结果是否一致。这些验证手段本质上是把重构的拍脑袋变成可证伪没有这些手段我对重构项目是否成功始终是存疑的。我个人的体会是所谓发散创新的优雅重构从来不是靠某个灵光一现的天才设计而是靠一套能把整个业务语言、边界、一致性、兼容性都梳理清楚的方法论。领域驱动设计给你的不是一堆时髦概念而是一个逼着你把话说清楚、把边界划清楚、把后果想清楚的思维框架。重构完之后再去回看那些老接口你会明白真正让API变得优雅的不是框架多新、命名多酷而是每一个接口都开始像一句能被人读懂的业务语言。
返回列表