Files
go-admin/docs/contract.md
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

9.7 KiB
Raw Permalink Blame History

公共契约面

本文写给第三方应用作者:你写一个装进 go-admin 的业务模块,可以依赖什么、 怎么注册进来、哪些东西随时可能变。

主仓贡献者的编码约定见根目录 AGENTS.md,设计取舍见 docs/architecture.md


承诺稳定的包

用途
common/actions 通用 CRUD ActionIndex / View / Create / Update / Delete / Permission
common/dto 分页、search tag 解析、Control / Index 接口
common/models ActiveRecordControlByModelTimeModel
common/middleware AuthCheckRoleInitMiddleware

依据不是拍脑袋列的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/globaldto 侧保留同名常量作为 deprecated 别名,fork 不受影响
common/middleware/handler/auth.go app/admin/modelsSysUser / SysRole 该段断言恒失败、设的是零值且开源版无人读取,属死代码,已删除

之所以不把它们记成"已知例外":这份文档的作用就是告诉你哪些包可以依赖, 如果第一条下面就挂着例外脚注,后来人会照着例外抄,边界从第一天起就是脏的。


其余包不保证稳定

common/ 下没有出现在上表里的包(common/globalcommon/storagecommon/databasecommon/file_storecommon/responsecommon/servicecommon/apiscommon/middleware/handler、根 common 包……)以及 app/admin 的内部实现,均不承诺稳定

其中 common/globalcommon/middleware/handler、根 common 包是 common/middleware 的编译期依赖 —— 它们会被一起拉进你的依赖图,但这不代表 它们的 API 稳定。不要因为"都在 common/ 目录下"就认为是契约面。

规划中的 001(模块路径改名)会把非契约包移进 internal/,由编译器强制这条边界。 届时上表之外的包对外部模块直接不可见 —— 现在就照上表写,那次改动对你零成本。


注册路由

一个应用模块要注册自己的路由,写一个 func() 签名的 InitRouter (照抄 app/demo/router/router.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): panic 护栏——你的 InitRouter panic 了,其余模块照常注册、进程不退出,日志里会写明 是哪一行注册的;失败分级——sdk.Runtime.SetAppRoutersWith(f, runtime.WithFatal()) 声明「我起不来就别启动」。方式一(包级 AppRouters)没有护栏,panic 直接掀桌。

InitRouter() 内部的约定:自己拿 sdk.Runtime.GetEngine(),按需建 gin.RouterGroup,通过 init() 自注册到你自己包内的 routerCheckRole / routerNoCheckRole 列表,不在任何中心文件手工列举 (与 AGENTS.md「路由注册」一节一致)。


注册数据库迁移

框架自身的迁移不变:

migration.Migrate.SetVersion(migration.GetFilename(fileName), _1786700001000DemoMenu)

应用的迁移走 ForApp

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 先跑完) 需要依赖拓扑排序,属于后续阶段。

看当前状态、看这次会跑什么,不用猜:

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 是纯只读的,不建表、不改表结构,可以直接对生产库执行。


硬约束:注册要赶在启动钩子之前

三个注册入口——AppRouterssdk.Runtime.SetAppRoutersmigration.ForApp—— 都必须在 cmd/api/server.gorunStartupHooks() 执行之前调用完。

init() 是最省事的位置:Go 规范保证包级变量初始化与 init()main() 之前 单 goroutine 顺序执行,注册期天然没有并发写。但它不是唯一合法位置—— 在 run() 之类早于启动钩子的地方注册同样成立。这条规则约束的是顺序 不是你写在哪个函数里。

sdk.Runtime.SetAppRouters 的准确语义以 core 为准:

go-admin-core docs/contract.md

那份文档写明了注册类与资源类的划分、封闭时刻、护栏边界(只覆盖同步 panic 你自己 go func() 出去的 panic 框架够不着)、以及配置热更新会在运行期 重新执行 setup 回调这件事。

主仓这边只补三条它管不着的:

  1. RunAppRouters() 跑过之后,core 的注册表就封闭了,再调 sdk.Runtime.SetAppRouters 会被丢弃并记一条 ERROR 日志。包级 AppRouters 没有这个机制——它就是一个普通 slice,什么时候 append 都"成功"runStartupHooks() 之后 append 的那些永远不会被执行,且不出声。 这是继续推荐方式二的理由之一。

  2. 封闭是黏性的,而 sdk.Runtime 是包级单例。 写测试时若会触发启动钩子, 必须换掉它再还原,否则同一个测试二进制里后面的测试会静默丢注册:

    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/checksilentcontract-import-boundary 检查固化,make checksilent 在 CI 里跑,违反即失败 (测试文件同样算 —— 一个删掉 app/admin 的 fork 也应该能跑 go test ./...)。

靠人工评审列契约面会漏。上面那两处反向依赖里,第二处就是评审没发现、 靠机器全量扫描才找出来的。

tools/checksilent 还检查另外五类"不出声的失败",写模块时值得先看一眼 go run ./tools/checksilent -h