文档目录
glen
· 预计阅读 3 分钟

第 2 章 应用生命周期:从 NewApp 到 WebShell

本文导航
第 2 章 应用生命周期:从 NewApp 到 WebShell

第 2 章 应用生命周期:从 NewApp 到 WebShell

上一章能跑起来,但顺序是「黑盒」。这一章把启动过程拆开,解释每一步为什么这样安排。理解了顺序,后面遇到「配置没生成」「依赖注入失败」「迁移没执行」时,才知道该看哪一步。

两个入口

owl.NewApp(subApps...).WebShell()                 // HTTP 服务
owl.NewApp(subApps...).ConsoleShell(rootCmd)      // 命令行
  • WebShell:注册路由、收集菜单、执行 Bootstrap,最后启动 Gin。
  • ConsoleShell:只执行 Bootstrap,并把各子应用的 RegisterCommands() 挂到 Cobra,然后执行命令。

两种模式都会做依赖注入、配置生成、统一迁移和 Provider 的 Boot()。区别只在「是否注册 HTTP 路由和菜单」。

NewApp 阶段

owl.NewApp() 依次做六件事:

  1. 初始化雪花 ID 生成器:utils.InitSnowFlakeWorker(1, 3);
  2. 创建 dig 容器:dig.New();
  3. 推断路径:setPath(),得到 runDir(当前工作目录)和 basePath(可执行文件目录);
  4. 确保配置目录存在:ensureConfDir();
  5. 注册基础绑定:registerBaseBindings(),把 *Application 注册为 foundation.Application,并注册 MenuRepository;
  6. 注册基础 Provider:registerBaseServiceProviders(),包括 conf、log、otel、event、appconf、validator。

注意:此时还没有读取配置。框架刻意把「生成配置文件」和「加载配置」拆成两个阶段。

为什么配置要分两阶段

如果启动时直接用 NewConfigure 扫描 conf/,而某个 Provider 的默认配置还没写盘,读取时就会因为缺键而 panic。Owl 的处理顺序是:

各 Provider 的 Conf() 写盘/补键
        ↓
各 Provider 的 Register()(只注册工厂,不读配置)
        ↓
initLogger() 首次解析 Logger,触发 NewConfigure 一次性加载 conf/
        ↓
后续 RegisterRouters / Bootstrap 才真正使用配置
  • 文件不存在:写入默认内容;
  • 文件已存在:conf.ValidateConfigKeys 把默认内容里缺少的键补进去,已有值不覆盖;
  • 补不进去的键只告警,不阻断启动。

这条规则解释了为什么升级框架后,旧的 conf/*.yaml 会自动多出新字段。

newSubApp 阶段

WebShell / ConsoleShell 最终都会进入 newSubApp(subApps...),顺序如下:

1. 对每个 SubApp 注入 app,并注册 Binds 里的构造函数
2. 收集 RegisterCommands()
3. 收集 ServiceProviders()
4. generateProviderConfigs(子应用 Provider)    // 先落盘配置
5. registerProviders(子应用 Provider)          // 只注册工厂
6. initLogger() + initTelemetry()              // 加载 conf/
7. collectConfigs()                            // 收集 setting.Registrar
8. Web 模式:
     RegisterRouters() → RegisterMenus() → AddMenu() → Bootstrap()
   Console 模式:
     只执行 Bootstrap()
9. runAutoMigrate()                            // Before → 按 hash 迁移 → After
10. 所有 Provider 的 Boot()
11. Web 模式启动 HTTP;Console 模式执行 Cobra

几个容易记错的点:

  • Bootstrap() 在所有子应用的 RegisterRouters() 和菜单收集之后执行,适合做 Seeder、事件监听注册;
  • RegisterMigrate() 返回的模型在所有 Bootstrap() 结束后统一迁移;
  • Provider.Boot() 最后执行,此时依赖已全部就绪;
  • 系统配置(setting.Registrar)在注册路由之前收集,Web 和命令行都会执行。

菜单的收集与归属

Web 模式下,框架对每个子应用调用 RegisterMenus()。拿到菜单后,框架会做一件事:

registered := app.RegisterMenus()
for _, menu := range registered {
    menu.SubApp = app.Name()   // 标记根菜单属于哪个子系统
}

这样前端可以按 ?app=admin,cms 过滤只显示指定子系统的菜单。FilterMenusBySubApp 只检查根菜单的 SubApp,子节点跟随根节点。

迁移的顺序

所有 SubApp 的 BeforeMigrate(串行)
        ↓
按模型类型计算 schema hash(并行)
        ↓
只对 hash 变化的模型执行 AutoMigrate(串行)
        ↓
所有 SubApp 的 AfterMigrate(并行)

hash 记录在 storage/migrate_hash.txt,每行是 包名.类型名=hash。结构没变的模型不会重复迁移;但 BeforeMigrate / AfterMigrate 每次启动都会执行。

如果某个模型结构没变、但你想强制迁移,删掉 storage/migrate_hash.txt 对应行即可。

路径推断规则

GetConfigPath()、GetStoragePath() 等最终都走 inferDir:

先看 可执行文件目录/basePath + path
不存在 → 再看 当前工作目录/runDir + path

开发时通常用 go run .,可执行文件在临时目录,basePath 下没有 conf/,于是回落到项目目录,看起来一切正常。但打成二进制并用 systemd / Docker 启动时,工作目录可能不同,conf/ 和 storage/ 的落点会变。部署时务必确认实际路径。

启动日志怎么读

框架在 debug 日志级别下会打印每个阶段耗时:

startup begin Binds[order]
startup Binds[order] took 120µs
startup begin generateProviderConfigs
...
startup begin AutoMigrate(1 models)

如果启动卡住,看最后一条 startup begin 就知道停在哪个阶段。排查「依赖注入失败」时,通常会停在 Binds 或某个 RegisterRouters。

自检

  • [ ] 能按顺序说出 NewApp 的六件事;
  • [ ] 能解释为什么先 Conf() 落盘、再 Register()、最后 initLogger();
  • [ ] 能说出 Web 与 Console 模式下 RegisterRouters / RegisterMenus / Bootstrap 是否执行;
  • [ ] 能解释 storage/migrate_hash.txt 的作用;
  • [ ] 部署时能确认 conf/、storage/ 实际落在哪个目录。

评论

加载评论中…

上一篇 第 1 章 第一个 Owl 应用:5 分钟跑通 下一篇 第 3 章 SubApp 契约:子应用必须实现的 10 个方法

BitLabs © 2026 · 论坛讨论 · 博客长文 · 文档手册