第 3 章 项目目录结构与工程标准化规范详解

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

@

第 3 章 项目目录结构与工程标准化规范详解

1 学习目标

学完本章,你应该能够:

  1. 默写项目顶层 12 个目录的职责,并说清"按职责切目录"与"按技术层切目录"的区别;
  2. 掌握 apps/<module> 的标准分包规范(api / impl / pb),理解"一个模块就是一个小项目";
  3. 掌握 provider/ 多云适配层的分包规范(operator 聚合器 / connectivity / 具体资源 operator / mapping);
  4. 理解 proto 生成物文件族(*.pb.go / rpc.pb.go / rpc_grpc.pb.go / *_enum.pb.go)各自的来历与分工;
  5. 用"三板斧"识别一个模块是已落地还是仅 proto 占位,为二开实战选好地块。

前置知识:

  • 第 1 章的分层架构(cmd / protocol / apps / provider 四层);
  • 第 2 章的 make gen(知道 .pb.go 文件是工具生成的);
  • Go 的包(package)与 import 概念;
  • 接口(interface)的基本概念。

本章你会动手做的事:

  1. 对照顶层目录表,在仓库里逐个点开 12 个目录,确认各自职责;
  2. 以 host 模块为样板,把 api/、impl/、pb 文件族逐个过一遍,亲手画出"职责地图";
  3. 用"空地皮三板斧"扫描 apps/ 下全部模块,自己数一数几个落地、几个占位。

先看本章概念总览脑图:

第 3 章总览顶层目录模块分包provider 三层pb 文件族12 个部门按职责切分模块化单体api 翻译官impl 业务员dao+sql 仓管pb 契约抽象接口厂商聚合器资源 operatorhost.pb.gorpc.pb.gorpc_grpc.pb.go*_enum.pb.go

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 客户端 SDKrest/ 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/)的方式,正是微服务工程里"模块化单体"的标准做法——每个业务域高内聚,未来想拆成独立微服务,把模块目录搬出去就行。

类比:两种组织公司的方式——按业务线设部门(主机部、账单部各自配齐产品/研发/测试)vs 按工种设部门(全公司的产品坐一层、研发坐一层)。按工种切,跨部门办一件小事要跑遍全楼;按业务线切,一个部门内部就能闭环。对应到工程里就是:CMDB 按"业务域"(apps 下 20 个模块)而非"技术层"(全局 controller/service/dao)切目录,一个模块的路由、逻辑、SQL 全在一起,高内聚、可整体搬走——这就是"模块化单体":单体部署的便利 + 模块级的清晰边界。

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
类比:一个模块就是一家小型分公司:前台翻译官(api/)只负责把客人的方言(HTTP 参数)翻译成公司内部语言(Go 结构体),不掺和业务规则;业务员(impl/)负责真正的业务判断——查重、对账、编排流程;仓管员(dao.go + sql.go)只管按单子进出仓库(MySQL),单子怎么写(SQL 常量)单独锁在一个文件夹里。对应到工程里就是:三层各司其职、单向依赖,改路由不动业务,改业务不动 SQL——这是任何一个 Go 业务模块都可以照抄的骨架。

三个值得注意的规范细节:

  1. 接口契约手写在模块根包:比如 resource 模块在 apps/resource/interface.go:22-26 手写了 Service{Put, Delete, RPCServer} 接口——proto 生成的 ServiceServer 定义了 RPC 方法,但模块内部之间调用用的 Go 接口是手写的,这样调用方依赖接口而非实现,方便测试替身(mock)。
  2. SQL 常量与 DAO 分离:impl/sql.go 里全是 const 的 SQL 字符串(insertHostSQL / updateHostSQL / queryHostSQL…),dao.go 里只写执行逻辑。SQL 集中管理,review 时一眼看全数据操作面。
  3. 枚举辅助是生成的: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 返回的私有格式翻译成统一模型。
类比:这就是一套乐高:抽象接口层定义"插槽的形状"——不管哪家厂商的积木,必须能插进"主机采集器"这个槽;厂商聚合器是"底板"——阿里云底板、腾讯云底板各自一块;资源 operator 是"积木块"——每块积木内部藏着一家云的 SDK 玩法,对外露出统一的插脚。对应到工程里就是:上层业务(apps/task 同步任务)只认接口和统一模型,新增一家云厂商 = 生产一块新底板 + 一套积木,业务代码零改动。

2.4 proto 生成物文件族:四个文件各司其职

很多初学者看到 *.pb.go 一堆文件就懵,其实规律很简单:

文件谁生成装了什么你会怎么用它
host.pb.goprotoc领域 message 结构体(Host/Describe/HostSet)定义变量、JSON 序列化
rpc.pb.goprotocRPC 请求/响应 message(QueryHostRequest 等)handler 解析参数
rpc_grpc.pb.goprotocServiceServer/ServiceClient 接口 + 注册函数impl 要实现 Server 接口;client 要用 Client
host_enum.pb.gomcube 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 为例):

  1. api/http.go 是不是空包(只有 package 声明)→ 是;
  2. impl/ 里有没有 DAO/SQL → 没有;
  3. 代码里有没有它的表(模块 impl/sql.go 的 SQL 常量、或 GORM TableName())→ 没有。

三条全中,就是"仅 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 核心要点总结

  1. 目录按职责域切:apps=业务、provider=多云适配、其余=基建;
  2. 模块四件套:app.go(身份+领域方法)、api/(翻译)、impl/(业务)、*.pb.go(契约);
  3. impl 标配四方法:init() 注册 → Name() 报身份 → Config() 延迟初始化 → Registry() 装载;
  4. SQL 常量集中在 impl/sql.go,与 DAO 逻辑分离;
  5. provider 三层乐高:抽象接口 → 厂商聚合器 → 资源 operator + mapping。

5 自测题(对话式)

最后三道题,检验你是否真的吃透了这套工程规范。

面试官
为什么 service 结构体要嵌入 host.UnimplementedServiceServer 而不是直接实现 host.ServiceServer 接口?删掉这个嵌入字段,什么场景下项目会编译失败?
候选人
先说这个基类是什么:它是 protoc 生成的"未实现基类",对接口的每个方法都给出一个返回"Unimplemented"的默认实现。嵌入它之后,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 模块(目前是空地皮),按本章规范,你需要创建哪些文件、在每个文件里写什么"标配代码"?
候选人
按"模块四件套 + impl 四方法"的规范来,清单如下:
① 契约: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 章二开实战选好了地块。

复习提示: - 目录按职责域切:apps=业务、provider=多云适配、其余=基建——这是"模块化单体"的标准做法,模块可整体搬出去拆微服务。 - 模块四件套(app.go / api / impl / pb)+ impl 四方法(init() 注册 → Name() 报身份 → Config() 延迟初始化 → Registry() 装载);SQL 常量集中 sql.go,永远"改 proto,不改 pb"。 - provider 三层乐高:抽象接口 → 厂商聚合器 → 资源 operator + mapping;空地皮识别三板斧:看 api 是否空包、impl 有无 DAO/SQL、代码里有无它的表。 - 下一章我们钻进数据层——数据库架构与核心业务模型,看看 13 张表背后的统一资源模型是怎么设计的。
About Me

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

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

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

目标

学AI,加油!加油!