mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4846 字
13 分钟
Golang 整洁架构实践
2022-05-25

一、Golang 整洁架构实践#

为什么要谈开发规范#

在快节奏的软件开发领域,开发规范是提升团队效率和软件质量的关键。统一的编码标准确保代码一致性,降低理解和维护成本。明确的命名约定、格式和注释规则让代码更易于阅读和修改。遵循清晰的架构和布局规范,可帮助团队确保软件设计的清晰性和一致性,使系统更易于理解和扩展。它们指导开发者如何组织代码结构、如何分离关注点以及如何实现模块化,这些都是确保软件长期可维护性的关键因素。

Golang 在编程规范和工程化方面具有一些显著优势:

  • 简洁的语法:Go 语言的语法简洁明了,减少了编写复杂代码的可能性,使代码更易于理解和维护。对于同一需求,不同开发者总能写出类似的代码。
  • 强制格式化以及丰富的静态分析工具:Go 语言提供了 gofmtgolintgo vet 等工具,可自动格式化代码、静态检测代码规范和问题,确保代码风格一致性并减少基本代码质量问题。
  • 明确的命名约定:Go 语言鼓励使用易于理解的命名方式,如驼峰式命名法,这有助于提高代码可读性。Go 官方也定义了如何编写测试以及测试代码的命名规范,这些举措明显提升了工程一致性。
  • 内置的并发支持:Go 语言内置对并发编程的支持(如 goroutine 和 channel),使编写高效、可靠的并发代码更加容易。

当然 Go 也存在一些缺点,例如通过大小写决定变量是否导出,以及 internal 包不可被其他库导入,这些都是相对隐藏的规则。

由于 Golang 是一门通用编程语言,在不同开发场景下存在不同的开发规范。但在最经典常见的 Web 开发领域,我认为有必要定义一套公认的最佳编码实践,就像 Java 的 SpringBoot 全家桶一样,让任何人都能迅速上手并投入开发,减少人工编写样板代码,提升整体开发效率。

为照顾更多场景,我计划对标 start.spring.io

  1. 介绍什么是整洁架构,代码应当如何进行分层,如何编写松耦合、易于测试和易于维护的代码。
  2. 介绍业内最佳实践,如日志封装与使用、错误处理、国际化、链路跟踪、依赖注入等。
  3. 如何快速创建此类项目,快速创建业务实体并生成样板代码,制作项目脚手架工具。

什么是整洁架构#

整洁架构(Clean Architecture) 是由 Robert C. Martin 提出的一种软件架构风格,旨在实现软件系统的高内聚、低耦合,以及业务逻辑的独立性。这种架构风格在多种编程语言中都很流行,尤其适用于需要处理复杂业务逻辑和多个外部依赖的系统。关于它的介绍和优缺点,在前面的链接或网络上已有大量资料,此处不再赘述。这里主要阐述如何将整洁架构应用于 Golang 开发。首先,来看整洁架构的同心圆分层图:

graph TD subgraph "Clean Architecture 同心圆" E[" Entities<br/>领域模型 / 业务实体"] U[" Use Cases<br/>业务用例 / 应用逻辑"] I[" Interface Adapters<br/>接口适配器<br/>(Repository / Controller)"] F[" Frameworks & Drivers<br/>框架与驱动<br/>(Web / DB / External)"] end E ~~~ U U ~~~ I I ~~~ F style E fill:#e3f2fd,stroke:#1565c0,stroke-width:3px,color:#0d47a1 style U fill:#e8f5e9,stroke:#2e7d32,stroke-width:3px,color:#1b5e20 style I fill:#fff8e1,stroke:#f9a825,stroke-width:3px,color:#e65100 style F fill:#ffebee,stroke:#c62828,stroke-width:3px,color:#b71c1c

依赖规则(Dependency Rule):箭头只能从外层指向内层。内层的代码(Entities)不能依赖外层的代码(Frameworks),但外层可以依赖内层。这保证了核心业务逻辑不会被外部框架或数据库实现所污染。

一言以蔽之:所有业务应当围绕 entities 来创建,在 use cases 中实现业务,使用接口/适配器的形式接入网关、持久化、控制器等组件,最外层将适配器连接到具体的框架或驱动上。

在代码结构上体现为:

  • models 层:对应于 entities,用于定义业务所需的领域模型
  • repository 层:定义领域模型的持久层操作接口及具体实现,一般情况下与 model 是 1:1 关系,但也不限定可同时操作多个模型
  • use cases 层:接收领域模型和基本类型参数,依赖 repository 接口实现业务逻辑,根据业务模块划分,一个用例可同时对应多个实体
  • api 层:简单适配框架的入参与出参,调用 use cases 代码将业务逻辑暴露出去

下面这张图展示了一个典型的 HTTP 请求如何流经各层,以及依赖方向如何由外向内:

sequenceDiagram participant Client participant API as API 层 (Handler) participant UC as Use Case 层 participant Repo as Repository 接口 participant Impl as Repository 实现 participant DB as 数据库 Client->>API: HTTP Request activate API API->>API: 参数绑定 & 校验 API->>UC: 调用业务方法 activate UC UC->>Repo: 调用接口方法 activate Repo Repo->>Impl: 接口分发到具体实现 activate Impl Impl->>DB: SQL / ORM 操作 activate DB DB-->>Impl: 返回数据 deactivate DB Impl-->>Repo: 转换为领域模型 deactivate Impl Repo-->>UC: 返回领域对象 deactivate Repo UC-->>API: 返回业务结果 deactivate UC API-->>Client: HTTP Response (JSON) deactivate API Note over Client,DB: 依赖方向:API → UseCase → Repository 接口 ← Repository 实现 → DB

因此可以创建以下包结构:

.
├── app
├── models
├── go.mod
├── go.sum
├── internal
└── pkg

领域模型与持久化模型不一致时如何处理?#

在真实的业务场景中,领域模型(Domain Model)与持久化模型(Persistence Model)往往并不一致。领域模型关注业务概念和业务规则,而持久化模型关注如何高效地存储和检索数据。例如,用户注册时填写的密码在领域模型中可能是明文字符串,但在持久化时必须存储哈希值以确保安全性。

处理这种不一致的常见策略是双模型设计:在 models 层定义干净的领域模型,在 repository 层定义持久化模型,两者的转换在 repository 实现中完成。领域模型应该是一个纯粹的业务载体,不包含任何序列化或数据库相关的标签:

// 领域模型 - 干净的 Go 结构体
type User struct {
ID string
Username string
Email string
Password string // 明文密码,仅在内存中使用
CreatedAt time.Time
}

而持久化模型可以包含 ORM 框架所需的标签,如 gorm 的列名定义、json 的序列化配置等:

// 持久化模型 - 包含存储元数据
type UserEntity struct {
ID string `gorm:"column:id;primaryKey"`
Username string `gorm:"column:username"`
Email string `gorm:"column:email"`
Password string `gorm:"column:password_hash"` // 存储哈希而非明文
CreatedAt int64 `gorm:"column:created_at"`
}

两者之间的转换逻辑应当放在 repository 层:

// repository 内部的转换函数
func (r *userRepository) toDomain(e *UserEntity) *User {
return &User{
ID: e.ID,
Username: e.Username,
Email: e.Email,
Password: "", // 领域模型中不返回密码
CreatedAt: time.Unix(e.CreatedAt, 0),
}
}
func (r *userRepository) toEntity(u *User) *UserEntity {
return &UserEntity{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Password: hashPassword(u.Password), // 写入时哈希
CreatedAt: u.CreatedAt.Unix(),
}
}

这种设计的好处是领域模型始终保持干净,不被持久化 concerns 污染。当需要更换 ORM 框架或数据库时,只需要修改 repository 层的实现,领域逻辑无需改动。

另一种策略是贫血模型 + 映射器模式:直接使用扁平的数据库表结构作为领域模型,通过独立的映射器(Mapper)将数据库记录转换为业务对象。这种方式适合业务逻辑简单的场景,可以减少对象转换的开销。

如何编写权限校验代码?#

权限校验是后台系统的常见需求,通常分为全局/路由级权限校验业务级权限校验两个层次。前者在请求到达业务逻辑之前拦截,后者嵌入在业务逻辑内部根据具体数据判断。

全局/路由级权限校验#

全局权限校验通常以中间件的形式实现,在请求进入具体的 use case 之前完成拦截。以 JWT 认证为例,常见的设计是在 api 层注册中间件,从请求头中提取 token、验证签名、解析出用户身份,然后将用户 ID 注入到请求上下文中:

api/middleware/auth.go
func JWTAuthMiddleware() func(c *gin.Context) {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
c.AbortWithStatusJSON(401, gin.H{"error": "missing token"})
return
}
claims, err := parseToken(strings.TrimPrefix(token, "Bearer "))
if err != nil {
c.AbortWithStatusJSON(403, gin.H{"error": "invalid token"})
return
}
// 将用户 ID 注入到上下文
c.Set("user_id", claims.UserID)
c.Next()
}
}

对于路由级的权限控制,可以利用框架的路由组功能,将需要相同权限的路由聚合在一起:

api/routes/user.go
userGroup := r.Group("/users", JWTAuthMiddleware(), RequireRole("admin"))
{
userGroup.GET("", userHandler.List)
userGroup.POST("", userHandler.Create)
userGroup.DELETE("/:id", userHandler.Delete)
}

这种方式将权限配置从业务代码中分离出来,权限规则一目了然。

业务级权限校验#

路由级权限只能控制到「能否访问某个接口」,但无法控制「能否操作某条具体数据」。例如,所有管理员都能访问「删除用户」接口,但管理员 A 可能只能删除自己创建的部门下的用户,这时就需要业务级权限校验。

业务级权限校验的核心是在 use case 层根据具体的数据 Owner 和当前操作者的关系进行判断。常见的模式有 RBAC(基于角色的访问控制)和 ABAC(基于属性的访问控制):

use_cases/user_usecase.go
func (uc *userUseCase) Delete(ctx context.Context, operatorID, targetUserID string) error {
operator, err := uc.userRepo.FindByID(ctx, operatorID)
if err != nil {
return ErrOperatorNotFound
}
targetUser, err := uc.userRepo.FindByID(ctx, targetUserID)
if err != nil {
return ErrUserNotFound
}
if operator.Role == "department_admin" {
if targetUser.CreatedBy != operator.ID {
return ErrPermissionDenied
}
}
return uc.userRepo.Delete(ctx, targetUserID)
}

一个更好的设计是将权限判断逻辑抽取为独立的权限服务(Permission Service):

domain/permission.go
type PermissionService interface {
CanAccess(ctx context.Context, subject, object interface{}, action string) (bool, error)
}
// use_cases/user_usecase.go
func (uc *userUseCase) Delete(ctx context.Context, operatorID, targetUserID string) error {
allowed, err := uc.permission.CanAccess(ctx, operatorID, targetUserID, "delete")
if err != nil {
return err
}
if !allowed {
return ErrPermissionDenied
}
return uc.userRepo.Delete(ctx, targetUserID)
}

这样权限规则可以独立于业务逻辑存在,便于集中管理和修改。

错误码使用的最佳实践#

错误处理是工程实践中容易忽视但至关重要的环节。在 Go 语言中,错误通常是一个 error 接口,传统的做法是返回 errors.New("xxx")fmt.Errorf("xxx %d", arg) 构造错误值。然而,当系统规模扩大、团队成员增多时,字符串化的错误信息会带来维护困难——字符串字面量散落在代码各处,拼写错误难以发现,客户端难以程序化处理。

一个实用的错误码规范应该满足以下需求:客户端可程序化处理错误类型可识别错误信息人类可读错误链可追溯

错误码定义#

常见做法是定义一个错误码常量池,每个错误码对应一个具体的业务错误:

models/error_code.go
package models
// 错误码采用 6 位数字格式:模块(2位) + 具体错误(4位)
const (
ErrCodeOK = 0
ErrCodeInternal = 100001 // 服务器内部错误
ErrCodeInvalidParam = 100002 // 参数错误
ErrCodeUserNotFound = 101001 // 用户不存在
ErrCodeUserAlreadyExists = 101002 // 用户已存在
ErrCodePasswordIncorrect = 101003 // 密码错误
ErrCodeOrderNotFound = 102001 // 订单不存在
ErrCodeOrderStatusInvalid = 102002 // 订单状态不允许此操作
)

错误结构体设计#

有了错误码,还需要一个承载完整错误信息的结构体:

models/error.go
type BusinessError struct {
Code int `json:"code"`
Message string `json:"message"`
Detail string `json:"detail,omitempty"`
Cause error `json:"-"`
}
func (e *BusinessError) Error() string {
return fmt.Sprintf("[%d] %s: %v", e.Code, e.Message, e.Cause)
}
func NewBusinessError(code int, message string, cause error) *BusinessError {
return &BusinessError{Code: code, Message: message, Cause: cause}
}
var (
ErrUserNotFound = NewBusinessError(ErrCodeUserNotFound, "用户不存在", nil)
ErrPasswordIncorrect = NewBusinessError(ErrCodePasswordIncorrect, "密码错误", nil)
)

错误处理流程#

在 repository、use case、api 各层之间错误应该逐层传递和转换。原则是:底层返回技术错误,上层转换为业务错误

// repository 层 - 数据库操作失败时转换为业务错误
func (r *userRepo) FindByID(ctx context.Context, id string) (*User, error) {
var entity UserEntity
if err := r.db.WithContext(ctx).First(&entity, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, NewBusinessError(ErrCodeInternal, "查询用户失败", err)
}
return toDomain(&entity), nil
}
// use case 层 - 业务逻辑错误
func (uc *userUseCase) Login(ctx context.Context, username, password string) (*User, error) {
user, err := uc.userRepo.FindByUsername(ctx, username)
if err != nil {
return nil, err
}
if !verifyPassword(password, user.PasswordHash) {
return nil, NewBusinessError(ErrCodePasswordIncorrect, "密码错误", nil)
}
return user, nil
}
// api 层 - 错误最终以统一格式返回
func (h *userHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"code": ErrCodeInvalidParam, "message": "参数错误"})
return
}
user, err := h.userUseCase.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
var be *BusinessError
if errors.As(err, &be) {
c.JSON(200, gin.H{"code": be.Code, "message": be.Message})
return
}
c.JSON(500, gin.H{"code": ErrCodeInternal, "message": "服务器内部错误"})
return
}
c.JSON(200, gin.H{"code": 0, "message": "登录成功", "data": user})
}

这样设计的好处是:错误码全局唯一,便于日志检索和问题定位;错误信息中英分离,便于国际化处理;错误链完整保留,便于线上问题追溯。

国际化功能实现#

国际化(i18n)和本地化(l10n)是后台系统不可忽视的需求。当系统需要面向不同语言区域的用户提供服务时,所有面向用户的文本信息——包括错误提示、菜单项、按钮文字、邮件模板等——都不能写死在代码中,而应该抽取到独立的语言资源文件中。

Go 生态中常用的国际化方案是 golang.org/x/text/language 配合 github.com/nicksnyder/go-i18ngo-playground/validator 等库。这里介绍一种轻量且实用的实现思路。

语言资源文件设计#

按照语言创建 JSON 或 TOML 格式的资源文件,放在 locales/ 目录下:

locales/zh-CN.json
{
"user": {
"not_found": "用户不存在",
"login_success": "登录成功",
"login_failed": "登录失败,用户名或密码错误"
},
"error": {
"invalid_param": "参数 {0} 校验失败: {1}",
"internal": "服务器内部错误,请稍后重试"
}
}
locales/en-US.json
{
"user": {
"not_found": "User not found",
"login_success": "Login successful",
"login_failed": "Login failed: incorrect username or password"
},
"error": {
"invalid_param": "Parameter {0} validation failed: {1}",
"internal": "Internal server error, please try again later"
}
}

翻译服务封装#

将语言切换逻辑封装为独立的翻译服务,便于在任意位置注入使用:

i18n/translator.go
type Translator struct {
bundle *i18n.Bundle
}
func NewTranslator() *Translator {
t := &Translator{bundle: i18n.NewBundle(language.English)}
t.bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
t.bundle.LoadMessageFile("locales/zh-CN.json")
t.bundle.LoadMessageFile("locales/en-US.json")
return t
}
func (t *Translator) Translate(tag, messageID string, args ...interface{}) string {
trans, _ := t.bundle.Translate(language.MustParse(tag), messageID, args...)
return trans
}

在错误处理中使用翻译#

更优雅的做法是让 BusinessError 携带错误消息的翻译 key,在 api 层统一处理翻译:

type BusinessError struct {
Code int `json:"code"`
MessageKey string `json:"-"`
Args []interface{} `json:"-"`
Cause error `json:"-"`
}

这样 repositoryuse case 层只需要传递翻译 key 和参数,不必关心当前是哪种语言。语言切换在 api 层的中间件中完成,从请求头或用户设置中读取语言偏好,注入到请求上下文中。

完整的国际化实现还需要注意日期时间格式、货币格式、数字格式等本地化处理,Go 的 golang.org/x/text 包提供了这些功能,可以按需引入。

依赖注入(Dependency Injection)#

依赖注入(DI)是实现整洁架构的关键技术手段。通过 DI,各层之间的依赖关系不再通过硬编码的 import 或直接构造来建立,而是通过接口和构造函数参数传递。这使得每一层都可以独立测试和替换,真正实现依赖反转原则(Dependency Inversion Principle)。

构造函数注入模式#

在 Go 中,最常见且推荐的 DI 方式是构造函数注入(Constructor Injection)。每一层的结构体通过构造函数接收其依赖的接口:

use_cases/user_usecase.go
type UserUseCase interface {
GetByID(ctx context.Context, id string) (*User, error)
Create(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
}
type userUseCase struct {
userRepo repository.UserRepository
permission PermissionService
translator *i18n.Translator
logger *slog.Logger
}
// 构造函数注入所有依赖
func NewUserUseCase(
userRepo repository.UserRepository,
permission PermissionService,
translator *i18n.Translator,
logger *slog.Logger,
) UserUseCase {
return &userUseCase{
userRepo: userRepo,
permission: permission,
translator: translator,
logger: logger,
}
}

api 层同理,通过构造函数接收 UseCase 接口:

api/user_handler.go
type UserHandler struct {
userUseCase use_cases.UserUseCase
}
func NewUserHandler(uc use_cases.UserUseCase) *UserHandler {
return &UserHandler{userUseCase: uc}
}

手动组装依赖#

在小型项目中,可以在 main.goapp 包中手动组装所有依赖:

app/app.go
func InitApp() (*App, error) {
// 1. 基础设施层
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed to connect database: %w", err)
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
translator := i18n.NewTranslator()
// 2. Repository 层
userRepo := repository.NewUserRepository(db, logger)
// 3. Use Case 层(依赖注入)
userUC := use_cases.NewUserUseCase(userRepo, permissionSvc, translator, logger)
// 4. API 层(依赖注入)
userHandler := api.NewUserHandler(userUC)
// 5. 路由注册
router := gin.Default()
api.RegisterRoutes(router, userHandler)
return &App{Router: router, DB: db}, nil
}

使用 Wire 自动注入#

当项目规模增大、依赖关系变复杂时,手动组装变得冗长且容易出错。Google 的 Wire 是 Go 生态中最成熟的编译期依赖注入工具,在编译时生成依赖注入代码,不引入运行时反射开销:

app/wire.go
//go:build wireinject
func InitApp() (*App, error) {
wire.Build(
provideDB, provideLogger, provideTranslator,
repository.NewUserRepository,
use_cases.NewUserUseCase,
api.NewUserHandler,
provideRouter,
wire.Struct(new(App), "*"),
)
return nil, nil
}

运行 wire 命令后,Wire 自动生成 wire_gen.go,包含完整的依赖组装逻辑。

接口绑定与依赖反转#

DI 的核心在于面向接口编程。在 use case 层定义所需的接口,而不是直接依赖具体的 repository 实现:

use_cases/interfaces.go
type UserRepository interface {
FindByID(ctx context.Context, id string) (*models.User, error)
FindByUsername(ctx context.Context, username string) (*models.User, error)
Create(ctx context.Context, user *models.User) error
Delete(ctx context.Context, id string) error
}

repository 层提供具体实现并确保编译期满足接口约束:

repository/user_repository.go
type userRepository struct {
db *gorm.DB
logger *slog.Logger
}
var _ use_cases.UserRepository = (*userRepository)(nil)
func NewUserRepository(db *gorm.DB, logger *slog.Logger) use_cases.UserRepository {
return &userRepository{db: db, logger: logger}
}

这种设计的优势在于:当需要替换数据库(如从 PostgreSQL 切换到 MongoDB),只需要新增一个实现 UserRepository 接口的结构体,在 DI 容器中替换 Provider 即可,use case 层代码完全不受影响。

测试策略#

整洁架构的一大优势是每一层都可以独立测试。由于层与层之间通过接口解耦,可以轻松地为每一层编写单元测试,使用 Mock 或 Stub 替代真实的依赖。

各层测试职责#

层级测试重点依赖替代方式
API 层HTTP 路由、参数绑定、响应格式httptest 模拟请求,Mock UseCase
Use Case 层业务逻辑正确性Mock Repository 接口
Repository 层数据库交互正确性SQLite 内存库或 Docker 临时数据库
Models 层纯数据结构,通常不需要单元测试

Repository 层:集成测试#

Repository 层的测试涉及真实数据库操作,推荐使用 SQLite 内存数据库或 Docker 启动临时数据库实例:

repository/user_repository_test.go
type UserRepositoryTestSuite struct {
suite.Suite
db *gorm.DB
repo use_cases.UserRepository
}
func (s *UserRepositoryTestSuite) SetupSuite() {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
s.Require().NoError(err)
db.AutoMigrate(&UserEntity{})
s.db = db
s.repo = NewUserRepository(db, slog.Default())
}
func (s *UserRepositoryTestSuite) TestCreate() {
user := &models.User{ID: "user-001", Username: "testuser", Email: "test@example.com"}
err := s.repo.Create(context.Background(), user)
assert.NoError(s.T(), err)
found, err := s.repo.FindByID(context.Background(), "user-001")
assert.NoError(s.T(), err)
assert.Equal(s.T(), "testuser", found.Username)
}

Use Case 层:基于 Mock 的单元测试#

Use Case 层是业务逻辑的核心,应当使用 Mock 替代 Repository 接口。推荐 gomocktestify/mock

use_cases/user_usecase_test.go
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*models.User, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil { return nil, args.Error(1) }
return args.Get(0).(*models.User), args.Error(1)
}
func (m *MockUserRepository) Create(ctx context.Context, user *models.User) error {
return m.Called(ctx, user).Error(0)
}
func (m *MockUserRepository) Delete(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
func TestUserUseCase_GetByID_Success(t *testing.T) {
mockRepo := new(MockUserRepository)
uc := NewUserUseCase(mockRepo, nil, nil, slog.Default())
expectedUser := &models.User{ID: "user-001", Username: "testuser"}
mockRepo.On("FindByID", mock.Anything, "user-001").Return(expectedUser, nil)
user, err := uc.GetByID(context.Background(), "user-001")
assert.NoError(t, err)
assert.Equal(t, "testuser", user.Username)
mockRepo.AssertExpectations(t)
}
func TestUserUseCase_GetByID_NotFound(t *testing.T) {
mockRepo := new(MockUserRepository)
uc := NewUserUseCase(mockRepo, nil, nil, slog.Default())
mockRepo.On("FindByID", mock.Anything, "non-existent").Return(nil, ErrUserNotFound)
user, err := uc.GetByID(context.Background(), "non-existent")
assert.Error(t, err)
assert.Nil(t, user)
}

API 层:HTTP 接口测试#

使用 net/http/httptest 进行 HTTP 级别的测试,Mock 掉 Use Case 层:

api/user_handler_test.go
func TestUserHandler_GetByID(t *testing.T) {
gin.SetMode(gin.TestMode)
mockUC := new(MockUserUseCase)
handler := NewUserHandler(mockUC)
mockUC.On("GetByID", mock.Anything, "u1").
Return(&models.User{ID: "u1", Username: "testuser"}, nil)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "u1"}}
c.Request = httptest.NewRequest(http.MethodGet, "/users/u1", nil)
handler.GetByID(c)
assert.Equal(t, http.StatusOK, w.Code)
mockUC.AssertExpectations(t)
}

接口契约测试#

对于多个 Repository 实现的场景(如 MySQL、PostgreSQL),可以使用契约测试确保所有实现行为一致:

repository/contract_test.go
func UserRepositoryContract(t *testing.T, repo use_cases.UserRepository) {
t.Run("Create and FindByID", func(t *testing.T) {
user := &models.User{ID: "c-001", Username: "contract_test"}
require.NoError(t, repo.Create(context.Background(), user))
found, err := repo.FindByID(context.Background(), "c-001")
require.NoError(t, err)
assert.Equal(t, "contract_test", found.Username)
})
}

项目脚手架#

一个成熟的项目不仅需要合理的架构设计,还需要配套的工程化工具来提升开发效率。

推荐项目结构#

结合整洁架构四层模型和 Go 社区的 Standard Go Project Layout,推荐如下目录结构:

myapp/
├── cmd/
└── server/
└── main.go # 程序入口
├── internal/
├── models/ # Entities:领域模型
├── repository/ # Repository 接口与实现
└── postgres/ # PostgreSQL 具体实现
├── usecase/ # Use Cases:业务逻辑
└── interfaces.go # 依赖的接口定义
├── api/ # API 层:HTTP Handler
├── handler/
├── middleware/
└── router.go
├── i18n/ # 国际化
└── app/ # 应用组装 & Wire
├── pkg/ # 可被外部导入的公共包
├── locales/ # 语言资源文件
├── migrations/ # 数据库迁移脚本
├── configs/ # 配置文件
├── Makefile
├── Dockerfile
├── docker-compose.yml
├── go.mod
└── go.sum

Makefile 示例#

# Makefile
APP_NAME := myapp
VERSION := $(shell git describe --tags --always --dirty)
LDFLAGS := -ldflags "-X main.Version=$(VERSION)"
.PHONY: all build run test lint clean wire docker
all: lint test build
build:
CGO_ENABLED=0 go build $(LDFLAGS) -o bin/$(APP_NAME) ./cmd/server
run:
go run ./cmd/server
test:
go test -v -race -coverprofile=coverage.out ./...
test-unit:
go test -v -race -short ./...
lint:
golangci-lint run ./...
wire:
cd internal/app && wire
migrate-up:
migrate -path migrations -database "$(DB_URL)" up
migrate-create:
migrate create -ext sql -dir migrations -seq $(NAME)
docker:
docker build -t $(APP_NAME):$(VERSION) .
clean:
rm -rf bin/ coverage.out

Taskfile 替代方案#

Taskfile 是一个用 YAML 编写的任务运行器,语法更简洁,跨平台兼容性更好:

# Taskfile.yml — 精简配置示例
version: "3"
vars:
APP_NAME: myapp
tasks:
build:
cmds: [go build -o bin/{{.APP_NAME}} ./cmd/server]
test:
cmds: [go test -v -race ./...]
wire:
dir: internal/app
cmds: [wire]
docker:
cmds: [docker build -t {{.APP_NAME}} .]

Docker 多阶段构建#

多阶段构建可以大幅减小最终镜像体积,分为构建阶段运行阶段

# 构建阶段
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-w -s -X main.Version=$(git describe --tags --always)" \
-o /app/server ./cmd/server
# 运行阶段
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
COPY configs/ ./configs/
COPY locales/ ./locales/
RUN adduser -D -g '' appuser
USER appuser
EXPOSE 8080
ENTRYPOINT ["./server"]

配合 docker-compose.yml 搭建本地开发环境:

services:
app:
build: .
ports: ["8080:8080"]
environment: [DB_HOST=postgres, DB_NAME=myapp, DB_USER=myapp, DB_PASSWORD=secret]
depends_on:
postgres: { condition: service_healthy }
postgres:
image: postgres:16-alpine
environment: { POSTGRES_DB: myapp, POSTGRES_USER: myapp, POSTGRES_PASSWORD: secret }
ports: ["5432:5432"]
healthcheck: { test: ["CMD-SHELL", "pg_isready -U myapp"], interval: 5s, retries: 5 }

二、常见问题#

Q1:整洁架构一定要分四层吗?#

不是。Robert C. Martin 的整洁架构定义的是依赖方向原则(依赖只能从外层指向内层),而非固定的层数。四层(models → repository → use cases → api)是 Web 服务的常见划分,但实际项目可以根据复杂度调整。关键在于保持依赖方向正确,核心业务逻辑不依赖外部框架。

Q2:整洁架构会不会导致代码量增加?#

是的,整洁架构会增加一定的代码量(接口定义、依赖注入、DTO 转换等),这是关注点分离的代价。但对于中大型项目,这种付出是值得的:业务逻辑与基础设施解耦后,替换数据库、缓存或 HTTP 框架时只需修改外层代码,内层完全不变。小型项目可以适当简化,不必严格遵循所有层次。

Q3:Go 项目应该使用依赖注入框架吗?#

Go 社区对此有分歧。google/wire 提供编译期依赖注入,类型安全且无运行时开销,适合大型项目。但许多 Go 开发者更倾向手动在 main.go 中组装依赖,认为这更符合 Go 的显式哲学。建议:小型项目手动组装,大型项目或依赖关系复杂时使用 wire。

Q4:internal 包和整洁架构有什么关系?#

internal 是 Go 的包可见性机制,编译器强制阻止外部导入。在整洁架构中,可以用 internal/domain 保护核心业务逻辑,internal/infra 保护基础设施实现,确保依赖方向不被违反。它是 Go 语言层面实现整洁架构依赖规则的有力工具。

Q5:整洁架构如何与六边形架构(Hexagonal Architecture)区分?#

两者核心思想相同:业务逻辑居中、外部依赖可替换。区别在于:整洁架构强调层级依赖方向,六边形架构强调端口(Port)与适配器(Adapter)。实践中两者经常混用,Go 社区更常使用整洁架构的术语,但用接口定义端口的思想来自六边形架构。

小结#

  1. 整洁架构的核心是依赖方向:外层依赖内层,内层不依赖外层,业务逻辑与框架、数据库、UI 解耦
  2. 四层划分是常见实践:models(领域模型)→ repository(数据访问)→ use cases(业务用例)→ api(接口适配),可根据项目复杂度调整
  3. Go 的 internal 包是实现依赖规则的天然工具,编译器强制阻止跨层不当引用
  4. 依赖注入可选:小型项目手动组装,大型项目使用 wire 等编译期注入工具
  5. 整洁架构有代价:代码量和复杂度增加,但换来的是可测试性、可维护性和技术栈无关性

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Golang 整洁架构实践
https://blog.souloss.com/posts/golang/go-clean-architectureitecture/
作者
Souloss
发布于
2022-05-25
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时