第 1 章 项目整体架构与技术栈全景拆解
1 学习目标
学完本章,你应该能够:
- 用一句话说清 CMDB 是什么、解决什么问题;
- 画出(或默写出)项目的整体分层拓扑图:从
main.go到 HTTP/gRPC 双服务的完整链路; - 列出项目的核心技术栈,并说清每一项技术为什么被选中;
- 理解"模块自注册"机制:为什么
main.go里几乎看不到业务代码,以及新增模块时主程序为什么一行都不用改; - 说清启动五步曲的固定顺序与依赖关系,以及优雅关闭为什么要"先停 gRPC、再停 HTTP"。
前置知识:
- Go 基本语法(能看懂
import、struct、goroutine、init()函数); - 知道 HTTP/RESTful 是什么(不要求写过服务端);
- 听说过 gRPC/protobuf(不知道也行,本章先把它当"内部服务之间的快速通道"来理解);
- 会基本的命令行操作。
本章你会动手做的事:
- 对照启动链路图,在源码中从
main.go一路点进cmd/start.go,亲手确认五步启动的顺序; - 打开
apps/api.go、apps/internal.go与cmd/start.go:24,追踪 host 模块"匿名导入 →init()→ Registry"的完整注册路径; - 拿
go.mod对照技术栈表,逐项确认每个依赖的用途与版本。
先看一张本章概念总览脑图,建立全局印象:
2 知识点通俗系统化讲解
2.1 先把架构想象成一栋楼
读源码最怕一上来就扎进文件堆。我们先把 CMDB 想象成一栋三层的楼:
main.go/cmd/ 是入口层,protocol/ 是双协议服务层,apps/ 是 20 个业务模块,provider/ 是多云适配层。逐层对应到代码:
- 一楼(入口层):
main.go和cmd/目录。这层只干一件事——把门打开(解析命令行参数、加载配置),然后把客人往楼上引。你会发现main.go只有一行核心代码: - 二楼(服务层):
protocol/目录。这层提供两种"服务窗口":一个 HTTP 窗口(给浏览器、curl 用,走 RESTful),一个 gRPC 窗口(给内部服务用,高性能二进制协议)。两个窗口背后是同一批"业务员"。 - 三楼(业务层):
apps/目录。这里是 20 个业务模块(主机、凭证、任务、账单……),每个模块是一个独立的小部门,自己管自己的路由、逻辑和数据库操作。 - 楼外还有一个"外包公司":
provider/目录。它专门负责跟 5 家云厂商打交道——每家云的 API 都不一样,这家"外包公司"把差异抹平,统一输出标准格式的数据。
2.2 完整启动链路(先看骨架,第 6 章再逐段精讲)
程序的启动顺序一环扣一环,顺序是固定的(cmd/start.go:32-99):
main.go
└─ cmd.Execute() // cobra 根命令
├─ start 子命令 (cmd/start.go)
│ ├─ loadGlobalConfig // 读 TOML 配置文件或环境变量
│ ├─ loadGlobalLogger // 初始化 zap 日志
│ ├─ loadCache // 初始化缓存(内存 or Redis)
│ ├─ app.InitAllApp() // 初始化所有已注册的业务模块
│ └─ newService → service{http, grpc}
│ ├─ http.Start() // go-restful, /cmdb/api/v1/<module>
│ └─ grpc.Start() // gRPC server, :18060
└─ init 子命令 (cmd/init.go)
└─ createTables() // 读 SQL 文件建表(默认路径 docs/schema/tables.sql,文件需自备)
用大白话解释每一步:
- 加载配置:数据库地址、监听端口、加密密钥等,都写在
etc/config.toml里(也支持环境变量方式)。配置加载后存成全局单例conf.C(),之后任何地方都能取用——如果没加载就取用,直接panic报错(conf/load.go:13-19),这是"快速失败"思想。 - 初始化日志:基于 zap,支持输出到控制台或文件、text 或 JSON 格式。
- 初始化缓存:默认用内存缓存,也可以切 Redis(
cmd/start.go:165-183)。 - 初始化所有 app:
app.InitAllApp()会遍历所有注册进来的业务模块,逐个调用其Config()方法——这一步各模块会去拿数据库连接、装配依赖(cmd/start.go:49)。 - 启动双服务:HTTP 监听
127.0.0.1:8060,gRPC 监听127.0.0.1:18060(默认值见conf/config.go:72-81)。
还有一个容易忽略的细节:优雅关闭。程序监听了 SIGTERM/SIGINT/SIGHUP/SIGQUIT 四种终止信号,收到信号后先停 gRPC、再停 HTTP(cmd/start.go:185-206)。为什么要"优雅"?因为如果直接杀进程,正在处理的请求会被拦腰截断,数据库事务可能只提交了一半。先停 gRPC(不接受新请求、等存量请求处理完)再停 HTTP,能最大程度保证数据完整。
conf.C() 的脾气:配置没加载就取用,直接 panic。这是刻意的"快速失败",别把它当 bug。2.3 双协议暴露:一份业务代码,两个服务窗口
这是本项目最值得学的架构决策之一。绝大多数 Go 教学项目只暴露 HTTP;而企业级项目往往是:内部服务之间用 gRPC(快、强类型),对外/前端用 HTTP(通用、好调试)。CMDB 的做法是:
- HTTP 侧(
protocol/http.go):用 go-restful 构建 RESTful 服务,路径前缀/{应用名}/api,即/cmdb/api(protocol/http.go:73-75)。挂了两个过滤器:CORS 跨域过滤器、mcenter 鉴权拦截器RestfulServerInterceptor(protocol/http.go:34-42)。启动后还会自动生成 OpenAPI 文档(/apidocs.json),并把所有路由条目自动上报给权限中心 mcenter(protocol/http.go:96-140)——这就实现了"路由即权限点",不用手工去权限系统里录接口。 - gRPC 侧(
protocol/grpc.go):注册了 recovery(panic 恢复)和 mcenter 鉴权两个拦截器(protocol/grpc.go:24-28),启动 1 秒后向 mcenter 注册自己的实例地址(protocol/grpc.go:68),相当于服务自注册,供其他服务发现和调用。
关键点来了:HTTP 的 handler 并不重新写一遍业务逻辑,而是直接复用 gRPC 的服务实现。这句话的原理在第 5 章讲控制器层时会用源码详细展开,这里先记住结论:一份 ServiceServer 实现,同时被 gRPC 和 HTTP 两个协议层消费,业务代码零重复。
ServiceServer 统一承载。2.4 模块注册机制:mcube app 模式
CMDB 的 main.go 里几乎看不到业务代码,秘密在于 mcube 框架的 app 注册模式:
每个业务模块(如 apps/host)在自己的包里写一个 init() 函数,向框架"自报家门",注册三种身份之一(仓库根目录底稿《CMDB源码拆解底稿.md》1.3 节):
app.RegistryGrpcApp(svr)—— 我要暴露 gRPC 方法;app.RegistryRESTfulApp(h)—— 我要暴露 HTTP 路由;app.RegistryInternalApp(svr)—— 我是内部服务,给其他模块调用(不对外)。
这些模块如何被主程序感知?靠两个"聚合文件"用匿名导入触发 init():
// apps/api.go:7-12 —— 聚合所有 HTTP 层
import (
_ "github.com/infraboard/cmdb/apps/bill/api"
_ "github.com/infraboard/cmdb/apps/dict/api"
_ "github.com/infraboard/cmdb/apps/host/api"
_ "github.com/infraboard/cmdb/apps/resource/api"
_ "github.com/infraboard/cmdb/apps/secret/api"
_ "github.com/infraboard/cmdb/apps/task/api"
)
// apps/internal.go:4-10 —— 聚合所有 impl 层
import (
_ "github.com/infraboard/cmdb/apps/bill/impl"
_ "github.com/infraboard/cmdb/apps/health/impl"
_ "github.com/infraboard/cmdb/apps/host/impl"
_ "github.com/infraboard/cmdb/apps/rds/impl"
_ "github.com/infraboard/cmdb/apps/resource/impl"
_ "github.com/infraboard/cmdb/apps/secret/impl"
_ "github.com/infraboard/cmdb/apps/task/impl"
)
而 cmd/start.go:24 有一行 _ "github.com/infraboard/cmdb/apps",它像多米诺骨牌的第一张:导入 apps 包 → 触发 apps/api.go 和 apps/internal.go 的导入 → 触发每个模块 init() → 模块全部注册进框架。之后 app.InitAllApp()、app.LoadGrpcApp()、app.LoadRESTfulApp() 就能统一驱动它们。
这种模式的好处:新增一个业务模块,主程序、protocol 层一行代码都不用改,只需要在 apps/api.go / apps/internal.go 加一行匿名导入。这就是"微内核 + 插件"的雏形。
init() 里的 Registry 调用)去前台登记是坐哪个工位(gRPC/HTTP/内部),公司大门(main.go)和门禁系统(protocol 层)完全不用改造。对应到工程里就是:Go 的包机制保证"被导入就执行 init()",mcube 把注册结果收进框架,主程序与业务模块彻底解耦。2.5 技术栈全景:为什么选它们?
下表来自 go.mod 的真实依赖(仓库根目录底稿《CMDB源码拆解底稿.md》0.2 节):
| 维度 | 选型 | 为什么选它(大白话) |
|---|---|---|
| 语言 | Go 1.18(go.mod:3) | 部署简单(编译出一个二进制)、并发模型适合"批量拉云资源"这种 IO 密集场景 |
| HTTP 框架 | go-restful v3 + go-restful-openapi | 相比 Gin,go-restful 原生支持 WebService/Route 分组和 OpenAPI 元数据,适合"路由即文档、路由即权限"的工程化玩法 |
| RPC 框架 | gRPC + protobuf | 强类型契约:接口定义写在 .proto 文件里,一次生成服务端+客户端代码,内部调用性能远高于 JSON over HTTP |
| ORM | GORM v1.23.10 + MySQL 驱动 | 快速开发为主,配合"垂直分表"的统一资源模型使用 |
| 应用框架 | infraboard/mcube v1.9.5 | 自研微框架,提供 app 注册、分页、异常、缓存等基建,让业务代码只关注业务 |
| 鉴权 | infraboard/mcenter | 配套的权限中心:token 校验、endpoint 权限点、服务实例注册,CMDB 启动时自动对接 |
| CLI | spf13/cobra | Go 生态事实标准的命令行框架,start/init/version 子命令都靠它 |
| 校验 | go-playground/validator/v10 | 用 struct tag(如 validate:"required")声明式校验参数 |
| 云 SDK | 阿里云 alibabacloud-go、腾讯云 tencentcloud-sdk-go、华为云 huaweicloud-sdk-go-v3、AWS aws-sdk-go-v2、VMware govmomi | 每家云一套官方 SDK,provider 层负责把它们包装成统一接口 |
| 配置 | BurntSushi/toml + caarlos0/env | TOML 文件为主,环境变量为辅(容器化部署友好) |
| 加密 | mcube/crypto/cbc | 凭证 Secret 的 CBC 对称加密(apps/secret/interface.go:14) |
2.6 必须提前知道的一个真相:这是个"进行中的项目"
学习开源项目要有一个清醒认知:不是所有目录里的代码都是"完成态"。CMDB 的 20 个业务模块中,有真实实现的只有 7 个(resource、secret、host、task、rds〔部分〕、bill〔部分〕、health——判据见 apps/internal.go 的匿名导入,正好 7 行);dict 只挂了 HTTP 路由层(apps/api.go 有它、apps/internal.go 没有它);其余 12 个模块(cost、account、order、database、metric、mongodb、redis、oss、disk、dns、eip、lb)只有 proto 定义、没有实现、没有路由——是作者预留的"空地皮"(仓库根目录底稿《CMDB源码拆解底稿.md》7.3 节)。
这不是缺点,反而对学习者是好事:你既能学到"建好的楼",又能拿"空地皮"练手——第 8 章的二开实战就会带你在一块空地皮上盖房子。
apps/redis),发现里面除了 pb 文件啥都没有,第一反应往往是"我下错仓库了?"。没有——那就是仅 proto 占位的"空地皮"。判断方法第 3 章会教,这里先建立预期:本项目 20 个模块中 7 个已落地、dict 半落地(仅路由)、12 个是占位,看源码时先掂量一下脚下这块地是"楼"还是"地皮",能省下大量困惑。3 核心代码片段逐行解析
3.1 片段:main.go —— 短得令人发指的入口
// main.go:7 —— 整个程序的入口只有一次委托
package main
import "github.com/infraboard/cmdb/cmd"
func main() {
cmd.Execute() // 把控制权交给 cobra 命令行框架
}
cmd.Execute()是 cobra 根命令的入口。所有真正的启动逻辑都在cmd/包的各个子命令里。- 为什么这么写? 因为命令行程序往往不止一种启动方式(启动服务
start、初始化init、查版本version),把 main.go 薄成一张纸,复杂度全部下沉到cmd/包,职责更清晰。
3.2 片段:start 子命令的启动编排(节选自 cmd/start.go:28-72)
var serviceCmd = &cobra.Command{
Use: "start", // 子命令名:cmdb start
Short: "CMDB后端API服务",
RunE: func(cmd *cobra.Command, args []string) error {
// 1. 初始化全局配置(支持 file/env 两种来源)
if err := loadGlobalConfig(confType); err != nil {
return err
}
// 2. 初始化全局日志(依赖配置先就绪)
if err := loadGlobalLogger(); err != nil {
return err
}
// 3. 加载缓存(memory 或 redis,看配置)
if err := loadCache(); err != nil {
return err
}
// 4. 初始化所有注册的业务模块(每个模块的 Config() 被调用)
if err := app.InitAllApp(); err != nil {
return err
}
// 5. 监听退出信号(SIGTERM 等 4 种)
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGTERM, syscall.SIGINT, syscall.SIGHUP, syscall.SIGQUIT)
// 6. 构建 service 对象(内含 http + grpc 两个服务)
svr, err := newService(conf.C())
if err != nil {
return err
}
// 7. 另起一个 goroutine 等信号(因为第 8 步会阻塞主 goroutine)
go svr.waitSign(ch)
// 8. 启动服务(HTTP 阻塞运行,gRPC 在内部以 goroutine 启动)
if err := svr.start(); err != nil {
if !strings.Contains(err.Error(), "http: Server closed") {
return err
}
}
return nil
},
}
注意 1~4 步的顺序是刻意安排的:日志依赖配置(要先知道日志级别)、业务模块依赖配置和日志(模块初始化时可能要连数据库、打日志)。如果你自己写项目时把顺序搞反了,就会遇到"日志还没初始化就往里写"的隐性 bug。
3.3 片段:service 的 start 与优雅关闭(cmd/start.go:92-99, 185-206 节选)
func (s *service) start() error {
// 打印已加载的模块清单,启动时一眼看清"哪些模块装进来了"
s.log.Infof("loaded grpc app: %s", app.LoadedGrpcApp())
s.log.Infof("loaded http app: %s", app.LoadedRESTfulApp())
s.log.Infof("loaded internal app: %s", app.LoadedInternalApp())
go s.grpc.Start() // gRPC 放到子 goroutine 里跑
return s.http.Start() // HTTP 在当前 goroutine 阻塞运行(作为主服务)
}
func (s *service) waitSign(sign chan os.Signal) {
for sg := range sign { // 循环接收信号
switch v := sg.(type) {
default:
s.log.Infof("receive signal '%v', start graceful shutdown", v.String())
// 先停 gRPC:注销 mcenter 注册 + 等存量请求结束
if err := s.grpc.Stop(); err != nil {
s.log.Errorf("grpc graceful shutdown err: %s, force exit", err)
}
// 再停 HTTP:给 30 秒宽限期处理存量请求
if err := s.http.Stop(); err != nil {
s.log.Errorf("http graceful shutdown err: %s, force exit", err)
}
return
}
}
}
waitSign里的grpc.Stop()不是简单关闭,而是先向 mcenter 注销实例再GracefulStop()(protocol/grpc.go:95-110),保证别的服务不会把请求打到一个垂死的实例上。- HTTP 的
Stop()用context.WithTimeout(30*time.Second)兜底:最多等 30 秒,超时强制退出(protocol/http.go:111-120)。
4 业务逻辑流程图与核心要点总结
4.1 一图看懂整体架构
┌─────────────────────────────────────┐
│ 客户端 / SDK 调用方 │
│ (curl / 前端 / 其他微服务 / client包) │
└──────────────┬──────────────────────┘
HTTP (RESTful) │ gRPC (protobuf)
127.0.0.1:8060 │ 127.0.0.1:18060
┌──────────────▼──────────────────────┐
│ protocol 层 │
│ http.go: CORS + mcenter鉴权过滤器 │
│ grpc.go: recovery + 鉴权拦截器 │
│ (启动时向 mcenter 注册路由/实例) │
└──────────────┬──────────────────────┘
│ app.LoadRESTfulApp / LoadGrpcApp
┌──────────────▼──────────────────────┐
│ apps 业务层(20 个模块,7 个已落地) │
│ ┌─────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │secret│ │task │ │ host │ │resource│ │
│ └──┬──┘ └──┬───┘ └──┬───┘ └──┬───┘ │
│ │(互相通过 app.GetGrpcApp 调用) │
└─────┼────────┼────────┼───────┼──────┘
│ │ │ │
┌─────────▼────────▼────────▼───────▼──────┐
│ MySQL (resource_meta/spec/cost/status/ │
│ resource_host/secret/task/...) │
└─────────▲────────────────────────────────┘
│ 同步时
┌─────────┴────────────────────────────────┐
│ provider 层(多云适配) │
│ aliyun / txyun / huawei / aws / vsphere │
│ 统一输出 *resource.Resource │
└─────────▲────────────────────────────────┘
│ HTTPS API
┌─────────┴────────────────────────────────┐
│ 云厂商:阿里云 / 腾讯云 / 华为云 / AWS / VMware │
└──────────────────────────────────────────┘
4.2 核心要点总结
- 启动五步曲:配置 → 日志 → 缓存 → InitAllApp → 双服务启动,顺序不可乱;
- 双协议、一份业务:HTTP handler 复用 gRPC 实现,业务代码只写一遍;
- 模块自注册:
init()+ 匿名导入聚合,main.go 保持零业务; - 外部依赖两个:MySQL(必须)和 mcenter(鉴权/注册中心,
protocol/http.go:26-29中连接失败会直接panic); - provider 层只管"拉数据、转格式",不写库、不对账——对账逻辑在业务层(第 6 章详解)。
5 自测题(对话式)
学完不等于学会。下面三道题来自本章内容,先自己口头作答,再看候选人参考答案。
protocol/ 层的代码需要怎么改?这样拆有什么收益和代价?(提示:想想共享的 conf.C() 和数据库连接)service{http, grpc} 进程里,两边共享同一份 conf.C() 配置单例、同一个 MySQL 连接池(GetDB() 是包级单例),而且 HTTP handler 是进程内直接复用 gRPC 的 ServiceServer 实现——是函数调用,不走网络。拆成两个进程要改的点:
newService 拆成两套启动逻辑;最关键的是 HTTP 进程不能再进程内复用 gRPC 实现,要么改成真正的 gRPC 网络调用(用 client 包),要么把业务实现抽成双方都能调用的独立层;配置和连接池则各自进程各持一份。收益:故障隔离(对外 HTTP 挂了不影响内部 RPC)、可以按流量独立扩缩容、安全边界更清晰。代价:原来零开销的进程内调用变成一次网络序列化、两份配置/日志/监控要维护、mcenter 里要注册两个实例、部署复杂度上升。
结论:中小规模下"单体双窗口"性价比最高;等流量和团队规模上来了再拆,这也是微服务演进的常见路径。
main.go 只有一行 cmd.Execute(),业务模块是靠什么机制"自动"被加载的?请按"匿名导入 → init() → Registry"链条,写出 host 模块从被感知到被 InitAllApp() 初始化的完整路径。①
cmd/start.go:24 匿名导入 apps 包;②
apps 包里的两个聚合文件 apps/api.go、apps/internal.go 又匿名导入了所有模块的 api/impl 子包(其中就有 apps/host/impl);③ Go 保证包被导入就执行其
init(),于是 host/impl 的 init() 调用 app.RegistryGrpcApp(svr),把包级单例注册进框架;④ 之后
app.InitAllApp() 遍历所有已注册模块,回调其 Config()——这一步才真正拿数据库连接、装配依赖;⑤ 最后
LoadGrpcApp()/LoadRESTfulApp() 把实现分别装配到 gRPC Server 和 HTTP 路由上。这个设计的工程价值:新增模块只需要在聚合文件加一行匿名导入,主程序和 protocol 层零改动——"微内核 + 插件"的雏形。
grpc.Stop() 不是简单关闭:它会先向 mcenter 注销实例再 GracefulStop()。先停 gRPC,意味着注册中心第一时间把这个实例摘掉,内部服务不再发新请求过来,同时等存量请求处理完;这时 HTTP 还活着,外部请求还能被完整处理。之后再停 HTTP,且有 30 秒超时宽限兜底。如果反过来:先停 HTTP,对外窗口立刻关闭,用户请求直接失败;而 gRPC 实例还挂在 mcenter 上"报活着",内部调用会继续打进来,等轮到 gRPC 关闭时,这些请求可能被拦腰截断——数据库事务提交一半,数据不一致的风险反而更大。
一句话总结:先摘"内部流量的入口",再关"对外的窗口",保证存量请求都能体面地跑完。
6 本章小结
本章我们从"一栋楼"的比喻出发,建立了 CMDB 的全局认知:入口层(cmd)→ 服务层(protocol,HTTP+gRPC 双窗口)→ 业务层(apps,模块自注册)→ 多云适配层(provider)。技术栈方面,go-restful 负责 HTTP、gRPC+protobuf 负责内部 RPC、GORM/原生 SQL 负责数据、mcube/mcenter 负责框架基建与鉴权。同时我们明确了项目的真实状态:20 个模块中 7 个已落地、dict 半落地(仅路由)、12 个是 proto 占位——这既是学习对象,也是二开练手场。