记录 ACE Framework 四个核心机制的具体实现


仓颉(Cangjie)没有运行时反射 API。Spring 那套扫描 classpath、读注解、动态代理的路子在这里行不通。但这个限制逼出了一套更干净的设计——所有"魔法"都发生在编译期,生成的是普通仓颉代码,可以 --debug-macro 审计,可以被编译器完整优化。

这篇文章挑四个最核心的实现机制拆开讲:洋葱管线的正确实现、零反射依赖注入的工作原理、AOP 方法织入的展开方式、以及 ORM 里类型安全的行映射是怎么做的。


一、洋葱管线:一个不起眼的 Bug 和它的解法

ACE 的 HTTP 中间件模型和 Koa 一样——洋葱。请求从外到内穿过每一层中间件,next() 把控制权交给下一层,next() 返回后再执行当前层的后置逻辑。

实现起来就是一个递归分发函数:

public func compose(middlewares: Array<Middleware>): (Context) -> Unit {
    return {
        ctx: Context =>
        func dispatch(i: Int64): Unit {
            if (i >= middlewares.size) { return }
            let mw = middlewares[i]
            let called = Array<Bool>(1, repeat: false)   // ← 关键
            let next: Next = {
                =>
                if (called[0]) {
                    throw Exception("next() called multiple times in one middleware")
                }
                called[0] = true
                dispatch(i + 1)
            }
            mw(ctx, next)
        }
        dispatch(0)
    }
}

called 变量的写法很反直觉——为什么不写 var called = false,然后在闭包里 called = true

因为仓颉闭包捕获 var 并在闭包内重新赋值时行为不可靠。这是踩过的真实坑:用 var bool 写守卫,某些情况下赋值不生效,next() 可以被调用两次而不抛异常。

解法是把状态存进引用类型(Array<Bool>),在闭包里调用下标赋值方法而非重新绑定变量。闭包捕获的是对象引用本身,引用不变,通过引用修改内容是安全的。这个惯用法在整个项目里反复出现——凡是闭包需要累积或修改状态,一律换引用类型。

测试验证了洋葱行为:

@Test
func test_onion_order() {
    let trace = ArrayList<String>()
    let app = App()
    app.use({ _, next => trace.add("A("); next(); trace.add(")A") })
       .use({ _, next => trace.add("B("); next(); trace.add(")B") })
    app.handle(Context.of("GET", "/"))
    @Expect(joinTrace(trace), "A(B()B)A")
}

App 本身就是 compose 的薄壳:注册中间件 → 调 compose 组合 → 调 pipeline(ctx) 执行。异常统一在 App.handle 里兜底,不让任何中间件的未捕获异常打崩进程。


二、零反射依赖注入:顶层 let 是钥匙

Spring 的 IoC 靠反射扫描类路径。仓颉没有反射,所以 ACE 用了完全不同的机制:编译期宏生成顶层 let,顶层 let 的初始化器在程序启动期(main 执行前)自动运行

用户写:

@Service
public class TaskService {
    @Inject var repo: TaskRepository
}

serviceReg() 这个宏辅助函数展开成:

// 原始类声明原样保留
public class TaskService {
    // @Log 宏注入的 Logger prop 也在这里...
    var repo: TaskRepository
    public init(repo: TaskRepository) { this.repo = repo }
}

// 宏在类旁生成的顶层 let:
let __ace_reg_TaskService = registerBean(
    "TaskService",
    Scope.Singleton,
    {=>
        let __b = TaskService(
            (resolveBean("TaskRepository") as TaskRepository).getOrThrow()
        )
        __b
    }
)

registerBean 只是把工厂闭包存进容器的 HashMap,不立即执行。真正的构造发生在第一次 resolveBean("TaskService") 时——Singleton 作用域下构造一次后缓存,后续复用同一实例。

关键在于不需要任何扫描。只需在 main.cjimport task_api.service.*,包初始化就执行了所有 __ace_reg_* 变量的初始化器,所有 Bean 的工厂闭包就都进了容器。导入即注册,无需 @ComponentScan,无需 XML,也不需要枚举类名。

容器的 resolve 里有循环依赖检测——用一个 ArrayList<String> 作为解析栈,发现同名 Bean 已在栈中就立即报错并打印依赖路径,而不是静默死锁:

// container.cj,resolve() 内
for (i in 0..resolvingStack.size) {
    if (resolvingStack[i] == name) {
        var path = ""
        for (j in 0..resolvingStack.size) { path = path + resolvingStack[j] + " → " }
        throw Exception("ACE IoC: 循环依赖 ${path}${name}")
    }
}
resolvingStack.add(name)
let rawInst = factories.get(name).getOrThrow()()
// 移除栈顶——仓颉 ArrayList.remove 只接受 Range<Int64>,不能按索引删
resolvingStack.remove((resolvingStack.size - 1)..resolvingStack.size)

接口注入(@Inject var repo: UserRepository 其中 UserRepository 是接口)走另一条路径 resolveByInterface:容器维护一个 接口名 → [实现类名] 的候选列表,单一候选直接用,多候选看 @Primary,还是歧义就抛错并列出候选让开发者决定。


三、AOP 方法织入:{=> <原体> }() 的妙用

@Cacheable@Retry@Timed@Transactional 这些 AOP 注解都共享同一套织入模式。核心问题是:如何在保留原方法签名、兼容方法体内任意 return 语句的前提下,在方法执行前后插入逻辑?

关键技巧是把原方法体包进一个立即调用的闭包

// wrapMethodBody 生成的结构:
public func someMethod(param: String): Result {
    // before advice
    let __ret = {=> <原方法体> }()   // ← 原体的所有 return 都变成闭包返回值
    // after advice
    __ret
}

{=> <原方法体>}() 里的任何 return 都只是从这个匿名闭包返回,不会提前退出外层方法。这样 after advice 永远能执行到。这个技巧来自仓颉官方的 Memoize 示例。

@Cacheable[5000](5 秒 TTL)为例,展开结果大致是:

// 宏生成的缓存字段(挂在 Bean 实例上,随 Singleton 存活):
var _ace_cache_findUser = TtlCache<User>()

// 原方法被替换为:
public func findUser(id: Int64): User {
    let __key = id.toString()
    match (_ace_cache_findUser.get(__key)) {
        case Some(v) => return v
        case None => ()
    }
    let __r = {=>
        // 原方法体在这里
        repo.findOne(id).getOrThrow()
    }()
    _ace_cache_findUser.put(__key, __r, 5000)
    return __r
}

TtlCache 内部用 Mutex 串行化所有操作(含读路径的惰性过期移除),并有 maxEntries 容量上限,防止参数记忆化导致无界增长。缓存键由参数的 toString() 拼接而成,多参数之间用 | 分隔。

@Retry[3] 展开后是一个有界循环,最后一次失败重抛:

public func callRemote(url: String): Response {
    var __ace_left = 3
    while (__ace_left > 1) {
        __ace_left -= 1
        try { return {=> <原方法体> }() } catch (_: Exception) { () }
    }
    return {=> <原方法体> }()  // 第 3 次,不 catch,让异常透出
}

所有这些展开代码用 cjpm build --debug-macro 可以直接审计,没有任何运行时"黑盒"。


四、零反射 ORM:接口契约 + 宏生成映射器

ORM 的核心问题是:如何在没有反射的情况下,把一行数据库结果映射到一个类型安全的实体对象?

ACE 的答案是 EntityMapper<T> 接口——它把"实体与表之间的全部知识"封装成一组方法,由 @Entity 宏在编译期为每个实体生成具体实现:

public interface EntityMapper<T> {
    func table(): String
    func idColumn(): String
    func columns(): Array<ColumnSpec>      // 全部列的元数据(名、类型、是否主键、外键)
    func fromRow(r: Row): T               // 结果行 → 实体对象
    func insertParams(e: T): Array<DbValue>  // 实体 → 非主键列的值
    func idOf(e: T): DbValue
    func setId(e: T, id: Int64): Unit     // 自增 id 回填
}

开发者声明:

@Entity["t_items"]
public class Item {
    @Id[]
    public var id: Int64 = 0
    @Column["item_name"]
    public var name: String = ""
    @ManyToOne["t_users"]
    public var ownerId: Int64 = 0
}

@Entity 宏在编译期生成 ItemMapper <: EntityMapper<Item>fromRow 的展开大致是这样:

func fromRow(r: Row): Item {
    let e = Item()
    e.id = r.getInt64("id")
    e.name = r.getString("item_name")  // 用列别名
    e.ownerId = r.getInt64("ownerId")
    e
}

这是纯静态赋值代码,每一列的名字和类型在编译期固定,编译器可以完整优化,没有任何运行时字段查找或类型断言。

泛型仓储 Repository<T> 只持有 DataSourceEntityMapper<T>,不知道具体实体类型的任何细节,所有"知识"通过映射器接口注入:

public open class Repository<T> {
    let ds: DataSource
    let m: EntityMapper<T>

    public func findOne(id: Int64): ?T {
        let sql = "SELECT * FROM ${m.table()} WHERE ${m.idColumn()} = ${dia().placeholder(1)}"
        let rows = ds.query(sql, [DbInt64(id)])
        if (rows.isEmpty()) { return None }
        Some(m.fromRow(rows[0]))
    }
}

SQL 占位符语法(SQLite 用 ?,PostgreSQL 用 $1)委托给 Dialect 抽象;RETURNING id / LAST_INSERT_ID() / SERIAL / AUTO_INCREMENT 也全部委托。同一套 Repository<T> 代码跑在三种数据库上,差异封在方言层里。

软删除视图 withDeleted() / 禁级联视图 noCascade() 返回新实例而非修改原对象,与整个框架不可变对象的风格保持一致:

public func withDeleted(): Repository<T> {
    let r = Repository<T>(ds, m)
    r.includeSoftDeleted = true
    r.cascadeEnabled = cascadeEnabled  // 保留其他视图状态
    return r
}

五、一个被枚举解决的三字段问题

响应体最初是三个平行字段:body: String(文本)、bodyBytes: Array<UInt8>(二进制)、streamBody: 闭包(流式)。三者互斥,但同时存在于 Context 里——如果中间件先设了 body,后面又设了 bodyBytes,适配层得靠优先级规则来裁决。

把三者合并成一个枚举消除了歧义:

public enum ResponseBody {
    | NoBody
    | TextBody(String)
    | BytesBody(Array<UInt8>)
    | StreamBody(((Array<UInt8>) -> Unit) -> Unit)
}

StreamBody 的类型签名解读:它持有一个 producerproducer 接收一个"字节写出闭包",自行多次调用该闭包推送数据块。核心层(ace-web)不知道"chunked 传输"是什么,它只定义了接口形状——适配层(ace-http)拿到 producer,把 stdx 的 HttpResponseWriter.write 包装成写出闭包传进去。

适配层回写响应时做一次 match 就够了:

match (ctx.responseBody()) {
    case NoBody => ()
    case TextBody(s) => resp.body = s.toArray()
    case BytesBody(b) => resp.body = b
    case StreamBody(producer) =>
        resp.setChunkedTransfer(true)
        producer({ chunk => resp.write(chunk) })
}

SSE(Server-Sent Events)在这之上多一层帧编码:sse() 函数设好 Content-Type: text/event-stream 响应头,然后把 SseSink(负责把字符串编码成 SSE 帧格式)挂进 StreamBody。控制器方法可以直接返回 SseStream 类型,@Get 宏识别到返回类型后自动调用 writeTo(ctx),而不是把它序列化成 JSON——返回类型即协议,不需要额外注解。


几个仓颉特有的坑

项目里积累下来有几个反复踩的仓颉语言陷阱:

std.regex 只认 POSIX 语法。路由里的内联正则 :id([0-9]+) 要用 [0-9] 而不是 \d,后者在仓颉 regex 里是无效模式,不会报错,但永远不匹配。path-to-regexp 移植时把所有 \d 替换成了 [0-9]

闭包捕获 var 并重新赋值不可靠(前文已述)。凡是闭包内需要修改状态,一律换引用类型调方法,不重新绑定变量。

ArrayList.remove 只接受 Range<Int64>,没有按索引删单个元素的重载。移除栈顶写成 list.remove((list.size - 1)..list.size) 而不是 list.remove(list.size - 1)

字节比较要带 u8 后缀b == 47 是整型比较(可能编译报错),b == 47u8 才是字节比较。String.toArray() 得到 Array<UInt8>,路由的路径归一化、百分号解码等字节操作里大量出现这个后缀。


五个模块(ace-webace-routerace-bodyparserace-framework-macrosace-orm)合起来大约一万五千行仓颉代码。没有反射,没有代码生成工具,所有"声明即生效"的能力都靠编译期宏在合法仓颉代码里扩展——宏的输出是普通代码,不是字节码补丁,调试时可以完整追踪。

这大概是"语言限制强迫出更好设计"的一个具体案例。

Logo

一站式 AI 云服务平台

更多推荐