ARTICLE DETAIL

资讯详情

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

Go微服务实战:六边形架构+gRPC适配器搭建指南

Go微服务实战:六边形架构+gRPC适配器搭建指南 最近在帮团队把一套跑了好几年的单体订单服务拆成微服务架构选型讨论到最后没有悬念Go gRPC 六边形架构Hexagonal Architecture。这个组合在 Go 社区不算新鲜但真正动手落地的时候你会发现网上文章讲概念的居多能照着把项目骨架搭起来、把 gRPC 适配器接进去、再跑通联调的实战资料反而分散。这篇文章记录了我从零搭建这套项目时的完整路径包括六边形架构的核心思想、端口和适配器在 Go 代码里的落法、gRPC 的接入方式以及启动联调和上线前必须处理的细节。如果你正在设计 Go 微服务的边界或者刚接触六边形架构不知道从哪下手这篇应该能帮你省不少弯路。1. 六边形架构怎么解开三层架构的死结1.1 三层架构的依赖方向为什么是个问题很多从 Java/Spring 转过来的朋友都问过我同一个问题为什么 Go 微服务里这么推崇六边形架构三层架构不是用得挺好吗要回答这个问题得先看三层架构的依赖方向。控制器Controller调 ServiceService 调 DAO/Repository数据一路从数据库经过 Service 加工再返回给视图。这套模型在单体应用里非常直观问题在于依赖方向是“自上而下”的——顶层控制器认识 ServiceService 认识 DAODAO 认识具体数据库。一旦业务逻辑变了Service 要改一旦数据库从 MySQL 换成 PostgreSQLDAO 层要改一旦要把 HTTP 接口换成 gRPC控制器又要动。三层架构并没有真正隔离“业务”和“技术”它只是把技术细节按层压扁了。六边形架构解决的是这个问题业务逻辑被放在最中心它不 import 任何技术框架的代码只定义自己需要的端口接口。外部的一切——HTTP/gRPC 服务、消息队列、数据库、第三方 SDK——全都作为适配器插在端口上。依赖方向从“内核向外”变成了“外部向内”。用人话说三层架构像你把手机直接焊在充电头上想换充电协议得把手机拆开六边形架构像 USB-C 接口手机只认接口形状充电头、充电宝、显示器都能往里插。1.2 Java生态为什么还是以三层为主说句公道话Java 项目里三层架构依然是主流这不代表 Java 开发者落后。Spring Boot 的出现把 MVC 模式固化成了默认姿势Controller 一个注解、Service 一个 Service、Mapper 直接注入一个 CRUD 项目十分钟就能跑起来。再加上 Transactional 在 Service 上管理事务切面非常方便绝大多数业务系统的复杂度完全够用。而六边形架构要求你先抽象出业务系统对外部世界的所有需求端口再为每个端口编写适配器这种设计在中小型单体项目里确实显得“过度设计”——接口文件一大堆新人接手成本高。三层架构在团队认知、框架支持、上手速度上都有优势。但要看到的是Java 生态里微服务落地时很多团队也在往“领域驱动 端口适配器”方向靠Spring 社区也有 Repository 和 Service 解耦的实践只是没有 Go 社区这么强调“依赖方向必须向内”的纪律。原因很简单Go 没有 Spring 那样的强容器帮你自动装配如果你不在架构层面强行定规矩业务代码很容易直接 import 一个具体的 repository 实现最后又退化成“两层架构”。1.3 Go微服务为什么更适合六边形架构我自己的体会是三个理由。第一Go 的接口是隐式实现任何类型只要方法集满足接口就可以被当作该接口使用这让“面向接口编程”几乎零成本。你不用显式声明“我实现了哪个接口”代码就会自然地向“依赖接口、不依赖实现”倾斜。第二Go 的internal目录提供了语言级访问边界。把core放在internal/core下外部模块根本 import 不进去从语法层面保证业务核心不会被外部包意外依赖。第三微服务的部署单元比单体小得多一个服务只需要关注少数几条业务规则。六边形架构把业务和传输协议、存储实现彻底切开后你换存储、换消息队列、加一个 gRPC 接口都不需要动业务代码。这对频繁迭代的微服务团队来说是实打实的收益。我整理了一张对比表方便直观感受两类架构的差异对比维度三层架构六边形架构依赖方向自上而下越底层越贴近技术由外向内核心不依赖技术业务和技术耦合业务代码可直接触碰 DAO/数据库类型业务只依赖端口接口换技术栈要改对应层及上层调用新增或替换适配器即可单元测试需要 mock 数据库或框架注入内存适配器核心测试不碰外部依赖上手成本低需要先做端口设计适合场景单体系统、以 CRUD 为主的管理后台微服务、业务规则复杂、需要频繁演进2. 先定领域核心与端口这块是六边形架构的心脏2.1 端口、适配器、领域核心各管什么动手写代码前我建议先花 30 分钟把三件事想清楚领域核心是什么、它需要哪些端口、每个端口会有哪些外部实现。领域核心就是你的业务规则跟传输协议、存储技术、消息格式全都没关系。以订单服务为例创建订单时的校验规则、订单状态流转逻辑、金额计算这些属于领域核心。你给用户提供的是 HTTP 还是 gRPC数据落到 MySQL 还是 MongoDB领域核心根本不关心。端口分两类。入站端口inbound port是“别人想让我这个服务做事时领域核心暴露出来的能力”通常就是业务服务的接口比如CreateOrder、GetOrder。出站端口outbound port是“领域核心做事时需要外部配合的能力”比如保存订单需要一个仓储接口、扣减库存需要一个库存客户端接口。外部世界的具体实现都是适配器gRPC server 是入站适配器PostgreSQL 仓储是出站适配器gRPC 客户端调另一个服务也是出站适配器。一句话总结端口是契约适配器是实现领域核心只在契约层面工作。2.2 入站端口与出站端口的Go接口定义我以订单服务为例展示端口定义。先建一个internal/core/domain包放领域模型再建internal/core/port包放接口最后internal/core/service放业务实现。这是 Go 社区比较常见的约定接口放在“消费方”而不是放在“实现方”。// internal/core/domain/order.go package domain import time type OrderStatus string const ( OrderStatusPending OrderStatus PENDING OrderStatusPaid OrderStatus PAID OrderStatusCancelled OrderStatus CANCELLED ) type OrderItem struct { ProductID string Quantity int32 Price float64 } type Order struct { ID string UserID string Items []OrderItem Status OrderStatus CreatedAt time.Time }接下来定义端口// internal/core/port/input.go package port import ( context example.com/order-service/internal/core/domain ) type CreateOrderCommand struct { UserID string Items []OrderItemInput } type OrderItemInput struct { ProductID string Quantity int32 } type OrderService interface { CreateOrder(ctx context.Context, cmd CreateOrderCommand) (*domain.Order, error) GetOrder(ctx context.Context, orderID string) (*domain.Order, error) } // internal/core/port/output.go type OrderRepository interface { Save(ctx context.Context, order *domain.Order) error FindByID(ctx context.Context, orderID string) (*domain.Order, error) } type PaymentClient interface { Charge(ctx context.Context, order *domain.Order) error }注意几个细节CreateOrderCommand是命令对象它只携带当前请求所需的业务参数不暴露底层数据库模型OrderRepository、PaymentClient都是出站端口领域核心只知道要“保存订单”“发起扣款”但不知道背后是 PostgreSQL 还是支付 SDK。2.3 业务服务实现只依赖接口不依赖技术端口定义好了接着是业务实现。orderService结构体里存的字段全部是接口类型而不是具体实现类型// internal/core/service/order_service.go package service import ( context time example.com/order-service/internal/core/domain example.com/order-service/internal/core/port ) type orderService struct { orders port.OrderRepository payment port.PaymentClient } func NewOrderService(orders port.OrderRepository, payment port.PaymentClient) port.OrderService { return orderService{orders: orders, payment: payment} } func (s *orderService) CreateOrder(ctx context.Context, cmd port.CreateOrderCommand) (*domain.Order, error) { order : domain.Order{ ID: generateID(), UserID: cmd.UserID, Status: domain.OrderStatusPending, CreatedAt: time.Now(), } for _, item : range cmd.Items { order.Items append(order.Items, domain.OrderItem{ ProductID: item.ProductID, Quantity: item.Quantity, }) } if err : s.orders.Save(ctx, order); err ! nil { return nil, err } if err : s.payment.Charge(ctx, order); err ! nil { order.Status domain.OrderStatusCancelled _ s.orders.Save(ctx, order) return nil, err } order.Status domain.OrderStatusPaid if err : s.orders.Save(ctx, order); err ! nil { return nil, err } return order, nil } func (s *orderService) GetOrder(ctx context.Context, orderID string) (*domain.Order, error) { return s.orders.FindByID(ctx, orderID) }这个文件里看不到任何gorm、pgx、grpc的 import这就是六边形架构想要的效果当你往这个服务里加一个 Kafka 消息通知时当你把数据库从 MySQL 换成 PostgreSQL 时业务代码一行都不用动。这也意味着测试变得非常舒服——用内存版的OrderRepository和 fake 的PaymentClient就能把核心业务逻辑完整测一遍不需要起真数据库。3. gRPC适配器怎么接进来proto、生成代码与注册流程3.1 gRPC在六边形架构里到底算什么角色很多人第一次接触 gRPC容易把它理解成“一种类似 HTTP 的传输协议”但在六边形架构的语境里gRPC 更准确的身份是一对适配器。如果别人通过 gRPC 调用你的服务那么grpc.Server和你的 proto 服务实现就是入站适配器负责把网络请求翻译成领域核心能理解的方法调用。如果你的服务需要通过 gRPC 调用别人的服务那么 gRPC 客户端就是出站适配器负责把领域核心的出站端口翻译成远程调用。同一个服务gRPC 的两端在不同方向上扮演不同角色这个认知对后面写代码很有帮助。有个很常见的反模式是直接把业务逻辑写在 gRPC 服务实现里甚至直接用生成的 pb 类型贯穿整个业务层。这样做的后果是一旦你还要给服务加一个 HTTP/json 接口或者 MQ 消费者就得把同样的业务逻辑复制一份。正确做法是 gRPC 适配器只做三件事解析请求、调用领域服务、把结果转换成响应。3.2 定义proto并生成Go代码先说前置工具。需要安装protoc同时安装两个 Go 插件go install google.golang.org/protobuf/cmd/protoc-gen-golatest go install google.golang.org/grpc/cmd/protoc-gen-go-grpclatest然后定义一个 proto 文件// proto/order/order.proto syntax proto3; package order.v1; option go_package example.com/order-service/gen/order/v1;orderpb; service OrderService { rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse); rpc GetOrder(GetOrderRequest) returns (GetOrderResponse); } message CreateOrderRequest { string user_id 1; repeated OrderItem items 2; } message OrderItem { string product_id 1; int32 quantity 2; } message CreateOrderResponse { string order_id 1; } message GetOrderRequest { string order_id 1; } message GetOrderResponse { string order_id 1; string user_id 2; string status 3; repeated OrderItem items 4; }生成代码命令protoc --go_out. --go-grpc_out. proto/order/order.proto执行后会在gen/order/v1下生成order.pb.go和order_grpc.pb.go。我自己踩过的一个坑是 protoc 插件版本和 grpc-go 库版本不一致导致生成代码编译失败尽量让 protoc-gen-go、protoc-gen-go-grpc 和依赖库保持相近的版本装最新版通常最稳。3.3 实现gRPC服务请求转换与错误映射的落点接着写入站适配器。它会实现生成的OrderServiceServer接口内部持有port.OrderService只在接口和数据结构之间做转换。// internal/adapter/inbound/grpc/server.go package grpc import ( context errors example.com/order-service/gen/order/v1 example.com/order-service/internal/core/domain example.com/order-service/internal/core/port google.golang.org/grpc/codes google.golang.org/grpc/status ) type orderGRPCServer struct { orderpb.UnimplementedOrderServiceServer svc port.OrderService } func NewOrderGRPCServer(svc port.OrderService) orderpb.OrderServiceServer { return orderGRPCServer{svc: svc} } func (s *orderGRPCServer) CreateOrder(ctx context.Context, req *orderpb.CreateOrderRequest) (*orderpb.CreateOrderResponse, error) { cmd : port.CreateOrderCommand{ UserID: req.GetUserId(), } for _, item : range req.GetItems() { cmd.Items append(cmd.Items, port.OrderItemInput{ ProductID: item.GetProductId(), Quantity: item.GetQuantity(), }) } order, err : s.svc.CreateOrder(ctx, cmd) if err ! nil { return nil, mapError(err) } return orderpb.CreateOrderResponse{OrderId: order.ID}, nil } func (s *orderGRPCServer) GetOrder(ctx context.Context, req *orderpb.GetOrderRequest) (*orderpb.GetOrderResponse, error) { order, err : s.svc.GetOrder(ctx, req.GetOrderId()) if err ! nil { return nil, mapError(err) } resp : orderpb.GetOrderResponse{ OrderId: order.ID, UserId: order.UserID, Status: string(order.Status), } for _, item : range order.Items { resp.Items append(resp.Items, orderpb.OrderItem{ ProductId: item.ProductID, Quantity: item.Quantity, }) } return resp, nil } func mapError(err error) error { switch { case errors.Is(err, domain.ErrOrderNotFound): return status.Error(codes.NotFound, err.Error()) case errors.Is(err, domain.ErrInvalidArgument): return status.Error(codes.InvalidArgument, err.Error()) default: return status.Error(codes.Internal, internal error) } }有两点经验值得单独说。一是转换放这里业务判断不放这里。适配器层只做“外部数据格式到领域命令/领域模型”的转换不要在适配器里写订单金额、库存校验之类的业务逻辑否则端口边界形同虚设。二是错误映射一定要收敛到这个函数里。gRPC 的status.Error是跨进程的错误表达客户端拿到的只有code message。如果你不管理错误直接把 Go 内部 error 透传出去调用方会非常困惑而且可能把内部堆栈细节暴露出去。统一在适配器出口做映射比散落在各个 handler 里好维护得多。3.4 拦截器放在哪一层比较合适gRPC 拦截器Interceptor负责跨接口的横切逻辑请求日志、鉴权、panic 恢复、耗时统计。这些东西不适合塞进业务服务也不适合塞进每个 handler放适配器层是最合适的位置。下面是一个简单的日志拦截器我用它替代以前“每个 handler 里手打日志”的做法// internal/adapter/inbound/grpc/interceptor.go func UnaryLoggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { started : time.Now() resp, err : handler(ctx, req) log.Printf(method%s duration%s err%v, info.FullMethod, time.Since(started), err) return resp, err }鉴权拦截器同理校验 token 通过后再调 handler失败返回codes.Unauthenticated。这样做的好处是业务 core 完全感知不到“别人怎么进来的”gRPC 加拦截器也好、HTTP 加中间件也好对领域核心而言都是外部世界的事。4. 项目骨架怎么搭目录结构、依赖注入与启动入口4.1 一个可以直接抄的目录结构到现在为止我们已经有了 domain、port、service、adapter 四大块。把它们放进一个合理的目录结构里我推荐下面这种order-service/ ├── cmd/ │ └── server/ │ └── main.go ├── internal/ │ ├── core/ │ │ ├── domain/ # 纯领域模型与规则 │ │ ├── port/ # 入站/出站端口接口 │ │ └── service/ # 领域服务实现 │ └── adapter/ │ ├── inbound/ │ │ └── grpc/ # gRPC 入站适配器 │ │ ├── server.go │ │ └── interceptor.go │ └── outbound/ │ ├── repository/ │ │ ├── postgres/ # PostgreSQL 仓储适配器 │ │ └── memory/ # 内存仓储适配器测试用 │ └── grpcclient/ │ └── payment_client.go # 支付服务 gRPC 客户端适配器 ├── proto/ │ └── order/ │ └── order.proto ├── configs/ │ └── config.yaml ├── gen/ │ └── order/ │ └── v1/ # protoc 生成的 pb 代码 ├── docker-compose.yml └── go.mod几个关键点解释一下。cmd/server/main.go是唯一入口业务代码不负责“启动服务器”这个职责。internal/core不 importinternal/adapterinternal/adapterimportinternal/core依赖方向单向向内。gen/是生成代码目录不建议手动编辑也不建议放进internal因为有些下游工具处理生成代码路径时会更方便。4.2 main.go 是做依赖注入的唯一地方六边形架构中的依赖组装集中在一个地方main.go。这里的术语叫组合根composition root意思是所有对象的创建、接口实现的选择、配置注入都在这一个文件里完成。好处是你看一眼 main.go就能知道“这个服务由哪些部件组成、各自用的是什么实现”。// cmd/server/main.go package main import ( context log os/signal syscall example.com/order-service/internal/adapter/inbound/grpc example.com/order-service/internal/adapter/outbound/grpcclient example.com/order-service/internal/adapter/outbound/repository/postgres example.com/order-service/internal/core/service ) func main() { ctx, stop : signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() cfg : loadConfig() repo : postgres.NewRepository(cfg.DatabaseDSN) payment : grpcclient.NewPaymentClient(cfg.PaymentServiceAddr) svc : service.NewOrderService(repo, payment) grpcServer : grpc.NewServer(cfg.GRPCPort, svc) go grpcServer.Start(ctx) -ctx.Done() grpcServer.GracefulStop() }组合根的另一个好处是换实现非常方便。开发环境想用内存仓储在 main.go 里把postgres.NewRepository换成memory.NewRepository就行要给服务加缓存也只需要在 main.go 里包一层带缓存的适配器。所有上层代码完全无感。我不太建议在小项目里引入 wire 这类依赖注入框架。手工组装这个阶段足够清晰wire 带来的收益主要体现在大型多模组项目里但会引入代码生成和额外的学习成本。先把手写组装做到干净利落比一开始就上框架靠谱。4.3 internal包与Go模块边界的意义internal目录是 Go 语言层面的访问控制只有同一主模块内的代码可以 importinternal下的包外部模块无论怎么 import 都会报错。这给六边形架构提供了很实在的保护。假设有同事图省事把core/domain的领域对象直接 import 到某个外部脚本里Go 编译器会直接拒绝因为那个脚本在模块边界之外。这种语言级的约束比“项目规范文档里写着不能越界”可靠多了。另外一个容易被忽略的好处是Go 的包管理让你在go get依赖时不会误拉别人项目里的internal代码很多开源库也用了同样的机制保护自己的内部实现。5. 启动与联调从 docker compose 到 grpcurl 全流程5.1 先把基础设施拉起来docker compose 起步本地联调的第一步通常是启动外部依赖。很多微服务项目卡在“代码跑起来了但服务起不来”根因往往是数据库、消息队列这些基础设施没就绪。一份最简单的 docker-compose.yml 只需要数据库services: postgres: image: postgres:16-alpine environment: POSTGRES_USER: order POSTGRES_PASSWORD: order POSTGRES_DB: order ports: - 5432:5432 healthcheck: test: [CMD-SHELL, pg_isready -U order] interval: 3s timeout: 3s retries: 10启动命令docker compose up -d docker compose ps等看到 postgres 的 health 状态变成 healthy 再启动业务服务。我也习惯在config.yaml里把数据库地址、服务监听端口、依赖服务地址全部用环境变量可覆盖的方式写出来联调换环境时就不用改代码重新编译。5.2 本地启动服务与健康检查基础设施就绪后启动业务服务go run ./cmd/server服务启动后先用健康检查确认 gRPC 服务器在监听。gRPC 官方提供grpc_health_probe这个工具注意它需要服务端实现grpc.health.v1.Health服务好在 grpc-go 生态里有现成包可以注册。如果不想引入健康检查包也可以先用 grpcurl 打一个真实 RPC 来验证。我个人的习惯是先在 main 里注册 health 服务因为 K8s 探活也要用它本地联调和上线用的是同一套机制能少踩很多环境差异的坑。5.3 用 grpcurl 和 grpcui 做接口验证grpcurl 是 gRPC 界的 curl最常用的几个操作# 列出服务 grpcurl -plaintext localhost:50051 list # 调用一元 RPC grpcurl -plaintext -d {user_id: u_101, items: [{product_id: p_1, quantity: 2}]} \ localhost:50051 order.v1.OrderService/CreateOrder # 查询订单 grpcurl -plaintext -d {order_id: xxx} localhost:50051 order.v1.OrderService/GetOrdergrpcurl 默认会找本地的 proto 文件做反射如果服务端开启了 gRPC 反射也可以不用提供 proto。实操时我建议至少开反射grpc-go 里注册 reflection 服务即可联调效率高很多。想要更直观的界面可以装 grpcuigrpcui -plaintext localhost:50051它会打开一个网页左侧是服务列表和方法表单右侧是响应的 JSON基本可以替代 Postman 来验证 gRPC 接口。我第一次用 grpcui 调试接口时省了不少时间特别是调带嵌套 message 的接口不用手写一大段 JSON。5.4 多服务联调时最容易倒下的地方本地跑通单个服务不难多个服务联调时才会暴露问题。我归纳了四个高频雷区。第一是 proto 版本不同步。两个服务共用同一个 protoA 改了字段编号但 B 没重新生成代码调用时会出现数据错乱。解决方法是把 proto 文件放到独立仓库或者用 buf 管理 schema用 CI 保证生成代码和 proto 文件同步。本地联调时要养成“先拉最新 proto、再生成代码、再编译”的习惯。第二是服务地址配置错误。联调环境里 A 服务调用 B 服务B 的地址在新环境发生了变化A 还在用旧地址。这个问题最隐蔽因为业务代码没问题、proto 也没问题就是连不上。建议把依赖服务地址都收敛到配置文件里并且启动时打印“当前连接的服务地址”一眼就能定位。第三是本地证书和明文传输的问题。联调环境默认用grpc.WithTransportCredentials(insecure.NewCredentials())建连生产环境切换成 TLS。很多团队在本地和生产之间用了两套截然不同的连接逻辑容易出岔子。建议把“是否启用 TLS”做成配置项而不是硬编码。第四是端口冲突。多个服务都想监听 50051第二个起不来。联调脚本里可以加一个启动前检测端口的小函数发现问题直接提示别等到日志刷屏才反应过来。联调的顺序我建议大家固定下来先看基础设施健康再确认本服务监听端口然后 grpcurl 直接打一个最简接口通了再往业务链路上接。跳步排查往往会浪费更多时间。6. 上线前必须处理的三类细节超时、错误映射与兼容性6.1 deadline 不设置goroutine 就会越积越多gRPC 客户端调用远程服务时默认没有超时时间。如果不给上下文设置 deadline下游服务一旦 hang 住调用方的 goroutine 就会一直挂着。在微服务链路里这种现象会一级级传递最终表现为整个集群的连接和 goroutine 越攒越多服务莫名其妙变得卡顿。我建议在适配器层统一处理超时。客户端每次调用都基于context.WithTimeout设置超时ctx, cancel : context.WithTimeout(ctx, 2*time.Second) defer cancel() resp, err : s.conn.CreateOrder(ctx, req)超时时间怎么定我的经验是先给一个保守值比如 2 秒然后在压测时根据 P99 延迟调整。如果是链路中间服务超时要明显短于上游给这个服务的 deadline留出余量如果服务本身是上游的最末端可以相对宽松一点。服务端也要响应 context 取消。在业务 service 里定期检查ctx.Err()一旦客户端取消或超时及时停止后续操作。很多服务端问题都出现在“客户端早就取消了服务端还在继续计算交易”。6.2 错误码映射比想象中重要如果服务端直接往 gRPC 返回一个普通 Go error客户端拿到的会是Unavailable或者Unknown这个语义对调用方几乎没用。gRPC 的错误码是全链路判断问题的关键信号我一般按下面这张表做映射业务场景领域错误gRPC 错误码请求参数非法ErrInvalidArgumentInvalidArgument资源不存在ErrOrderNotFoundNotFound资源已存在/冲突ErrOrderExistsAlreadyExists依赖下游失败包装后的下游错误Unavailable服务内部未知错误兜底错误Internal调用超时context.DeadlineExceededDeadlineExceeded映射逻辑要放在适配器出口不要在业务 core 里直接 importgoogle.golang.org/grpc/status否则领域核心就和 gRPC 协议耦合了。实现方式就是前面mapError那个函数。还有一个细节Internal错误返回给客户端时message 尽量用通用文案具体的堆栈和原因只写进服务端日志里避免泄露内部信息。6.3 proto 演进要守住几条硬规矩proto 文件是微服务之间的共享契约它和 REST 的 JSON 不同改起来有更强的兼容性约束。我把几条硬规矩列出来。字段编号一旦被使用就永久保留不能修改也不能复用。要删除字段时可以把编号标记为reserved防止未来不小心复用已经废弃的编号。新增字段永远使用新的编号。不要修改已有字段的数据类型很多类型变化从 proto 编译器角度是合法的比如把int32改成int64但线上通信时会导致数据被截断或错位。消息之间尽量用嵌套而不是顶层大量平铺字段新增字段时容易管理。接口方法如果要加参数优先给请求 message 添加字段而不是新增方法。还有一个经验proto 里不要塞业务状态字符串和魔法值所有枚举用enum定义。看到客户端直接传PAID这种字符串时我第一个反应就是“这协议后来没人维护了”用枚举至少在编译器层面能挡住一批低级错误。6.4 测试策略domain 用内存适配器adapter 用 bufconn六边形架构带来的最大红利之一就是测试不需要依赖真实外部资源。领域服务的测试用内存适配器即可package service type memoryOrderRepo struct { orders map[string]*domain.Order } func (r *memoryOrderRepo) Save(ctx context.Context, o *domain.Order) error { if r.orders nil { r.orders make(map[string]*domain.Order) } r.orders[o.ID] o return nil } func (r *memoryOrderRepo) FindByID(ctx context.Context, id string) (*domain.Order, error) { o, ok : r.orders[id] if !ok { return nil, domain.ErrOrderNotFound } return o, nil }测试里注入memoryOrderRepo和一个 fake 的PaymentClient就能把“创建订单成功、扣款失败回滚状态”这类核心业务路径跑通整个过程不需要连数据库、不需要起 gRPC 服务。gRPC 适配器层的测试可以用 bufconn它基于内存网络模拟真实连接不需要暴露端口适合跑在 CI 里。先把线程安全的 listener 建好启动 server然后在测试里用bufconn的Dialer建连调 proto 生成的 client 接口就能测到完整的 gRPC 请求链路。这个方法是我目前最推荐的 adapter 测试方式比“起一个真实端口再连”稳定得多也不会出现端口冲突。到这里Go 微服务基于六边形架构加 gRPC 的搭建路径基本完整了。我再分享一个亲测有效的小建议不要一开始就把端口设计得很完美先搭一个最小闭环——一个业务方法、一个出站端口、一个 gRPC 接口——跑通之后再逐个加方法。边写边体会“修改适配器不影响 core”的感觉比看多少篇架构文章都更有说服力。等团队里有人开始主动问“这个接口应该放 core 还是 adapter”的时候说明这个架构已经在你的代码库里真正扎根了。
返回列表