框架语义规则¶
本文定义 Cullinan 保证什么、哪些行为只是兼容保留,以及哪些场景现在会触发 warning 或启动期失败。目标是把 Cullinan 的运行时模型讲清楚:先写装饰器式业务代码,通过导入执行完成发现,在需要时再引入明确的运行时边界。
推荐的语义化包路径¶
Cullinan 当前推荐的主路径是:
cullinan—— 应用启动入口(@application、configure)cullinan.application——Application、module等高级应用语义cullinan.web—— 控制器、路由装饰器、请求/响应、参数与中间件cullinan.core—— IoC/DI、生命周期与语义诊断
像 cullinan.runtime、cullinan.transport、cullinan.support 这样的更底层层级仍然可用,但不应成为普通业务应用的默认入门路径。
这也意味着默认学习路径不是先学 Tornado 再学 Cullinan。Tornado 与 ASGI 是框架边界之后的执行后端;对应用代码真正构成主契约的,是 Cullinan 自身的 Web 与 application 语义。
1. 组件发现依赖“导入执行”,不是静态 AST 扫描,也不是显式 app 注册¶
Cullinan 通过导入 Python 模块并执行装饰器来发现组件。
- 受保证:模块顶层定义、且在模块导入时就执行到的
@service、@controller、@component、@provider - 不受保证:定义在函数、工厂、条件分支或其他局部作用域里的类;这些定义通常要等运行到对应代码块时装饰器才会执行
from cullinan.core import component
@component
class TopLevelCache:
pass
def build_repository():
@component
class LocalRepository:
pass
return LocalRepository
TopLevelCache 属于受支持的自动发现路径;LocalRepository 不属于自动顶层扫描契约。Cullinan 现在会对这类写法发 warning,如果它发生在 refresh() 之后,则会直接失败。如果某段能力需要更强的归属、reload 或热插拔运行时保证,应通过 @module 表达边界,而不是退回到手工 app 注册思路。
2. Inject() 是严格类型契约¶
只有当 Cullinan 能把注解归一化为稳定且唯一的依赖契约时,Inject() 才会成功。
当前支持的典型形式包括:
T"T"Optional[T]Annotated[T, ...]Final[T]Provider[T]list[T]、set[T]、tuple[T, ...]Union[A, B]/A | B(前提是最终只命中一个候选)
Cullinan 已不再回退到属性名猜测。如果注解缺失、不受支持或存在歧义,启动会直接以类型化诊断失败。
3. InjectByName() 的语义是“显式名称绑定”¶
InjectByName() 按组件注册名解析,而不是按类型解析。
推荐写法:
from cullinan.core import InjectByName
class ReportController:
report_service: "ReportService" = InjectByName("ReportService")
兼容写法:
class ReportController:
report_service = InjectByName()
兼容写法目前仍会回退到属性名,但 Cullinan 现在会给出 warning,因为这种绑定在重构时更容易悄悄失效。即使使用按名注入,也建议保留真实类型注解,便于表达语义与静态检查。
4. refresh() 之后注册会被冻结¶
ApplicationContext.refresh() 是结构边界:
- 会消费并注册 pending 的装饰器组件
- 会完成定义校验与预热
- 会冻结相关注册表
从这一刻开始,再去新增装饰器组件就不再是受支持的运行时变更路径。Cullinan 现在会给出明确的语义错误,并附上修复建议。
PendingRegistry.clear() 一致性:自 v0.93a10 起,对已冻结的注册表调用 clear() 同样会抛出 RuntimeError——与 add() 行为保持一致。在测试清理中使用 PendingRegistry.reset() 可完整重置注册表(包括冻结状态)。
5. 作用域规则是强约束,不是"尽量工作"¶
Cullinan 把作用域兼容性视为硬规则。尤其是 singleton 组件不能直接依赖 request 作用域组件。现在这类情况会给出结构化生命周期诊断,而不是等到更晚阶段以不稳定方式出错。
传递强制检查:作用域检查现在会递归遍历完整依赖链——包括显式 dependencies=[...] 声明和字段注入标记(Inject()、InjectByName())。如果单例依赖另一个单例,而后者又传递依赖了请求作用域对象,refresh() 时会检测并拒绝,错误信息中会标明完整链路。
6. 兼容 API 只是兼容,不是推荐入口¶
像 @injectable、@inject_constructor、get_injection_registry() 这样的旧接口仍然保留,目的是让历史代码还能导入,但它们不再代表推荐编程模型。现在使用这些 API 时,Cullinan 会发出 warning。
7. 如何理解新的 warning 和报错¶
Cullinan 现在把关键诊断统一表达为:
- 语义规则:框架当前强制执行的契约
- 当前问题:运行时实际观察到的现象
- 建议:最安全、最受支持的修复方式
当框架能够确定你违反了核心语义时,会直接失败;当代码虽然还能运行,但明显容易误导开发者时,则会发 warning。
8. 编译环境下的模块发现¶
当 Cullinan 运行在 Nuitka 或 PyInstaller 环境中时,标准的 pkgutil.walk_packages 可能无法发现全部用户模块——尤其是在 --onefile 模式下,文件系统布局与开发环境不同。
explicit_modules 配置:可通过 configure() 提供显式模块列表:
from cullinan import configure
configure(explicit_modules=[
"myapp",
"myapp.services",
"myapp.web",
])
该列表作为统一扫描管道中的最高优先级策略(S0),在回退到 user_packages(S1)等启发式方法之前使用。每个条目会被递归遍历以发现子包。
深层子包发现:list_submodules() 现在会在 pkgutil.walk_packages 基础上增加基于文件系统的递归扫描回退。如果深层嵌套包(如 club.fnep.infrastructure.discord)被 walk_packages 遗漏,文件系统回退会通过直接遍历 __init__.py 目录和 .py 文件来发现它们。