记录 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.cj里import 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>只持有DataSource和EntityMapper<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的类型签名解读:它持有一个producer,producer接收一个"字节写出闭包",自行多次调用该闭包推送数据块。核心层(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-web、ace-router、ace-bodyparser、ace-framework-macros、ace-orm)合起来大约一万五千行仓颉代码。没有反射,没有代码生成工具,所有"声明即生效"的能力都靠编译期宏在合法仓颉代码里扩展——宏的输出是普通代码,不是字节码补丁,调试时可以完整追踪。
这大概是"语言限制强迫出更好设计"的一个具体案例。