Files
serialization-cj/docs/父类字段序列化方案.md
T
xrain fe1daec4f9 docs: @JsonExportFields 支持自动补 open(已实测)——非 open 类标注时宏自动加 open 修饰符
- 实测验证:宏展开的 open 在语义检查前生效,子类可正常继承
- 提供两种模式:默认自动补 open(推荐);requireOpen 严格模式非 open 报错
- 非 class 声明标注 → ASTException 编译期报错(已实测,错误定位到标注处)
- 测试计划更新为对应三条用例
2026-08-17 13:13:59 +08:00

24 KiB
Raw Blame History

方案:父类字段序列化支持(宏 + 静态导出方法)

状态:待评审 目标仓库:simapi-serialization 关联问题:Cangjie 反射无法通过父类字段句柄读写子类实例的值,导致继承的父类字段无法序列化/反序列化。


1. 问题与调研结论

1.1 现象

open class BaseUser {
    public var _id: String = ""
    public var _createdAt: String = ""
}

class User <: BaseUser {
    public var _name: String = ""
}

let u = User()
JsonSerializer.Serialize(u)
// 当前输出只含 User 自身字段:{ "_name": "alice" }
// 期望输出包含父类字段:{ "_id": "...", "_createdAt": "...", "_name": "..." }

1.2 根因(已实测确认,cjc 1.1.3)

Cangjie 标准库 std.reflect 的读写句柄全部带声明类严格校验variable_info_cjnative.cj 硬编码):

// InstanceVariableInfo.getValue / setValue
func checkDeclaringClassType(instance: Any): Unit {
    let declaringClassTypeInfo = getFieldDeclaringClassType(_declaringTypeInfo)
    let instanceTypeInfo = TypeInfo.of(instance)
    if (declaringClassTypeInfo != instanceTypeInfo) {
        throw IllegalTypeException(
            "The input instance should be \"${declaringClassTypeInfo}\", but now it`s \"${instanceTypeInfo}\"")
    }
}

declaringClassTypeInfo != TypeInfo.of(instance)严格相等,不是子类型判断。实测三类句柄全部被堵死:

句柄 在子类实例上读写父类成员 实测结果
InstanceVariableInfovar 字段) baseFields.getValue(child) IllegalTypeException
InstancePropertyInfoprop baseProps.getValue(child) IllegalTypeException
InstanceFunctionInfo(方法) baseFunc.apply(child, args) IllegalTypeException

1.3 两个被否掉的路线(均已实测)

路线 A:宏把父类字段「复制」到子类声明里

不可行,Cangjie 编译器禁止字段遮蔽

error: the variable '_id' must not shadow a member variable of the supertype

即使宏能在子类里生成同名同类型字段,编译器直接拒绝编译。

路线 B:宏在编译期查询父类的字段列表,自动生成代码

不可行,Cangjie 宏是纯语法层操作:

  • 宏的输入只有被标注声明自身的 Tokens(如 class User <: BaseUser {...} 这段代码);
  • 宏没有编译期类型查询 API(无类似 Rust proc_macro 的 TypeInfo 上下文);
  • std.astPosition 只含 fileID/line/column,无源码读取 API
  • 因此宏看不到父类声明里的字段(父类可能在别的文件/包)。

1.4 唯一的可行通路(已实测验证)

静态方法反射调用没有「实例声明类检查」,且子类实例可直接作为父类类型参数传入

// 父类上定义一个静态方法,参数类型是父类
public static func exportJsonFields(instance: BaseUser): HashMap<String, Any> {
    var m = HashMap<String, Any>()
    m["_id"] = instance._id      // 静态类型访问,编译期就知道字段存在
    m
}

// 反射调用(核心验证通过):
let f = TypeInfo.of<BaseUser>().getStaticFunction("exportJsonFields", [TypeInfo.of<BaseUser>()])
let args: Array<Any> = [child]   // Child 实例作为 BaseUser 参数 ✓
let result = f.apply(TypeInfo.of<BaseUser>(), args)   // ✓ 调用成功

原理:exportJsonFields 内部用静态类型 BaseUser 访问 _id,走的是普通字段访问(非反射),完全合法;外部通过静态方法反射调用,checkStaticDeclaringType 只校验声明的类(用 thisType 参数),不校验实例的运行时类型。

1.5 参考实现:soulsoft_serialization 是怎么做的(已读源码)

soulsoft 走的路线与本方案不同——它完全不用反射读字段,而是宏给每个类生成序列化方法,运行时是普通方法调用链:

@Serialization[superType: BaseUser]   // 子类显式告诉宏:我的父类是 BaseUser
public class User <: BaseUser { ... }

宏在编译期(AST 层)为类生成 4 个方法 + 实现 ISerialization<T> 接口:

方法 可见性 作用
serializeObject(options): DataModel public 序列化入口,构造 DataModelStruct 后调追加方法
serializeObject(dms, options): Unit protected 追加本类字段(dms.add(Field("_id", this._id.serializeObject(options)))编译期静态访问
deserializeObject(dm, options): T public static 反序列化入口,构造实例后调追加方法
deserializeObject(dms, data, options): Unit protected static 回填本类字段(data._id = String.deserializeObject(...)编译期静态赋值

继承串联靠编译期方法调用,不是反射

// 子类生成的方法,方法体第一行:
super.serializeObject(dms, options)              // 序列化:父类字段由父类方法追加
BaseUser.deserializeObject(dms, data, options)   // 反序列化:先回填父类字段

关键点:

  • 父类字段访问是宏生成的 this._id / data._id = ...静态类型访问,天然绕开反射的声明类检查(与本方案 §2.2 同理);
  • 运行时 data.serializeObject(options) 是接口虚方法调用、T.deserializeObject(...) 是泛型静态调用,全部编译期绑定,零反射、零类型转换
  • 宏怎么知道父类是谁?用户显式传 superType 参数——印证了「宏无法查询类型信息」的限制,soulsoft 用「让用户声明」解决;
  • 代价:每个类都要标宏,且 API 带泛型约束 where T <: ISerializable——不标宏的类编译期就拒绝,「任意类免标注序列化」能力不存在。

与本方案的关系:soulsoft 的「宏生成静态类型字段代码」思路与本方案一致,可以借鉴;但它的「每个类标宏 + 泛型约束」形态会破坏 simapi_serialization「任意类免标注、Serialize(obj)/Deserialize<T>(json)」的 API 契约。因此本方案只借其「宏生成字段代码」的技巧,保留自己的反射入口(详见 §8 路线对比)。


2. 方案总览

本方案 = 路线 A(推荐):保留 simapi_serialization 现有的反射入口 API, 仅对「想暴露父类字段」的父类用宏生成静态导出/导入方法,库沿继承链调用。 路线 B(soulsoft 全宏风格)见 §8 对比,作为备选。

┌─────────────────────────────────────────────────────────┐
│ 宏 @JsonExportFields(标注在父类上)                      │
│   → 编译期解析父类声明的 var 字段                         │
│   → 自动生成两个静态方法(静态类型访问,无反射检查):      │
│       exportJsonFields(instance: Parent)                │
│       importJsonFields(instance: Parent, jsonObj)       │
└─────────────────────────────────────────────────────────┘
                          ↓ 运行时
┌─────────────────────────────────────────────────────────┐
│ 序列化库 JsonWriter / JsonReader                         │
│   → 遍历 superClass 链(Child → Parent → ... → Object)  │
│   → 每个父类若存在约定静态方法则调用并合并字段             │
│   → 无宏的类行为不变(向后兼容)                          │
└─────────────────────────────────────────────────────────┘

2.1 为什么宏标注在「父类」而不是「子类」

宏只能看到自己标注的那份声明的 Tokens。父类自己知道自己有哪些字段,所以宏标注在父类上,才能解析出字段清单并生成代码。子类不需要任何改动,运行时靠继承链自动找到父类的导出方法。

2.2 为什么能绕过反射限制

  • 导出方法内部:instance._id 是静态类型字段访问(编译期解析),不是反射,无任何运行时检查;
  • 导出方法外部:通过静态方法反射调用,静态方法没有「实例声明类」校验,只校验声明类(用 thisType 参数),子类实例天然是父类类型的合法值。

2.3 为什么不用 soulsoft 的「每个类标宏」形态

soulsoft 的 API 带泛型约束 where T <: ISerializable,不标宏的类在编译期就被拒绝——这要求所有序列化类都标注宏,与 simapi_serialization「任意类免标注、Serialize(obj)/Deserialize<T>(json)」的契约冲突。本方案把「宏生成字段代码」与「反射入口」分层:宏只在父类上补「父类字段」这一块,其余照旧反射,二者互不干扰。


3. 宏设计

3.1 宏定义

// 文件:src/json/JsonExportFieldsMacro.cj
// 包:simapi_serialization.json(宏包需单独编译,见 3.3

macro package simapi_serialization.json

import std.ast.*
import std.collection.*

/// 标注在父类上:自动生成父类字段的 JSON 导出/导入静态方法
public macro JsonExportFields(input: Tokens): Tokens {
    // 1. 解析输入的 ClassDecl(非类声明 → 抛 MacroException
    // 2. 校验 open:非 open class → 抛 MacroException(见 3.1.1
    // 3. 收集 public var 字段(跳过 @JsonIgnore
    // 4. 生成 exportJsonFields / importJsonFields 两个静态方法
    // 5. 拼回原类声明 + 新方法,返回 Tokens
}

3.1.1 open 处理(两种模式,默认自动补 open)

@JsonExportFields 的语义是「暴露父类字段给子类继承链」——标注的类必须可被继承。Cangjie 类默认不可继承(非 open 类被继承会编译报错 super class is not inheritable),所以宏展开时处理 open

/// 遍历类声明的修饰符,检查是否含 open(参考 soulsoft Extensions.cj isOpen
func isOpenClass(decl: ClassDecl): Bool {
    for (m in decl.modifiers) {
        if (m.keyword.kind == TokenKind.OPEN) {
            return true
        }
    }
    false
}

模式一(推荐):自动补 open

// 宏展开入口内:
if (!isOpenClass(decl)) {
    decl.modifiers.add(Modifier(Token(TokenKind.OPEN)))   // 自动加 open
}
  • 已实测可行:宏给 class Base 自动加 open 后,子类 class Child <: Base 编译通过、正常运行(macro_open_probe 验证);
  • 宏展开的 open 在语义检查之前生效,所以后续继承检查能看到;
  • 用户少写一个关键字,标注 @JsonExportFields 本身就表达了「我要被继承」的意图。

模式二:严格校验(不自动补,非 open 报错)

if (!isOpenClass(decl)) {
    throw ASTException("@JsonExportFields 只能标注在 open class 上,"
        + "因为非 open 类不能被继承,不存在子类场景。")
}
  • ASTException(宏内实际可用异常类,soulsoft 同款)→ 编译期直接报错,错误定位到标注处(main.cj:5:1 形式);
  • 顺带校验:标注在 struct / interface / enum / func 等非 class 声明上也抛错(parseDecl 结果 as ClassDecl 失败即报,已实测)。

取舍:自动补 open 更省事但「隐式改变类语义」;严格校验更显式但要求用户记得写 open。默认建议模式一(自动补),可在宏属性中提供开关,如 @JsonExportFields[requireOpen: true] 切到模式二。

  • 参考先例:soulsoft Extensions.cj:75-82 遍历 modifiersTokenKind.OPENSerializationMacro.cj:27throw ASTException(...) 做宏内校验;cd.modifiers.add(Modifier(Token(TokenKind.OPEN))) 与 soulsoft funcDecl.modifiers.add(Modifier(Token(TokenKind.PUBLIC))) 同款操作。

3.2 生成的目标代码

对如下父类:

@JsonExportFields
open class BaseUser {
    public var _id: String = ""
    public var _createdAt: String = ""
    @JsonIgnore
    public var _temp: String = ""
}

宏展开后等价于:

open class BaseUser {
    public var _id: String = ""
    public var _createdAt: String = ""
    @JsonIgnore
    public var _temp: String = ""

    /// 导出:字段名 → 值(静态类型访问)
    public static func exportJsonFields(instance: BaseUser): HashMap<String, Any> {
        var m = HashMap<String, Any>()
        m["_id"] = instance._id
        m["_createdAt"] = instance._createdAt
        // _temp 被 @JsonIgnore 跳过
        m
    }

    /// 导入:按 JSON 键回填字段(静态类型赋值)
    public static func importJsonFields(instance: BaseUser, json: HashMap<String, Any>): Unit {
        for ((k, v) in json) {
            match (k) {
                case "_id" =>
                    if (let s: String <- v) { instance._id = s }
                case "_createdAt" =>
                    if (let s: String <- v) { instance._createdAt = s }
                case _ => ()   // 未知键忽略
            }
        }
    }
}

3.3 宏包的编译方式

Cangjie 宏需要两步编译(cjc --compile-macro 先编宏,再编使用宏的代码)。cjpm 对宏的支持:

  • 方案一:宏定义放在独立包(如 simapi_serialization.macros),cjpm 中通过依赖引入,宏包内 cjpm.toml 声明为宏包;
  • 方案二:宏定义与库同包,使用 --compile-macro 先行编译——需要确认 cjpm 是否自动处理。

⚠️ 待验证项:cjpm 对同包宏的编译顺序支持(见 §7 风险)。

3.4 宏需要处理的细节

细节 处理
字段类型 TypeInfo.get(字段类型名) 在运行时做类型转换(import 侧)
@JsonPropertyName export 键用注解名;import 匹配也用注解名
@JsonIgnore 直接跳过,不生成导出/导入语句
let(不可变)字段 只导出,不导入(导入侧跳过)
无字段 生成空方法(返回空 map / 空操作),保证约定方法一定存在
命名冲突 若用户已手写同名静态方法,宏应报错或跳过(设计取舍:报错更安全)
泛型父类 v1 不支持,文档注明(反射对泛型类型参数处理复杂)
多层继承 每个父类各自标注宏,序列化库逐层调用

4. 序列化库改动

4.1 JsonWriter(序列化侧)

writeObject 中,反射写完子类自身字段后,沿 superClass 链向上逐个检查约定方法:

private static func writeObject(value: Any, ct: ClassTypeInfo, options: JsonOption, depth: Int64): JsonValue {
    let obj = JsonObject()
    // 1) 子类自身字段(现状不变)
    let fields = ReflectionCache.getFields(ct, options)
    for (f in fields) {
        if (f.ignore) { continue }
        let raw = f.variable.getOrThrow().getValue(value)
        obj.put(f.jsonName, writeValue(raw, options, depth + 1))
    }
    // 2) 父类字段(新增):沿继承链调用约定静态方法
    var parent = ct.superClass
    while (let Some(p) <- parent) {
        let parentFields = try {
            let f = p.getStaticFunction("exportJsonFields", [p])
            let args: Array<Any> = [value]      // 子类实例作父类参数
            (f.apply(p, args) as HashMap<String, Any>) ?? HashMap<String, Any>()
        } catch (_: Exception) {
            HashMap<String, Any>()              // 无宏的父类 → 空,继续上层
        }
        for ((k, v) in parentFields) {
            obj.put(k, writeValue(v, options, depth + 1))
        }
        parent = p.superClass
    }
    obj
}

要点:

  • 静态方法返回的是已序列化的字段值Any),直接 writeValue 二次处理即可(嵌套对象/集合/枚举都会正确递归);
  • 异常即「无此方法」,静默继续向上,无宏的类完全不受影响
  • 需加缓存:父类→是否含约定方法,避免每次反射查找(性能)。

4.2 JsonReader(反序列化侧)

readObject 中,构造实例并写完子类字段后,同样沿 superClass 链调用约定方法回填:

private static func readObject(value: JsonValue, ct: ClassTypeInfo, options: JsonOption, depth: Int64): Any {
    let instance = ct.construct([])
    // 1) 子类自身字段(现状不变)
    let fields = ReflectionCache.getFields(ct, options)
    let jobj = value.asObject()
    for (f in fields) {
        if (f.ignore || !f.mutable) { continue }
        match (jobj.get(f.jsonName)) {
            case Some(jv) =>
                if (jv is JsonNull) { continue }
                let fieldVal = readValue(jv, f.typeInfo.getOrThrow(), options, depth + 1)
                f.variable.getOrThrow().setValue(instance, fieldVal)
            case None => ()
        }
    }
    // 2) 父类字段(新增):收集 JSON 中父类能识别的键
    var parent = ct.superClass
    while (let Some(p) <- parent) {
        let importFn = try {
            p.getStaticFunction("importJsonFields", [p, TypeInfo.of<HashMap<String, Any>>()])
        } catch (_: Exception) {
            // 无宏的父类 → 跳过
            parent = p.superClass
            continue
        }
        // 只把父类认识的键传给导入方法(避免把子类字段塞给父类)
        let parentKeys = exportKeysOf(p)   // 复用 exportJsonFields 的键名集合(缓存)
        var pjson = HashMap<String, Any>()
        for (k in parentKeys) {
            match (jobj.get(k)) {
                case Some(jv) => pjson[k] = jsonToAny(jv)
                case None => ()
            }
        }
        importFn.apply(p, [instance, pjson])
        parent = p.superClass
    }
    instance
}

要点:

  • 导入方法只回填它认识的键,未知键忽略(宏已生成 case _ => ());
  • 传给导入方法的 JSON 值用 jsonToAny 还原为通用 Any,由宏内部做 as 转换;
  • null 值:宏侧 if (let s: String <- v) 天然跳过 null,保持「None/默认值」语义。

4.3 缓存

新增 ReflectionCache 缓存:

// 类型 → 是否有 exportJsonFields/importJsonFields 约定方法(Bool
private static let _parentExportCache = HashMap<String, Bool>()
private static let _parentImportCache = HashMap<String, Bool>()

5. 使用示例

import simapi_serialization.*
import simapi_serialization.json.JsonExportFields   // 引入宏

// 父类标注宏
@JsonExportFields
open class BaseUser {
    public var _id: String = ""
    public var _createdAt: String = ""
}

// 子类零改动
class User <: BaseUser {
    public var _name: String = ""
    public var _age: Int64 = 0
}

main() {
    let u = User()
    u._id = "u-1"
    u._createdAt = "2025-01-01"
    u._name = "alice"
    u._age = 30

    let json = JsonSerializer.Serialize(u)
    // 输出(字段顺序:子类自身字段 + 父类字段):
    // { "_name": "alice", "_age": 30, "_id": "u-1", "_createdAt": "2025-01-01" }

    let u2 = JsonSerializer.Deserialize<User>(json)
    // u2._id == "u-1"  ✓  u2._name == "alice"  ✓
}

6. 测试计划

src/json/ 下新增测试(复用现有 JsonSerializer_test.cj 的测试基建):

用例 断言
父类字段序列化 输出包含 _id/_createdAt,且 @JsonIgnore 父类字段不输出
父类字段反序列化 Deserialize<User> 后父类字段值正确
多层继承 GrandParent → Parent → Child 三层字段全部输出
无宏的类 行为与现状完全一致(回归)
子类字段与父类字段重名 子类优先(输出一份,值取子类的)
父类嵌套对象字段 父类字段值是对象时正确递归
父类字段 null 导入时保持默认值
与 @JsonPropertyName 组合 父类字段用注解名输出/匹配
顶层序列化父类类型实例 Serialize(BaseUser 实例) 直接可用
宏自动补 open @JsonExportFields class Base(无 open)展开后子类可继承(编译通过)
宏严格模式报错 @JsonExportFields[requireOpen: true] 标注非 open class 编译失败
宏非 class 标注报错 对 struct/interface/enum 标注 @JsonExportFields 编译失败

7. 风险与待验证项

# 风险 说明/对策
1 cjpm 对宏包的支持 Cangjie 宏需 cjc --compile-macro 先行编译;需验证 cjpm 依赖系统能否自动处理宏包(若不能,改独立宏包目录 + 手动编译脚本,或文档说明编译顺序)
2 宏解析字段的复杂度 需要处理 public var/public let/注解/默认值表达式;用 parseDecl 解析后遍历 ClassDecl.members 中的 VarDecl
3 as HashMap<String, Any> 的类型转换 静态方法反射 apply 返回 Any,需 ?? 兜底;若反射返回类型严格匹配失败,可在宏生成时返回 Array<(String, Any)> 或专用 DTO 规避
4 父类字段顺序 继承链遍历顺序(父类在前还是后)影响 JSON 键顺序;JSON 对象键无序,不构成功能问题
5 与现有「父类字段不序列化」测试冲突 现有测试 objectSerialize 断言 _id/_createdAt 不输出——该断言需在宏方案落地后反转/条件化(无宏时仍不输出,有宏时输出)

8. 路线对比与备选方案

8.1 两条可行路线的取舍

路线 A:入口不变 + 父类宏(本方案) 路线 Bsoulsoft 全宏风格
宏标在哪 仅父类@JsonExportFields 每个类@Serialization[superType: 父类]
继承串联 库运行时反射 superClass 链 + 静态方法调用 编译期 super.serializeObject(...) / Parent.deserializeObject(...) 调用链
Serialize(obj) / Deserialize<T>(json) 完全不变(无泛型约束,任意类) 需保留反射回退,否则无宏类编译期拒绝
无宏的类 照旧全反射(向后兼容) 无法序列化(除非入口加反射回退)
调用方改动 每个类要加标注 + superType
运行时开销 一次静态方法反射查找(可缓存) 零反射,纯方法调用
类型安全 导出方法内静态访问(安全),返回值 as 转换(需兜底) 全程编译期强类型
宏实现复杂度 中(解析字段生成 2 个静态方法) 高(生成 4 方法 + 接口实现 + 构造器 + prop)
字段约定 无(所有 public var 必须 _ 前缀 + var + 类型注解(isAllowedMemberVarDecl

结论:路线 B 的运行时性能更优,但要求每个类标宏、约束字段命名、改变 API 形态——与 simapi_serialization「任意类免标注」的核心价值冲突。推荐路线 A:只借 soulsoft「宏生成静态类型字段代码」的技巧,入口 API、调用方、无宏类行为全部保持现状。

8.2 备选方案总表

方案 可行性 改动量 说明
路线 A:宏标注父类 + 静态导出方法(推荐) 已验证 自动、向后兼容、无反射限制、API 不变
路线 B:soulsoft 全宏风格(每类标宏 + superType 可参考 零反射,但破坏免标注 API、约束字段命名
序列化库运行时遍历继承链 + 反射读父类字段 getValue 声明类严格校验,直接抛异常
宏把父类字段复制到子类 编译器禁止字段遮蔽
用户在子类手写父类字段(复制粘贴) 小但人工 破坏继承语义、易漏改;仅作为临时 workaround
序列化库要求父类字段改用 prop + 方法反射 方法反射同样有声明类校验(已实测)

9. 实施步骤(评审通过后)

  1. 新建宏文件 src/json/JsonExportFieldsMacro.cj(或独立宏包),实现 JsonExportFields 宏;
  2. 验证 cjpm 对宏包的编译流程(风险 #1),必要时调整项目结构;
  3. JsonWriter.writeObjectsuperClass 链调用 exportJsonFields
  4. JsonReader.readObjectsuperClass 链调用 importJsonFields
  5. ReflectionCache 增加约定方法存在性缓存;
  6. 新增 §6 测试用例;调整现有 objectSerialize 断言(风险 #5);
  7. cjpm test 全绿;
  8. 提交并推送。