在仓颉语言里造一个没有反射的服务端框架
记录 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)合起来大约一万五千行仓颉代码。没有反射,没有代码生成工具,所有"声明即生效"的能力都靠编译期宏在合法仓颉代码里扩展——宏的输出是普通代码,不是字节码补丁,调试时可以完整追踪。
这大概是"语言限制强迫出更好设计"的一个具体案例。
更多推荐



所有评论(0)