本文基于 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:接口到实现列表;
  • primaryOfqualifierOf:多实现消歧信息;
  • 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 友好语言,这种设计比运行时扫描更直接,也更容易定位失败原因。

Logo

一站式 AI 云服务平台

更多推荐