aceboot 的零反射 IoC:从注解到可审计的依赖装配
本文基于 aceboot 当前实现,解释
@Service、@Inject、作用域、生命周期和条件装配如何协同工作。示例以仓颉代码表示。
1. 为什么 aceboot 不做运行时扫描
aceboot 面向仓颉,而仓颉没有 Java 式运行时反射 API。框架不能在启动后扫描 classpath、读取类注解,再动态调用构造器。因此 aceboot 选择了另一条路径:宏在编译期读取声明,生成普通仓颉代码;运行时只执行显式注册和 Map 查找。
这带来三个直接结果:
- 不需要组件扫描、XML 或运行时反射;
cjpm build --debug-macro可以审计生成代码;- 依赖关系是静态可见的,适合 AOT 和编译器优化。
2. 从 @Service 到 Bean 工厂
业务代码只写声明:
@Service
public class TaskService {
@Inject
var repo: TaskRepository
public func find(id: Int64): Task {
repo.find(id)
}
}
宏会保留类声明,并生成一个顶层初始化表达式。其核心形态等价于:
let __ace_reg_TaskService = registerBean(
"TaskService",
Scope.Singleton,
{=>
let bean = TaskService(
(resolveBean("TaskRepository") as TaskRepository).getOrThrow()
)
bean
}
)
这里的工厂闭包只登记,不立即构造。第一次解析 TaskService 时,容器才调用工厂;单例结果随后放入 singletons。原型作用域则每次调用工厂:
@Service // Singleton:首次 resolve 构造一次
public class ConfigService {}
@Prototype // Prototype:每次 resolve 创建新对象
public class RequestScratch {}
3. “导入即注册”如何成立
生成的注册表达式位于 Bean 所在包。应用在入口导入该包时,包初始化会执行这些顶层初始化器,于是工厂进入容器:
import task_api.service.*
import task_api.controller.*
main() {
AceApplication.run("0.0.0.0", 8080)
}
所以 ACE 不需要 @ComponentScan,也不需要手工枚举 Bean。代价是必须保证应用确实导入包含声明的包;没有导入,就没有注册。
4. 容器的解析算法
Container 维护以下核心状态:
factories:Bean 名称到工厂闭包;scopeOf:Bean 作用域;singletons:已构造的单例;interfaceCandidates:接口到实现列表;primaryOf、qualifierOf:多实现消歧信息;resolvingStack:当前解析路径,用于检测循环依赖。
单例解析可概括为:
resolve(name)
├─ 检查 Bean 是否存在、条件是否满足
├─ Singleton 且已有缓存 → 返回缓存
├─ name 已在 resolvingStack → 抛出循环依赖异常
├─ 入栈,调用工厂(工厂继续 resolve 依赖)
├─ 执行 resolve hook,写入单例缓存
└─ 出栈并返回实例
循环依赖不会变成死循环。比如 A -> B -> A 会输出包含依赖路径的错误,定位成本远低于线程阻塞或栈溢出。
5. 接口注入与多实现
当字段类型是接口时,容器按候选列表解析:唯一候选直接使用;多候选优先使用 @Primary;仍有歧义则要求注入点使用 @Qualifier。
public interface PaymentGateway {
func pay(): String
}
@Service @Primary
public class AlipayGateway <: PaymentGateway {
public func pay(): String { "alipay" }
}
@Service @Qualifier["wechat"]
public class WechatGateway <: PaymentGateway {
public func pay(): String { "wechat" }
}
@Service
public class OrderService {
@Inject public var gateway: PaymentGateway // Alipay
@Inject @Qualifier["wechat"] public var backup: PaymentGateway
}
解析顺序是:限定名 → 具体类型名 → 接口候选。候选还会经过 @Profile 和 @Conditional 过滤,因此环境未启用的实现不会参与歧义判断。
6. 生命周期与作用域边界
@Service
public class ConnectionPoolHolder {
@PostConstruct
public func open(): Unit { /* 构造后执行 */ }
@PreDestroy
public func close(): Unit { /* 优雅停机执行 */ }
}
宏把 @PostConstruct 调用放进工厂,Bean 构造完成后立即执行;@PreDestroy 则登记销毁钩子。应用关闭时,已实例化的单例按注册逆序执行钩子,单个钩子失败不会阻断其余资源释放。
需要特别区分请求态对象:单例 Service 中不能直接注入当前请求 Context,因为单例只构造一次。请求数据应通过请求上下文持有者读取,或使用 Request 作用域。
7. 配置驱动的条件装配
@Service @Profile["dev"]
public class MockSmsService <: SmsService {}
@Service @Conditional["redis.url"]
public class RedisCache <: Cache {}
配置加载完成后,容器在解析期判断条件:profile 不匹配,或配置键不存在、值为 false/0/空字符串时,Bean 不参与解析。这样“哪个实现存在”由配置决定,但业务代码仍依赖接口。
8. IoC 与路由、AOP的连接
IoC 并不是独立功能。控制器也是 Bean,路由宏生成的处理器会从容器解析控制器,再绑定参数并调用方法;@Timed、@Cacheable 等宏则直接改写方法体或注册运行时钩子。最终执行链仍是显式仓颉代码:
导入包
→ 顶层 let 登记 Bean、路由、组件
→ AceApplication 加载配置
→ 组件 setup、构建中间件
→ 首次请求解析控制器及其依赖
→ 宏生成的参数绑定、方法调用、响应序列化
9. 如何验证和审计
构建时打开宏调试输出:
source scripts/env.sh
cjpm build --debug-macro
cjpm test
重点审计三类结果:工厂是否按预期解析依赖;单例是否只缓存一次;控制器和生命周期钩子是否生成了明确调用。aceboot 的 IoC “魔法”最终都应能还原为这些普通操作。
结语
aceboot 的 IoC 不是把反射换成另一种黑盒,而是把注册、构造、缓存和销毁全部前移为可检查的代码。宏负责消除样板,容器负责作用域和状态,业务代码仍然依赖接口。对仓颉这样的静态、AOT 友好语言,这种设计比运行时扫描更直接,也更容易定位失败原因。
更多推荐




所有评论(0)