第 5 章 全层级源码精讲:路由、控制器、服务层、数据层

2025-12-07T10:00:00+08:00 | 18分钟阅读 | 更新于 2025-12-07T10:00:00+08:00

@

第 5 章 全层级源码精讲:路由、控制器、服务层、数据层

1 学习目标

学完本章你应该能够:

  1. 说出 HTTP handler 的"四方法"生命周期(Config/Name/Version/Registry)与路由注册流程;
  2. 解释"HTTP 层复用 gRPC 实现"的完整代码路径——尤其是 app.GetGrpcApp(AppName).(Service) 这一行为什么没有网络开销;
  3. 区分 Service 层两种装配模式:自足型(只依赖 DB)与编排型(task 模块的接口注入),并说出各自的适用场景;
  4. 对比两种 DB 访问范式——GORM 链式(resource/secret)与原生 SQL + sqlbuilder(host/task/bill)——的写法差异与真实利弊;
  5. 指出本章两个"一等一的 bug 教学案例"(Put 不写标签、BuildQuery 别名不匹配)的成因与修复方向。

前置知识

  • 第 1 章的框架启动流程与 mcenter 权限中心(“路由即权限"的背景);
  • 第 4 章的数据模型:垂直分表、resource_id 关联、主键 = 云厂商 ID(本章 tx.Save 的 upsert 依赖它);
  • GORM 基础:Begin/Commit/Save/Scan 的语义;
  • go-restful 的 WebService/Route 概念(看到即可,不要求写过)。

本章你会动手做的事

  1. 从 apps/resource/api/http.go 的 Metadata(label.Resource, ...) 出发,追一遍路由上报 mcenter 的路径,验证"注册即权限”;
  2. 带上 vendor 条件实际调一次 /search,亲手复现 BuildQuery 的别名 bug(Unknown column);
  3. 并排打开 apps/resource/impl/dao.go 与 apps/host/impl/sql.go,同一条"查主机"用两种范式各读一遍。

先挂出本章的知识地图:

第 5 章总览Router 层Controller 层Service 层DAO 层路由+文档+权限三合一Metadata 上报 mcenter注册即权限三段式 handlerGetGrpcApp 进程内直调出接口前脱敏自足型:只依赖 DB编排型:task 注入面向接口可 mockGORM 链式原生 SQL+sqlbuilder两个 bug 教学案例

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 层。

类比:这四层像一家手续规范的营业厅:Router 是门口的指示牌(哪个窗口办什么事,顺便公示办事指南和"需要什么资格"),Controller 是窗口接待员(听懂你说什么、转给后台、把结果递回来,自己不拍板),Service 是后台经办(真正审材料、走流程),DAO 是档案室(只管存取档案,不管业务)。对应到工程里就是:接待员一旦开始"拍板",分层就烂了——后面 5.2.3 会给你一条判断分层是否健康的铁律。

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 里给角色授权时,看到的就是这些"资源:动作"对。路由注册的瞬间,权限体系就同步完成了——这叫"路由即权限"。

类比:这就像酒店办理入住时顺手把房卡(门禁权限)开好了——登记房间号(注册路由)的同时,你的房卡能开哪层楼、哪个门(权限点 resource:list)已经写进门禁系统(mcenter)。对应到工程里就是:路由注册是"一次声明,三处生效"——路由本身、OpenAPI 文档、权限点,全由 Registry 这一处代码派生,杜绝了"文档过期""权限漏配"这类手工同步的 classic 问题。

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 除外)——这是判断代码分层是否健康的铁律。

⚠️ 新手必踩的坑:写着写着 handler,就想在里面加"如果查不到就默认给个空列表""如果参数是管理员就放开过滤"之类的分支——一旦这么写,业务逻辑就漏进了翻译层,双协议复用也会被破坏(gRPC 入口不走这段代码,行为立刻不一致)。正确姿势:任何"要不要、怎么算"的判断都下沉到 Service;handler 只负责翻译协议和脱敏这类"表示层"动作。判断标准就一条:把 handler 里的方法体遮住方法名,你分不清它属于哪个模块,说明分层到位;一眼看出它在"办业务",说明越界了。

2.4 已落地的路由全景

模块方法 + 路径(相对 /cmdb/api/v1/<module>)handler证据
resourceGET /searchSearchResourceapps/resource/api/http.go:40
resourceGET /discovery/prometheusDiscoveryPrometheusapps/resource/api/http.go:51
secretPOST / GET / GET /{id} DELETE /{id}Create/Query/Describe/DeleteSecretapps/secret/api/http.go:40-82
hostPOST / GET / GET /{id} PUT /{id} PATCH /{id} DELETE /{id}6 个 handlerapps/host/api/http.go:40-88
dictGET /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/lbapi/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)。

类比:自足型像独立小店——自己有库房(DB)就能开张;编排型像总包施工队——自己不生产水泥钢材,水电(secret)、土建(host)、装修(rds)全部分包给专业队,自己只排工期、验收。对应到工程里就是:总包手里握的只有"分包合同"(接口),换一家施工队(换实现/注入 mock)不耽误开工;将来公司做大要物理拆分(微服务化),合同条款(接口签名)不变,把"内部直呼"换成"正式公函"(gRPC Client)就行。

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)。对学习者的启示:两种都要会读,二开时建议统一到你团队选定的那一种。

类比:范式 A 像点菜系统:一道一道加菜(Where),最后统一下单(Scan),省心但你看不到后厨最终怎么炒的;范式 B 像明档厨房:菜谱(SQL 常量)全贴在墙上,怎么炒一目了然,但每加一道菜你都得自己动手改菜谱。对应到工程里就是:GORM 快、省事、SQL 不直观;原生 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 的高发地层。

⚠️ 新手必踩的坑:这类 bug 最阴险的地方在于它平时不发作——不带 vendor/type/keywords 条件的检索一切正常,一旦某个用户在页面里选了"厂商=阿里云",接口直接 500。复现与排查方法:带上条件实际调一次 /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 的维护税,也是 GORM Scan(&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 核心要点总结

  1. Router 层 = 路由 + OpenAPI 元数据 + 权限声明三合一;
  2. Controller 的灵魂一行:app.GetGrpcApp(AppName).(Service)——双协议复用的机关;
  3. Service 装配两种形态:自足型(只依赖 DB)与编排型(task 的接口注入);
  4. DAO 两种范式:GORM 链式(快、省事、SQL 不直观)vs 原生 SQL + sqlbuilder(直观、可审、费手);
  5. 安全三件套:入库前 CBC 加密、密文前缀标记、出库前脱敏;
  6. 两处真实 bug 现场:Put 不写标签、BuildQuery 别名不匹配——第 8 章修复。

5 自测题(对话式)

老规矩,三道原书思考题的"面试现场版"。先自己答,再对答案。

面试官
handler.Config() 里用了 app.GetGrpcApp(resource.AppName).(resource.Service) 类型断言。如果 impl 没有实现 Service 接口(比如手误漏了一个方法),这个断言会在什么时刻、以什么方式失败?有没有办法把失败提前到编译期?(提示:var _ resource.Service = svr)
候选人
失败发生在运行时,具体是进程启动阶段执行到 Config() 里这行断言的瞬间:单返回值形式的断言 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 主键自主可控"的账,两面都要会算。
面试官
对比两种 DAO 范式:如果要给 host 模块的 QueryHost 增加"按标签过滤"能力,两种范式各自要怎么改?哪种改动风险更小?为什么?
候选人
原生 SQL 路线(host 现状):在 sql.go 的 queryHostSQL 常量里 JOIN resource_tag(或改用 EXISTS 子查询),在 sqlbuilder 的 Where 分支里按需追加标签条件;标签只用于过滤、不用于展示,rows.Scan 的列清单不用动。风险点是 JOIN tag 表产生 1:N 行膨胀,但现有代码已有 GroupBy("r.id") 兜着。

GORM 路线(借 resource 的 BuildQuery):Joins + Where 追加标签条件,看似一两行——但本章刚讲过别名陷阱:JOIN 声明与 WHERE 前缀必须严格一致,而 resource 模块现存的别名 bug 恰好说明这类改动极易翻车;且 BuildQuery 是通用模型层,动它影响所有资源类型的检索,牵一发动全身。

结论:在 host 自己的原生 SQL 里加过滤风险更小——影响面局限在 host 模块、SQL 明晃晃可审、GroupBy 已防重复;通用层改动收益大但爆炸半径也大,还叠加历史包袱。

6 本章小结

复习提示: - 分层铁律:上层只翻译、业务只在 Service;双协议复用的机关就一行 app.GetGrpcApp(AppName).(Service)——进程内直调同一个 Go 对象,零网络开销; - DAO 两范式各有各的税:GORM 链式省手但 SQL 不直观(别名 bug 的温床),原生 SQL 可审但要交"Scan 列顺序"的维护税;secret 的安全三件套(入库前加密、密文前缀、出库脱敏)是密钥类系统的标配; - 分层读完了,下一章把这些层串起来跑:跟着 CreateTask 从路由一路走到 task_record 落库,你会看到本章每一层在真实业务流程里各自出力的样子。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!