ARTICLE DETAIL

资讯详情

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

Go订单服务重构:用六边形架构拯救失控的Service层

Go订单服务重构:用六边形架构拯救失控的Service层 1. Service 层失控的真实样貌从一个订单服务说起刚接手这套系统的时候我打开 OrderService.go第一反应是有点绝望。1800 行代码不算注释光 CreateOrder 一个方法就占了 260 行先查用户、查商品、调库存接口、算价格、开事务写订单、扣库存、发消息、发短信、再写一条操作流水。中间夹杂着各种重试、限流、幂等逻辑还有一个被注释掉改了又改的折扣规则。这不是某个同学写代码不认真而是典型三层架构项目跑半年之后的必然结果。Controller 只做参数绑定DAO 只做 SQL剩下的所有事情都会流向 Service 层它不复杂才奇怪。这篇文章不打算做纯理论分析我会用一次真实的 Go 重构经历把 Service 层失控的现象、六边形架构的核心思路、以及重构过程中踩过的坑摊开来讲。如果你现在的项目里也有一个所有业务都会路过的 Service这篇应该能给你一些可操作的判断和步骤。1.1 三层架构里 Service 层的宿命标准三层架构的分工是 Controller 接收 HTTP 请求并解析参数Service 处理业务逻辑DAO 访问数据库。这句描述听起来很清楚但实际落地时有一个关键问题始终没被回答什么算业务逻辑订单创建算业务逻辑库存校验也算可库存校验要调外部系统的 HTTP 接口那这个 HTTP 客户端放哪放 DAO 不合适它的默认职责是数据访问放 Controller 更不合适业务校验本来就应该留在 Service。于是它只能进 Service。消息通知、短信验证码、操作日志、缓存预热、权限校验、分布式锁……所有既不是纯业务、也不是纯数据访问的代码最终都会堆在 Service 层。所以 Service 层越写越复杂不是某个人水平不行而是架构给所有代码留了一个万能的去处。当每个模块的新需求都习惯性往 Service 里塞代码时它在三个月内膨胀成上千行的上帝类几乎是必然的。1.2 症状清单哪些代码不该出现在 Service 里我重构前对当时的 Service 层做过一次症状扫描问题大致归成四类基础设施调用Service 里直接持有 Redis Client、MQ Producer、短信 SDK、外部 HTTP Client在业务方法里同步调用还要处理重试和降级。横切关注点操作日志、审计流水、分布式锁、链路追踪埋点、权限判断这些跟业务结果本身没关系但和业务代码逐行混在一起。数据访问细节事务的 Begin、Commit、Rollback 直接暴露在业务方法里。SQL 确实被 DAO 封装了但事务边界完全由 Service 控制换个存储方案往往要改业务方法。业务规则这部分是应该存在的但它和上面三类东西搅在一起之后没有人能说清楚下单流程到底包含哪些步骤、每一步失败应该怎么办。你可以对照一下自己的项目。如果 Service 方法普遍超过 200 行、依赖对象超过五个、每次加需求都要把整个方法从头读到尾那大概率已经中招了。1.3 问题根源不在代码风格而在依赖方向为什么三层架构最后都会变成这样我的观点是三层架构看似把系统分成了清晰的三层但它的依赖方向是单向向下的Controller 依赖 ServiceService 依赖 DAO。它没有回答一个关键问题业务逻辑应该依赖什么于是为了把系统跑起来Service 最先做的事情就是把能依赖的都依赖上。DB、Redis、MQ、外部 API每新增一项技术组件Service 的依赖列表就多一个字段。依赖一旦建立后续每次改动都在这个巨大的对象上修修补补。更隐蔽的是这种结构会让单元测试变得极难写。你想测一个 CreateOrder得先初始化数据库连接、Redis、MQ否则 Service 对象根本构造不出来。测试成本高到一定程度团队的正常反应就是放弃写测试。没有测试托底重构就更不敢做再往后就是这代码没人敢碰直到某天业务逼着你必须改。所以问题不是这个 Service 写得太乱而是架构没有给代码提供更好的去处。业务逻辑和技术实现之间需要一条明确的边界这条边界不应该由 DAO 提供也不应该由 Controller 提供而应该由业务逻辑自己声明。这正是六边形架构的出发点。2. 六边形架构到底改了哪根弦端口与适配器的依赖纪律2.1 端口与适配器的本质六边形架构也叫端口与适配器架构Ports and Adapters最早由 Alistair Cockburn 提出。它把系统画成一个六边形内侧是业务核心包含领域模型和用例六边形的边上是端口代表业务核心对外部世界的所有需求六边形外面是适配器负责把真实世界的 HTTP 请求、数据库连接、消息队列转换成端口能理解的调用。用 Go 表达端口就是 interface。业务核心需要保存订单那就定义OrderRepository接口业务代码只消费这个接口底层是 MySQL 还是 Postgres 还是内存数组由适配器决定。入口也同理HTTP handler 是入口适配器它解析请求、校验格式然后调用用例的某个方法。同一个用例可以挂 HTTP、GRPC、CLI 多个入口适配器因为业务核心根本不关心调用从哪里来。2.2 依赖方向颠倒之后发生了什么三层架构的依赖方向是 Controller 依赖 ServiceService 依赖 DAO 和各种客户端。六边形架构把这条链倒了过来业务核心不再是依赖外部实现的一方而是主动定义自己需要什么样的端口让外部适配器来满足它。这个倒过来是本质变化也就是依赖倒置原则在架构层面的落地。之前是 Service 主动找 MySQL、MQ、短信供应商彼此强耦合现在业务核心只依赖自己声明的端口接口基础设施反过来去实现这些接口。实际感受是业务表达变得干净。CreateOrder 用例里不再出现任何 Redis 命令、短信模板 ID 或者 HTTP 调用细节只有业务步骤的顺序查用户、扣库存、保存订单、发事件。它读起来像是产品经理在晨会上讲需求而不是一份数据库操作清单。2.3 六边形架构和三层架构的直观对比对比维度三层架构六边形架构依赖方向Controller → Service → DAO单向向下外部 → 适配器 → 端口 ← 用例依赖倒置业务逻辑位置Service 层和技术代码混居领域模型 用例独立于技术细节外部资源访问Service 直接持有各种客户端通过出口端口声明适配器实现单元测试需要 mock 基础设施或起依赖容器端口传 fake 实现纯内存运行替换数据库/短信服务商修改 Service 里所有调用点只替换对应适配器新成员上手成本顺着大 Service 从头读到尾看用例就能理解业务主流程当然六边形架构并非没有成本接口变多、适配器文件变多、组装逻辑多了一层第一次接触的人会觉得绕。但当一个项目的 Service 已经变成上帝类时这笔成本是值得的。3. 重构实录Go 订单模块从三层到六边形的完整手术过程3.1 重构前奏先把现状和边界画清楚重构不能上来就删代码。我做的第一件事是把订单模块的调用关系画成一张粗糙的依赖图谁调用了 OrderServiceOrderService 又持有哪些依赖。用 go list 配合 grep 就能整理出来再把每个方法的依赖列成清单。画完之后现状非常清晰OrderService 依赖了 9 个外部对象OrderController 只做参数绑定OrderDAO 只做 SQL 操作中间的整块面条都是 Service 的职责范围。我没有急着把所有模块推倒重来而是先按业务子域切边界第一批只重构订单创建这一个用例。这样做的原因是架构重构最大的风险是改坏了没人知道。收窄到单个用例可以快速验证架构思路也方便在这个范围内把端口设计的争论解决掉再复制到其他模块。3.2 第一步让领域模型和用例先落地先从领域模型开始。订单不再是一张数据库表的映射而是一个带业务状态的聚合对象type OrderStatus int const ( OrderStatusPending OrderStatus iota OrderStatusCreated OrderStatusPaid OrderStatusCancelled ) type OrderItem struct { SkuID string Name string Price int64 Count int } type Order struct { ID string UserID string Items []OrderItem Total int64 Status OrderStatus CreatedAt time.Time }然后是用例。用例是业务场景的表达式以用户意图命名type CreateOrderCommand struct { UserID string SkuID string Count int Address string } type CreateOrderUseCase struct { users UserRepository inventory InventoryGateway orders OrderRepository events EventPublisher notify Notifier } func (uc *CreateOrderUseCase) Execute(ctx context.Context, cmd CreateOrderCommand) (*Order, error) { user, err : uc.users.FindByID(ctx, cmd.UserID) if err ! nil { return nil, fmt.Errorf(find user: %w, err) } if err : uc.inventory.Reserve(ctx, cmd.SkuID, cmd.Count); err ! nil { return nil, fmt.Errorf(reserve inventory: %w, err) } order : buildOrder(user, cmd) if err : uc.orders.Save(ctx, order); err ! nil { return nil, fmt.Errorf(save order: %w, err) } if err : uc.events.Publish(ctx, OrderCreatedEvent{OrderID: order.ID}); err ! nil { return nil, fmt.Errorf(publish order created: %w, err) } uc.notify.Notify(ctx, user.Phone, 订单创建成功) return order, nil }这个用例表达了业务步骤但它不认识任何 SDK、数据库连接或框架。它只依赖端口——也就是在领域包里定义的接口。这个阶段我没碰任何具体实现先把骨架搭到能编译过再进入下一步。3.3 第二步用 interface 把端口钉死在领域层六边形重构里最关键的一步是端口接口的位置。它必须定义在业务侧由适配器去实现而不是定义在实现侧。我当时在订单包下新增了端口定义type UserRepository interface { FindByID(ctx context.Context, id string) (*User, error) } type OrderRepository interface { Save(ctx context.Context, order *Order) error FindByID(ctx context.Context, id string) (*Order, error) } type InventoryGateway interface { Reserve(ctx context.Context, skuID string, count int) error } type EventPublisher interface { Publish(ctx context.Context, event any) error } type Notifier interface { Notify(ctx context.Context, phone, message string) error }关于接口定义位置Go 社区有一句经典的话接口属于使用方。把接口定义在领域层意味着领域模型不依赖任何第三方包只靠接口描述需求。如果把接口定义在 repository 包里再让 usecase 去引用它依赖方向就反了换实现的时候业务代码的导入路径也得跟着改。这里有个实用建议端口方法尽量贴合业务动作避免使用太宽泛的返回值。Reserve(ctx, skuID, count)比UpdateInventory(ctx, params)更能让读者理解意图。用 Go 1.18 的泛型之前尤其要克制用any做返回值的冲动否则适配器实现里全是类型断言业务侧也读不懂。3.4 第三步基础设施全部降级为适配器端口定义好之后原来散落在 Service 里的 Redis、MQ、HTTP Client 都被改造成适配器。比如短信通知变成了一个 Notifier 的实现type SMSNotifier struct { client *sms.Client signName string template string } func (n *SMSNotifier) Notify(ctx context.Context, phone, message string) error { // 把业务消息转换成具体短信平台的请求参数 return n.client.Send(ctx, phone, n.signName, n.template, map[string]string{ content: message, }) }数据库仓储适配器也类似它实现 OrderRepository 接口内部完成表和领域对象之间的映射。原来 OrderDAO 的代码大部分可以平移过来但它现在的定位是适配器不承载任何业务判断。这个阶段要守住一条线业务逻辑不要被往下推。写实现的人很容易顺手在仓储适配器里加一套这个订单能不能创建的状态判断这是不行的。适配器只做协议转换和技术实现一旦出现业务规则立刻把它往用例层提。边界要反复守否则重构完只是把上帝类拆成了上帝适配器。3.5 第四步在启动区完成组装六边形架构里组装依赖的地方被放在最外层通常是 main 函数或独立的组装文件。这是唯一一个需要同时知道所有具体实现的地方func main() { db : setupDB() mq : setupMQ() userRepo : adapter.NewUserRepo(db) orderRepo : adapter.NewOrderRepo(db) inventoryClient : adapter.NewInventoryClient(conf.InventoryAddr) eventPublisher : adapter.NewMQPublisher(mq) notifier : adapter.NewSMSNotifier(conf.SMS) createOrder : usecase.CreateOrderUseCase{ users: userRepo, inventory: inventoryClient, orders: orderRepo, events: eventPublisher, notify: notifier, } orderHandler : adapter.NewOrderHandler(createOrder) router : setupRouter(orderHandler) router.Run(:8080) }这个启动区就是组装根Composition Root。业务代码不自己去 new 依赖全部在启动时注入。这样的好处是后续新增 GRPC 入口或命令行工具都只是新增一个入口适配器然后复用同一个用例不需要改动业务核心。到这里订单创建这个用例已经从三层切换成了六边形。Controller 不再直接依赖巨大的 ServiceHTTP handler 只做参数解析然后调用用例原来 Service 里的基础设施代码全部被适配器吸收业务步骤收敛到一个方法里。4. 重构中最容易翻车的四个地方我踩过的坑4.1 接口定义位置循环依赖是自己写出来的第一处坑是接口定义位置引发的循环依赖。我重构另一个模块时一开始图省事把接口定义在 repository 包里让 usecase 去引用 repository 包。结果领域包为了定义聚合对象又引用了 repository 的接口一旦适配器再反过来引用领域包依赖图直接打结。Go 的 import 循环往往就是设计边界松弛的报警器。正确做法前面说过接口定义在使用方也就是领域或用例侧让实现方去引用领域侧。所有依赖都指向领域模型循环依赖自然消失。4.2 事务边界拆分后数据一致性怎么保住第二个坑是事务。三层架构里整个 CreateOrder 由一个 Service 开一个事务包到底数据库操作用同一个事务。拆分成多个端口之后如果每个适配器自己开事务、自己提交一旦涉及多张表、多个资源一致性就碎了。我最终采用的做法是数据库写操作尽量收敛到聚合仓储的一个方法里。下单这个动作包含订单主表和订单明细表属于同一个聚合变化让 OrderRepository.Save 在内部用一个事务写入两张表。这样事务边界和聚合边界对齐用例层不需要感知事务也能保证一致性。如果用例里确实涉及先写库再发消息这类跨资源操作我的经验是用事务发件箱Outbox模式或者至少做到事务提交后再发事件而不是在用例层纠结先做哪一步。六边形架构不会自动给你分布式一致性它只是把这个问题暴露得更加明确了。4.3 端口粒度别把 CRUD 当业务能力第三个坑是端口粒度。很多人重构完之后端口长得跟之前的 DAO 一模一样Save、Delete、FindByID、FindPage。这其实是把表驱动思想换了一层马甲。端口应该描述业务能力而不是数据操作。以订单模块为例我不需要暴露OrderRepository.UpdateStatus我可能需要的是MarkOrderPaid(ctx, orderID, paidAt)。前者暴露了数据更新动作后者表达了业务意图。用业务动作命名端口调用侧代码更好读实现侧也可以针对这个动作做幂等、加专门的 SQL不会被通用的 CRUD 语义限制住。我有一个实操判断方法每定义一个端口方法前问自己如果我把这个方法改成纯内存实现业务语义还成立吗如果成立说明抽象合适如果只有挂在数据库上才成立那它很可能是一条 CRUD不是业务能力。4.4 迁移节奏用绞杀者模式不做大爆炸最后一个坑是节奏。我见过一个团队打算用一个迭代把整套系统翻成六边形结果一个多月没有交付物最后被业务部门叫停重构无疾而终。我推荐绞杀者模式的节奏新需求直接走新架构老接口的路由逐步切到新用例每次只迁移一个业务入口。第一个迭代迁创建订单第二个迁取消订单第三个再迁查询订单。每个迭代结束都能上线风险被限制在一个用例范围内。等所有入口都迁完老代码直接删除不需要单独安排一个重构月。这种节奏对团队心态也友好新架构在真实流量里跑一段时间端口设计的问题会及时暴露而不是等大爆炸之后集中爆发到时候想回头都难。5. 重构一年后的复盘收益、代价与判断标准5.1 测试从起一堆依赖变成纯内存跑用例重构后的收益最先体现在测试上。以前给 OrderService 写单测要先初始化数据库还要 mock Redis、MQ否则依赖注入不进去。很多时候测试环境连不上程序员就干脆不写单测了。重构之后CreateOrderUseCase 的测试只需要传入几个 fake 端口实现type fakeOrderRepo struct { saved *Order } func (f *fakeOrderRepo) Save(ctx context.Context, order *Order) error { f.saved order return nil } func (f *fakeOrderRepo) FindByID(ctx context.Context, id string) (*Order, error) { return f.saved, nil }没有网络没有磁盘 IO测试从秒级降到毫秒级。更关键的是用例测试挂掉时能立刻分辨是业务逻辑出了问题还是适配器出了问题用例的测试挂了是业务编排错适配器的测试挂了是技术实现错。这种二分定位能力比任何测试框架都值钱。5.2 换数据库、换短信商不再伤筋动骨第二个收益是替换成本下降。中间有一次团队要把短信服务商从 A 厂商切到 B 厂商旧代码里十几个 Service 方法都在调用同一个短信客户端需要逐个找、逐个替换。重构之后只需要写一个新的 SMSNotifier 适配器替换启动区里的注入跑一遍端口契约测试就完事。后来我们评估缓存方案替换Redis 客户端替换对业务用例层零感知。虽然最终因为运维成本没有落地但代码层面至少不再被一个具体客户端绑死。5.3 什么时候不值得做六边形架构也必须说句公道话不是所有项目都适合六边形架构。复盘时我总结了几个不值得做的信号。项目处于快速验证期需求天天变首要目标是短路径跑通功能这时候直接三层甚至单文件都可能更好。团队没有写接口、做依赖注入的习惯也没有自动化测试文化。硬上六边形会得到一堆为了接口而接口的空洞抽象比原来的大 Service 更难维护。你的痛点只是某个方法太长而不是整个依赖方向有问题。可以先抽函数、抽结构体没必要直接上升为架构重构。判断标准可以很简单如果 Service 层的复杂度来自业务规则本身六边形帮不上忙如果复杂度来自业务和技术细节互相纠缠六边形就有价值。后者正是它真正适用的场景。5.4 给同样想重构的团队一句大实话不要把六边形架构当成银弹要把它当成一种约束。约束的意义在于新同学想往用例里塞一段 Redis 操作时他会发现这里根本没有 Redis 的依赖得停下来想想该怎么设计端口。这种被动训练比任何 Code Review 规则都更持久。重构不是炫技它的终点不是代码变漂亮而是让团队敢改代码。如果你的项目已经出现 Service 层几百行方法遍地走的信号与其继续打补丁不如挑一个业务入口画一个边界用一个两周的迭代把它迁出来试试。我自己在这次重构里最大的感受是代码量没有减少甚至多了一批接口和适配器文件但每次需求变更时的恐惧感小了很多。以前改一个订单状态流转要同时打开巨大的 Service 文件和三个外部客户端还要担心哪一步破坏了别的功能现在只关注用例里的状态流转端口不变底层实现随便换。这种技术复杂度被挡在门外的感觉体验过一次就回不去了。
返回列表