把一个单体应用拆成多个微服务之后,最先撞上的问题往往不是服务间通信,而是代码到底该怎么放。Go语言的package机制和Go Modules依赖管理工具,为微服务场景提供了相当灵活的组织能力,但用得不好也容易陷入依赖混乱、循环引用、共享代码到处复制的窘境。这篇文章结合实际项目经验,聊聊Go在微服务中的代码组织方案。

单仓库多服务:最常见的组织方式
中小团队做微服务,最普遍的选择是Monorepo,也就是把所有服务放在一个Git仓库里管理。Go Modules天然支持这种模式:一个仓库一个go.mod,各个服务作为子目录存在。这种布局的好处是依赖版本统一、代码复用方便、跨服务重构成本低,一次提交可以同时修改多个服务。
一个典型的目录结构大致是这样的:
my-project/
├── go.mod
├── go.sum
├── cmd/ # 各服务的入口目录
│ ├── user-service/
│ │ └── main.go
│ ├── order-service/
│ │ └── main.go
│ └── gateway/
│ └── main.go
├── internal/ # 私有代码,禁止被外部仓库导入
│ ├── user/ # 用户域的领域逻辑
│ ├── order/ # 订单域的领域逻辑
│ └── platform/ # 平台级共享代码:日志、配置、中间件
└── pkg/ # 可被外部项目安全引用的公共库
└── validator/
这个结构的核心思想是cmd只放薄薄一层启动逻辑,真正的业务实现全部下沉到internal。每个服务的main.go往往只有二十来行:加载配置、初始化依赖、启动HTTP或gRPC服务器。这样做的好处是服务的启动方式一目了然,排查问题的时候不用在入口文件里翻找半天。
需要特别说明的是pkg目录并不是必须的,它更像一种约定:放在这里的代码意味着允许外部项目导入,所以要承担兼容性责任。如果你没有对外提供公共库的需求,整个项目都可以不设这个目录。
internal目录:Go防止依赖泄漏的官方机制
很多从Java转过来的开发者会忽略internal目录的特殊性。这是Go编译器在语言层面实现的规则:位于internal下的包,只能被以该internal的父目录为根的子树内的代码导入。外部模块试图导入时,编译器会直接报错。
举个例子,假设项目模块名是github.com/yourteam/my-project,那么my-project/internal/user这个包只能在my-project仓库内部被引用。哪怕你把仓库公开了,别人写import "github.com/yourteam/my-project/internal/user"也会编译失败。
在微服务场景下,这个机制非常有价值。服务A的领域逻辑不应该被服务B直接import,否则所谓的服务边界就成了摆设,久而久之又会退化成“分布式的单体”。实践中有两种约束方式:第一种是每个服务一个顶级目录,各自维护自己的internal:
my-project/
├── services/
│ ├── user/
│ │ ├── cmd/main.go
│ │ └── internal/ # 只有user服务内部能用
│ │ ├── handler/
│ │ └── repo/
│ └── order/
│ ├── cmd/main.go
│ └── internal/
│ ├── handler/
│ └── repo/
└── internal/ # 全仓库共享的平台代码
└── database/
这种结构下,services/user/internal里的代码只有user服务能导入,order服务强行引用会直接编译报错,边界由编译器保证而不是靠code review自觉。第二种方式是配合lint规则,通过golangci-lint的depguard插件限制import路径,适合目录结构已经定型、不方便调整的老项目。
多模块仓库与共享库抽取的取舍
单go.mod的Monorepo有一个明显痛点:改一行公共代码,所有服务的CI都要重新构建,编译缓存也容易失效。当仓库膨胀到几十个服务、代码量上百万行时,可以考虑Go 1.18之后正式支持的multi-module仓库,也就是在一个Git仓库里放多个go.mod:
my-project/
├── go.work # 工作区文件,串联多个模块
├── services/
│ ├── user/
│ │ ├── go.mod // module github.com/yourteam/my-project/user
│ │ └── main.go
│ └── order/
│ ├── go.mod
│ └── main.go
└── shared/
├── go.mod // module github.com/yourteam/my-project/shared
└── logger/
顶层的go.work文件让本地开发时多个模块之间可以直接互相引用,不需要频繁执行replace或发布版本。命令很简单:go work init ./shared ./services/user即可创建工作区。需要注意go.work只影响本地开发,提交到代码仓库后其他同事也能共享同样的开发环境,但生产构建一般还是走正常的版本依赖。
至于共享代码要不要抽成独立仓库,建议遵循一个原则:稳定的基础设施代码(日志封装、消息队列客户端、统一错误码)适合抽成独立模块或独立仓库,由专人维护并打版本号;业务层面的公共逻辑则尽量通过服务接口暴露而不是共享包,否则一次改动会像多米诺骨牌一样波及所有下游。Go Modules的语义化版本管理在这里能发挥很大作用,共享库遵循v2以上的版本要携带版本后缀的规则,依赖方升级时也有明确的预期。
总结一下选型思路:服务少、团队小,单模块Monorepo加internal约束最省心;服务规模上来后引入go.work做多模块拆分;只有当某些代码需要跨团队复用且变更节奏与服务本身不同步时,才值得抽成独立仓库。结构没有绝对的好坏,关键是要让服务边界清晰、依赖方向单一,这些目标达成,代码组织就不会成为微服务的负担。