2025年5月04日·阅读约1分钟

用 Go 泛型实现 CRUD 仓库模式,构建清晰的 Go 数据层

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

用 Go 泛型实现 CRUD 仓库模式,构建清晰的 Go 数据层

为什么 Go 中的 CRUD 仓库容易变得混乱

CRUD 仓库一开始很简单。你写 GetUser,然后 ListUsers,接着为 OrdersInvoices 也写一遍。再过几个实体,数据层就变成了一堆几乎相同的复制品,微小的差异很容易被忽略。

最常重复的并不是 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 还是以后换成别的存储。

如何在不使用反射的情况下仍实现复用流程

标准化 CRUD 行为
为你的实体创建一致的 list 和 get 端点,确保行为稳定一致。
生成 API

反射通常在你想让一段代码“填充任意结构体”时悄悄出现。那会把错误隐藏到运行时并让规则不清晰。

更干净的做法是只复用那些无聊的部分:执行查询、遍历行、检查受影响的行数,并一致地包装错误。把与结构体映射相关的工作保持显式。

拆分职责: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 写成常量(selectByIDinsertupdate)并实现一次 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。依赖一个小的执行器接口,包含你会调用的方法(QueryContextExecContextQueryRowContext)。这样服务可以在传入 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

把仓库变成真正的 API
对你的数据建模并生成 Go 后端,不再手动重复 CRUD。
构建后端

只有当每个方法在各处都表现一致时,通用仓库才有意义。大多数痛点来自小不一致:一个仓库按 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

针对通用与实体特定部分的测试策略

部署到你运行的地方
部署到 AppMaster Cloud 或你偏好的云提供商,无需重写后端。
部署应用

只有当容易信任时,这种方法才值得。沿着代码的同一条线拆分测试:共享帮助函数只需测试一次,然后分别测试每个实体的 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 而每次返回的顺序不同,导致分页在请求间重复或漏掉记录。把排序显式化(哪怕只是按主键)并保持默认一致。

在采用之前的快速清单

发布清晰的数据层
使用可视化模型在需求变化时保持数据访问一致。
试用 AppMaster

在把通用仓库推广到每个包之前,确保它能去除重复而不隐藏重要的数据库行为。

先从一致性开始。如果一个仓库接受 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
}

从这里开始,该模式易于扩展:把结构复制到下一个实体,保持输入与存储模型分离,并让扫描显式化,这样改动依然明显且编译器友好。

常见问题

Go 的通用 CRUD 仓库到底解决了什么问题?

使用泛型来复用“流程”(查询、扫描循环、未找到处理、分页默认、错误映射),但保持每个实体的 SQL 和行映射显式可见。这样你能减少重复,同时避免把数据层变成在运行时悄悄出错的“魔法”。

为什么要避免基于反射的“扫描任意结构体”CRUD 工具?

反射会把映射规则隐藏起来,并把错误从编译期移到运行时。你会失去编译器校验、IDE 的提示也会变差,小的 schema 变动就可能变成事故。结合泛型和显式的 scanner 函数,可以在共享重复部分的同时保留类型安全。

ID 类型的合理约束是什么?

一个好的默认是 comparable,因为 ID 经常需要比较、作为 map 的键以及被传递。如果系统同时使用多种 ID 风格(比如 int64 和 UUID 字符串),把 ID 类型设为泛型可以避免在所有仓库中强制选用一种。

实体约束应该包含什么(不该包含什么)?

保持最小化:通常只需要共享 CRUD 流程所必需的东西,比如 GetID()SetID()。避免通过嵌入或巧妙的类型集强制要求常见字段,这会把领域类型和仓库模式耦合在一起,使重构变得痛苦。

如何同时干净地支持 `*sql.DB` 与 `*sql.Tx`?

使用一个小的执行器接口(常叫 DBTX),只包含你会调用的方法,例如 QueryContextQueryRowContextExecContext。这样仓库代码就可以对 *sql.DB*sql.Tx 运行,而无需分支或复制方法。

从 Get 返回“未找到”的最佳信号是什么?

返回零值并伴随 nil 错误会让调用方无法判断是“未找到”还是“字段为空”。使用共享的哨兵错误,例如 ErrNotFound,把状态放在错误通道,服务代码可以用 errors.Is 可靠地分支处理。

Create/Update 应该接受完整的实体结构体吗?

将输入和存储模型分开。优选 Create(ctx, CreateInput)Update(ctx, id, UpdateInput),这样调用者无法设置服务器拥有的字段(如 ID 或时间戳)。对于 patch 类型的更新,使用指针(或可空类型),这样可以区分“未设置”和“设置为零值”。

如何防止 List 分页返回不一致的结果?

每次都明确设置稳定的 ORDER BY,最好基于唯一列(通常是主键)。否则当有新行插入或查询计划改变时,分页可能会跳过或重复记录。

仓库应向服务提供什么样的错误契约?

向服务层暴露一小组可分支的错误,例如 ErrNotFoundErrConflict,并把其他错误包装为来自底层 DB 的上下文信息。不要让调用方解析错误字符串;用 errors.Is 检查并为日志提供有用的信息即可。

如何在不过度测试的情况下测试通用仓库模式?

先对共享帮助函数做一次测试(分页规范化、未找到映射、受影响行检查),然后单独测试每个实体的 SQL 和扫描逻辑。再增加一些“契约测试”:创建后能 Get 到相同数据、更新改变预期字段、删除后 Get 返回 ErrNotFound、List 的排序是稳定的。

容易上手
创造一些 惊人的东西

使用免费计划试用 AppMaster。
准备就绪后,您可以选择合适的订阅。

开始吧