Files
go-admin/docs/contract.md
T
zhangwenjian 15fb128236 docs📝: describe the rules rather than who follows them
The warning on Authorizator matters to anyone keeping a copy of that file, not
to one particular consumer, and it reads better addressed to all of them: check
what reads those context keys before taking this change.
2026-09-01 19:56:27 +08:00

200 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 公共契约面
> 本文写给**第三方应用作者**:你写一个装进 go-admin 的业务模块,可以依赖什么、
> 怎么注册进来、哪些东西随时可能变。
>
> 主仓贡献者的编码约定见根目录 `AGENTS.md`,设计取舍见 `docs/architecture.md`。
---
## 承诺稳定的包
| 包 | 用途 |
|---|---|
| `common/actions` | 通用 CRUD ActionIndex / View / Create / Update / Delete / Permission |
| `common/dto` | 分页、`search` tag 解析、`Control` / `Index` 接口 |
| `common/models` | `ActiveRecord``ControlBy``ModelTime``Model` |
| `common/middleware` | `AuthCheckRole``InitMiddleware` 等 |
**依据不是拍脑袋列的**`app/demo` 是一个可编译、有测试、CI 会跑的标准 CRUD 模块,
把它的 `go-admin/` 前缀 import 全部去重之后,恰好就是这四个包 —— 它代表
"写一个标准模块所需要的最小依赖面"。你的模块如果需要第五个包,先在 issue 里说一声,
那多半意味着契约面缺了什么。
"稳定"的含义:**在 `2.x` 内不做破坏性变更**。新增导出符号不算破坏;改签名、
改语义、删除导出符号算,会走 major 版本并在 release note 里单列。
### 没有已知例外
这四个包**不 import `app/` 下的任何东西**2026-08-31 起由 CI 强制
(见下方「边界由 CI 守着」)。在此之前有两处反向依赖,都已根治:
| 原位置 | 反向依赖 | 处理 |
|---|---|---|
| `common/middleware/logger.go` | `app/admin/service/dto` 的两个操作日志状态常量 | 常量下沉到 `common/global``dto` 侧保留同名常量作为 deprecated 别名,fork 不受影响 |
| `common/middleware/handler/auth.go` | `app/admin/models``SysUser` / `SysRole` | 该段断言恒失败、设的是零值且开源版无人读取,属死代码,已删除 |
之所以不把它们记成"已知例外":这份文档的作用就是告诉你哪些包可以依赖,
如果第一条下面就挂着例外脚注,后来人会照着例外抄,边界从第一天起就是脏的。
---
## 其余包不保证稳定
`common/` 下没有出现在上表里的包(`common/global``common/storage`
`common/database``common/file_store``common/response``common/service`
`common/apis``common/middleware/handler`、根 `common` 包……)以及
`app/admin` 的内部实现,**均不承诺稳定**。
其中 `common/global``common/middleware/handler`、根 `common` 包是
`common/middleware` 的编译期依赖 —— 它们会被一起拉进你的依赖图,但这不代表
它们的 API 稳定。**不要因为"都在 `common/` 目录下"就认为是契约面。**
规划中的 001(模块路径改名)会把非契约包移进 `internal/`,由编译器强制这条边界。
届时上表之外的包对外部模块直接不可见 —— 现在就照上表写,那次改动对你零成本。
---
## 注册路由
一个应用模块要注册自己的路由,写一个 `func()` 签名的 `InitRouter`
(照抄 `app/demo/router/router.go`),然后二选一接进来:
```go
// 方式一(历史写法,仍然有效):在主仓 cmd/api/<name>.go 里
AppRouters = append(AppRouters, router.InitRouter)
// 方式二(推荐):不需要 import go-admin/cmd/api
sdk.Runtime.SetAppRouters(router.InitRouter)
```
方式二是本次新接上的。差别只有一个但很关键:方式一要求你的模块
`import "go-admin/cmd/api"` —— 那是主程序的命令包,让业务模块依赖它很别扭,
也正是"主仓要为每个模块加一个七行文件"的根源。
**执行顺序**:先跑完包级 `AppRouters`,再由 core 的 `sdk.Runtime.RunAppRouters()`
跑它自己的注册表,各自内部保持注册顺序。别依赖跨来源的相对顺序,各模块的
`RouterGroup` 前缀互不相同,本来就不该有顺序依赖。
走方式二还多拿到两样东西,都在 core 那边实现(见
[core 的 `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)):
**panic 护栏**——你的 `InitRouter` panic 了,其余模块照常注册、进程不退出,日志里会写明
是哪一行注册的;**失败分级**——`sdk.Runtime.SetAppRoutersWith(f, runtime.WithFatal())`
声明「我起不来就别启动」。方式一(包级 `AppRouters`)没有护栏,panic 直接掀桌。
`InitRouter()` 内部的约定:自己拿 `sdk.Runtime.GetEngine()`,按需建
`gin.RouterGroup`,通过 `init()` 自注册到你自己包内的
`routerCheckRole` / `routerNoCheckRole` 列表,不在任何中心文件手工列举
(与 `AGENTS.md`「路由注册」一节一致)。
---
## 注册数据库迁移
框架自身的迁移不变:
```go
migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700001000DemoMenu)
```
应用的迁移走 `ForApp`
```go
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.ForApp("crm").SetVersion(migration.GetFilename(fileName), initCrmTables)
}
func initCrmTables(db *gorm.DB, version, appCode string) error {
return db.Transaction(func(tx *gorm.DB) error {
// ... schema / data changes ...
return tx.Create(&common.Migration{Version: version, AppCode: appCode}).Error
})
}
```
四条必须知道的规则:
1. **完成记录由迁移函数自己写**,而且要写在自己的事务里。框架的调度循环只做
"这个 version 在 `sys_migration` 里有没有" 的判断,从不代你插入 —— 这样
"数据改完了"和"标记成已完成"才是同一个事务,不会出现改了一半却被记成成功。
2. **`AppCode` 必须写进去**。签名多带一个 `appCode` 参数就是为此 —— 忘了写,
schema 上那一列等于白加,你的迁移会被记成框架的。
3. **落库的 `version` 是加了前缀的**`ForApp("crm")` 注册 `1786800001000`
实际写进 `sys_migration.version` 的是 `crm-1786800001000`,函数收到的
`version` 参数已经是这个带前缀的值,照抄进 `common.Migration{Version: version}`
即可。前缀的意义是:两个来源不同的应用哪怕碰巧生成同一个毫秒时间戳,也不会撞主键、
不会有一方被误判为"已应用"。
4. **应用 code 一律小写**`ForApp` 会自己 `strings.ToLower` 一遍。`core` 是保留字
`migrate status` 用它表示框架自身,`--app core` 选中框架),`ForApp("core")`
会 panic。
顺序保证:**同一应用内按版本号严格有序**。跨应用顺序不做承诺 —— 由于前缀的存在,
今天的实际顺序是"先跑完全部框架迁移,再按 appCode 字母序逐个应用跑完"
但这是实现细节,不要依赖它。跨应用依赖(应用 A 的迁移要求应用 B 先跑完)
需要依赖拓扑排序,属于后续阶段。
看当前状态、看这次会跑什么,不用猜:
```bash
go-admin migrate status -c config/settings.yml # 按应用分组列出已应用 / 待应用
go-admin migrate --dry-run -c config/settings.yml # 列出会执行什么、什么顺序,不写库
go-admin migrate --app crm -c config/settings.yml # 只跑 crm 的迁移
```
`status``--dry-run` 是纯只读的,不建表、不改表结构,可以直接对生产库执行。
---
## 硬约束:注册要赶在启动钩子之前
三个注册入口——`AppRouters``sdk.Runtime.SetAppRouters``migration.ForApp`——
都必须在 `cmd/api/server.go``runStartupHooks()` 执行之前调用完。
`init()` 是最省事的位置:Go 规范保证包级变量初始化与 `init()``main()` 之前
**单 goroutine 顺序执行**,注册期天然没有并发写。但它不是唯一合法位置——
`run()` 之类早于启动钩子的地方注册同样成立。这条规则约束的是**顺序**,
不是你写在哪个函数里。
`sdk.Runtime.SetAppRouters` 的准确语义以 core 为准:
> [go-admin-core `docs/contract.md`](https://github.com/go-admin-team/go-admin-core/blob/main/docs/contract.md)
那份文档写明了注册类与资源类的划分、封闭时刻、护栏边界(**只覆盖同步 panic,
你自己 `go func()` 出去的 panic 框架够不着**)、以及配置热更新会在运行期
重新执行 setup 回调这件事。
主仓这边只补三条它管不着的:
1. **`RunAppRouters()` 跑过之后,core 的注册表就封闭了**,再调
`sdk.Runtime.SetAppRouters` 会被丢弃并记一条 ERROR 日志。包级 `AppRouters`
没有这个机制——它就是一个普通 slice,什么时候 append 都"成功"
`runStartupHooks()` 之后 append 的那些永远不会被执行,且不出声。
这是继续推荐方式二的理由之一。
2. **封闭是黏性的,而 `sdk.Runtime` 是包级单例。** 写测试时若会触发启动钩子,
必须换掉它再还原,否则同一个测试二进制里后面的测试会静默丢注册:
```go
previous := sdk.Runtime
t.Cleanup(func() { sdk.Runtime = previous })
sdk.Runtime = runtime.NewConfig()
```
`cmd/api/server_test.go` 里的 `freshRuntime` 就是这个。
3. **`migration.ForApp` 是主仓的东西**,core 不认识它,上面那份文档不覆盖它。
它的约束仍然是"注册要在迁移调度循环跑起来之前",实践上就是 `init()`。
---
## 边界由 CI 守着
`common/`、`core/` 不得 import `app/`,这条由 `tools/checksilent` 的
`contract-import-boundary` 检查固化,`make checksilent` 在 CI 里跑,违反即失败
(测试文件同样算 —— 一个删掉 `app/admin` 的 fork 也应该能跑 `go test ./...`)。
靠人工评审列契约面会漏。上面那两处反向依赖里,第二处就是评审没发现、
靠机器全量扫描才找出来的。
`tools/checksilent` 还检查另外五类"不出声的失败",写模块时值得先看一眼
`go run ./tools/checksilent -h`。