☰
Go微服务实战:六边形架构+gRPC适配器搭建指南
2026/9/26 16:54:17 网站建设 项目流程

最近在帮团队把一套跑了好几年的单体订单服务拆成微服务,架构选型讨论到最后没有悬念:Go + gRPC + 六边形架构(Hexagonal Architecture)。这个组合在 Go 社区不算新鲜,但真正动手落地的时候,你会发现网上文章讲概念的居多,能照着把项目骨架搭起来、把 gRPC 适配器接进去、再跑通联调的实战资料反而分散。这篇文章记录了我从零搭建这套项目时的完整路径,包括六边形架构的核心思想、端口和适配器在 Go 代码里的落法、gRPC 的接入方式,以及启动联调和上线前必须处理的细节。如果你正在设计 Go 微服务的边界,或者刚接触六边形架构不知道从哪下手,这篇应该能帮你省不少弯路。

1. 六边形架构怎么解开三层架构的死结

1.1 三层架构的依赖方向为什么是个问题

很多从 Java/Spring 转过来的朋友都问过我同一个问题:为什么 Go 微服务里这么推崇六边形架构,三层架构不是用得挺好吗?要回答这个问题,得先看三层架构的依赖方向。

控制器(Controller)调 Service,Service 调 DAO/Repository,数据一路从数据库经过 Service 加工再返回给视图。这套模型在单体应用里非常直观,问题在于依赖方向是“自上而下”的——顶层控制器认识 Service,Service 认识 DAO,DAO 认识具体数据库。一旦业务逻辑变了,Service 要改;一旦数据库从 MySQL 换成 PostgreSQL,DAO 层要改;一旦要把 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-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest

然后定义一个 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/adapter,internal/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/GetOrder

grpcurl 默认会找本地的 proto 文件做反射,如果服务端开启了 gRPC 反射,也可以不用提供 proto。实操时我建议至少开反射(grpc-go 里注册 reflection 服务即可),联调效率高很多。

想要更直观的界面,可以装 grpcui:

grpcui -plaintext localhost:50051

它会打开一个网页,左侧是服务列表和方法表单,右侧是响应的 JSON,基本可以替代 Postman 来验证 gRPC 接口。我第一次用 grpcui 调试接口时省了不少时间,特别是调带嵌套 message 的接口,不用手写一大段 JSON。

5.4 多服务联调时最容易倒下的地方

本地跑通单个服务不难,多个服务联调时才会暴露问题。我归纳了四个高频雷区。

第一是 proto 版本不同步。两个服务共用同一个 proto,A 改了字段编号但 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”的时候,说明这个架构已经在你的代码库里真正扎根了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询