ARTICLE DETAIL

资讯详情

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

微服务越拆越乱?六边形架构与DDD划清业务边界

微服务越拆越乱?六边形架构与DDD划清业务边界 做过微服务的人应该都有同感系统拆着拆着代码反而比以前更乱了。服务边界模糊、业务逻辑散落在Controller里、换一个数据库驱动要动半个系统——这些问题单靠微服务本身根本解决不了。我自己的实践结论是微服务能不能做到高可维护关键不在注册中心选型、不在网关怎么配而在每个服务内部的架构边界划得够不够清楚。六边形架构Hexagonal Architecture也叫端口-适配器模式是我目前用下来最顺手的一套边界方案它跟DDD领域驱动设计配合起来能给微服务系好最容易松掉的那几颗扣子。这篇文章我会把六边形架构拆开揉碎结合一次订单系统的重构案例讲清它跟DDD如何配合、端口怎么定义、适配器怎么实现、微服务之间的通信和事务怎么落地最后把我踩过的坑也一并交代清楚。适合正在做微服务拆分、或者觉得服务内部代码越写越乱的同学参考。1. 微服务的痛点为什么单靠框架解决不了1.1 不是拆分的问题是边界的问题微服务拆分的初衷是把大系统切成小块让每个团队能独立开发、独立部署。但我在实际项目里观察到一个很普遍的现象服务拆完之后代码量并没有变少只是从“一个混乱的大泥球”变成了“几个各自混乱的小泥球”。举个我见过很多次的场景。一个订单服务Controller里直接写了查数据库的逻辑Mapper接口塞在Web层业务方法里既处理订单状态流转又拼短信模板还调用下游支付接口。乍一看功能都能跑但接需求的时候就非常痛苦要改一个优惠券的计算规则你得分清哪些逻辑在Service里、哪些在仓储实现里、哪些又被Controller不知道什么时候塞了一段写完代码想做个单元测试发现依赖了数据库连接、Redis连接和一堆外部RPC压根 mock 不动。如果你拆微服务只是想“把大文件变小文件”那么拆到最后每个服务内部依然是一团乱麻。康威定律说得直白——系统架构会复制组织的沟通结构但前提是你得先有一个明确的架构骨架让组织去对齐。光靠框架约束边界是不够的真正的问题出在业务逻辑与技术组件之间的依赖方向没有理顺。1.2 六边形架构的本质把技术细节挡在门外六边形架构最早是Alistair Cockburn提出的它解决的正是上面这个问题。核心思想一句话可以概括业务逻辑位于系统中心通过“端口”对外暴露能力通过“适配器”接入外部技术实现依赖方向永远从外向内。为什么叫“六边形”实际上这个六边形不是指一定要六个端口而是用形状表达“系统与外部世界之间有多个接入点”这一事实。每个接入点就是端口Port端口的实现就是适配器Adapter。你接HTTP是一种适配器接MQ是一种适配器接数据库也是一种适配器。它们之间互相独立替换任何一方都不会动到业务内核。听上去有点像传统分层架构差别其实非常关键。在普通的三层架构里数据访问层是被Service层直接依赖的数据库的变化会穿透业务代码而在六边形架构里数据访问能力被封装成业务侧的端口数据库驱动只是这个端口的一个适配器实现。换句话说不是系统去适配技术而是技术来适配系统。这套思路落到微服务里价值会被放大——每个微服务本身就是一个独立的六边形服务内部的领域模型自成一个内核不依赖Spring容器、不依赖数据库、不依赖任何外部框架。正因为如此你会发现自己服务的核心逻辑单元测试好写得多技术升级也从容得多。2. 端口、适配器与依赖方向如何在代码里落地2.1 端口是“业务契约”不是“技术接口”在六边形架构里最容易犯的错误是把端口直接理解成Java的interface然后在里面定义一堆CRUD方法。我在早期实践的时候就干过这种事结果换汤不换药数据库DAO改了个实现领域层照样跟着改。端口与普通接口最大的区别在于它站在业务侧说话。比如OrderRepository这个端口不应该定义findById、save、delete这类跟数据库操作一一对应的方法而应该定义领域语义比如findPendingOrder()、saveOrderPaymentResult()。前者暴露的是存储细节后者表达的是业务意图。适配器内部可以调用JPA、MyBatis、或者干脆发一个RPC去另一个服务取数这些对内核完全透明。同样的道理入站端口也应该面向用例来命名。比如PlaceOrderUseCase而不是OrderController。Controller只是HTTP协议的适配器它把请求参数转换成领域对象调用PlaceOrderUseCase然后把返回值翻译成HTTP响应。这样做的直接收益是换掉Web框架、改REST为gRPC、或者加一个MQ监听入口业务内核一行都不用动。2.2 依赖反转规则箭头必须指向领域层六边形架构能不能成立核心看依赖方向。规则倒是不复杂适配器依赖端口端口依赖领域领域层不依赖任何外部组件。这么说可能还是抽象我用一个实际中的例子说明。假设订单服务要调用户服务的接口拿用户等级。传统写法是OrderService直接注入一个UserServiceFeignClient然后在业务代码里调getUserLevel。六边形怎么写在领域层定义一个UserProfilePort里面只有一个方法getUserLevelByUserId()然后在外层写一个适配器UserProfileHttpAdapter实现这个端口内部封装Feign调用。业务代码只依赖UserProfilePort压根不知道远程HTTP的存在。这样做的好处在你需要写单元测试的时候会非常明显直接mock一个UserProfilePort指定返回高等级用户领域逻辑里的折扣计算就可以被精确验证不需要启动Spring容器不需要MockWebServer。在Java生态里依赖方向的强制执行可以借助Maven模块划分来实现。把领域层和应用层拆成一个独立的Maven模块这个模块不依赖任何基础设施框架只依赖标准库和少量工具包。适配器模块反过来依赖领域模块。这是比代码规范更可靠的架构约束——编译不过的东西永远不会进入代码库。2.3 DDD与六边形架构各管一段六边形架构和DDD经常被放在一起讨论但它们的职责完全不同。简单说六边形架构解决的是“分层与隔离”的技术问题DDD解决的是“复杂业务如何建模”的业务问题。两者是互补关系。DDD的聚合、实体、值对象这些概念正好是六边形内核里应该承载的内容。在DDD里我们通过事件风暴找出的聚合根和限界上下文天然构成了微服务的边界参考——一个限界上下文对应一个微服务已经是比较公认的实践。而六边形架构恰好为这个“限界上下文”提供了理想的代码载体内层是聚合和领域服务外层是技术实现。我可以把这套结构给一个清晰的拆解领域层内核聚合根、实体、值对象、领域服务、领域事件。不依赖任何框架。应用层靠近内核的环应用服务编排用例做事务管理、权限校验、事件发布。不依赖技术框架但依赖领域层。端口层由领域层定义仓储接口、外部系统网关接口、消息发布接口。适配器层最外层Controller、MessageListener、RepositoryImpl、FeignClient、JmsProducer全部技术实现都在这。基础设施层配置、线程池、ORM配置、健康检查等支撑代码。这种划分的好处用大白话说就是“把变的部分和不变的部分彻底分开”。业务规则是系统里最稳定、最值钱的部分把它保护在最中心不让外部技术变化波及它这就是高可维护性的底层来源。2.4 一个实际的模块结构供参考这里给一个Spring Boot项目的模块结构示例你可以直接当脚手架参考order-service/ ├── order-domain纯Java模块无框架依赖 │ ├── order/聚合根、订单实体、值对象 │ ├── service/领域服务如OrderStateMachine │ ├── port/仓储端口、用户端口、支付端口 │ └── event/领域事件定义 ├── order-applicationSpring Boot可以引用但仍然是纯逻辑编排 │ ├── usecase/PlaceOrder、CancelOrder等用例实现 │ └── assembler/DTO与领域对象的转换 ├── order-adapter-web │ ├── controller/HTTP入站适配器 │ └── dto/请求响应参数 ├── order-adapter-persistence │ ├── repository/JPA实现 │ ├── dao/JpaRepositories │ └── entity/数据库实体 ├── order-adapter-mq │ ├── listener/消息监听入站适配器 │ └── publisher/消息发布出站适配器 └── order-adapter-rpc └── client/调用其他服务的出站适配器每个服务都是这样一套结构服务之间只有RPC或消息通信没有代码层面的直接依赖。这个结构我跑了两个大项目稳定性和演进性都经受住了考验。3. 订单系统重构实录一步步把六边形搭起来3.1 第一步从业务事件出发圈定领域模型重构的第一步不是写代码而是把领域模型画清楚。我们当时用了事件风暴Event Storming的方式把订单从下单到完成的所有业务事件贴满了一面墙。这一步产出的是什么不是一张漂亮架构图而是一个个有边界感的聚合。以订单为例我们最终定了几个聚合订单聚合订单 订单项 订单状态、支付聚合支付单 支付流水、营销聚合优惠券 使用记录。这几个聚合各自有独立的生命周期是事务边界的最小单位。选聚合的时候一定要克制。我们最初几乎把客户信息、库存信息也拉进了订单聚合画快照图的时候觉得很完整结果发现订单的聚合太大、状态太多行为一复杂就特别难维护。后来回归了DDD的基准思想聚合越小并发能力越强事务范围越可控。那些从其他上下文带过来的信息只存快照最终一致性由事件来保证。3.2 第二步定义端口并附上完整代码模型定下来之后我们按用例定义入站端口按领域能力定义出站端口。这里我直接贴一个简化版但结构完整的代码方便说明。先看入站端口即用例接口public interface PlaceOrderUseCase { PlaceOrderResult placeOrder(PlaceOrderCommand command); }实现这个接口的是应用服务它编排领域模型但不直接碰数据库和远程服务Service public class PlaceOrderService implements PlaceOrderUseCase { private final OrderRepository orderRepository; private final ProductInventoryPort inventoryPort; private final DiscountPolicyPort discountPolicyPort; // 构造器注入 Override Transactional public PlaceOrderResult placeOrder(PlaceOrderCommand command) { // 从端口获取外部信息 InventorySnapshot inventory inventoryPort.reserveInventory(command.getSkuList()); DiscountPolicy discount discountPolicyPort.getApplicablePolicy(command.getUserId(), command.getAmount()); // 创建聚合根领域模型内部负责自己的不变量 Order order Order.create( command.getUserId(), command.getSkuList(), inventory, discount ); // 保存聚合触发领域事件 orderRepository.save(order); order.publishEvents(); return PlaceOrderResult.from(order); } }出站端口全部定义在领域模块里实现放在适配器模块里public interface OrderRepository { Order findById(OrderId orderId); OrderId nextId(); void save(Order order); } public interface ProductInventoryPort { InventorySnapshot reserveInventory(ListSkuItem skuItems); } public interface DiscountPolicyPort { DiscountPolicy getApplicablePolicy(Long userId, Money amount); }注意Order是被作为聚合整体存取和映射的而不是直接把行数据成对地交给Service去拼。仓储接口返回的也是聚合或快照不是数据库实体。这个细节决定了领域模型能不能被完整还原——如果仓储层把Order打散成一条条订单项数据交给Service用那聚合的封装性就名存实亡了。3.3 第三步编写适配器把技术框架锁在最外层端口定义好后适配器挨个实现。下面是订单仓储适配器的一个缩略写法Repository public class JpaOrderRepository implements OrderRepository { private final JpaOrderDao jpaOrderDao; private final OrderMapper orderMapper; Override public void save(Order order) { OrderEntity entity orderMapper.toEntity(order); jpaOrderDao.save(entity); } Override public Order findById(OrderId orderId) { OrderEntity entity jpaOrderDao.findById(orderId.getValue()) .orElseThrow(() - new OrderNotFoundException(orderId)); return orderMapper.toDomain(entity); } }这里的OrderMapper可能是手写的转换器也可能是MapStruct生成的。我更倾向于用MapStruct手写映射代码量大、容易漏字段而且数据实体和领域模型之间的字段名往往需要显式说明代码生成工具在这里特别合适。再比如用户信息端口的具体实现就是一个REST客户端适配器Component public class UserServiceRpcAdapter implements UserProfilePort { private final UserFeignClient userFeignClient; Override public UserProfile getUserProfile(Long userId) { // Feign调用数据转换 return UserProfileConverter.toDomain(userFeignClient.queryUser(userId)); } }所有HTTP、JPA、MQ相关的注解全部发生在适配器层领域模块没有任何一个Spring注解。这不仅是设计洁癖更重要的目的是跑单元测试时不烧脑壳。3.4 第四步用测试验证架构有效性架构搭得对不对跑一遍测试就知道。我当时定的测试策略是这样的——领域层纯JUnit测试测试订单状态流转、折扣计算、库存扣减这些核心规则。没有任何I/O运行毫秒级覆盖率要求最高。应用层mock出站端口来测用例编排逻辑比如构造一个InventoryPort返回库存不足验证下单用例确实抛出异常且没有调用仓库保存。适配器层写集成测试起真实的H2/Testcontainers测JPA映射起MockWebServer测HTTP适配器只验证协议转换和字段映射是否正确。测试比例大致控制在领域层55%、应用层30%、适配器层15%。这套金字塔很能说明问题适配器层是最可能受技术框架升级影响的但因为被端口隔离在外调整它不会牵连上层测试。对比一下以前的代码Controller加个请求参数导致三大层同时改测试的场面是真的一去不回头了。4. 微服务通信、事务与架构演进的落地经验4.1 服务间通信接口适配器与消息适配器微服务之间的通信本质上是不同六边形之间的适配器对话。RPC场景一个服务出站适配器发出的请求经过网络到达另一个服务的入站适配器然后进入它的应用层用例——这中间经过的都是端口没有内核与内核的直接耦合。具体到技术选型同步调用我一般用OpenFeign配Sentinel做熔断降级异步场景用Spring Cloud Stream RocketMQ或Kafka。这里给一条经验同一个业务链路尽量统一通信方式。如果一个用户查询操作一会儿走HTTP同步、一会儿发消息异步回调链路会变得极难排查最终一致性模型也会被搅浑。另外适配器层是对超时、重试、熔断做统一处理的天然位置。领域层不知道也不会关心某个远程调用会超时但适配器里可以配置合理的超时阈值、连接池大小、失败重试策略。这是一种责任的明确划分业务规则负责“该做什么”技术策略负责“做得稳不稳”。4.2 事务边界别让“分布式事务”毁掉你的架构收益微服务化之后原来一个本地事务能搞定的事情现在跨了服务就变成分布式事务问题。我见过无数团队在这上面翻车项目上线半年了还在跟最终一致性缠斗。六边形架构给世界一个非常实用的启发事务边界应该在应用层用例的服务方法上开而不是在领域方法里开。聚合内部有状态修改可以在聚合方法里套一个事务模板但跨聚合操作事务还是通过应用层编排、最终用数据库事务或本地消息表来收口。举个例子订单创建需要扣库存与锁定优惠券。如果这些动作跨了订单服务和营销服务两个微服务就不该想用一个全局XA事务把两边绑死。我们在实践中用的是Saga模式 本地消息表订单创建成功就发一个“库存预占命令”到MQ库存服务消费后回执结果订单侧开一张本地事务消息表记录待确认状态消费端成功后更新消息状态。这套链路下来极端情况下会有短暂的最终一致窗口但配合对账Job基本不会出现无法自愈的脏数据。务实的建议是这样的单体应用内聚合之间的数据一致性用数据库本地事务。跨微服务的写操作优先用事件 本地消息表或者Saga。不要为了一个耦合在一起的多服务更新去强行引入分布式事务中间件成本和复杂度普遍不值得。在六边形架构里因为所有外部依赖都经过端口替换事务方案的影响面被控制得最小。我自己的经历是早期用Seata后来觉得Saga更稳妥从Seata切换到Saga时只调整了应用服务方法和消息适配器领域层完全没动——这就是隔离的直接红利。4.3 几个高频问题的排查与避坑实操过程中六边形架构也会遇到具体问题。我把真正常见的坑列成一张速查表都是自己踩过或帮别人排查过的。现象根因正确处理领域对象里出现Jackson注解、JPA注解领域模块依赖了技术框架清理所有注解序列化/持久化在适配器完成映射Controller里拿到了数据库实体直接在页面展示适配器穿透DTO和实体混用入站适配器必须做DTO与领域对象的装配转换端口方法都是save、findById、delete端口按技术操作命名未面向业务以业务行为命名端口仓储实现内部承担技术细节同一个聚合里几十个字段事务一大片聚合边界圈得太大重新事件风暴用小聚合 领域事件协作想让Feign直接注入领域Service出站/入站端口没有定义拆分端口类型rpc适配器实现出站端口Controller实现入站端口用例依赖了具体Repository实现比如JpaOrderRepo应用层直接依赖了适配器应用层依赖端口适配器通过Spring注入有一个打开认知的门道值得单独说写代码时反复问自己“这个依赖是从外向内还是从内向外”。如果发现内层import了外层的东西无论如何都该停下来。这个直觉练出来后写新模块基本一次成型不用事后返工调结构。另外还有一个细节是端口粒度控制。端口太粗领域层会变成贫血逻辑应用层胖得不行端口太细适配器就得写大量样板代码。我的习惯是入站端口以用户用例为单位一个用例一个方法出站端口以领域能力为单位比如库存端口、用户端口、支付端口不搞通用老接口。4.4 架构演进与规模控制最后聊一下六边形架构在微服务体系里的演进节奏。这个架构不是雪花——一旦系统复杂到一定程度内核抽象本身也会成为一种负担。如果三个团队共用一个巨大的共享内核那协同起来比单块还痛苦。从规模上我给一个经验判断系统在10个服务以下时六边形架构的落地收益最明显尤其是每个服务的领域逻辑都在快速变化。当服务超过20个需要关注的就是服务间契约治理了各个六边形的出站端口逐渐升级成独立的服务契约或API版本管理机制。在服务内部也可以用“筋膜枪”的方式慢慢改造老项目不要求一次全部重写可以先把一个核心服务的业务代码从Controller层剥离定义端口、写适配器、补测试跑一起综合判断后再铺开到其他服务。没有谁规定六边形架构必须一步到位但只要你开始让依赖方向反转高可维护性的价值就会逐日显现。我个人的体会是架构之美不在于用了多少新概念、画了多复杂的图而在于当需求来临时你知道改动会落在哪个环里心跳不会加速。六边形架构给了我这种确定性——在写微服务的这几年里它是帮我保住睡眠质量的最好投资。
返回列表