Skip to content

Latest commit

 

History

History
400 lines (291 loc) · 21.9 KB

File metadata and controls

400 lines (291 loc) · 21.9 KB

模块系统与轻量 DI 设计

这份文档是 server 的架构与边界设计说明,不再承担日常执行级后端规范的唯一入口。

后端执行级治理真值现在收口到 server/AGENTS.md

  • server/AGENTS.md 负责日常实现约束,例如 module 生命周期、internal/moduleapi 与 contract 边界、DI 与 wiring、Go 代码组织、Ent/migration 与 backend validation
  • server/AGENTS.md 也负责 server agent 的任务生命周期模板,例如 startup preflight 后的 task classification、boundary decision、implementation path、validation 与 closeout 记录格式
  • 本文档负责解释为什么采用 module-oriented modular monolith backend、为什么保持轻量 DI,以及为什么这些边界服务于长期可扩展性

如果实现级规则与本文档的表述需要分层,执行时以 server/AGENTS.md 为准;若该执行级规则改变了模块系统、DI 职责或后端长期边界,再同步更新本文档。

术语说明:

  • 当前 backend 的 canonical 业务能力单元是 module
  • 当前 canonical 物理路径已经迁到 module / modules,跨模块稳定边界也已经迁到 internal/moduleapi
  • 除非明确讨论现有包路径,否则都应按 compile-time module 语义理解

1. 设计目标

这份文档只回答两件事:

  • 模块之间如何协作
  • 为什么引入轻量 DI,以及它具体负责什么

目标不是引入一个“万能容器”,而是让平台在模块数量增加时仍保持清晰、可测、可维护。

补充一点:当前仓库不仅需要“能实现后端功能”,还需要一条对 agent 和人工贡献者都稳定的 server 开发路径。

因此制度化后的分层是:

  • server/AGENTS.md
    • 回答“后端任务现在应该怎么做”
    • 包括 startup preflight 之后的 task classification、boundary decision、implementation path、validation 与 closeout 模板
  • 本文档
    • 回答“为什么后端任务必须这么做”
    • 用架构理由支撑 compile-time registry、显式 CLI、module-owned persistence、stable capability 边界与轻量 DI

2. 模块交互模型

模块之间禁止直接依赖彼此内部实现。

统一交互方式:

  • 平台核心提供基础设施能力
  • 模块通过稳定 capability contract 暴露公开服务
  • 其它模块通过接口解析依赖

典型例子:

  • auth 模块公开 AuthService 与后续 AuthSessionService 这类认证生命周期 capability
  • user 模块公开稳定用户身份与用户管理 capability,而不是把 token/session 细节长期留在 user
  • rbac 模块依赖 UserService 或稳定身份查询接口,而不是依赖 modules/user/service 的具体 struct
  • user 只对 rbac 暴露稳定用户能力,例如用户存在性检查、基础身份查询、删除前约束检查
  • rbac 处理 user_roles 写入时通过该稳定接口校验 user_id,而不是 import user 的 Ent 包、repository 或其它私有持久化实现
  • saved-view 仅通过 SavedViewService 持久化调用方私有的列表状态,不依赖 user;需要该能力的业务模块显式依赖 saved-view 并继续拥有筛选状态与路由鉴权语义
  • auth 不拥有 role、permission、resource metadata;rbac 不拥有 login、token、session、cookie

这样做的收益:

  • 模块边界清晰
  • 单元测试更容易替换实现
  • 后续拆分包结构时影响更小

3. 轻量 DI 的职责

容器只负责四类事情:

  • 注册单例服务
  • 解析服务
  • 注册模块公开服务
  • 统一释放资源

容器不负责:

  • 包扫描
  • 自动发现 provider
  • 通过 tag 注入字段
  • 隐式构造依赖图
  • 复杂的 request scope / session scope

这意味着代码仍然保持 Go 风格的显式装配,只是把“装配入口”集中管理。

当前长期治理方向补充如下:

  • 保持 compile-time modular monolith,而不是演进为 runtime plugin platform
  • 当前以 module.Specmodule.Builder 与 compile-time generated registry 作为 module registry,生命周期与接线命名也按 Module 语义收口
  • 不引入 runtime filesystem scan、runtime plugin discovery、hot-load 或 generalized reflection plugin system
  • 启动仍然要求 deterministic wiring 和单体进程语义

这也是为什么执行级治理必须坚持这些制度:

  • graft serve 只负责 runtime 启动,不隐式执行 migration
  • graft migrate up 继续作为 schema 变更的显式入口
  • Register / Boot / Shutdown 必须职责分离,而不是把声明、启动、结构修改混在一个阶段
  • 模块之间优先通过 moduleapi 或稳定 contract 协作,而不是互相 import 内部实现
  • 新的业务真相必须留在 modules/<name>/**,而不是回流 internal/** 或已退场的 internal/ent/**

如果这些规则不制度化,compile-time modular monolith 很快就会退化为“仍在单体里运行、但边界已经不可审计”的共享代码库。

这套方向对长期多工作树的约束也必须明确:

  • 模块日常实现优先留在 modules/<name>/** 这类 module-owned 边界
  • 这里的“zero-shared”是指功能开发工作树尽量不共同持有业务实现面,不是要求所有 tracked file 绝对不共享
  • 临时 server feature worktree 的任务范围默认围绕 server/modules/<name>/**,但目录不形成长期占用
  • internal/moduleapi/**internal/contract/** 只承载稳定共享契约,不作为长期“先放这里再说”的缓冲层
  • internal/moduleregistry/generated.go 可以保留为唯一集中 compile-time 接线热点,但它应是机械生成产物,而不是新的手写中心化模块清单
  • internal/moduleregistry/generated.go 当前保持 tracked;它和其他生成热点在开发者集成工作区串行生成并收口
  • cmd/graft/**AGENTS.mdai-plan/**、migration 入口调整与共享 registry wiring 变更需要明确的 bounded integration slice,不形成长期 feature worktree 的 standing ownership
  • 若某个方向仍需要频繁同时修改 module-owned 代码和 core wiring / core schema,开发者应先在集成工作区收口 authority,再继续派发独立任务
  • bounded scope forbids unrelated expansion, not required authority repair
  • 若 authority 明确位于 module descriptor、shared contract、OpenAPI source 或 compile-time wiring,则允许通过最小必要跨边界切片同步修复 authority,而不是默认把 drift 留给下游兼容

4. 推荐接口

推荐抽象出两个能力:

4.1 服务注册

type Registry interface {
    RegisterSingleton(key any, provider Provider) error
    RegisterModuleService(module string, key any, provider Provider) error
}

4.2 服务解析

type Resolver interface {
    Resolve(key any) (any, error)
    MustResolve(key any) any
}

如果实现时使用泛型,可以再包装成更易用的 typed API,但内部模型仍应保持“显式注册 + 显式解析”。


5. 模块上下文建议

模块上下文不应继续无限膨胀。

建议分成三类能力:

5.1 基础设施句柄

  • 配置
  • 日志
  • i18n
  • auth-owned credential / session repository and user-owned profile repository
  • HTTP 路由根
  • 事件总线

5.2 注册器

  • 菜单注册器
  • 权限注册器
  • 定时任务注册器

5.3 服务能力

  • Service Registry
  • Service Resolver

这样可以避免把所有内容都塞进一个巨大的 Context 结构体。

长期并行开发方向补充约束:

  • module.Context 不应无限扩张为“万能业务依赖袋”
  • 业务模块私有 repository 依赖更适合在 Builder 阶段装配,而不是长期保留中心化 store.Factory 聚合入口
  • capability 必须在 Builder 阶段注册,生命周期稳定,不暴露 repository、ORM entity、Ent client、storeent 实现或模块内部 struct
  • capability 只允许暴露:
    • cross-module business ability
    • dev/reset hook
    • stable query/service contract

当前 MVP 收敛阶段对这些能力补充约束:

  • auth 作为独立模块拥有认证与会话生命周期 capability;后续 Phase 1~3 通过 moduleapi 迁出 token/session/cookie/refresh store 与 /auth/* 运行时所有权

  • authAuthServiceAuthSessionServiceAuthFlowServiceAuthCredentialManagementService 的唯一容器注册者;user 只注册 UserIdentityProviderUserBootstrapProvider,不得提供认证装配 bundle 或认证持久化。

  • user 继续拥有用户资料与用户管理资源;如保留管理员按用户维度的 session 治理入口,也只能调用 auth capability,而不是反向持有 auth 持久化

  • OpenAPI spec covered 不等于 runtime migrated;shared contract 已存在时,module ownership 仍需单独迁移与验证

  • event bus 采用 server/internal/eventbus 下的最小进程内实现;它保持同步发布、顺序派发和错误回传,作为模块间即时观察者通知,不承担后台投递职责

  • 通用异步事件基础设施位于 server/internal/event,由 Runtime 持有并统一注入;它通过稳定的 EventPublisherEventHandler 与 dispatcher 边界承载后台消费,不替换或隐式改变 eventbus 的语义

  • 模块只能依赖其稳定接口,不直接依赖具体实现

  • scheduler 作为运行期调度能力,应通过独立接口注入,而不是让业务模块直接依赖 robfig/cron

  • i18n 能力统一经由 server/internal/i18n facade 注入;当前阶段先补强自管 registry,而不是在模块侧直接绑定未来可能引入的 go-i18n

  • locale resource 的物理 ownership 可以位于 server/modules/<name>/locales/*.yamlserver/internal/<owner>/locales/*.yaml, 但 YAML 解析、校验、duplicate 检查、registry construction 与 Freeze 仍只属于 server/internal/i18n

  • compile-time module descriptor 可以携带 locale resource provider 或 locale resource list,用于让 runtime 在模块 Register 前统一预注册 owner-local resources;这不等于允许模块自持 loader 或平行 registry

  • realtime hub 与 ticket service 属于 core 注入能力;业务模块只能消费稳定的 publish / subscribe / ticket issue 边界,不得各自维护第二套长连接基础设施

  • platform-network 是平台出站网络策略的唯一 owner。它通过 OutboundNetworkProvider 暴露当前有效策略,并通过 OutboundHTTPClientFactory 暴露 NewOutboundHTTPClient(ctx, options ...OutboundHTTPClientOption);消费者只能使用该 factory,不能自行 new http.Transport、构造未受策略约束的 http.Client,也不能读取或修改 HTTP_PROXYHTTPS_PROXYNO_PROXY 环境变量。

  • OutboundHTTPClientOption 只表达调用方的 HTTP 客户端行为,例如 timeout、TLS、User-Agent、retry、tracing 或 metrics; 代理、NO_PROXY、受信任 CA 与后续 mTLS 等平台网络策略始终由 OutboundNetworkProvider 决定,不能回写或扩张 network.outbound 的职责。

  • 网络诊断通过可注册的 OutboundNetworkDiagnosticTarget capability 暴露:target 自己声明稳定 Name、本地化 DisplayName 并执行受控 ExecuteOutboundDiagnostic(ctx)。诊断路由只能选择已注册 target,不能接收管理员提供的 URL;首个 target 是 Update 模块注册的 Platform Update Service。

  • 需要长期后台采样的模块能力,例如容器 stats collector,应通过模块 Boot 生命周期启动并在 Shutdown 阶段停止,而不是在 HTTP handler 内临时采样


6. 模块注册阶段约束

Register 阶段只做声明式工作:

  • 注册 HTTP 路由
  • 注册菜单
  • 注册权限点
  • 注册 message bundles / message keys
  • 注册任务定义
  • 注册公开服务

Register 阶段不要做:

  • 长时间初始化
  • 启动 goroutine
  • 执行耗时任务
  • 依赖其它模块已经 Boot 完成的逻辑

Boot 阶段才做真正的运行时启动。

这里的制度化原因不是形式主义,而是为了保持开发流程可审计:

  • Register 只做声明,才能在 closeout 中追溯 route、menu、permission、message、service 是否有清晰 owner
  • Boot 不做 schema 修改,才能保证 schema 变化继续只经过显式 migration 链
  • Shutdown 明确回收边界,才能让 runtime 生命周期、测试和 smoke 验证口径一致

补充说明:

  • 同步 eventbus 订阅和异步 EventHandler 注册都属于 Register 阶段的声明式工作;注册面在进入 Boot 前冻结,避免运行期隐式追加消费者
  • 异步 dispatcher 在 Boot 阶段启动 worker;ShutdownRuntime 先停止接收新异步事件,再在有界关闭上下文中 drain 或标记未完成项,随后关闭依赖资源。模块不得自行启动不受 Runtime 管理的事件 goroutine
  • scheduler 读取 cron registry 并真正启动任务属于 Boot 阶段
  • audit 的 HTTP middleware 注册属于 Register 阶段,event 订阅也应在这一阶段声明完成
  • i18n message 注册属于 Register 阶段;runtime 必须在进入 Boot 前冻结 i18n 注册面,并把 duplicate-key 视为声明期问题而不是运行期兜底
  • 若 locale resource 由 module 或 internal owner package 自己 go:embed,runtime 必须在对应模块 Register 前完成统一 预注册;模块内的 registerMessages() 只允许校验 key 已存在,不得变成资源加载入口
  • 模块通过 module.Context.LifecycleContext 获取当前阶段上下文;Shutdown 阶段由 runtime 注入独立的有界关闭 ctx,避免直接复用已取消的 runCtx

7. 服务边界规范

跨模块公开服务建议统一放在 internal/moduleapi 或等价稳定位置。

规范如下:

  • 暴露接口,不暴露实现细节
  • 接口尽量面向业务能力,而不是数据库细节
  • 平台共用 typed contract 放 server/internal/contract,模块私有稳定契约放 server/modules/<module>/contract, 真正跨模块公开的稳定契约继续放 server/internal/moduleapi
  • 模块只允许依赖:
    • server/internal/moduleapi/**
    • server/internal/contract/**
  • 其它模块公开 capability contract 或 stable DTO contract
  • 禁止直接依赖其它模块的 service/**storeent/**ent/schema/**、migration 目录
  • 模块上下文与公开接口不直接暴露 *ent.Client 或其它具体 ORM 句柄
  • 数据访问通过 repository / store factory 边界下沉到模块内部
  • 返回值尽量稳定,不把 Ent entity 直接作为跨模块返回类型
  • user_roles 这类最终归 rbac 拥有的持久化细节,必须留在 rbac 自己的 schema / repository / migration / test 边界内,不通过 user capability 反向泄露实现细节
  • user_roles 的跨模块协作边界应保持在 user_id / role_id 标识符级别,而不是继续依赖跨模块 Ent edge 或共享 ORM 真相
  • permission codeevent namemessage key、跨模块状态枚举等高风险契约,应遵循 契约治理与魔法值治理规范 的 canonical ownership、lifecycle 与 compatibility 规则
  • 跨模块注册、发布、解析边界优先消费 typed contract,而不是继续以裸字符串作为长期真值
  • 同一语义的跨模块 contract 只允许一个 canonical definition;兼容期 alias 只能用于过渡,不得演变为平行标准
  • 当前 locale 范围只收敛到 zh-CN / en-US;新增模块 message contract 不得假定仓库已进入多语言自由扩展阶段

这套边界同时服务于 server agent 的 boundary decision:

  • 如果能力是平台级公共语义,就进入 internal/contract/**
  • 如果能力是跨模块稳定业务协作,就进入 internal/moduleapi/**
  • 如果能力只属于单个模块的 route fragment、permission code、message key 或菜单语义,就留在该模块 contract/**
  • 如果某项协作仍需要直接 import 对方 repository、handler、storeent 或 schema,说明边界尚未设计完成,不应直接进入实现

示例:

  • 好:GetUserByID(ctx, id) (UserSummary, error)
  • 好:CheckUserExists(ctx, userID) error
  • 好:CheckUserDeletionConstraints(ctx, userID) error
  • 差:GetEntClient()
  • 差:AssignRoleViaUserRepo()
  • 差:GetUserRepo() *UserRepository

当前 MVP 收敛阶段新增的边界约束:

  • event bus 的公开接口只表达 Subscribe / Publish / handler 注册 这类最小能力
  • 不提前暴露 ack、retry、dead-letter、consumer-group、partition 等分布式 MQ 语义
  • 通用异步事件基础设施是与 eventbus 并存的项目级能力,不以审计、日志或任一业务模块命名;业务模块仅依赖 EventEventPublisherEventHandler 抽象
  • 异步事件的公共外壳只包含通用标识、类型、来源、版本、载荷、元数据和时间/关联信息;业务语义留在模块自有 payload DTO,投递状态、重试次数与错误信息属于 delivery 记录而非业务 Event 字段
  • DeliveryBestEffort 使用有界内存 buffer、worker pool、有界重试和优雅关闭;队列饱和和关闭超时必须显式反馈或记录,不能静默丢弃
  • DeliveryDurable 使用 core-owned PostgreSQL Outbox:事件 envelope 和当前注册 consumer 的 (event_id, consumer_id) delivery 在同一事务写入;worker 用租约、FOR UPDATE SKIP LOCKED 和至少一次投递完成重启及多实例恢复。业务状态变更需要 durable event 时,必须复用同一 SQL transaction 调用注入的 TransactionalPublisher.PublishTx,不得在提交后以 Publisher 补写形成 dual-write 缺口
  • Redis Stream、RabbitMQ、Kafka 等外部 transport 仅在真实规模和运维能力证明需要后,通过 EventPublisher/dispatcher adapter 引入;业务模块不得直接依赖具体 MQ 客户端
  • audit 的跨模块边界优先暴露“写审计记录”或“发送审计事件”的稳定能力,而不是暴露底层仓储
  • scheduler 的跨模块边界优先暴露 RegisterJob / RemoveJob / Start / Stop 这类仓库内稳定接口
  • 业务模块禁止直接 import github.com/robfig/cron/v3
  • platform-network 对跨模块公开的 OutboundNetworkProviderOutboundHTTPClientFactoryOutboundNetworkDiagnosticTarget registry 必须位于 internal/moduleapi。它们返回策略值、配置过的 client 或受控诊断 结果,不暴露具体 http.Transport、System Config store 或网络模块私有实现。首期只有 update 消费 factory; Marketplace、Registry、Helm、AI、Webhook 与 OAuth/OIDC 等后续主动 HTTP 消费者直接复用该 capability,不新增模块级代理 配置。
  • 权限声明的稳定元数据(如 permission codecategory)由权限注册声明侧提供 canonical truth,持久化层只负责保存,不在仓储或 migration 默认值里二次推断

7.1 当前多工作树过渡补充

在 server ownership 还处于从共享热点继续下沉到 module-owned 边界的阶段,额外补充以下约束:

  • server/internal/ent/** 目前只允许作为过渡兼容层存在,不得继续新增业务真相
  • 只要 import、runtime、test 或 generation 仍依赖 server/internal/ent/**,它就还是共享治理热点,而不是可安全独占的 module-owned 面
  • 只有在这些依赖完全清除后,server/internal/ent/** 才允许物理删除
  • 当前治理允许 fresh DB rebuild;不要求为了历史 mixed migration 继续维护长期兼容回放
  • 这不改变 graft migrate up 作为显式迁移入口的规则,只是明确当前 ownership checkpoint 可以基于新库重建来验证目标边界

8. 模块失败策略

建议把模块分两类:

  • 核心模块:失败时阻止应用启动
  • 可选模块:失败时记录错误并跳过

第一阶段默认这些属于核心模块:

  • user
  • rbac

这些在更广能力面上仍可保持可选,但在当前 MVP 闭环收敛阶段需要优先补齐实现:

  • audit
  • scheduler

9. 需要测试的关键场景

  • 重复注册同一模块
  • 缺失依赖模块
  • 循环依赖
  • 模块公开服务未注册
  • 模块请求服务但服务不存在
  • 同一服务被重复注册
  • Register -> Boot -> Shutdown 顺序错误
  • 模块关闭时资源未释放
  • event handler panic 是否被 recover
  • event handler error 是否被稳定记录
  • audit middleware 与 event 审计路径是否都可用
  • scheduler 是否能按显式生命周期启动、停止与移除任务

10. 多工作树与未来兼容边界

为支持长期多工作树并行开发,同时保留未来三方插件生态的可演进边界,当前设计补充以下约束:

  • module.Spec 是当前 compile-time 模块元数据基础
  • capability interface 是未来跨模块调用边界
  • internal/moduleapi/**internal/contract/** 是未来唯一稳定公开 API 面
  • 模块内部实现必须与稳定公开边界严格分离

当前不实现:

  • runtime plugin loading
  • external plugin marketplace
  • hot reload lifecycle
  • sandbox execution
  • distributed plugin protocol
  • microservice decomposition

运行时资源、capability visibility 与 composition declaration 的具体规则见 运行时组合与资源治理设计.mdmodule.Context 继续只是 Module lifecycle 的显式注入表面; 它不得演变为能够动态派生所有服务可见性、自动重启依赖或接管 Task/Agent 生命周期的全局 Context。

这样做的目的,是让未来若需要“类似 Jenkins 的三方插件生态”时,仍有稳定的元数据、契约与 capability 边界可复用; 但在当前阶段,仓库仍然只是 compile-time modular monolith。

11. 结论

这个项目需要 DI,但只需要“轻量、显式、可调试”的 DI。

判断标准很简单:

  • 如果一个新模块接入时,依赖注册与解析路径清晰,那容器设计就是对的
  • 如果实现者开始依赖反射魔法、扫描和隐式行为,说明容器已经做过头了
  • 如果为了赶 MVP 闭环而把 event bus、审计、调度直接泄漏为底层实现细节,说明边界已经被破坏