大家好。我用 typescript 语言编写了一个命令行工具,有如下功能
- 可以通过
json生成 class 代码。 - 生成的 class 具备序列化与序列化能力。
- 可以适用于所有有效
json数据格式。 - 支持
json,也可以使用json5。 - 可以通过
ref语法,引用已经定义的结构,生成递归类型 - 目前已经支持
dart、arkTs语言,后续支持其他语言也不是问题
工具地址
为什么要开发这工具
- 当初开发 Flutter 的时候,使用官方推荐的
json_serializable实在觉得有点麻烦,用起来需要定义一堆东西,还需要按约定的规则对应好属性。如果跟 http 请求结合起来使用,需要手写的模版代码就更多了。 - 类型的命名我个人觉得是个很痛苦的事情,就算是一个人开发一个项目,也很难从头到尾保持一致的命名规范,如果多人协同开发,可能连基本的驼峰命名都有可能难以保证。但本质上,class 的名称其实是无关紧要的,只需要保证命名规范的完全一致性和可识别性即可。
- 只要是强类型语言,在与动态数据交互的时候,为了保证类型安全,都需要提前定义 class 类型并实现序列化与反序列化。但不同的语言处理方案却完全不同。但本质上,不同语言的 class 结构,与序列化与反序列化逻辑基本类似。
例子
下面我介绍下这个工具如何使用。这是我第一次写开源工具,难免有所考虑不到的地方,欢迎大家给我提出意见或建议。
如何开始
因为这是个命令行工具,所以需要从命令行开始。我以 npx 这个工具开始。其他执行方式基本类似,可以在上面的 工具地址 的文档中找到使用方法。
npx json2class build -l dart@3
-l 是 --language 的缩写,用于指定需要构建的语言。目前已经支持 dart@3 和 arkTs@12。很显然,@后面的数字代表版本号
执行该条命令后,默认会在执行命令所在的目录去查找所有的 .json(5) 文件,也支持文件夹查找,但最多支持三层。
如果一切没有问题,默认就会在执行命令所在的文件夹生成一个构建好的代码文件。如果是 dart 生成的就是 json2class.dart。如果是 arkTs,生成的就是 json2class.ets。
可以通过 -s 或 --search 指定一个查找 .json(5) 的目录,支持绝对路径和相对路径
可以通过 -o 或 --output 指定一个输出目录。
推荐将生成的文件 json2class.* 加入 git 忽略。
JSON 如何配置
只要你的 json 是合法的,就是一个有效的配置,就可以完成代码的构建
1. 一个最简单的例子
// 文件名: test.json5
{
a: 1,
b: "bbb",
c: true,
o: {
o1: 100,
o2: 'ooo',
o3: false,
},
}
生成的代码 dart 代码大致如下
class test {
num a = 0;
String b = '';
bool c = false;
testo o = testo();
}
class testo {
num o1 = 0;
String o2 = '';
bool o3 = false;
}
没错,我使用了文件名 + 属性名直接拼接的方式当做 class 类名。这在使用上完全没有问题,既保证了命名规范从一而终的一致性、也保留了充分的可识别性。
从生成的代码中,你可以看到属性类型和属性默认值与JSON的大致关系。亦或是如果不想设置属性默认值,这些在上面的 工具地址 中都有详细描述,这里不再赘述。
2. 递归类型如何创建?
之前有提到,只要你的 json 是合法的,构建就不会有问题。但合法的 json 并不能一定保证能生成我需要的类型。
class test {
num a = 0;
String b = '';
bool c = false;
testo o = testo();
}
class testo {
num o1 = 0;
String o2 = '';
bool o3 = false;
testo? child; // 类型递归
}
像这样的一个递归类型,只使用 json 就无法描述,我们需要约定特殊语法。
// test.json5
{
a: 123,
b: 'abc',
c: true,
o: {
o1: 100,
o2: 'ooo',
o3: false,
child: { $meta: { ref: '/test#/o' } }
},
}
ref 引用 用 # 分为两个部分:
/test 是文件名,这里当然也可以是其他文件名,只要存在于你的 json 查找目录中的文件。如果是多层目录,需要带上文件夹层级。
/o 就是你需要引用的字段对应的类型,在这个例子中就是 testo。如果你需要引用的类型就是当前文件的,可以省略前半部分,{ ref: '#/o' },此写法与当前例子等效,但需要注意的是 # 一定是必须的。
目前 $meta 下 ref 是唯一支持的配置。如果大家在使用过程中,有更好的想法可以告诉我,可以通过 $meta 加入更多特性的支持。
3. 冲突、特殊字符怎么办?
按类名通过 文件名 + 字段名 拼接的方式,在一些极端情况下,会产生类型冲突的情况,此时命令行工具会给出提示,你只需要修改文件名即可消除冲突。
修改文件名会产生新的类名,这里再次强调,类名具体是什么其实真的不太重要,重要的是
- 类名生成规则的从一而终的一致性
- 类名的可识别性
如果字段中,有特殊的字符,这些字符是属于该语言的关键字,命令行工具在识别到以后,会通过特定算法做转换,不影响属性的使用,也不会影响序列化后的字段名。
生成后代码如何使用
1. 序列化与反序列化
import 'json2class.dart';
main() {
final t = testo();
t.fromJson({
"test": {
"o1": 100,
"o2": 'ooo',
"o3": true,
}
});
print(t.toJson());
}
生成的代码最核心功能就是序列化与反序列化。
fromJson:反序列化toJson:序列化
为了方便字符串格式的JSON数据转换,还提供了一个 fromAny,他可以接收任意参数,尝试转换成 Map 后在调用 fromJson 实现反序列化。
完整的生成代码的使用文档可以参见上面的 工具地址,这里不再赘述。
2. 构造 mock 数据
可以预见,该工具生成的代码,最佳的使用场景是在获取网络数据。在后端没有准备好联调时,我们就需要构造假数据进行开发了。我们在拿到后端接口返回定义时就可以同时配置好 mock 数据
// test.json
{
"result": {
"statusCode": "",
"statusMessage": "",
"data": {
"userName": "json2class",
"userPhone": "13888888888",
"userAvator": "http://xxx.yyy.com/avator.png"
}
}
}
main() {
final result = testresult();
result.fromPreset();
setState(() {});
}
通过 fromPreset 方法,就可以将 json 中配置的数据填充到对象中,去完成业务逻辑的编码。当后面后端具备联调条件时,只需要将 fromPreset,替换成真实的接口调用即可。当然你也可以把这里封装一下,通过泛型,实现所有接口统一入口,通过一个参数实现 mock 数据 的一键切换。
3. 填充规则
为什么会设计填充规则这个玩意?因为前端在与后端交互时,为了保证前端代码的健壮性,一定不能信任任何后端数据,这也是为什么我们拿到后端的 json 数据后,要做反序列化、类型化的原因。
那么在真实的业务场景下,如果接口返回数据的结构、类型与我们定义的不一致,怎么办?直接报错?这肯定不行。当发生结构或类型与定义不一致时,填充 null 应该可行,填充该类型的一个 默认值 似乎也不错。为了满足不同的需求,所以设计了 填充规则。
- 针对对象 Key 的填充
很好理解。无非就三种情况。
- 输入与定义的字段都存在,且类型相同。这是最完美的情况,直接填充赋值。
- 输入与定义的字段都存在,但类型不同。那么通过
DiffType枚举值决定如何填充。 - 定义的字段不存在。那么通过
MissKey枚举值决定如何填充。
- 针对数组长度一样的数组
这个应该也容易理解,输入的数组长度与带填充的数组长度一样,那么按元素位置逐个填充,如果发生对应位置类型不匹配的情况,按 DiffType 处理。
- 针对数组长度不一样的数组
针对长度超出的那部分元素,按如下规则处理。
- 枚举值
MoreIndex:输入数组长度 > 原数组
| 枚举值 | 效果 |
|---|---|
| Fill | 按输入值插入,类型不一致时,根据字段是否可选,设置默认值 / null |
| Drop | 丢弃多余的输入数据,数组长度与原数组长度一致 |
| Null | 多出的数据填充为 null 值(非可选字段强制 Null 等同 Fill) |
- 枚举值
MissIndex:输入数组长度 < 原数组
| 枚举值 | 效果 |
|---|---|
| Fill | 填充默认值,多维数组会递归填充 |
| Drop | 丢弃多余的原始数据,数组长度与输入数组长度一致 |
| Null | 多出的数据填充为 null 值(非可选字段强制 Null 等同 Fill) |
| Skip | 原数组中多余的数据不做处理,保留原值 |
一般情况下,我们在接收后端数组数据的时候,一般会 new 一个新对象,那么接收数组长度为 0,即 输入数组长度 > 原数组,受枚举值 MoreIndex 影响,而默认配置的 MoreIndex 是 MoreIndex.Fill,所以最后表现的行为是将数据逐个填充到原数组中,符合预期。
最后
这就是全部内容,感谢您看到这里。该项目也是在多年的实际项目中逐渐迭代、优化、提炼而形成的。非常希望大家能在各自的项目中用起来,如果在使用过程中遇到问题,或者有任何改进的建议,欢迎通过如下方式反馈:
- 在 GitHub Issues 提交问题的或建议
- 发送邮件至 Yang Fan<yangfanzn@gmail.com>
- 评论区留言