成为全栈·Go 后端篇·用 Go 重写分层架构:领域包、装配根与最小抽象

成为全栈·Go 后端篇·用 Go 重写分层架构:领域包、装配根与最小抽象

语言迁移最容易复制的是语法,最值得重新审视的是边界。一个清晰的 Go 工程要让请求入口、业务规则、数据模型和基础设施各自承担有限责任。

本文代码快照:提交 ceac4e0(Go 1.26.6)。Node 对照来自同一仓库的 node-backend 实现;以源码和契约为准。

前言:重写不是把文件名逐个翻译

重写第一版,我照着 Node 的目录在 Go 里建了四个包,handler 一路往下转,最底下一行只有 return repo.List(ctx)。编译过了、请求也通了,只是每加一个接口就要在四个文件之间跳一遍。

Node 项目里可能有 routes、controllers、services、repositories;把它们翻译成 Go 的 handler、service、repository,目录看起来会很熟悉。但如果每个请求仍要经过三层只有一行的转发,或者为了"架构完整"创建一个通用 RepositoryT,文件多了,业务却未必更清楚。

M6 的目标是复刻既有 Node 后端对外可见的行为,守住冻结 OpenAPI 契约以及数据库不变量。它不是原有 TypeScript 文件的逐字翻译。Go 工程重新组织为传输层、领域包、平台设施和 bootstrap 装配根。每个部分围绕责任边界划分,而不是为了对应原语言里的某种类名。

本文会讨论四个设计问题:包边界怎么定;bootstrap 为什么单独存在;持久化模型为什么不直接作为 API DTO;为什么服务直接依赖 GORM,却没有再套一层通用 Repository。关键不是某种目录树"唯一正确",而是如何判断一个抽象是否真的替我们隔离变化。

一、先从一条请求的责任链出发

用"创建文章"作为例子,请求大致经过如下路径:

text 复制代码
HTTP 请求
  ↓
transport/httpapi:路由、身份、请求校验、错误信封
  ↓
article.Service:文章规则、分类标签、事务
  ↓
platform/database + platform/model:数据库驱动与持久化行
  ↓
统一响应 DTO:映射为契约字段

这一条链把协议责任和业务责任拆开。httpapi 知道 HTTP 状态码、请求体、认证主体和统一响应结构;article 知道草稿、投稿、发布、归属校验以及标签同步;platform/database 知道 GORM 连接、数据库方言、连接池和行锁;platform/model 描述数据库中的行。

如果把数据库 SQL 放进 HTTP handler,协议代码会同时承担业务规则和数据持久化,测试也需要跨越太多边界。反过来,如果让领域服务直接操作 http.ResponseWriter,它就无法从 HTTP 传输中独立出来。边界不是为了"纯洁",而是为每部分保留可观察的输入、输出与失败方式。

这里也要留意,图里的层次不是单向依赖必须绝对纯净的教条。领域服务会调用数据库,这是它完成业务用例所需的基础设施;关键是领域规则不依赖具体 HTTP 响应格式,也不把一条 ORM 数据库行直接当成公开 API 模型。

二、Go 里用包表达边界,不必把每个名词都变成接口

当前主要包分布如下:

包 责任
cmd/server 读取配置、打开资源、启动 HTTP Server、处理退出
internal/bootstrap 组装依赖,绑定操作处理器并校验完整性
internal/transport/httpapi HTTP 路由、身份与权限边界、校验、统一响应
internal/article 文章查询、状态动作、分类标签同步
internal/auth 注册、登录、刷新、微信身份与账号能力
internal/comment 评论可见性、审核和通知联动
internal/member / administration 会员能力与管理功能
internal/platform/model GORM 持久化行与表名
internal/platform/database 驱动、迁移、连接池及数据库专用能力
internal/platform/storage 本地文件与 R2 对象存储实现
internal/notification 通知记录及其事务写入逻辑

Go 的 package 是代码组织和可见性的基本单位。internal 目录限制外部 module 导入,包内小写标识符不导出。一个包应该围绕稳定的概念或变化原因聚合代码,而不是照搬 UML 图里的每个对象都建一个目录。

接口也不应该成为文件组织的默认产物。若某个服务只有一个实际实现,而且调用方并不需要在生产路径中替换它,先定义一堆接口可能只增加跳转成本。等存在真实替换点、测试隔离需要或明确的所有权边界,再定义最小接口通常更直接。Go 接口由使用方按需求声明,也不必先为每个实现写一份"全功能接口"。

M6 在少数外部替换边界使用小接口,例如 auth.WechatClient 表示微信 code 换取身份的外部能力,storage.Provider 表示存储目标,httpapi.Identity 表示身份解析。它们隔离的是运行环境或外部服务变化,不是抽象所有内部业务。

三、bootstrap 是唯一装配根

Go 的 cmd/server 做运行时入口,bootstrap/app.go 负责组合应用。它加载冻结契约,构建 Identity/Auth 服务和领域 service,建立 provider 映射,再把 68 个 OpenAPI 操作绑定到对应 handler。所有契约声明的操作都应有注册处理器;缺一个,应用在启动时就失败,而不是等流量首次访问时才暴露遗漏。

下面是它的装配过程。整段代码里没有一处业务规则,只有「谁需要谁」:

go 复制代码
// internal/bootstrap/app.go(节选,省略 provider 映射与部分 Bind 调用)
// New 作为唯一装配根连接各领域,并检查全部契约操作已注册。
func New(db *gorm.DB, c config.Config, o Options) (*httpapi.App, error) {
	catalog, err := contract.Load()
	if err != nil {
		return nil, err
	}
	identity := auth.New(db, c.JWTSecret)
	identity.AppID = c.WechatAppID
	identity.Wechat = o.Wechat
	if identity.Wechat == nil {
		identity.Wechat = auth.WechatHTTP{AppID: c.WechatAppID, Secret: c.WechatSecret}
	}
	// ...(此处按配置构造 local 与 r2 两个 storage.Provider)
	content := article.New(db)
	app := httpapi.New(catalog, identity, c.Origins)
	app.TrustedProxies = c.TrustedProxies
	app.BindAuth(identity)
	app.BindContent(content, taxonomy.New(db))
	comments := comment.New(db)
	// ...(此处把评论拒绝率写入 comments 并绑定发现类操作)
	app.BindMember(member.New(db), administration.New(db, content))
	app.BindAttachments(attachment.New(db, c.Storage, p))
	for id := range catalog.Operations {
		if !app.Registered[id] {
			return nil, fmt.Errorf("operation not implemented: %s", id)
		}
	}
	return app, nil
}

最后那个 for id := range catalog.Operations 循环就是完整性检查:契约声明了多少操作,装配根就要求注册多少个,任一缺失 New 直接返回错误,进程起不来。Options 结构允许测试注入微信与存储替身,正式运行才落到真实适配器,把变化点收敛到一处。

这个设计把"对象怎样产生"集中起来,但不把业务放到 bootstrap。比如文章服务的规则仍然在 article 包;bootstrap 只知道它需要怎样的依赖,并把它提供给 HTTP 层。职责可以拆成:

  • cmd/server 决定进程何时启动和关闭;
  • bootstrap 决定应用由哪些实现组成;
  • httpapi 决定一条 HTTP 操作通过哪些公共边界;
  • domain service 决定业务允许什么状态变化。

这种显式装配也让不同命令有机会复用同一组基础组件。例如 migrate 可以加载配置和数据库,但不需要构造 HTTP handler;data 工具则具有独立的数据连接配置。若所有东西都藏在全局 init() 或隐式容器里,程序的真实依赖就更难从入口读出来。

装配根的另一个职责是完整性检查。契约里有多少 operation、当前注册了多少 handler,这两者的对应关系属于启动期结构约束。用 map 注册 provider 并在启动时验证,可以在发布前发现漏接,而不是只靠一批碰巧访问到的页面。

四、HTTP handler 为什么不能成为"万能服务"

Handler 的输入是 HTTP Request,输出是 HTTP Response;它适合做边界转换,不适合持有全部业务工作流。当前 httpapi 把方法、路径、OpenAPI operation、身份要求、请求体解码和 schema 校验连接起来,再调用具体领域处理器。

如果把文章的分类校验、标签删除与重建、slug 约束、事务控制和响应映射都写进同一个 handler,测试一项业务规则就必须伪造完整请求上下文,重复逻辑也会散落在不同端点。业务服务接收经过边界确认的值,并返回明确结果或错误;HTTP 层再把领域错误翻译成契约要求的状态码和信封。

同样,不要把所有逻辑都塞进一个"万能 service"。文章、评论、身份、附件都有不同的不变量和协作对象。领域包按能力划分,让相关规则可以在一起审阅。跨领域工作流若涉及同一数据库原子提交,就由实际用例明确持有事务,并通过依赖参数或小范围协作实现;不要隐式地让任意 service 随时开启一个不可见事务。

M6 中的 notification 具有特别的协作意义:文章发布、评论审核等动作会产生通知。通知记录如果属于业务动作的一部分,就应共享该动作的事务;通知发送到外部系统则是另一种可靠性问题。保存通知数据和向第三方发送消息不能都被一句"调用通知服务"掩盖。

五、持久化行与 API DTO 不应共用一个 struct

GORM model 描述的是表行:数据库列名、索引或关联、nullable 状态与写入字段。API DTO 描述的是契约:字段是否存在、是否允许 null、客户端是否可见、日期要以什么格式表达。两者生命周期不一样。下面是文章持久化行的真实定义:

go 复制代码
// internal/platform/model/articles.go
// Article 文章持久化行,毫秒时间和显式软删除字段与 Node 数据一致。
type Article struct {
	ID           int64   `gorm:"column:id;primaryKey;autoIncrement"`
	Title        string  `gorm:"column:title"`
	Slug         *string `gorm:"column:slug"`
	Summary      *string `gorm:"column:summary"`
	Content      string  `gorm:"column:content"`
	CoverImage   *string `gorm:"column:cover_image"`
	AuthorID     int64   `gorm:"column:author_id"`
	AuthorName   *string `gorm:"column:author_name"`
	CategoryID   *int64  `gorm:"column:category_id"`
	CategoryName *string `gorm:"column:category_name"`
	CategorySlug *string `gorm:"column:category_slug"`
	Status       string  `gorm:"column:status"`
	Tags         *string `gorm:"column:tags"`
	ViewCount    int64   `gorm:"column:view_count"`
	LikeCount    int64   `gorm:"column:like_count"`
	PublishedAt  *int64  `gorm:"column:published_at"`
	CreatedAt    int64   `gorm:"column:created_at"`
	UpdatedAt    int64   `gorm:"column:updated_at"`
	DeletedAt    *int64  `gorm:"column:deleted_at"`
}

func (Article) TableName() string { return "articles" }

光看类型就能读出契约线索:可空列一律是 *string 或 *int64,必填列才是值类型;DeletedAt 是显式软删除时间;时间列统一是 int64 毫秒,与 Node 数据保持一致。这些字段直接对应数据库列。如果把这张表行当成响应模型,Content 全文、DeletedAt 和内部关联都会被一起序列化出去。

例如,一个 Article 行可以包含内部审核字段、删除时间或数据库关联,而公开文章响应不应该因为使用同一个 struct 就把这些列序列化出去。管理端和前台还可能有不同的响应投影;列表摘要只需要标题、slug、摘要、封面和统计,不必把全文 Markdown 一并加载。

因此,服务层或 transport mapper 会明确从持久化行映射到契约输出。看起来多了一段赋值,但这一步就是隐私、字段与兼容边界。契约变更时,响应映射是需要审阅的位置;数据库迁移时,API 输出可以不变。这样两种变化就不必被 ORM 的自动序列化行为捆在一起。

输入也有同样的问题。Go 的零值让字段缺失与值为 false、0 或空字符串容易混淆。若一个更新端点需要区分"没有提交这个字段"和"明确把字段清空",输入层要保留字段存在性,再通过白名单映射到数据库更新。这个逻辑不能交给持久化 struct 的默认 JSON 解码猜测。

六、为什么没有通用 Repository

常见分层方案会创建 UserRepository、ArticleRepository、CommentRepository,甚至一个支持任意实体的 RepositoryT。当仓储需要提供有意义的领域查询、隔离特定存储系统,或被多个用例共享时,这样做可以成立。但只为了把 db.Where(...).First(...) 包成一个名字,抽象可能没有真正隔离变化。

M6 的业务服务直接依赖 *gorm.DB。这里的依据是当前服务以关系型数据库为目标,GORM 已经是明确的持久化工具;服务需要组合多次查询、更新和事务,而通用 repository 往往会把查询能力重新发明一遍。它也可能在 service 和 ORM 之间制造第二套不完整 DSL。

直接使用 GORM 不代表把数据库细节散落到所有地方。数据库初始化、迁移、方言差异、连接池和锁在 platform/database;领域查询与更新仍在相关领域包中;事务由用例掌握并明确传递。依赖具体 ORM 的成本是可见的:若将来真的换 ORM,领域 service 会有改动。我们接受这个成本,因为当前没有第二个关系型引擎接口需要被运行时替换,且三种数据库都通过 GORM 驱动和显式方言迁移支持。

一个判断抽象价值的问题是:删掉它之后,哪种变化会更难?如果答案只是"代码看起来更不分层",要继续追问它到底隔离了什么。如果抽象让调用方不再知道事务细节,却又不能跨多条写操作原子提交,那它隐藏的是正确性必需信息,而不是不必要噪音。

七、事务由业务用例明确持有

创建文章涉及文章行、分类关系、标签关系,某些操作还会写通知。如果只插入主表成功,而标签同步失败,数据库会留下不完整状态。事务边界应覆盖必须共同成功或失败的一组写入。

示意代码:

go 复制代码
err := db.Transaction(func(tx *gorm.DB) error {
    article, err := createArticleRow(tx, input)
    if err != nil {
        return err
    }
    if err := syncTags(tx, article.ID, input.TagIDs); err != nil {
        return err
    }
    return nil
})

注意 tx 被显式传给 helper。它们不能在内部又随意从全局 db 取一个连接,否则部分写操作可能落到事务外。把事务作为参数透传,会让原子范围能被代码阅读者看见,也便于测试回滚行为。

GORM 默认会为单条写操作提供默认事务。项目选择 SkipDefaultTransaction 来减少不必要的单语句包装,但这意味着多步业务写入必须显式开启事务。此选项不是"所有写操作都无需事务",更不是性能优化的口号;它要求工程师准确识别事务不变量。文章、互动计数、刷新令牌旋转等路径都要结合业务和数据库约束判断原子范围。

事务失败后的可观察结果也要验证。只看返回 error 不够,还要重新读取数据确认文章、标签关系和通知全部回滚,或者检查失败响应是否按契约映射。事务是数据正确性的工具,其证据是失败路径留下的状态。

八、怎么判断分层是否真的帮到团队

一个实用检查方法是取一个具体变更,而不是点评目录树:

  1. 如果 OpenAPI 响应字段改变,需要改哪些包?是否能在明确 mapper 看到输出边界?
  2. 如果把 HTTP 入口从本机 server 换成另一个 listener,领域规则是否仍可用?
  3. 如果存储从本地磁盘切到 R2,变化是否只集中在 Provider 和配置适配?
  4. 如果文章写入增加一项关系数据,事务范围和回滚路径在哪里?
  5. 如果少注册一个 operation,系统是在启动时暴露,还是上线后由用户发现?
  6. 如果测试替换微信上游,是否只需注入 WechatClient?

如果答案能落到具体代码位置,分层就有实际作用。如果每个小改动都要穿过 Controller、UseCase、Port、Adapter、Repository、Gateway 六层才改变一条查询,那么抽象数量可能已超过变化数量。

这不是反对接口、仓储或用例层。复杂业务、多团队共享、多个存储引擎或长期插件化系统都可能需要更丰富边界。教学项目也不应为了少文件而牺牲事务正确性和清晰性。好的架构是符合当前变化轴并能在证据出现时演进的架构。

小结:包边界应该能解释责任和变化

M6 用 transport/httpapi 处理 HTTP 协议,用领域包表达业务,用 platform 放数据库与存储等设施,由 bootstrap 显式组装。持久化行与 API DTO 分开,操作注册在启动时核对完整性;service 直接用 GORM,事务由用例明确持有。少量接口只出现在真实外部替换边界。

读一套 Go 工程时,不妨追一条请求和一个失败路径。它们比"分了几层"更能说明架构是否合理:业务规则是否有归属,事务是否可见,字段是否按契约投影,依赖是否在运行入口明确装配,以及故障能否在适当边界被处理。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
北冥you鱼1 小时前
Go 语言 Ticker 定时任务全解析:原理、陷阱与最佳实践
开发语言·数据库·golang
我不会起名字3222 小时前
Go 服务升级 Go 1.2x 后 P99 变差:GMP 调度相关的 3 个变化
golang·go·gmp·调度·gomaxprocs
喵了几个咪3 小时前
GoWind CMS 拆解:给 NL2SQL 上四把锁——智能问数的安全边界
开发语言·后端·安全·微服务·golang·gowind
喵了几个咪15 小时前
GoWind CMS 拆解:在 Go 里给 TipTap 搭一套 Yjs 协同后端
开发语言·后端·golang
孔令飞20 小时前
知己知彼,百战不殆:AI SKILL 实现原理剖析
云原生·go·ai编程
l1t1 天前
测试用go语言执行脚本把数据保存到SQLite数据库
数据库·golang·sqlite
Agent世界1 天前
千问Go上线我的应用:一句话生成可部署 AI 软件的技术拆解
开发语言·人工智能·golang·ai智能体
念何架构之路1 天前
Go reflect包值操作 Value 的读写、可设置性与 interface 布局
开发语言·后端·golang
紧跟先前的步伐1 天前
Go AI Agent 实战 18|Tool Calling 的基本原理与完整执行流程
前端·数据库·人工智能·ai·golang·1024程序员节