第 5 章 全层级源码精讲:路由、控制器、服务层、数据层
1 学习目标
学完本章你应该能够:
- 说出 HTTP handler 的"四方法"生命周期(Config/Name/Version/Registry)与路由注册流程;
- 解释"HTTP 层复用 gRPC 实现"的完整代码路径——尤其是
app.GetGrpcApp(AppName).(Service)这一行为什么没有网络开销; - 区分 Service 层两种装配模式:自足型(只依赖 DB)与编排型(task 模块的接口注入),并说出各自的适用场景;
- 对比两种 DB 访问范式——GORM 链式(resource/secret)与原生 SQL + sqlbuilder(host/task/bill)——的写法差异与真实利弊;
- 指出本章两个"一等一的 bug 教学案例"(
Put不写标签、BuildQuery别名不匹配)的成因与修复方向。
前置知识
- 第 1 章的框架启动流程与 mcenter 权限中心(“路由即权限"的背景);
- 第 4 章的数据模型:垂直分表、resource_id 关联、主键 = 云厂商 ID(本章
tx.Save的 upsert 依赖它); - GORM 基础:
Begin/Commit/Save/Scan的语义; - go-restful 的
WebService/Route概念(看到即可,不要求写过)。
本章你会动手做的事
- 从
apps/resource/api/http.go的Metadata(label.Resource, ...)出发,追一遍路由上报 mcenter 的路径,验证"注册即权限”; - 带上
vendor条件实际调一次/search,亲手复现BuildQuery的别名 bug(Unknown column); - 并排打开
apps/resource/impl/dao.go与apps/host/impl/sql.go,同一条"查主机"用两种范式各读一遍。
先挂出本章的知识地图:
2 知识点通俗系统化讲解
2.1 全层级地图:一个请求穿过四层
① Router 层(api/http.go 的 Registry)
ws.Route(ws.GET("/search").To(h.SearchResource)...)
② Controller 层(api/host.go 的 handler 方法)
解析 HTTP 参数 → 调 service → 写回响应
③ Service 层(impl/*.go 的业务方法)
查重/对账/编排/加密/记任务
④ DAO 层(impl/dao.go + impl/sql.go)
GORM 链式 或 原生 SQL,落库
本章按这个顺序自上而下精讲。每层都遵守同一个哲学:上一层只"翻译",不做业务;业务只在 Service 层。
2.2 Router 层:路由不只是路由,还是"权限声明书"
先看 resource 模块的完整路由注册(apps/resource/api/http.go:37-59):
func (h *handler) Registry(ws *restful.WebService) {
tags := []string{"资源检索"}
ws.Route(ws.GET("/search").To(h.SearchResource).
Doc("检索资源"). // OpenAPI 文档描述
Metadata(restfulspec.KeyOpenAPITags, tags). // 文档分组标签
Metadata(label.Resource, h.Name()). // 权限点:资源=resource
Metadata(label.Action, label.List.Value()). // 权限点:动作=list
Metadata(label.Auth, label.Enable). // 该路由需要鉴权
Reads(resource.SearchRequest{}). // 文档:请求参数类型
Writes(resource.ResourceSet{}). // 文档:响应类型
Returns(200, "OK", resource.ResourceSet{})) // 文档:返回码
// 资源发现(给 Prometheus 用)
ws.Route(ws.GET("/discovery/prometheus").To(h.DiscoveryPrometheus).
Doc("监控发现(Prometheus)").
Metadata(restfulspec.KeyOpenAPITags, tags).
Metadata(label.Resource, "prometheus_resource").
Metadata(label.Action, label.List.Value()).
Reads(resource.SearchRequest{}).
Writes(resource.ResourceSet{}).
Returns(200, "OK", resource.ResourceSet{}))
}
重点理解 Metadata(label.Resource/Action/Auth) 这几行:它们不是给 HTTP 请求用的,而是给 mcenter 权限中心用的。回忆第 1 章:protocol/http.go:96-140 启动时会遍历所有注册路由,把每条路由连同这些元数据上报给 mcenter,自动生成权限点(比如 resource:list)。运维在 mcenter 里给角色授权时,看到的就是这些"资源:动作"对。路由注册的瞬间,权限体系就同步完成了——这叫"路由即权限"。
2.3 Controller 层:薄如蝉翼的翻译官
handler 结构体的装配是理解"双协议复用"的钥匙(apps/resource/api/http.go:14-27):
var (
h = &handler{} // 包级单例 handler
)
type handler struct {
service resource.Service // 注意:持有的是 Service 接口(gRPC 实现的本地引用)
log logger.Logger
}
func (h *handler) Config() error {
h.log = zap.L().Named(resource.AppName)
// 魔法在这里:从框架的注册表里按模块名取出 gRPC 服务实现,
// 断言成 Go 接口后直接当本地方法调用!
h.service = app.GetGrpcApp(resource.AppName).(resource.Service)
return nil
}
app.GetGrpcApp("resource") 取到的就是 apps/resource/impl 里那个包级单例 svr。HTTP handler 拿到它后直接调方法,中间没有网络开销、没有序列化——所谓"HTTP 复用 gRPC 实现",其实是"HTTP 复用同一个 Go 对象"。
一个典型 handler 方法(apps/secret/api/http.go 中 QuerySecret 风格)的模式:
func (h *handler) QuerySecret(r *restful.Request, w *restful.Response) {
// 1. 参数绑定:URL Query → Go 结构体(模块根包提供的构造函数)
req := secret.NewQuerySecretRequestFromHTTP(r.Request)
// 2. 调 Service(进程内直接调用)
set, err := h.service.QuerySecret(r.Request.Context(), req)
if err != nil {
response.Failed(w, err) // 统一错误响应
return
}
// 3. 返回前脱敏(密钥绝不能出接口)
set.Desense()
response.Success(w, set)
}
三段式:绑参数 → 调服务 → 写响应。handler 里不允许出现 if 业务判断(if err != nil 除外)——这是判断代码分层是否健康的铁律。
2.4 已落地的路由全景
| 模块 | 方法 + 路径(相对 /cmdb/api/v1/<module>) | handler | 证据 |
|---|---|---|---|
| resource | GET /search | SearchResource | apps/resource/api/http.go:40 |
| resource | GET /discovery/prometheus | DiscoveryPrometheus | apps/resource/api/http.go:51 |
| secret | POST / GET / GET /{id} DELETE /{id} | Create/Query/Describe/DeleteSecret | apps/secret/api/http.go:40-82 |
| host | POST / GET / GET /{id} PUT /{id} PATCH /{id} DELETE /{id} | 6 个 handler | apps/host/api/http.go:40-88 |
| dict | GET /crendential_types GET /vendors GET /resource_types GET /regions | 静态字典 | apps/dict/api/http.go:35-69 |
| bill | 仅注册 Resource 壳,无 Route | — | apps/bill/api/http.go:31-34 |
| rds/redis/oss/disk/dns/eip/lb | api/http.go 空包,0 路由 | — | 各模块 api/http.go |
dict 模块最特殊:没有任何数据库交互,纯粹返回枚举字典(厂商列表、资源类型列表),是理解 handler 模式的最简样例,也最适合当探活接口。
2.5 Service 层:两种模块装配形态
形态一:自足型(resource、secret、host)——只依赖数据库:
// apps/resource/impl/impl.go 的 service 结构(示意,字段来自源码)
type service struct {
db *gorm.DB // GORM 流派:conf.C().MySQL.ORM() 拿到
log logger.Logger
resource.UnimplementedServiceServer
}
形态二:编排型(task)——不直接依赖某个模块的实现,而是从框架注册表按名字取依赖(apps/task/impl/impl.go:43-46):
func (s *service) Config() error {
// 依赖注入:按 AppName 从框架取出兄弟模块的接口实现
s.secret = app.GetInternalApp(secret.AppName).(secret.Service)
s.host = app.GetGrpcApp(host.AppName).(host.ServiceServer)
s.bill = app.GetGrpcApp(bill.AppName).(bill.ServiceServer)
s.rds = app.GetGrpcApp(rds.AppName).(rds.ServiceServer)
...
}
task 是"总指挥":创建任务后要驱动凭证解密、调用云厂商、再把数据交给 host/bill/rds 模块入库——它依赖四个兄弟模块,但全部面向接口编程,测试时可以注入 mock。这就是第 1 章说的"模块间通过 gRPC App 相互调用"的庐山真面目:本质是进程内的接口调用,将来拆成独立服务时,把这些接口换成真正的 gRPC Client 即可(接口签名已对齐)。
契约在哪定义? gRPC 方法契约由 rpc_grpc.pb.go 的 ServiceServer 接口定义(如 host 模块的 SyncHost/QueryHost/DescribeHost/UpdateHost/ReleaseHost 五方法,apps/host/rpc_grpc.pb.go:93-105);模块间契约是手写的 Service 接口(apps/resource/interface.go:22-26、apps/secret/interface.go:27-31)。
2.6 DAO 层:两种范式正面交锋
范式 A:GORM 链式(resource、secret 模块)
适合"标准 CRUD + 动态条件"。看 resource 的检索(apps/resource/impl/dao.go:12-17):
func (s *service) BuildQuery(ctx context.Context, req *resource.SearchRequest) (*gorm.DB, error) {
query := s.db.WithContext(ctx).
Table("resource_meta m"). // 主表取别名 m
Joins("LEFT JOIN resource_spec spsc ON spec.resource_id=m.id"). // JOIN 规格表
Joins("LEFT JOIN resource_cost cost ON cost.resource_id=m.id").
Joins("LEFT JOIN resource_status status ON status.resource_id=m.id")
// ……下面根据 req 里的非空字段逐个追加 Where(省略,5.3 节详讲)
return query, nil
}
GORM 的 *gorm.DB 是可链式累积的查询构造器:每调一次 .Where() 返回追加了条件的新语句,最后统一 .Scan() 执行。好处是动态查询写起来自然(有这个条件就加,没有就不加),代价是失去了对最终 SQL 的直观掌控(这也是 5.3 节别名 bug 的温床)。
范式 B:原生 SQL + sqlbuilder(host、task、bill 模块)
适合"复杂 JOIN + 精确控制"。看 host 的查询(apps/host/impl/host.go:43-79 节选):
query := sqlbuilder.NewQuery(queryHostSQL) // 以 SQL 常量为基座
if req.Keywords != "" { // 按需追加 WHERE
query.Where("r.name LIKE ? OR r.id = ? OR r.private_ip LIKE ? OR r.public_ip LIKE ?",
"%"+req.Keywords+"%", req.Keywords, req.Keywords+"%", req.Keywords+"%")
}
// 分页 + 排序 + 分组
querySQL, args := query.
GroupBy("r.id").
Order("r.sync_at").Desc().
Limit(req.OffSet(), uint(req.Page.PageSize)).
BuildQuery() // 生成最终 SQL + 参数切片
queryStmt, err := s.db.PrepareContext(ctx, querySQL) // 预编译防注入
defer queryStmt.Close()
rows, err := queryStmt.QueryContext(ctx, args...) // 参数化执行
PrepareContext + 参数化执行是防 SQL 注入的标准姿势;sqlbuilder 则补足了"原生 SQL 也要动态拼 WHERE"的需求。范式 B 的 SQL 全部明晃晃写在 sql.go 里,一眼可审。
为什么两种范式并存? 底稿明确指出这是历史演进的产物(仓库根目录底稿《CMDB源码拆解底稿.md》7.4 节):resource 模块是新写的(GORM),host/task/bill 是较早的代码(原生 SQL)。对学习者的启示:两种都要会读,二开时建议统一到你团队选定的那一种。
3 核心代码片段逐行解析
3.1 片段:resource.Put —— GORM 事务 + Upsert 的教科书
// apps/resource/impl/resource.go:10-53 —— 更新资源包含创建
func (s *service) Put(ctx context.Context, res *resource.Resource) (
*resource.Resource, error) {
// ① 参数校验(proto message 的 Validate 方法)
if err := res.Validate(); err != nil {
return nil, err
}
// ② 开启事务:四张子表要么全写成功,要么全回滚
var err error
tx := s.db.WithContext(ctx).Begin()
defer func() {
if err == nil {
tx.Commit() // 全成功 → 提交
} else {
tx.Rollback() // 任何一步失败 → 回滚
}
}()
// ③ proto 模型 → 分表模型(第 4 章讲过的 NewResource)
temp := NewResource(res)
// ④ 依次写四张表
if err := tx.Save(temp.ResourceMeta).Error; err != nil { // meta 表
return nil, err
}
if err := tx.Save(temp.ResourceSpec).Error; err != nil { // spec 表
return nil, err
}
if res.Cost != nil { // cost 表(有费用才写)
if err := tx.Save(temp.ResourceCost).Error; err != nil {
return nil, err
}
}
if err := tx.Save(temp.ResourceStatus).Error; err != nil { // status 表
return nil, err
}
// ⚠️ 注意:temp.Tags 被构建了,但这里没有 tx.Save(temp.Tags)!
// 这是底稿 7.2 节指出的真实缺口:标签不会写入 resource_tag 表
return res, nil
}
三个知识点:
tx.Save():GORM 的 upsert——主键存在则 UPDATE 全字段,不存在则 INSERT。Save前后不用先查一次,一条语句搞定"存在即更新,不存在即创建",这依赖我们在第 4 章讲的"主键 = 云厂商 ID"设计。- defer 决定事务命运:把 Commit/Rollback 写进 defer,函数任何路径退出时都会正确收尾,这是 Go 事务的标准写法。
- 真实的缺口:
Put没有持久化标签。这是"半成品项目"的典型样本——模型层准备好了(temp.Tags已构建),落库一步缺失。第 8 章给出修复方案。
Save 的 upsert 语义建立在"主键有值且由你掌控"的前提上——正因为 resource_meta.id 用的是云厂商 ID(同步来时已确定),Save 才能一条语句分清"该更新还是该插入"。另外盯紧那个 defer 闭包:它判断的是命名返回值/外层 err 变量,②里声明的 var err error 正是为此服务;如果你重构时顺手改成局部变量,Rollback 分支就永远走不到了。至于 tags 缺口——改这段代码时别被"temp.Tags 已构建"迷惑,构建了不等于落库了,要以数据库为准。3.2 片段:secret 的加密与脱敏 —— 安全三连
// apps/secret/interface.go:42-56 —— 加密:入库前调用
func (s *CreateSecretRequest) EncryptAPISecret(key string) error {
// 幂等保护:已经是密文就不再加密(防止二次加密)
if strings.HasPrefix(s.ApiSecret, conf.CIPHER_TEXT_PREFIX) {
return fmt.Errorf("text has ciphered")
}
// CBC 对称加密(key 来自配置 app.encrypt_key)
cipherText, err := cbc.Encrypt([]byte(s.ApiSecret), []byte(key))
if err != nil {
return err
}
// base64 编码后加上密文前缀标记
base64Str := base64.StdEncoding.EncodeToString(cipherText)
s.ApiSecret = fmt.Sprintf("%s%s", conf.CIPHER_TEXT_PREFIX, base64Str)
// 最终形态:@ciphered@xBa9sD7f...(前缀+base64密文)
return nil
}
// apps/secret/interface.go:104-113 —— api_key 掩码脱敏
func (s *CreateSecretRequest) DensenseKey() string {
if s.ApiKey == "" {
return ""
}
total := len(s.ApiKey)
if total > 8 {
// 只露前 4 后 4 位:LTAI5t****x9Km
return fmt.Sprintf("%s****%s", s.ApiKey[:4], s.ApiKey[total-4:])
}
return s.ApiKey
}
安全设计三板斧:落库前加密(apps/secret/impl/secret.go:19 调用 EncryptAPISecret)、密文带前缀标记(@ciphered@,一眼识别"这是密文",且防重复加密)、出接口前脱敏(Desense() 把 api_secret 置为 ******,DensenseKey() 掩码 api_key)。对称解密用同一个 key(DecryptAPISecret,interface.go:58-78),只有发起同步任务时才解密、且解密结果只存在于内存。
3.3 片段:BuildQuery 的动态条件与别名陷阱
// apps/resource/impl/dao.go 节选 —— 检索条件动态拼装
if req.Vendor != nil {
query = query.Where("spec.vendor = ?", req.Vendor) // ⚠️ 用了 spec 别名
}
if req.Type != nil {
query = query.Where("spec.type = ?", req.Type) // ⚠️ 同上
}
if req.Status != "" {
query = query.Where("status.phase = ?", req.Status)
}
if req.Keywords != "" {
query = query.Where("r.name LIKE ? OR r.id = ? OR r.private_ip LIKE ? OR r.public_ip LIKE ?",
// ⚠️ 用了 r 别名
"%"+req.Keywords+"%", req.Keywords, req.Keywords+"%", req.Keywords+"%")
}
对照 5.2.6 节的 JOIN 声明:spec 表声明的别名是 spsc,主表别名是 m——但 WHERE 里写的是 spec.vendor、r.name!别名对不上,这些条件一旦命中,MySQL 会直接报"Unknown column"。这是底稿 7.2 节明确指出的别名歧义 bug:字面写法源自旧版查询代码(注释掉的旧 buildQuery 里到处是 r./spec. 前缀),重构到 GORM 链式时没改干净。学习价值:代码里大段被注释的旧实现,往往是理解"新代码为什么长这样"的活化石,也是 bug 的高发地层。
/search,看 MySQL 报的 Unknown column 指向哪个别名,再回 dao.go 对照 JOIN 声明的别名。预防心法:GORM 链式查询里别名一旦声明,所有 Where/Order/GroupBy 的列前缀必须统一;重构查询代码时,把旧实现整段删掉比重命名保留更安全——被注释的旧代码就是下次重构的雷。3.4 片段:QueryHost 的手工行映射 —— 原生 SQL 的体力活
// apps/host/impl/host.go:97-121 节选 —— 原生 SQL 查询结果手工组装
for rows.Next() {
ins := host.NewDefaultHost()
base := ins.Resource.Meta // 借出三个子结构的指针
info := ins.Resource.Spec
desc := ins.Describe
err := rows.Scan( // 逐列按顺序塞进结构体字段
&base.Id, &info.ResourceType, &info.Vendor, &info.Region, &info.Zone,
&base.CreateAt, &info.ExpireAt,
&info.Category, &info.Type, &info.Name, &info.Description,
&ins.Resource.Status.Phase, &info.UpdateAt, &base.SyncAt, &info.Owner,
&publicIPList, &privateIPList, &ins.Resource.Cost.PayMode, &base, &base,
&base.CredentialId, &base.Domain, &base.Namespace, &base.Env, &base.UsageMode, &base.Id,
&desc.GpuSpec, &desc.OsType, &desc.OsName,
&desc.ImageId, &desc.InternetMaxBandwidthOut, &desc.InternetMaxBandwidthIn,
&keyPairNameList, &securityGroupsList,
)
if err != nil {
return nil, exception.NewInternalServerError("query host error, %s", err.Error())
}
// 字符串还原为数组:"1.1.1.1,2.2.2.2" → []string
ins.Resource.Status.LoadPrivateIPString(privateIPList)
ins.Resource.Status.LoadPublicIPString(publicIPList)
desc.LoadKeyPairNameString(keyPairNameList)
desc.LoadSecurityGroupsString(securityGroupsList)
set.Add(ins)
}
两个细节:
- 多值字段用逗号分隔字符串存库(IP 列表、安全组列表),读出后再
Load...String还原成数组——没有用 JSON 列,简单粗暴但有效,代价是无法按"单个 IP"精确索引(这是可以用第 8 章二开优化的点)。 rows.Scan的列顺序必须与queryHostSQL的 SELECT 列严格一致,加一列要同步改两处——这就是原生 SQL 的维护税,也是 GORMScan(&struct)自动映射存在的意义。
queryHostSQL 时只加了 SELECT 列、忘了同步改 rows.Scan 的参数列表,这类错误编译期完全无感知,只有运行到这一行才报错,而且报错位置离你改的 SQL 很远。排错口诀:看到 query 报"列数/目标数不匹配"类的错误,先 diff SQL 常量和 Scan 参数这两处的顺序与个数。这也是为什么底稿把它称为"维护税"——税虽然烦,但换来的是 SQL 全文可审,两范式各有各的账。4 业务逻辑流程图与核心要点总结
4.1 四层协作全景(以"检索资源"为例)
GET /cmdb/api/v1/resource/search?vendor=aliyun&page_size=20
│
▼ ① Router: apps/resource/api/http.go:40 已注册
│ (启动时该路由已上报 mcenter 成为权限点 resource:list)
│ (请求先过 mcenter 鉴权 Filter → token 校验通过)
▼ ② Controller: SearchResource(r, w)
│ req := resource.NewSearchRequestFromHTTP(r.Request)
│ │ 解析 keywords/domain/.../tag 选择器(with_tags 等)
│ h.service = app.GetGrpcApp("resource").(resource.Service) ← 进程内直调
▼ ③ Service: impl/resource.go Search(ctx, req)
│ BuildQuery: Table(resource_meta m) + 3 个 LEFT JOIN + 动态 WHERE
▼ ④ DAO(GORM): .Order("m.create_at DESC").Offset().Limit().Scan(&temp.Items)
│ + query.Count(&temp.Total)
▼ MySQL 返回 → ResourceSet(proto)→ response.Success(w, set)
4.2 核心要点总结
- Router 层 = 路由 + OpenAPI 元数据 + 权限声明三合一;
- Controller 的灵魂一行:
app.GetGrpcApp(AppName).(Service)——双协议复用的机关; - Service 装配两种形态:自足型(只依赖 DB)与编排型(task 的接口注入);
- DAO 两种范式:GORM 链式(快、省事、SQL 不直观)vs 原生 SQL + sqlbuilder(直观、可审、费手);
- 安全三件套:入库前 CBC 加密、密文前缀标记、出库前脱敏;
- 两处真实 bug 现场:
Put不写标签、BuildQuery别名不匹配——第 8 章修复。
5 自测题(对话式)
老规矩,三道原书思考题的"面试现场版"。先自己答,再对答案。
app.GetGrpcApp(resource.AppName).(resource.Service) 类型断言。如果 impl 没有实现 Service 接口(比如手误漏了一个方法),这个断言会在什么时刻、以什么方式失败?有没有办法把失败提前到编译期?(提示:var _ resource.Service = svr)x.(T) 失败会直接 panic。好处是它发生在启动期而非请求期——服务起不来,问题在上线前就会被发现;坏处是终究晚于编译期,报错是一行 panic 栈而不是"缺哪个方法"的友好提示。提前到编译期:在 impl 包里加
var _ resource.Service = svr——接口符合性检查,编译器立刻指出缺的方法。不过要补一个细节:本项目 service 结构体嵌入了 resource.UnimplementedServiceServer,方法集总是"齐"的(缺的方法有默认的 unimplemented 实现),所以编译期检查只能保证签名齐、保证不了真的实现了。这就是为什么模块间契约还要靠手写的 Service 接口(如 resource/interface.go、secret/interface.go)来约束——两套契约各管一头。resource.Put 用 tx.Save 实现 upsert。假如把主键设计从"云厂商 ID"改成"自增 ID + 厂商 ID 唯一索引",Save 还能正确 upsert 吗?需要改用什么 GORM 能力?Save 判断"更新还是插入"的依据是主键是否有值:主键有值按主键 WHERE 走 UPDATE,无值走 INSERT。改成自增主键后,云端同步来的对象并不知道库内那条记录的自增 id——每次 Save 都会因主键为空而 INSERT,同一台云资源被反复插入新行,数据翻倍。两条修法:① 用 GORM 的 upsert 能力(
clause.OnConflict),配合 (vendor, resource_id) 唯一索引,生成 INSERT ... ON DUPLICATE KEY UPDATE,一条语句搞定;② 退回"先查后写"——按唯一索引查出库内记录拿到主键,赋值后再 Save(这正是第 6 章 host 模块对账的姿势)。取舍:① SQL 语义复杂、带 MySQL 方言味,但一次往返;② 直观可控、但多一次查询。同时换来的是主键空间自主可控、彻底规避撞 ID 风险——这就是第 4 章说的"省一次映射 vs 主键自主可控"的账,两面都要会算。QueryHost 增加"按标签过滤"能力,两种范式各自要怎么改?哪种改动风险更小?为什么?GroupBy("r.id") 兜着。GORM 路线(借 resource 的 BuildQuery):Joins + Where 追加标签条件,看似一两行——但本章刚讲过别名陷阱:JOIN 声明与 WHERE 前缀必须严格一致,而 resource 模块现存的别名 bug 恰好说明这类改动极易翻车;且 BuildQuery 是通用模型层,动它影响所有资源类型的检索,牵一发动全身。
结论:在 host 自己的原生 SQL 里加过滤风险更小——影响面局限在 host 模块、SQL 明晃晃可审、GroupBy 已防重复;通用层改动收益大但爆炸半径也大,还叠加历史包袱。
6 本章小结
app.GetGrpcApp(AppName).(Service)——进程内直调同一个 Go 对象,零网络开销;
- DAO 两范式各有各的税:GORM 链式省手但 SQL 不直观(别名 bug 的温床),原生 SQL 可审但要交"Scan 列顺序"的维护税;secret 的安全三件套(入库前加密、密文前缀、出库脱敏)是密钥类系统的标配;
- 分层读完了,下一章把这些层串起来跑:跟着 CreateTask 从路由一路走到 task_record 落库,你会看到本章每一层在真实业务流程里各自出力的样子。