第 3 章 项目目录结构与工程标准化规范详解
1 学习目标
学完本章,你应该能够:
- 默写项目顶层 12 个目录的职责,并说清"按职责切目录"与"按技术层切目录"的区别;
- 掌握
apps/<module>的标准分包规范(api / impl / pb),理解"一个模块就是一个小项目"; - 掌握
provider/多云适配层的分包规范(operator 聚合器 / connectivity / 具体资源 operator / mapping); - 理解 proto 生成物文件族(
*.pb.go/rpc.pb.go/rpc_grpc.pb.go/*_enum.pb.go)各自的来历与分工; - 用"三板斧"识别一个模块是已落地还是仅 proto 占位,为二开实战选好地块。
前置知识:
- 第 1 章的分层架构(cmd / protocol / apps / provider 四层);
- 第 2 章的
make gen(知道.pb.go文件是工具生成的); - Go 的包(package)与 import 概念;
- 接口(interface)的基本概念。
本章你会动手做的事:
- 对照顶层目录表,在仓库里逐个点开 12 个目录,确认各自职责;
- 以 host 模块为样板,把
api/、impl/、pb 文件族逐个过一遍,亲手画出"职责地图"; - 用"空地皮三板斧"扫描
apps/下全部模块,自己数一数几个落地、几个占位。
先看本章概念总览脑图:
2 知识点通俗系统化讲解
2.1 顶层目录:先认全 12 个"部门"
| 目录 | 职责 | 关键文件 |
|---|---|---|
cmd/ | 程序命令与启动装配(cobra 子命令) | root.go start.go init.go |
conf/ | 全局配置结构体与加载(TOML/ENV) | config.go load.go log.go |
protocol/ | HTTP / gRPC 服务构建与中间件 | http.go grpc.go |
apps/ | 业务域模块(核心) | 20 个子模块,按 api/ impl/ pb/ 分包 |
provider/ | 多云厂商 SDK 抽象与适配 | base.go host.go bill.go … + 5 个厂商子目录 |
client/ | 对外 gRPC/REST 客户端 SDK | rest/ rpc/ |
common/pb/ | mcube protobuf 公共定义拷贝 | — |
etc/ | 配置文件样例 | config.example.toml 等 |
swagger/ | OpenAPI 文档装配 | docs.go |
test/ | 数据与工具脚本 | data/ tools/ |
utils/ | 通用工具(hash/enum/time/ptr) | enum.go hash.go time.go |
version/ | 构建版本信息 | version.go |
一眼扫过去,你会发现一个鲜明特征:业务代码全部集中在 apps/,与云厂商打交道的代码全部集中在 provider/,基础设施(配置/协议/命令)各占一个目录。这种"按职责切目录"而不是"按技术层切目录"(比如不搞一个全局 controllers/、一个全局 services/)的方式,正是微服务工程里"模块化单体"的标准做法——每个业务域高内聚,未来想拆成独立微服务,把模块目录搬出去就行。
2.2 apps/ 分包规范:以 host 模块为样板
一个完整落地的业务模块长这样(仓库根目录底稿《CMDB源码拆解底稿.md》2.2 节):
apps/host/
app.go // 包级常量 AppName + 领域辅助方法(构造器/Hash/脱敏等)
host.pb.go // protoc 生成:领域 message(Host/Describe/HostSet)
host_enum.pb.go // mcube 生成:枚举辅助方法(ParseSTATUSFromString 等)
rpc.pb.go // protoc 生成:请求 message + Service 定义
rpc_grpc.pb.go // protoc 生成:ServiceServer/ServiceClient 接口与注册函数
api/
http.go // handler 结构 + Registry(路由注册)
host.go // 各 HTTP handler 实现(每个方法一个函数)
impl/
impl.go // service 结构 + Config/Name/Registry + init 注册
host.go // gRPC 方法实现(业务逻辑主战场)
dao.go // 私有数据访问方法(save/update/delete)
sql.go // SQL 常量集中定义
我们把这个布局总结成一张"职责地图":
外部请求 ──→ api/(翻译官:HTTP ↔ Go 结构体,不含业务规则)
│ 持有 service 引用(其实是 impl 的实现)
▼
impl/(业务员:查重、对账、编排、记任务)
│
▼
dao.go + sql.go(仓管员:只管 SQL 和落库细节)
│
▼
MySQL
三个值得注意的规范细节:
- 接口契约手写在模块根包:比如 resource 模块在
apps/resource/interface.go:22-26手写了Service{Put, Delete, RPCServer}接口——proto 生成的ServiceServer定义了 RPC 方法,但模块内部之间调用用的 Go 接口是手写的,这样调用方依赖接口而非实现,方便测试替身(mock)。 - SQL 常量与 DAO 分离:
impl/sql.go里全是const的 SQL 字符串(insertHostSQL/updateHostSQL/queryHostSQL…),dao.go里只写执行逻辑。SQL 集中管理,review 时一眼看全数据操作面。 - 枚举辅助是生成的:
host_enum.pb.go里的ParseSTATUSFromString等函数由mcube generate enum生成(Makefile:65),业务代码里字符串转枚举不用手写 switch。
2.3 provider 分包规范:多云适配的"乐高结构"
provider/
base.go host.go bill.go rds.go redis.go oss.go dns.go lb.go
mongo.go cms.go event.go ← 抽象接口层(定义"什么是主机采集器")
aliyun/ txyun/ aws/ huawei/ vsphere/ openstack/ ← 厂商实现层
operator.go // 厂商聚合器(工厂):按需装配子 operator
connectivity/ // 凭证结构 + 各云 SDK ClientSet 懒加载
ecs|cvm|rds|redis|oss|... // 具体资源 operator + mapping(状态/付费归一化)
三层分工用大白话说:
- 接口层(
provider/host.go等):回答"我需要什么"。例如HostOperator接口要求实现PageQueryHost(req *QueryRequest) pager.Pager(provider/host.go:11-17)——注意返回的是分页器而不是全量数据,后面会讲为什么。 - 聚合器(
aliyun/operator.go):一个厂商对应一个Operator,它像"总代理",你要主机就找它拿HostOperator(),要账单就找它拿BillOperator()(provider/aliyun/operator.go:65-169)。 - 资源 operator(
aliyun/ecs/):真正干活的,持有该厂商该产品的 SDK client,把 SDK 返回的私有格式翻译成统一模型。
2.4 proto 生成物文件族:四个文件各司其职
很多初学者看到 *.pb.go 一堆文件就懵,其实规律很简单:
| 文件 | 谁生成 | 装了什么 | 你会怎么用它 |
|---|---|---|---|
host.pb.go | protoc | 领域 message 结构体(Host/Describe/HostSet) | 定义变量、JSON 序列化 |
rpc.pb.go | protoc | RPC 请求/响应 message(QueryHostRequest 等) | handler 解析参数 |
rpc_grpc.pb.go | protoc | ServiceServer/ServiceClient 接口 + 注册函数 | impl 要实现 Server 接口;client 要用 Client |
host_enum.pb.go | mcube CLI | 枚举的 String()/Parse...FromString() | 状态/厂商的字符串与数值互转 |
改动规则:.proto 文件(位于各模块 pb/ 子目录)是唯一事实来源,改完跑 make gen 重新生成;永远不要手改 .pb.go——下次生成会全部冲掉。(唯一例外:protoc-go-inject-tag 注入的自定义 tag,因为它写在 .proto 的注释里,重新生成也会回来。)
*.pb.go 里加了一个字段或改了一行代码,跑一次 make gen 之后全部蒸发——生成物的唯一事实来源是 .proto 文件。想改结构,永远去改 .proto 再重新生成;想在生成物上加 tag,用 protoc-go-inject-tag 把 tag 写在 proto 注释里(这是唯一能"存活"的注入方式)。记住口诀:改 proto,不改 pb。2.5 哪些模块是"空地皮"?一眼识别法
判断一个模块是否落地,看三样东西(以 redis 为例):
api/http.go是不是空包(只有 package 声明)→ 是;impl/里有没有 DAO/SQL → 没有;- 代码里有没有它的表(模块
impl/sql.go的 SQL 常量、或 GORMTableName())→ 没有。
三条全中,就是"仅 proto 占位"。用这个方法扫描全项目:有真实实现 7 个(resource、secret、host、task、rds〔部分〕、bill〔部分〕、health——判据就是 apps/internal.go 的匿名导入,正好 7 行);dict 半落地(api/http.go 挂了路由,但没有 impl 实现);占位 12 个(cost、account、order、database、metric、mongodb、redis、oss、disk、dns、eip、lb)(仓库根目录底稿《CMDB源码拆解底稿.md》7.3 节)。
3 核心代码片段逐行解析
3.1 片段:host 模块 impl.go —— 模块的"出生证明"
// apps/host/impl/impl.go
var (
svr = &service{} // 包级单例:整个模块唯一的服务实例
)
type service struct {
db *sql.DB // 该模块用的数据库连接(原生 SQL 流派)
log logger.Logger // 模块专属日志(以模块名命名,方便过滤)
host.UnimplementedServiceServer // 关键!嵌入 proto 生成的"未实现基类"
}
func (s *service) Config() error {
// InitAllApp() 时被框架回调:此刻才去拿数据库连接
db, err := conf.C().MySQL.GetDB()
if err != nil {
return err
}
s.log = zap.L().Named(s.Name()) // 日志器带上模块名,如 "host"
s.db = db
return nil
}
func (s *service) Name() string {
return host.AppName // "host"
}
func (s *service) Registry(server *grpc.Server) {
// gRPC 服务装载时被回调:把自己注册进 gRPC Server
host.RegisterServiceServer(server, svr)
}
func init() {
app.RegistryGrpcApp(svr) // 包被导入的瞬间,自动注册为 gRPC App
}
四个知识点:
host.UnimplementedServiceServer:proto 生成的基类,所有方法都返回"未实现"。嵌入它之后,service只需覆盖自己关心的方法,将来.proto新增方法时编译依然通过(不会因为少实现一个方法而编译失败),这是 gRPC Go 生态的标准防坑手法。Config()是延迟初始化钩子:构造单例时(init()执行时)配置还没加载,所以拿连接这种"依赖配置"的活,必须等框架调Config()时再干。Name()是模块身份:框架用它在app.GetGrpcApp(host.AppName)时找到你。- 包级
var svr = &service{}:整个模块单例,配合 init 注册,构成了"模块即插件"的实现基础。
3.2 片段:手写接口契约(apps/resource/interface.go:18-26)
const (
AppName = "resource" // 模块名常量,全局调度的 key
)
// Service 是 resource 模块对外(模块间)暴露的 Go 接口
// 它比 proto 的 ServiceServer 多了 Put/Delete 两个内部方法
type Service interface {
Put(context.Context, *Resource) (*Resource, error) // 内部模块用它写入资源
Delete(context.Context, *DeleteRequest) (*DeleteResponse, error)
RPCServer // 嵌入 proto 生成的 RPC 接口
}
为什么要"proto 接口 + 手写接口"两层?因为 Put/Delete 是模块间调用的,不需要暴露成 RPC。HTTP/gRPC 对外用 RPCServer 那部分;task、host 等兄弟模块在进程内调用时用完整的 Service 接口。对外契约和内部契约,边界分明。
3.3 片段:SQL 常量集中管理(apps/host/impl/sql.go 全文)
package impl
const (
insertHostSQL = `
INSERT INTO resource_host (
resource_id,gpu_spec,os_type,os_name,
image_id,internet_max_bandwidth_out,
internet_max_bandwidth_in,key_pair_name,security_groups
) VALUES (?,?,?,?,?,?,?,?,?);
`
updateHostSQL = `
UPDATE resource_host SET
gpu_spec=?,os_type=?,os_name=?,
image_id=?,internet_max_bandwidth_out=?,
internet_max_bandwidth_in=?,key_pair_name=?,security_groups=?
WHERE resource_id = ?
`
queryHostSQL = `
SELECT
r.*,
h.*
FROM
resource AS r
LEFT JOIN resource_host h ON r.id = h.resource_id
LEFT JOIN resource_tag t ON r.id = t.resource_id`
countHostSQL = `SELECT
COUNT(DISTINCT r.id)
FROM
resource AS r
LEFT JOIN resource_host h ON r.id = h.resource_id
LEFT JOIN resource_tag t ON r.id = t.resource_id
`
deleteHostSQL = `DELETE FROM resource_host WHERE resource_id = ?;`
)
注意 queryHostSQL 里 FROM resource AS r——数据库里并没有叫 resource 的表(权威 DDL 里基表叫 resource_meta)!这是项目"半重构"留下的著名坑点,我们先埋个标记,第 8 章详细解剖并修复它。
queryHostSQL 时别急着怀疑自己眼神——库里真的没有 resource 这张表,权威 DDL 里基表叫 resource_meta。这是项目"半重构"留下的著名坑点(host/rds 查询都引用了这张"幻影表"),也是第 8 章排雷清单上的常客。看源码遇到"SQL 引用了不存在的表",先记下来,别硬啃。4 业务逻辑流程图与核心要点总结
4.1 一个 HTTP 请求在模块内的完整旅程
GET /cmdb/api/v1/host/?keywords=web
│
▼
protocol/http.go: PathPrefix()="/cmdb/api" + LoadRESTfulApp 装配
│
▼
apps/host/api/http.go: Registry() 里 ws.Route(ws.GET("/")...)
│ 路由元数据 label.Resource/Action/Auth → 供 mcenter 鉴权
▼
apps/host/api/host.go: QueryHost(httpRequest)
│ NewQueryHostRequestFromHTTP(r) 把 URL 参数变成 Go 结构体
▼
handler.service = app.GetGrpcApp("host").(host.ServiceServer) ← 复用 gRPC 实现!
│
▼
apps/host/impl/host.go: QueryHost(ctx, req)
│ sqlbuilder.NewQuery(queryHostSQL) 动态拼 WHERE
▼
apps/host/impl/dao.go: PrepareContext + QueryContext
│
▼
MySQL resource(→resource_meta) JOIN resource_host
│
▼
rows.Scan(...) 逐列取出 → LoadPrivateIPString 等还原复杂字段 → HostSet 返回
4.2 核心要点总结
- 目录按职责域切:apps=业务、provider=多云适配、其余=基建;
- 模块四件套:
app.go(身份+领域方法)、api/(翻译)、impl/(业务)、*.pb.go(契约); - impl 标配四方法:
init()注册 →Name()报身份 →Config()延迟初始化 →Registry()装载; - SQL 常量集中在
impl/sql.go,与 DAO 逻辑分离; - provider 三层乐高:抽象接口 → 厂商聚合器 → 资源 operator + mapping。
5 自测题(对话式)
最后三道题,检验你是否真的吃透了这套工程规范。
service 结构体要嵌入 host.UnimplementedServiceServer 而不是直接实现 host.ServiceServer 接口?删掉这个嵌入字段,什么场景下项目会编译失败?service 只需覆盖自己关心的方法,其余方法由基类兜底。删掉会发生什么:Go 的接口是隐式实现——只要方法集不全,类型就不满足接口。删掉嵌入后,一旦
service 没把 ServiceServer 的方法全部实现,Registry() 里的 host.RegisterServiceServer(server, svr) 在编译期就过不了类型检查,项目直接编译失败。最典型的翻车场景:.proto 新增了一个 RPC 方法,重新
make gen 后接口变大了——没嵌基类的模块立即编译报错;嵌了的模块照常编译,没覆盖的新方法运行时返回"未实现",服务不崩。结论:这是 gRPC Go 生态的标准前向兼容手法,用"运行期可发现的未实现"换"编译期不炸"。
impl/sql.go 把 SQL 定义为常量、与 dao.go 分离,除了"集中管理",还有哪些好处?(提示:想想 SQL 注入面、code review、单元测试三个角度)SQL 注入面:SQL 全是静态常量、参数一律
? 占位,DAO 只负责传参——从结构上杜绝了字符串拼接注入的可能。想确认"有没有人偷偷拼 SQL",扫一眼 sql.go 这一个文件就够了,不用满项目翻。code review:数据操作面集中在一处,评审者不用在业务逻辑的缝隙里找散落的 SQL;改表结构时,拿着 DDL 对照
sql.go 一个文件就能核对完所有影响点——本章 3.3 节能一眼发现 queryHostSQL 引用了不存在的 resource 表,正是这种集中带来的可审计性。单元测试:SQL 作为纯文本常量可以被单独断言——比如检查每条查询是否都带了预期条件、字段名与 DDL 是否一致,不需要真连数据库就能做静态校验。
本质上是把"数据操作"从业务代码里声明式化出来:声明归声明,执行归执行。
apps/mongodb 模块(目前是空地皮),按本章规范,你需要创建哪些文件、在每个文件里写什么"标配代码"?① 契约:
apps/mongodb/pb/mongodb.proto 定义领域 message 和 Service,跑 make gen 生成 mongodb.pb.go、rpc.pb.go、rpc_grpc.pb.go(有枚举再让 mcube 生成 *_enum.pb.go);② 身份:
apps/mongodb/app.go 写 AppName = "mongodb" 常量和领域辅助方法;③ HTTP 层:
api/http.go 写 handler 结构和 Registry() 注册路由,api/mongodb.go 写各 handler 实现(参数翻译,不含业务规则);④ 实现层:
impl/impl.go 写包级单例 svr、嵌入 UnimplementedServiceServer,以及四方法——init() 里 RegistryGrpcApp/RegistryRESTfulApp、Name() 返回 AppName、Config() 拿数据库连接和日志器、Registry() 把自己注册进 gRPC Server;impl/mongodb.go 写业务逻辑,impl/dao.go + impl/sql.go 写数据访问与 SQL 常量;⑤ 别忘了两件配套事:聚合文件
apps/api.go、apps/internal.go 各加一行匿名导入(否则模块根本不会被加载),以及准备好建表 SQL(仓库没附带 DDL 文件,按第 4 章表清单把新表写进你自己的 tables.sql)并重新 init。这份清单本身就是检验有没有掌握本章规范的试金石。
6 本章小结
本章把项目的"骨架图纸"讲透了:顶层 12 目录各司其职、业务模块五件套(app.go + pb 族 + api + impl)、provider 三层乐高。理解了 impl.go 的"init 注册 + Config 钩子 + Unimplemented 基类"三板斧,你就掌握了看懂任何一个模块的钥匙。同时学会了识别"空地皮"模块的三板斧,为第 8 章二开实战选好了地块。
init() 注册 → Name() 报身份 → Config() 延迟初始化 → Registry() 装载);SQL 常量集中 sql.go,永远"改 proto,不改 pb"。
- provider 三层乐高:抽象接口 → 厂商聚合器 → 资源 operator + mapping;空地皮识别三板斧:看 api 是否空包、impl 有无 DAO/SQL、代码里有无它的表。
- 下一章我们钻进数据层——数据库架构与核心业务模型,看看 13 张表背后的统一资源模型是怎么设计的。