本文介绍了 Jsonnet 这一功能强大且灵活的 JSON 数据转换工具,作为解决复杂 JSON 生成与变换需求的选择。文章首先回顾了在实际项目中对 JSON 处理工具的需求背景,对比了 jsonpath、jsonpatch、jsonata 等工具后,最终选用图灵完备的配置语言 Jsonnet。Jsonnet 不仅是 JSON 的超集,支持生成 JSON、YAML 等多种格式,还具备良好的性能和扩展性,广泛应用于 Kubernetes、Grafana 等系统。
背景
在最近的工作中场景中,需要一种对 JSON 进行灵活 “生成” 、“转换” 的工具,需要同时兼具性能和灵活性,可以对任意复杂的 JSON 进行处理。在手淘【多语言项目】中的我们基于自研 JSONPath 结合自定义规则的方式对响应进行字段增强。该场景虽然很好的满足了项目的需求,但是对于更加灵活的场景却显得有点捉襟见肘。为此在经过一番探索和对比后,从 jsonpath、jsonpatch、jsonata、 和 jsonnet 、pkl 、graphql 等工具中,最终选择了 jsonnet,本文将对 jsonnet 的进行简单介绍和分析,希望可以抛砖引玉和大家进行交流。
简介
jsonnet 是一个谷歌开源的规范和实现,是一种图灵完备的配置编程语言(configuration language), 主要用于:生成数据(json、yaml、ini等),同时,jsonnet 也是 json 的超集,一个合法的 json 就是一个合法的 jsonnet 程序,并且带有完整的 IDE 和 社区支持,广泛应用于 k8s、grafana 等。目前拥有 cpp、rust、go 、java 等多个平台的实现。
{ person1: { name: "Alice", welcome: "Hello " + self.name + "!", }, person2: self.person1 { name: "Bob" },}
将会生成数据:
{ "person1": { "name": "Alice", "welcome": "Hello Alice!" }, "person2": { "name": "Bob", "welcome": "Hello Bob!" }}
实现分析
在本文中,我将主要分析 sjsonnet, 一种 Jvm 平台上的 jsonnet 实现,实际上其他的语言的实现也具备类似的结构,下面的表格做简单的归纳:
jsonnet | go-jsonnet | jrsonnet | sjsonnet | |
性能 | 低 | 高 | 高 | 高 |
解释器模式 | 是 | 是 | 是 | 是 |
支持native产物 | 是 | 是 | 是 | 是 |
实现语言 | CPP | Go | Rust | Scala |
支持作为库使用 | 是 | 是 | 是 | 是 |
标准库实现 | jsonnet | Go | Rust | Scala |
公司 | 个人 | DataBricks | ||
最新支持版本 | 0.21 | 0.21 | 0.20 | 0.21 |
▐ sjsonnet 的实现结构
sjsonnet 的实现主要包含 Parser、StaticOptimizer、Evaluator、Materializer 几个部分。
Parser : 主要将 jsonnet 的文本内容,转换为 抽象语法树 AST, 后续的所有操作都是在 AST 上进行的。
StaticOptimizer:静态优化器主要是进行常规的静态优化、比如 :分支裁剪、常量折叠、变量查找替换等,通过静态优化器优化之后,我们得到了一个优化的 AST。这个优化后的 AST 将会在 sjsonnet 内部进行缓存,后续针对同一个输入的 jsonnet 脚本,将直接返回这个优化后的 AST,从而避免了重复解析和优化的过程,这也是 sjsonnet 性能非常好的原因之一。在我们的实现中,我们基于 caffeine cache 实现了 parser cache。
Evaluator: 求值器的作用主要是结合输入的参数(jsonnet 中的 extVar 和 tlaVar) 以及 优化的 AST 进行求值,在过程中将会遍历 AST 并执行相关的逻辑,并最终生成一个 Val (即结果值)。这也就是我们一段 jsonnet 脚本的执行结果了。
Materializer:物化器,其核心的作用是 将 求值器产生的结果,转换为我们想要的格式,比如 json 字符串、JSONNode、yaml、ini 或则是 markdown格式的文本 或则 Spring 的 property 文件等。在 sjsonnet 中定义了 AstTransformer 类型,我们可以实现来转换为我们想要的结构。
通过上面的介绍,想必大家对 sjsonnet 的代码库都有了一个了解,那么如何在 Java 中使用呢。这里我们封装了 jsonnet 的 Java 实现。其中包含了不少的扩展和支持, 可以非常方便的在 Java 中使用,目前需要 Java 21。下面我们将结合这个库,介绍相关的用法。
使用指南
▐ 1. 基本用法
1.1 字段提取
场景:如果原始的 json 是一个非常大的 json 对象,而我们只需要其中的一部分,则可以只定义我们感兴趣的字段,对原始的数据进行裁剪。
输入
{ "store": { "book": [ { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }, { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }, { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }, { "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 } ], "bicycle": { "color": "red", "price": 19.95 } }}
脚本
#导入参数local input = std.extVar("input");#选择部分结果{ myColor: input.store.bicycle.color, myPrice: input.store.bicycle.price,}
输出
{ "myColor": "red", "myPrice": 19.95}
java 测试代码:
@Test fun testOnlyBicycle() { val jsonnet = readFile("jsonnet/case_only_bicycle.jsonnet") val store = readFile("jsonnet/store.json") val extVars = mapOf<String, Any>( "input" to ExternalVariable.code(store) ) val result = Jsonnet.interpretAsString(jsonnet, extVars, emptyMap<String, Any>()) println(result.value()) }
1.2 字段加减
场景:假设我们有一个较大的json,但是我们对其中的部分字段需要进行丢弃。
输入
{ "store": { "book": [ { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }, { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }, { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }, { "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 } ], "bicycle": { "color": "red", "price": 19.95 } }}
脚本
local input = std.extVar("input");
std.objectRemoveKey(input.store, "book")
输出
{ "bicycle": { "color": "red", "price": 19.95 }}
1.3 字段替换
场景:有个一个对象,但是我们需要将其中的某个 key 的值更新为新的 key,值保持不变
输入
{ "name": "Alice", "welcome": "Hello Alice!"}
脚本
local input = std.extVar("input");tb.obj.objectReplaceKey(input,"name", "newName")
输出
{ "newName": "Alice", "welcome": "Hello Alice!"}
1.4 字段计算
场景:比如我淘宝直播场景中,我们需要结合 tag 和 topic 生成新的 tagTopic。则可以利用jsonnet的字符串计算能力。
输入
{ "topic": "957c3914-a83d-498d-b039-efad8de7f3c2", "tag": "tb"}
脚本
local input = std.extVar("input");
input + { tagTopic: self.topic + "-" + self.tag,}
输出
{ "tag": "tb", "tagTopic": "957c3914-a83d-498d-b039-efad8de7f3c2-tb", "topic": "957c3914-a83d-498d-b039-efad8de7f3c2"}
1.5 和 jsonpath 结合
场景:如果我们已经非常熟悉了jsonpath的写法,也借助jsonpath 来进行字段的筛选。
输入
{ "store": { "book": [ { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }, { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }, { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }, { "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 } ], "bicycle": { "color": "red", "price": 19.95 } }}
脚本
local input = std.extVar("input");
tb.jsonpath.select(input, "$.store.book[?(@.price < 10)].title")
输出
["Sayings of the Century","Moby Dick"]
▐ 2. 高级用法
2.1 json数据变为 markdown
场景:需要将输出的内容转换为 markdown,而非 json
输入
{ "name": "Alice", "age": 2}
脚本
local input = std.extVar("input");
{ newName: "this is my new name " + input.name, newAge: input.age + 100}
输出
| newAge | newName || ------ | ------- || 102 | this is my new name Alice |
val inputData = """{"name":"Alice", "age":2}"""val jsonnet = readFile("jsonnet/test_ext_vars.jsonnet")val extVars = mutableMapOf("input" to Jsonnet.code(inputData)) as Map<String, Any>val markdown = Jsonnet.interpretAsMarkdown(jsonnet, extVars, emptyMap<String, Any>())println(markdown.value)
2.2. 自定义函数
场景:当标准库的函数已经不支持我们想要的功能时,我们可以考虑使用 jsonnet 本身来实现一个函数,也可以考虑使用 Java 或者 Scala 语言来实现一个函数。
比如要实现一个 uuid 函数:
private[functions] object RandomFunctions extends AbstractFunctionModule { override final val name: String = "random" /** * Module 的描述 * */ override val functions: Seq[(String, Val.Func)] = Seq( builtin(UUIDGen) )
private object UUIDGen extends Val.Builtin0("uuid") { override def evalRhs(ev: EvalScope, outerPos: Position): Val = { val uuid = java.util.UUID.randomUUID().toString Val.Str(pos, uuid) } override def staticSafe: Boolean = false }
}
脚本
local uuid = tb.random.uuid;uuid()
结果
"01ba45ac-5ef6-4c12-b544-014d4f1a35b6"
▐ 3. 性能优化
在目前的实现中,如果要进一步优化性能可以做以下几点:
参数值使用解析好的结构化(
Expr
)输入,即使用ExternalVariable#expr
来提供参数值,这样避免了重复解析。利用好 Parser Cache,从而避免jsonnet的重复解析和静态优化。
尽可能使用 Java 语言实现自定义的扩展函数。
针对经常用到的脚本,在应用启动的时候进行预热,从而避免首次执行时解析耗时。
其他在 sjsonnet 中的优化包括:使用 tableSwitch 、lazy 值裁剪、静态优化、避免重复计算、缓存 等手段。性能对比 jsonnet、sjsonnet 和 jrsonnet 如下:
小结
本文我们分析了一个 jsonnet 执行器的典型实现,一些典型场景的应用和常见的性能优化手段。目前我们已经在项目中善用了 jsonnet 来解决复杂的参数映射和转换问题,同时通过利用自定义函数来进一步的改善 jsonnet 的使用体验,于此同时也将部分的性能优化和问题修复提交给上游项目。在社区中也有 pkl 、cue 等新型项目可以支持类似的能力,但是目前在 JVM 平台,可能使用 jsonnet 更加简便。与此同时,也希望可以抛砖引玉,如果大家也有类似的场景,也欢迎相互交流,一起参与到 jsonnet 的建设中来。
团队介绍
本文作者虎鸣,来自淘天集团-终端平台团队。本团队支撑淘宝、天猫核心电商以及闲鱼、点淘等创新业务,服务数亿消费者,并作为核心技术团队,为每一年的双十一购物狂欢节提供基础技术保障。
在底层技术上,我们具备深厚的Android和iOS底层技术积累,拥有丰富的编译器、链接器、解释器技术应用实践。
在研发模式上,我们负责原生研发模式DX演进,服务数千开发者、承载数百亿日PV,深耕系统原生渲染技术,致力于建立下一代终端研发模式。
在网络技术上,我们在终端网络、传输和超大规模网关有深厚技术积累,负责开源方案XQUIC/Tengine,承载亿级长连和千万级QPS;在国际IETF标准、顶会SIGCOMM均有建树。
在终端技术上,我们打造领先行业的移动技术产品,涵盖多端架构、性能体验、组件框架、用户增长等关键领域,致力于移动端系统及厂商特性前沿探索。
在后端技术上,我们负责移动基础设施,有百万级QPS API网关、消息/推送、Serverless平台、自适应流控等柔性高可用解决方案。打造覆盖移动App全生命周期工程技术平台。
在跨端技术上,我们负责Weex2.0和核心Web容器,研究领域涉及W3C标准、WebKit内核、脚本引擎和自绘渲染引擎,面向Web标准提供一流跨端能力。通过卡片级小部件和小游戏技术,丰富创意供给,提供差异化的购物体验。
在前端技术上,我们在前端框架、工程、低代码领域长期深耕,支撑大促营销ProCode、LowCode、NoCode跨端页面研发;配套前沿的页面托管;负责ICE、微前端等开源方案,致力于提供简单友好的研发体系。
在这里,我们拥有最前沿的技术以及最优秀的人才;在这里,我们给你提供平台,师兄助你快速成长;在这里,校园化的办公环境,更有不定期团建high不停; 加入我们,让淘天成为你梦想起航的地方~
¤ 拓展阅读 ¤