最近遇到一个业务需求,需要统计业务方提供了哪些能力,这些能力通过一个总的 json 配置文件进行描述,以方便本地和平台都能解析这份配置,配置文件例如:
{ "components": [ { "dependency": "com.codelang.module:check:1.0.0", "name": "zhangsan", "verifiedContainer": [ "list", "home" ], "verifiedProtocol": [ "public" ], "version": "1.0.0" } ] } 复制代码
最简单的方式就是写一个 json 文件,让各个业务线都来改这份 json 文件,确实是个偷懒的方案,但这有几个缺点:
- json 这种纯文本文件会导致业务方录入不规范,比如 json key 大小写写错或是拼写单词错误了,导致平台和本地无法解析该字段
- 业务方不知道哪些 key 是必选的,导致每次都要去看下文档,哪些需要录入
- 无法知道这么多 key 对应着什么功能,json 里面也无法写注释,导致每次都要去查看文档该 key 表述的是什么意思
那有什么办法解决这些问题呢?我想到了是用注解的方式,对于业务方来说,他们只要按照注解需要的 value 进行录入即可,可选参数用默认值代替,并且还可以注解提示,来看下注解的定义:
annotation class Component( // 必选: 模块名称 val name: String, // 必选:模块版本 val version: String, // 必选:模块依赖 val dependency: String, // 可选:校验容器 val verifiedContainer: Array<String> = arrayOf(), // 可选:校验协议 val verifiedProtocol: Array<String> = arrayOf() ) 复制代码
那么,业务方只需要写一个类,用该注解进行描述即可,例如:
@Component( name = "zhangsan", version = "1.0.0", dependency = "com.aa.bb", verifiedContainer = ["list", "homeContainer"], verifiedProtocol = ["public"] ) class AComponent 复制代码
嗯,规范业务方录入这块完成了,那么,怎么将这份注解翻译成 json 文件呢? APT?这也太重了,如果模块新增功能了还要改注解处理器模块,我们只是写一个脚本而已。
之前看过基础部门关于隐私 API 的收集,采用 javaparse 去静态解析 sdk 里面的 sourceCode,如果方法是被 RequiresPermission 注解的话,就给收集起来。
静态解析确实是个好主意,但目前可参考的只有 java,如果业务方是用 kotlin 写的呢?既然有 java 文件解析,那一定就有 kotlin 文件解析,随着搜了一下,查到了三个库:
- kotlin-parser : 调研发现有点难用,无法根据注解方法的回调遍历注解参数
- kastree:遍历简单,可以拿到 Node 节点进行向下遍历
- kotlinx.ast :大而全的 ast 解析库,适配的规则非常多,但使用起来有点重
在简单了解和 demo 测试中,决定使用 kastree 这个轻量级的库来实现,在 README 的描述中,可以写个简单的伪代码:
// 读取 kt 文件内容 val code = File("xx/test.kt").readText() // 生成解析器 val file = Parser.parseFile(code) // 开始解析语法 Visitor.visit(file) { v, _ -> // v 为 Node 节点 Log.i("node",v) } 复制代码
用法非常简单,我们可以尝试解析我们的注解类了,不过,我们得先了解下如果遍历 Node 节点的,我们可以打印输出一下 Node 的结构是什么样的,以下去除了无用的信息,只保留了注解的 Node,如果想查看完全的 log 输出,可查看 demo 的 test.txt 文件,如下代码稍微整理了下结构:
Structured( // 注解的类名 name=App2Component, mods=[ AnnotationSet( target=null, anns=[ Annotation( // 注解类 Component names=[Component], typeArgs=[], args=[ // 注解参数 name ValueArg(name=name, asterisk=false, expr=StringTmpl( // 注解参数 name 对应的值 zhangsan elems=[Regular(str=zhangsan)], raw=false)), // 注解参数 version ValueArg(name=version, asterisk=false, expr=StringTmpl( // 注解参数 version 对应的值 1.0.0 elems=[Regular(str=1.0.0)], raw=false)), // 注解参数 dependency ValueArg(name=dependency, asterisk=false, expr=StringTmpl( // 注解参数 dependency 对应的值 com.aa.bb elems=[Regular(str=com.aa.bb)], raw=false))])]) ], ... ) 复制代码
整体 Node 节点跟 json 文件格式很像,每个节点都是一个类型,我们只需要根据节点类型一步步解析出我们要的数据即可,例如:
// 判断 node 节点是否是 Structured if (v is Node.Decl.Structured) { // 取出注解的类名 App2Component val className = v.name // mods 数组的第一个元素强转成 AnnotationSet 节点 val annotationSet = (v.mods[0] as Node.Modifier.AnnotationSet) // 拿到 Annotation 节点 val anno = annotationSet.anns[0] // 取出注解类名 Component val annoName = anns.names[0] // 遍历注解的参数值 anno.args.forEach { node -> val expr = node.expr if (expr is Node.Expr.StringTmpl) { val elems = expr.elems[0] if (elems is Node.Expr.StringTmpl.Elem.Regular) { // 输出注解参数名称和值 println("key=" + node.name + " value=" + elems.str) } } } ... } 复制代码
整体解析非常简单,参数名和值都可以通过遍历的方式拿到,这也即意味着,即使以后模块新增了功能点,只需要动我们的注解类就可以了,脚本完全不需要再改造。
在我们解析拿到了内容之后,那接下来的生成 json 文件就更简单了,我们只需给每个待解析的 kt 文件创建个 JSONObject 节点,然后将解析到的信息都 put 进去,如果有多个文件的话,则创建个 JSONArray,然后将 JSONObject add 进去即可,然后创建个 File,将 JSONArray 转成 string 写入即可。
当然,其中也遇到过坑,比如刚开始集成 kastree 时候,跟着 README 写了下示例,运行直接报错了,有点劝退的感觉:
Exception in thread "main" java.lang.IllegalStateException: LOGGING: Loading modules: [java.se, jdk.accessibility, jdk.attach, jdk.compiler, jdk.dynalink, jdk.httpserver, jdk.jartool, jdk.javadoc, jdk.jconsole, jdk.jdi, jdk.jfr, jdk.jshell, jdk.jsobject, jdk.management, jdk.management.jfr, jdk.naming.ldap, jdk.net, jdk.scripting.nashorn, jdk.sctp, jdk.security.auth, jdk.security.jgss, jdk.unsupported, jdk.unsupported.desktop, jdk.xml.dom, java.base, java.compiler, java.datatransfer, java.desktop, java.xml, java.instrument, java.logging, java.management, java.management.rmi, java.rmi, java.naming, java.net.http, java.prefs, java.scripting, java.security.jgss, java.security.sasl, java.sql, java.transaction.xa, java.sql.rowset, java.xml.crypto, jdk.internal.jvmstat, jdk.management.agent, jdk.jdwp.agent, jdk.internal.ed, jdk.internal.le, jdk.internal.opt] (no MessageCollector configured) at org.jetbrains.kotlin.cli.jvm.compiler.ClasspathRootsResolver.report(ClasspathRootsResolver.kt:312) at org.jetbrains.kotlin.cli.jvm.compiler.ClasspathRootsResolver.report$default(ClasspathRootsResolver.kt:310) at org.jetbrains.kotlin.cli.jvm.compiler.ClasspathRootsResolver.addModularRoots(ClasspathRootsResolver.kt:253) at org.jetbrains.kotlin.cli.jvm.compiler.ClasspathRootsResolver.computeRoots(ClasspathRootsResolver.kt:123) at org.jetbrains.kotlin.cli.jvm.compiler.ClasspathRootsResolver.convertClasspathRoots(ClasspathRootsResolver.kt:79) at org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment.<init>(KotlinCoreEnvironment.kt:279) at org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment.<init>(KotlinCoreEnvironment.kt:127) at org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment$Companion.createForProduction(KotlinCoreEnvironment.kt:463) at kastree.ast.psi.Parser$proj$2.invoke(Parser.kt:16) at kastree.ast.psi.Parser$proj$2.invoke(Parser.kt:14) at kotlin.SynchronizedLazyImpl.getValue(LazyJVM.kt:74) at kastree.ast.psi.Parser.getProj(Parser.kt) at kastree.ast.psi.Parser.parsePsiFile(Parser.kt:30) at kastree.ast.psi.Parser.parseFile(Parser.kt:23) at KtParseKt.parseKotlinFile(KtParse.kt:44) at KtParseKt.main(KtParse.kt:27) 复制代码
但仔细看了下日志,觉得可能跟 JDK 版本有关系,尝试将 jdk11 更改成 jdk8 运行,完美运行
总结
最终,我们通过 注解+脚本
的方式,规范了业务方的编码。对于 kt 、java 文件的解析,我们也可以玩出很多花样,比如 findbugs 、lint 等功能。