用 Go 泛型实现 CRUD 仓库模式,构建清晰的 Go 数据层
学习一个实用的 Go 泛型 CRUD 仓库模式:复用 list/get/create/update/delete 流程,使用可读的约束、不用反射并保持代码清晰。

为什么 Go 中的 CRUD 仓库容易变得混乱
CRUD 仓库一开始很简单。你写 GetUser,然后 ListUsers,接着为 Orders、Invoices 也写一遍。再过几个实体,数据层就变成了一堆几乎相同的复制品,微小的差异很容易被忽略。
最常重复的并不是 SQL 本身,而是周边流程:执行查询、扫描行、处理“未找到”、映射数据库错误、应用分页默认值,以及把输入转换为正确类型。
常见的痛点很熟悉:重复的 Scan 代码、重复的 context.Context 和事务模式、样板化的 LIMIT/OFFSET 处理(有时还要统计总数)、那句“0 行表示未找到”的检查,以及复制粘贴的 INSERT ... RETURNING id 各种变体。
当重复问题足够严重时,很多团队会转向反射。它承诺“写一次就行”的 CRUD:在运行时根据列填充任意结构体。但代价会在后面显现。大量使用反射的代码更难阅读,IDE 支持变差,失败从编译期移到运行期。像重命名字段或新增可空列这样的小改动,往往只会在测试或生产中暴露问题。
类型安全的复用意味着在不放弃 Go 日常便利(清晰的签名、编译器检查的类型、真正有用的自动补全)的前提下共享 CRUD 流程。借助泛型,你可以复用像 Get[T] 和 List[T] 这样的操作,同时仍要求每个实体提供那些无法推断的部分,例如如何把一行扫描成 T。
这个模式刻意聚焦于数据访问层。它保持 SQL 和映射的一致性与可预测性,而不是去建模领域、强制执行业务规则或替代服务层逻辑。
设计目标(以及本文不会尝试解决的问题)
一个好的仓库模式让日常数据库访问可预测。你应该能读懂一个仓库,快速看到它在做什么、运行了哪些 SQL、以及可能返回什么错误。
目标很简单:
- 端到端的类型安全(ID、实体和结果都不是
any) - 清晰表达意图的约束,而不是花哨的类型技巧
- 减少样板代码,但不隐藏重要行为
- 在 List/Get/Create/Update/Delete 上行为一致
非目标同样重要。这不是 ORM。它不应该猜测字段映射、自动联表或悄悄改变查询。“魔法映射”会把你拉回反射、标签和各种边界情况。
假设一个常见的 SQL 工作流:显式的 SQL(或薄层查询构建器)、清晰的事务边界,以及可推理的错误。当出现问题时,错误应告诉你“未找到”、“冲突/约束违反”或“数据库不可用”,而不是模糊的“仓库错误”。
关键决策是哪些东西泛型化,哪些保持每个实体独立。
- 泛型化:流程(执行查询、扫描、返回类型化值、翻译常见错误)。
- 每个实体独有:语义(表名、选中列、联表、SQL 字符串)。
试图把所有实体强行纳入一个通用过滤系统,通常会让代码比写两条清晰的查询更难读。
选择实体和 ID 的约束
大多数 CRUD 代码重复的原因是每个表有相同的基本动作,但每个实体有自己的字段。使用泛型时,技巧是共享一个尽量小的形状,并把其他东西保持自由。
先决定仓库必须了解实体的哪些内容。对很多团队来说,唯一通用的是 ID。时间戳可能有用,但并非普适,把它们强制到每个类型里往往会让模型显得不自然。
选一个能接受的 ID 类型
你的 ID 类型应与数据库中标识行的方式相匹配。有些项目使用 int64,有些使用 UUID 字符串。如果你想在服务间采用通用方案,就把 ID 设为泛型。如果整个代码库都使用同一种 ID,固定它可以缩短签名。
一个好的默认约束是 comparable,因为你会比较 ID、把它们用作 map 键并传递它们。
type ID interface {
comparable
}
type Entity[IDT ID] interface {
GetID() IDT
SetID(IDT)
}
保持实体约束最小化
避免通过结构体嵌入或像 ~struct{...} 这样的类型集技巧强制要求字段。它们看起来很强大,但会把你的领域类型和仓库模式耦合在一起。
相反,只要求共享 CRUD 流程需要的最小内容:
- 能获取和设置 ID(这样 Create 可以返回它,Update/Delete 可以以它为目标)
如果以后加入软删除或乐观锁之类的功能,再增加小而可选的接口(例如 GetVersion/SetVersion),并只在需要的地方使用它们。小接口通常会随着时间推移表现得更好。
一个保持可读性的通用仓库接口
仓库接口应描述应用所需,而不是数据库恰好在做什么。如果接口看起来像 SQL,就会把细节泄漏到各处。
保持方法集合精简且可预测。把 context.Context 放在第一位,然后是主要输入(ID 或数据),可选开关则打包到结构体里。
type Repository[T any, ID comparable, CreateIn any, UpdateIn any, ListQ any] interface {
Get(ctx context.Context, id ID) (T, error)
List(ctx context.Context, q ListQ) ([]T, error)
Create(ctx context.Context, in CreateIn) (T, error)
Update(ctx context.Context, id ID, in UpdateIn) (T, error)
Delete(ctx context.Context, id ID) error
}
对于 List,避免强制一种通用的过滤类型。过滤器是实体差异最大的地方。一个实用做法是每个实体有自己的查询类型,并可以嵌入一个小的共享分页结构。
type Page struct {
Limit int
Offset int
}
错误处理是仓库常常变得嘈杂的地方。预先决定调用方允许分支处理哪些错误。一个简单的集合通常足够:
ErrNotFound当 ID 不存在时ErrConflict表示唯一性冲突或版本冲突ErrValidation当输入无效时(只有在仓库进行校验时)
其他所有错误都可以作为低级错误的包装(数据库/网络)。有了这个约定,服务代码可以处理“未找到”或“冲突”,而无需关心底层今天是 PostgreSQL 还是以后换成别的存储。
如何在不使用反射的情况下仍实现复用流程
反射通常在你想让一段代码“填充任意结构体”时悄悄出现。那会把错误隐藏到运行时并让规则不清晰。
更干净的做法是只复用那些无聊的部分:执行查询、遍历行、检查受影响的行数,并一致地包装错误。把与结构体映射相关的工作保持显式。
拆分职责:SQL、映射、共享流程
一个实用的拆分如下:
- 每个实体:在一个地方保存 SQL 字符串和参数顺序
- 每个实体:编写小的映射函数,把行扫描成具体结构体
- 泛型:提供执行查询并调用映射器的共享流程
这样,泛型减少了重复,但不会隐藏数据库在做什么。
下面是一个小抽象,允许你传入 *sql.DB 或 *sql.Tx,而其它代码无需关心:
type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
泛型应该做什么(和不应该做什么)
泛型层不应尝试“理解”你的结构体。相反,它应该接受你提供的显式函数,例如:
- 一个把输入变成查询参数的 binder
- 一个把列读入实体的 scanner
例如,Customer 仓库可以把 SQL 写成常量(selectByID、insert、update)并实现一次 scanCustomer(rows)。一个通用的 List 可以处理循环、上下文和错误包装,而 scanCustomer 保持映射对类型安全且显而易见。
如果你增加了列,就更新 SQL 和 scanner。编译器会帮你找出被破坏的地方。
逐步实现该模式
目标是为 List/Get/Create/Update/Delete 提供一个可复用的流程,同时让每个仓库对自己的 SQL 和行映射保持诚实。
1)定义核心类型
从最少的约束开始。选择一个适合你代码库的 ID 类型和一个保持可预测的仓库接口。
type ID interface{ ~int64 | ~string }
type Repo[E any, K ID] interface {
Get(ctx context.Context, id K) (E, error)
List(ctx context.Context, limit, offset int) ([]E, error)
Create(ctx context.Context, e *E) error
Update(ctx context.Context, e *E) error
Delete(ctx context.Context, id K) error
}
2)为 DB 和事务添加执行器
不要把泛型代码直接绑定到 *sql.DB 或 *sql.Tx。依赖一个小的执行器接口,包含你会调用的方法(QueryContext、ExecContext、QueryRowContext)。这样服务可以在传入 DB 或事务时无需修改仓库代码。
3)构建带共享流程的通用基类
创建一个 baseRepo[E,K],存储执行器和一些函数字段。base 处理那些无聊的部分:调用查询、映射“未找到”、检查受影响行数并返回一致的错误。
4)实现实体特定部分
每个实体仓库提供那些无法泛型化的内容:
- list/get/create/update/delete 的 SQL
- 一个
scan(row)函数,把行转换为E - 一个
bind(...)函数,返回查询参数
5)连接具体仓库并在服务中使用
构建 NewCustomerRepo(exec Executor) *CustomerRepo,它嵌入或包装 baseRepo。你的服务层依赖 Repo[E,K] 接口并决定何时启动事务;仓库只使用它被给到的执行器。
在不出现意外的情况下处理 List/Get/Create/Update/Delete
只有当每个方法在各处都表现一致时,通用仓库才有意义。大多数痛点来自小不一致:一个仓库按 created_at 排序,另一个按 id;一个对于缺失行返回 nil, nil,另一个返回错误。
List:不会跳页的分页与排序
选择一种分页样式并始终应用。偏移分页(limit/offset)简单且适合管理后台。游标分页适合无限滚动,但需要稳定的排序键。
无论选哪种,都要把排序显式且稳定化。按唯一列(通常是主键)排序可以防止在新行出现时项目在页面间跳动。
Get:明确的“未找到”信号
Get(ctx, id) 应返回类型化的实体和一个明确的缺失记录信号,通常是共享的哨兵错误 ErrNotFound。避免返回零值实体和 nil 错误。调用者无法分辨“缺失”与“字段为空”。
把类型用于数据,把错误用于状态,这是一个好习惯。
在实现方法之前,先做几项决定并保持一致:
Create:接受输入类型(无 ID、无时间戳)还是完整实体?很多团队倾向Create(ctx, in CreateX)来避免调用者设置服务器端拥有的字段。Update:是整体替换还是补丁?如果是补丁,不要使用普通结构体来表示,零值会造成歧义。使用指针、可空类型或显式字段掩码。Delete:是硬删除还是软删除?如果是软删除,决定Get是否默认隐藏被删除的行。
还要决定写操作返回什么。低惊讶的选项是返回更新后的实体(包含 DB 默认值),或者只返回 ID 并在无变更时返回 ErrNotFound。
针对通用与实体特定部分的测试策略
只有当容易信任时,这种方法才值得。沿着代码的同一条线拆分测试:共享帮助函数只需测试一次,然后分别测试每个实体的 SQL 和扫描逻辑。
尽可能把共享部分当作小的纯函数来测试,例如分页验证、把排序键映射到允许的列、或构建 WHERE 片段。这些可以用快速的单元测试覆盖。
对于 list 查询,表驱动测试很有效,因为边界情况正是问题所在。覆盖空过滤器、未知排序键、limit 为 0、超过最大限制、负 offset,以及“下一页”边界(比如多取一条记录)等情形。
把每个实体测试聚焦在真正实体相关的内容:你期望运行的 SQL,以及行如何扫描到实体类型上。使用 SQL mock 或轻量测试数据库,确保扫描逻辑能处理 null、可选列和类型转换。
如果你的模式支持事务,用一个小的假执行器测试提交/回滚行为:
- Begin 返回一个 tx 范围的执行器
- 出错时,rollback 恰好被调用一次
- 成功时,commit 恰好被调用一次
- 若 commit 失败,该错误原样返回
你也可以为每个仓库添加小的“契约测试”:创建后 Get 返回相同数据、Update 修改预期字段、Delete 后 Get 返回未找到、List 在相同输入下返回稳定排序。
常见错误与陷阱
泛型会让人想把所有仓库整合成一个万能仓库。数据访问充满细小差异,而这些差异很关键。
常见的几个陷阱:
- 过度泛化,导致每个方法都接受一个巨大的选项包(联表、搜索、权限、软删除、缓存)。此时你已经构建了第二个 ORM。
- 过于巧妙的约束。如果读者需要解码类型集才能理解实体必须实现什么,抽象的成本会超过收益。
- 把输入类型当作 DB 模型。当 Create 和 Update 接受与从行扫描出来相同的结构体时,DB 细节会泄漏到处理器和测试中,模式变动会引起大范围影响。
List的隐式行为:不稳定排序、不一致的默认值或因实体不同而变化的分页规则。- 未找到处理方式不一致,迫使调用者解析错误字符串而不是使用
errors.Is。
一个具体例子:ListCustomers 因为没有设置 ORDER BY 而每次返回的顺序不同,导致分页在请求间重复或漏掉记录。把排序显式化(哪怕只是按主键)并保持默认一致。
在采用之前的快速清单
在把通用仓库推广到每个包之前,确保它能去除重复而不隐藏重要的数据库行为。
先从一致性开始。如果一个仓库接受 context.Context 而另一个不接受,或一个返回 (T, error) 而另一个返回 (*T, error),问题会在服务、测试和 mock 中蔓延。
确保每个实体仍有一个明显的 SQL 归属地。泛型应该复用流程(扫描、验证、映射错误),而不是把查询散落成字符串片段。
一组可以防止大多数惊讶的检查项:
- List/Get/Create/Update/Delete 采用统一签名约定
- 每个仓库使用的统一未找到规则
- 稳定的 list 排序,有文档并通过测试
- 能在
*sql.DB和*sql.Tx上运行相同代码的干净方式(通过执行器接口) - 泛型代码与实体规则之间清晰的边界(验证和业务检查留在泛型层之外)
如果你在 AppMaster 中快速构建内部工具,之后又要导出或扩展生成的 Go 代码,这些检查能让数据层保持可预测且容易测试。
一个现实例子:构建 Customer 仓库
下面是一个小型的 Customer 仓库形状,它在保持类型安全的同时不走花哨的路子。
先定义持久模型。把 ID 强类型化,这样你不会与其它 ID 混用:
type CustomerID int64
type Customer struct {
ID CustomerID
Name string
Status string // "active", "blocked", "trial"...
}
现在把“API 接受的内容”与“你存储的内容”分开。这是 Create 和 Update 应当不同的地方。
type CreateCustomerInput struct {
Name string
Status string
}
type UpdateCustomerInput struct {
Name *string
Status *string
}
你的通用基类可以处理共享流程(执行 SQL、扫描、映射错误),而 Customer 仓库拥有 Customer 特定的 SQL 和映射。从服务层来看,接口保持清晰:
type CustomerRepo interface {
Create(ctx context.Context, in CreateCustomerInput) (Customer, error)
Update(ctx context.Context, id CustomerID, in UpdateCustomerInput) (Customer, error)
Get(ctx context.Context, id CustomerID) (Customer, error)
Delete(ctx context.Context, id CustomerID) error
List(ctx context.Context, q CustomerListQuery) ([]Customer, int, error)
}
对于 List,把过滤和分页作为一等请求对象。它使调用点更可读,也更难忘记限制条件。
type CustomerListQuery struct {
Status *string // 过滤
Search *string // 名称包含
Limit int
Offset int
}
从这里开始,该模式易于扩展:把结构复制到下一个实体,保持输入与存储模型分离,并让扫描显式化,这样改动依然明显且编译器友好。
常见问题
使用泛型来复用“流程”(查询、扫描循环、未找到处理、分页默认、错误映射),但保持每个实体的 SQL 和行映射显式可见。这样你能减少重复,同时避免把数据层变成在运行时悄悄出错的“魔法”。
反射会把映射规则隐藏起来,并把错误从编译期移到运行时。你会失去编译器校验、IDE 的提示也会变差,小的 schema 变动就可能变成事故。结合泛型和显式的 scanner 函数,可以在共享重复部分的同时保留类型安全。
一个好的默认是 comparable,因为 ID 经常需要比较、作为 map 的键以及被传递。如果系统同时使用多种 ID 风格(比如 int64 和 UUID 字符串),把 ID 类型设为泛型可以避免在所有仓库中强制选用一种。
保持最小化:通常只需要共享 CRUD 流程所必需的东西,比如 GetID() 和 SetID()。避免通过嵌入或巧妙的类型集强制要求常见字段,这会把领域类型和仓库模式耦合在一起,使重构变得痛苦。
使用一个小的执行器接口(常叫 DBTX),只包含你会调用的方法,例如 QueryContext、QueryRowContext 和 ExecContext。这样仓库代码就可以对 *sql.DB 或 *sql.Tx 运行,而无需分支或复制方法。
返回零值并伴随 nil 错误会让调用方无法判断是“未找到”还是“字段为空”。使用共享的哨兵错误,例如 ErrNotFound,把状态放在错误通道,服务代码可以用 errors.Is 可靠地分支处理。
将输入和存储模型分开。优选 Create(ctx, CreateInput) 和 Update(ctx, id, UpdateInput),这样调用者无法设置服务器拥有的字段(如 ID 或时间戳)。对于 patch 类型的更新,使用指针(或可空类型),这样可以区分“未设置”和“设置为零值”。
每次都明确设置稳定的 ORDER BY,最好基于唯一列(通常是主键)。否则当有新行插入或查询计划改变时,分页可能会跳过或重复记录。
向服务层暴露一小组可分支的错误,例如 ErrNotFound 和 ErrConflict,并把其他错误包装为来自底层 DB 的上下文信息。不要让调用方解析错误字符串;用 errors.Is 检查并为日志提供有用的信息即可。
先对共享帮助函数做一次测试(分页规范化、未找到映射、受影响行检查),然后单独测试每个实体的 SQL 和扫描逻辑。再增加一些“契约测试”:创建后能 Get 到相同数据、更新改变预期字段、删除后 Get 返回 ErrNotFound、List 的排序是稳定的。


