第 2 章 环境搭建与项目完整启动流程

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

@

第 2 章 环境搭建与项目完整启动流程

1 学习目标

学完本章,你应该能够:

  1. 在本地把 CMDB 完整跑起来:MySQL → 建库 → 建表 → 启动服务 → 调通第一个接口;
  2. 说清配置文件里每一个配置段(app/mysql/mcenter/log)的含义,以及 TOML 字段如何通过 struct tag 映射到 Go 结构体;
  3. 掌握 make 体系:make run、make gen、make install、make build 各做什么;
  4. 理解三个底层机制:配置全局单例(未加载即 panic)、MySQL 三层连接管理(单例 + 共享连接池 + 启动期 ping)、缓存双实现切换;
  5. 启动失败时,能按"配置 → 依赖 → 日志"的路径定位常见报错(mcenter 缺失、配置字段名不一致等)。

前置知识:

  • 第 1 章的启动五步曲与整体分层认知;
  • Docker 基本命令(docker run、docker exec);
  • SQL 基础(create database、GRANT 授权语句);
  • 知道 make 是个构建任务工具即可,不要求会写 Makefile。

本章你会动手做的事:

  1. 用 Docker 拉起 MariaDB,建库授权,并用 init 子命令执行自备的建表 SQL(13 张表);
  2. 复制配置样例、修正 encrypt_key 字段名坑,make run 把服务跑起来;
  3. 用 curl 探活 dict 接口、浏览器打开 /apidocs.json,完成验证闭环。

先看本章概念总览脑图:

第 2 章总览原材料准备配置体系启动四步底层机制Go + MySQLprotoc 三件套mcenter 隐形依赖TOML + ENVconf.C() 单例encrypt_key 坑Docker 建库init 建表make runcurl 探活共享连接池multiStatements缓存双实现

2 知识点通俗系统化讲解

2.1 启动一台 CMDB 需要哪些"原材料"?

做饭要先备菜。跑起 CMDB 需要准备:

类比:把跑起一个后端服务当成做一桌菜:MySQL 是灶台(没有它什么都炒不了),配置文件是菜谱(咸淡口味、火候全写在上面),protoc 三件套是雕花工具——只有想改菜单(.proto)时才用得上, everyday 炒菜根本不需要。对应到工程里就是:先备齐运行时硬依赖(Go、MySQL),配置文件按样例改,而代码生成工具链只在二次开发改 proto 时才装。
原材料版本/要求用途
Go1.18+编译运行(go.mod:3)
MySQL(或 MariaDB)任意现代版本存数据,13 张表
protocv3.19.1protobuf 编译器(只在改 .proto 时需要)
protoc-gen-gov1.27.1protoc 的 Go 插件
protoc-gen-go-grpcv1.1.0protoc 的 gRPC 插件
protoc-go-inject-taglatest给生成代码注入自定义 struct tag
mcenter—鉴权中心(见 2.2.5 的说明)

前 6 项的版本要求直接来自 README.md:45-58。如果你不改 .proto 文件,protoc 三件套可以完全不装——生成的 .pb.go 代码已经提交在仓库里了。

2.2 第一步:用 Docker 起 MySQL

仓库 README 没有提供数据库搭建步骤,这里给出一套可用的 Docker 姿势(MariaDB 是 MySQL 的兼容分支):

# 拉起 MariaDB 容器,端口 3306,自动创建 cmdb 用户
$ docker run -p 3306:3306 -itd \
    -e MARIADB_USER=cmdb -e MARIADB_PASSWORD=123456 \
    -e MARIADB_ROOT_PASSWORD=123456 \
    --name mysql mariadb:latest

容器起来后,进容器建库授权:

$ docker exec -it mysql mysql -uroot -p123456

# 进入 mysql 命令行后执行:
create database cmdb;
GRANT ALL PRIVILEGES ON cmdb.* TO 'cmdb'@'%' IDENTIFIED BY '123456';
flush privileges;

为什么要单独 create database cmdb?因为默认配置里数据库名就是 cmdb(conf/config.go:175),DSN 拼接时用的也是它(conf/config.go:155)。

2.3 第二步:初始化配置文件

项目提供了配置样例 etc/config.example.toml,真实内容如下(逐段解释):

[app]
name = "cmdb"            # 应用名,决定 HTTP 路由前缀 /cmdb/api
http_host = "127.0.0.1"  # HTTP 监听地址
http_port = "8060"       # HTTP 端口
grpc_host = "127.0.0.1"  # gRPC 监听地址
grpc_port = "18060"      # gRPC 端口
key  = "this is your app key"   # ⚠️ 注意:代码里读的是 encrypt_key,见 2.2.6 踩坑提示

[mysql]
host = "127.0.0.1"
port = "3306"
username = "root"
password = "123456"
database = "cmdb"        # 要与上一步 create database 的名字一致

[mcenter]
adress="127.0.0.1:18010"       # mcenter 的 gRPC 地址
client_id="xxx"                # mcenter 分配的客户端凭证
client_secret="xxx"

[log]
level = "debug"          # 日志级别 debug/info/warn/error
path = "logs"            # 日志目录(输出到文件时用)
format = "text"          # text 或 json
to = "stdout"            # stdout(控制台)或 file

操作:把样例复制成正式配置——cp etc/config.example.toml etc/config.toml。为什么叫这个名字?因为 Makefile:46 的 make run 写死了 go run main.go start -f etc/config.toml。

对应这段 TOML 的 Go 结构体在 conf/config.go:34-40,用 struct tag 声明映射关系:

type Config struct {
	App     *app        `toml:"app"`
	Log     *log        `toml:"log"`
	MySQL   *mysql      `toml:"mysql"`
	Mcenter *rpc.Config `toml:"mcenter"`
	Cache   *_cache     `toml:"cache"`
}

同一份配置也支持环境变量注入:每个字段都标了 env:"..." tag(如 MYSQL_HOST、APP_HTTP_PORT),etc/config.example.env 里给出了完整清单,容器化部署时不必挂载文件。加载入口是 cmd/start.go:102-121 的 loadGlobalConfig:启动参数 confType 决定走 LoadConfigFromToml 还是 LoadConfigFromEnv(还预留了 etcd 方式,目前返回 not implemented)。

2.4 第三步:建表(init 子命令)

CMDB 不用 migration 工具,而是由 init 子命令一次性执行一个 SQL 文件里的全部 DDL。注意:这个 DDL 文件没有随仓库发布——cmd/init.go:68 的默认路径是 docs/schema/tables.sql,但仓库里并不存在 docs/ 目录,需要你按第 4 章的表清单(13 张,表名以各模块 TableName() 和 impl/sql.go 为准)自己准备一份:

# -s 指定 SQL 文件路径,默认就是 docs/schema/tables.sql(cmd/init.go:68)
$ go run main.go start -f etc/config.toml   # 先让配置就绪?不,init 是独立命令:
$ go run main.go init -f etc/config.toml -s docs/schema/tables.sql

cmd/init.go 的 createTables() 逻辑很直白(cmd/init.go:40-65):

func createTables() error {
	// 拿到全局数据库连接(单例)
	db, err := conf.C().MySQL.GetDB()
	if err != nil {
		return err
	}
	// 5 秒超时保护
	ctx, cancelfunc := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancelfunc()
	// 把整个 SQL 文件读进内存
	sqlFile, err := os.ReadFile(createTableFilePath)
	if err != nil {
		return err
	}
	fmt.Println("执行的SQL: ")
	fmt.Println(string(sqlFile))
	// 一次性执行全部建表语句
	_, err = db.ExecContext(ctx, string(sqlFile))
	return err
}

一个容易被忽略的细节:为什么一条 db.ExecContext 能执行多条建表语句?秘密在 DSN 的 multiStatements=true 参数(conf/config.go:155)——不开这个参数,MySQL 驱动会拒绝一次执行多条语句。

⚠️ 新手必踩的坑:自己写项目时照着这段代码抄,却忘了在 DSN 上加 multiStatements=true,结果 init 命令一执行就报错——MySQL 驱动默认只允许一次执行一条语句,整个 SQL 文件塞给它会被拒绝。记住因果链:init 一次执行整个 DDL 文件 ← 依赖 DSN 的 multiStatements=true(conf/config.go:155)。

2.5 第四步:启动服务

$ make run
# 等价于:go run main.go start -f etc/config.toml

启动成功后你会看到类似日志(来自 cmd/start.go:93-95 和 protocol/http.go:100):

loaded grpc app: [health resource secrets host task rds bill]
loaded http app: [resource dict secrets host bill task]
loaded internal app: [health resource secrets]
HTTP服务启动成功, 监听地址: 127.0.0.1:8060
GRPC 服务监听地址: 127.0.0.1:18060
Get the API using http://127.0.0.1:8060/apidocs.json

打开 http://127.0.0.1:8060/apidocs.json 就能看到自动生成的 OpenAPI 文档。

验证一下服务活着(dict 模块是静态字典接口,最适合当"探活"接口):

$ curl "http://127.0.0.1:8060/cmdb/api/v1/dict/vendors"

如果返回 401/鉴权错误——别慌,这说明服务本身是通的,只是被 mcenter 鉴权拦截器拦下了。

关于 mcenter 这个"隐形依赖":protocol/http.go:26-29 里 rpc.NewClient(conf.C().Mcenter) 失败会直接 panic,鉴权拦截器 middleware.RestfulServerInterceptor() 也会在请求进来时校验 token。也就是说,没有 mcenter 环境,接口调用会被鉴权挡住。学习源码阶段有两个选择:

  1. 部署配套的 mcenter 服务(完整体验);
  2. 本地调试时临时注释 protocol/http.go:42 的拦截器 Filter 与 protocol/grpc.go:27 的鉴权拦截器(仅限本地学习,切勿在生产这样做)。
⚠️ 新手必踩的坑:mcenter 是个不在材料清单里的"隐形依赖"。第 1 章说过 protocol/http.go:26-29 连接失败直接 panic——很多新手卡在"服务起不来"或"curl 返回 401",反复检查 MySQL 和配置都没问题,最后发现是 mcenter 没部署。排查顺序记好:起不来先查 mcenter 客户端初始化,能起来但 401 也是 mcenter 鉴权拦截。本地学习可以按上文注释拦截器,但注释范围仅限本地。

2.6 第五步(可选):编译与 protobuf 工具链

$ make install   # 安装 mcube CLI + protoc 插件三件套(Makefile:51-55)
$ make gen       # 重新生成 protobuf 代码(Makefile:62-65)
$ make build     # 编译出 dist/cmdb-api 二进制(带 git 版本信息注入)
$ make linux     # 交叉编译 Linux 版本(服务器部署用)

make gen 实际执行的三条命令值得认识一下(Makefile:63-65):

# 1. protoc 编译所有模块的 .proto,生成 *.pb.go 和 *_grpc.pb.go
protoc -I=. -I=common/pb --go_out=. --go-grpc_out=. \
    --go_opt=module="${PKG}" --go-grpc_opt=module="${PKG}"  apps/*/pb/*
# 2. 给生成代码注入自定义 tag(比如 gorm 的 column tag)
protoc-go-inject-tag -input=apps/*/*.pb.go
# 3. 用 mcube CLI 为枚举生成 ParseXxxFromString 等辅助方法
mcube generate enum -p -m apps/*/*.pb.go

2.7 踩坑预警(先记三件事,第 8 章展开)

⚠️ 新手必踩的坑:照抄官方样例也逃不掉的三个坑,先记住,第 8 章逐个解剖:
1. 配置样例的 key 字段名与代码不一致:样例里写 key = "...",但代码结构体的 tag 是 toml:"encrypt_key"(conf/config.go:61)。照抄样例的话,EncryptKey 会取不到值、落到代码默认值 "defualt app encrypt key"(注意源码里 default 就是这个拼写)。凭证加密密钥用默认值是非常危险的。正确写法是把 key 改成 encrypt_key。
2. 表名不一致:代码里 secret 模块的 TableName() 返回 secrets,而 DDL 建的表叫 secret(第 8 章详解)。
3. host/rds 查询 SQL 引用了不存在的 resource 表(第 8 章详解)。

3 核心代码片段逐行解析

3.1 片段:配置加载与全局单例(conf/load.go:13-19 + conf/config.go:43-53)

// conf/load.go —— 全局配置单例
func C() *Config {
	if global == nil {
		// 没加载配置就来取?直接崩溃,把问题暴露在最早的时刻
		panic("config not loaded")
	}
	return global
}

// conf/config.go —— 配置注入全局变量时,顺手把 mcenter 客户端也准备好
func (c *Config) InitGloabl() error {
	global = c
	// 提前加载 mcenter 客户端(后续 HTTP/gRPC 服务构建时要用)
	err := rpc.LoadClientFromConfig(c.Mcenter)
	if err != nil {
		panic("load mcenter client from config error: " + err.Error())
	}
	return nil
}
  • “全局单例 + 未初始化即 panic” 是 mcube 系项目的惯例:错误越早暴露,排查成本越低。
  • 注意方法名 InitGloabl 存在拼写问题(正确应为 InitGlobal)——源码原样,阅读时别怀疑自己看错了。

3.2 片段:MySQL 连接管理的三层设计(conf/config.go:119-170 节选)

// 第一层:ORM() —— 给 GORM 用的连接
func (m *mysql) ORM() (*gorm.DB, error) {
	conn, err := m.GetDB()          // 复用底层 *sql.DB 连接
	if err != nil {
		return nil, err
	}
	return gorm.Open(orm_mysql.New(orm_mysql.Config{
		Conn: conn,                 // 关键:GORM 复用同一个原生连接池!
	}), &gorm.Config{
		PrepareStmt: true,          // 预编译并缓存 SQL,后续执行更快
		SkipDefaultTransaction: true, // 关闭写操作默认事务,性能提升约 30%
		CreateBatchSize: 200,        // 批量插入每批 200 条
	})
}

// 第二层:GetDB() —— 单例(加锁防并发重复创建)
func (m *mysql) GetDB() (*sql.DB, error) {
	m.lock.Lock()                  // 互斥锁:多个模块同时要连接时只建一次
	defer m.lock.Unlock()
	if db == nil {
		conn, err := m.getDBConn()
		if err != nil {
			return nil, err
		}
		db = conn
	}
	return db, nil
}

// 第三层:getDBConn() —— 真正建立连接
func (m *mysql) getDBConn() (*sql.DB, error) {
	// DSN:注意末尾 multiStatements=true,init 建表命令靠它一次执行整个 SQL 文件
	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8&multiStatements=true",
		m.UserName, m.Password, m.Host, m.Port, m.Database)
	db, err := sql.Open("mysql", dsn)
	if err != nil {
		return nil, fmt.Errorf("connect to mysql<%s> error, %s", dsn, err.Error())
	}
	// 连接池参数:最大连接 200、最大空闲 50、连接最大存活 1800s、空闲最大 600s
	db.SetMaxOpenConns(m.MaxOpenConn)
	db.SetMaxIdleConns(m.MaxIdleConn)
	db.SetConnMaxLifetime(time.Second * time.Duration(m.MaxLifeTime))
	db.SetConnMaxIdleTime(time.Second * time.Duration(m.MaxIdleTime))
	// 5 秒内 ping 不通直接报错——启动期就发现数据库问题
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := db.PingContext(ctx); err != nil {
		return nil, fmt.Errorf("ping mysql<%s> error, %s", dsn, err.Error())
	}
	return db, nil
}

这里有个非常值得学习的架构决策:GORM 和原生 database/sql 共享同一个连接池。CMDB 里有的模块用 GORM(resource/secret),有的模块用原生 SQL(host/task/bill),如果各自开连接池,连接数会翻倍;通过 gorm.Open(mysql.New(mysql.Config{Conn: conn})) 把已有的 *sql.DB 塞给 GORM,全项目共享一份池子,资源利用率最优。

3.3 片段:缓存的两种实现切换(cmd/start.go:165-183)

func loadCache() error {
	l := zap.L().Named("INIT")
	c := conf.C()
	switch c.Cache.Type {
	case "memory", "":                    // 默认:进程内内存缓存
		ins := memory.NewCache(c.Cache.Memory)
		cache.SetGlobal(ins)
		l.Info("use cache in local memory")
	case "redis":                         // 可选:Redis 分布式缓存
		ins := redis.NewCache(c.Cache.Redis)
		cache.SetGlobal(ins)
		l.Info("use redis to cache")
	default:
		return fmt.Errorf("unknown cache type: %s", c.Cache.Type)
	}
	return nil
}

两种实现遵守同一个 cache.Cache 接口,切换只需改一行配置——这就是"面向接口编程"最朴素的落地。

类比:缓存双实现就像家里的热水壶 vs 楼下开水房:自己烧(内存缓存)快、零延迟,但只有这一户能用;开水房(Redis)多户共享、容量大,但要下个楼(网络往返)。对应到工程里就是:单机部署选 memory 起步最快;一旦多实例部署、需要共享缓存,改一行配置切到 Redis,业务代码因为依赖 cache.Cache 接口而一行不用动。

4 业务逻辑流程图与核心要点总结

4.1 完整启动流程图

┌─────────────────────────────────────────────────────────────┐
│                    环境准备(一次性)                           │
│  ① Docker 起 MariaDB → create database cmdb → 授权           │
│  ② cp etc/config.example.toml etc/config.toml → 改 mysql 段  │
│  ③ go run main.go init -f etc/config.toml                    │
│         └→ 读 SQL 文件(-s 指定,默认 docs/schema/tables.sql)   │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                    服务启动(日常)                             │
│  ④ make run (= go run main.go start -f etc/config.toml)      │
│         ├→ loadGlobalConfig   配置单例 conf.C() 就绪           │
│         ├→ loadGlobalLogger   zap 日志就绪                     │
│         ├→ loadCache          内存/Redis 缓存就绪              │
│         ├→ app.InitAllApp     7 个已实现模块 Config() 执行       │
│         │      └→ 各模块此时拿到共享的 MySQL 连接池              │
│         ├→ go grpc.Start()    监听 :18060,1s 后注册到 mcenter  │
│         └→ http.Start()       监听 :8060,注册路由+OpenAPI       │
│                └→ 阻塞,直到收到 SIGTERM 等信号                  │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                    验证                                       │
│  ⑤ curl http://127.0.0.1:8060/cmdb/api/v1/dict/vendors       │
│  ⑥ 浏览器打开 http://127.0.0.1:8060/apidocs.json              │
└─────────────────────────────────────────────────────────────┘

4.2 核心要点总结

  1. 建库 → 建表 → 改配置 → make run,四步跑起服务;
  2. multiStatements=true 是 init 命令一次执行整个 DDL 文件的前提;
  3. GORM 与原生 SQL 共享一个连接池,靠 gorm.Open(...Config{Conn: conn}) 实现;
  4. mcenter 是硬依赖:没有它,HTTP/gRPC 构建期就会 panic(rpc.NewClient 失败时);
  5. 配置样例的 key 字段与代码 encrypt_key 不一致,照抄有坑。

5 自测题(对话式)

老规矩,三道题先自己答,再看参考答案。

面试官
如果把 etc/config.toml 里的 app.name 从 cmdb 改成 mycmdb,会发生哪些连锁变化?(提示:看 protocol/http.go:73-75 的 PathPrefix(),以及 gRPC 向 mcenter 注册时用的 version.ServiceName)
候选人
这是个"牵一发动全身"的配置,我分两侧看:
HTTP 侧:app.name 决定路由前缀——protocol/http.go:73-75 的 PathPrefix() 用应用名拼出 /{应用名}/api,改成 mycmdb 后所有接口从 /cmdb/api/v1/... 变成 /mycmdb/api/v1/...。连带地,自动生成的 OpenAPI 文档里的路径、以及启动时自动上报给 mcenter 的路由权限点也会跟着变——前端、网关、已配置的调用方都要同步改路径,否则全部 404。
gRPC 侧:注意一个细节——gRPC 向 mcenter 注册实例时用的名字来自 version.ServiceName,它和配置里的 app.name 是两个来源;只改配置,gRPC 的注册名不会自动跟着变,要另改 version 包。这也提醒我们:同一个"应用名"在项目里可能有多处定义,改名前要全局搜一遍。
结论:能改,但代价是 HTTP 前缀、权限点上报、调用方全链路同步;学习阶段别乱动它。
面试官
init 命令为什么必须在 start 之前至少执行一次?如果跳过 init 直接 start,服务能启动成功吗?第一次同步任务执行时会怎样?
候选人
先分清两件事:建表是数据层的前置,不是服务启动的前置。
init 子命令做的是执行 -s 指定的 SQL 文件(默认路径 docs/schema/tables.sql,文件需按第 4 章表清单自备),一次性建出 13 张表;而 start 的五步启动(配置、日志、缓存、InitAllApp、双服务)只依赖配置和 MySQL 连接——getDBConn() 里 sql.Open + PingContext 只验证"数据库服务器通不通",并不会检查表存不存在。所以跳过 init 直接 start,服务本身能启动成功,日志看起来一切正常。
但雷在后面埋着:第一次执行同步任务或查询接口,SQL 跑到具体表名时会报 Table ... doesn't exist——错误从"启动期"被推迟到了"运行期",排查反而更绕。
结论:init 至少要跑一次;这也是"快速失败"思想的反面教材——启动期不做表存在性校验,问题暴露得就晚。
面试官
getDBConn() 里已经 sql.Open 成功了,为什么还要 PingContext?(提示:sql.Open 到底做没做真正的网络连接?查一下 Go 标准库文档)
候选人
关键在于 sql.Open 的语义:它只是构造 *sql.DB 对象、解析 DSN、初始化连接池配置,Go 标准库文档明确说明它可能只校验参数而不建立任何真正的网络连接——真正的 TCP 连接是惰性的,推迟到第一次执行语句时才建立。
所以如果没有 PingContext:DSN 里地址写错、密码不对、MySQL 没起,sql.Open 照样"成功",服务也照样"启动成功",直到第一个请求打进来才炸——错误暴露得太晚,而且是在生产流量上炸。
加了 PingContext 并配 5 秒超时后,"数据库连不上"在启动期就变成一条明确的报错,运维一眼定位。这与 conf.C() 未加载即 panic 是同一哲学:错误越早暴露,排查成本越低。
一句话总结:Open 是"拿到通讯录",Ping 才是"真的拨一通电话"。

6 本章小结

本章是动手章:Docker 起 MariaDB、建库授权、复制并修正配置、init 建表、make run 启动、curl 探活,一套完整闭环。同时我们拆解了三个底层机制:配置全局单例(未加载即 panic)、MySQL 三层连接管理(单例 + 共享连接池 + 启动期 ping 校验)、缓存双实现切换。最后记住三个坑的伏笔:encrypt_key 配置名、secrets 表名、resource 幻影表——第 8 章逐个解决。

复习提示: - 跑起来的四步闭环:建库 → 建表(init,依赖 multiStatements=true)→ 改配置(key 要改成 encrypt_key)→ make run,再用 curl 探活 + /apidocs.json 验证。 - 三个底层机制:conf.C() 未加载即 panic;GORM 与原生 SQL 靠 gorm.Open(...Config{Conn: conn}) 共享一个连接池;缓存 memory/Redis 遵守同一接口,一行配置切换。 - mcenter 是隐形硬依赖:起不来或 401,先查它。 - 下一章我们回到"看地图"模式——目录结构与工程规范,把每个目录的职责认全,为逐层读码做准备。
About Me

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

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

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

目标

学AI,加油!加油!