本文分析了不同语言下实现低代码平台后端的优缺点,如果你想引进低代码平台做数字化系统,或者从零开发一个低代码平台都可以参考本文。
入围选手
低代码平台后端要实现大量功能,所以不能选择太偏门的语言,本文重点讨论的是如下几个:
- Java,绝大部分国内低代码平台后端的选择
- 优点:语法简单可读性好,JDBC 是最大护城河,所有其它语言最羡慕的功能
- 缺点:内存占用大,难以编写异步代码,国内大部分还在用 JDK 8,缺少很多重要的语法糖
- Kotlin,Android 下官方主推语言,目前还很少人用在后端开发
- 优点:可以使用 JDBC,解决了 Null 问题,比 Java 8 多了很多语法糖,还有协程
- 缺点:相对于 Java 功能多了不少,但也意味着需要一定时间熟悉
- Node,Node 在国外低代码平台中非常常见,最著名的 Retool 就是,还有开源的 ToolJet、Budibase 等
- 优点:可以很容易处理低代码中的 JSON DSL,方便前后端共享代码
- 缺点:性能相对其它语言最差
- Rust,同时做到了高性能和内存安全
- 优点:高性能且内存占用低,语言级别支持异步,可以做成扩展嵌入到其它语言中
- 缺点:语法复杂,代码过段时间自己也看不懂了
- Go,国内许多站点的后端语言
- 优点:语法简单,适合编写高并发的网络应用
- 缺点:代码可读性一般,尤其是没有 enum、异常机制
至于 Python、PHP、Ruby 等语言没入选主要是因为和 Node 比没明显优势,性能还更差,目前也没见有哪个低代码平台使用,因此动态语言只选了 Node。
低代码平台要实现哪些功能
如果只是实现简单的业务逻辑和 CRUD,所有这些语言中只有 Rust 比较复杂,Rust 下的 Web 框架比如 Axum 接口比较底层,文档很少,需要通过示例和源码来学习,而且中间件写起来也更为麻烦,比如一个 timeout 中间件就需要上百行代码,需要掌握 Rust 异步的实现原理导致门槛高。
除了业务逻辑和 CRUD 之外更重要的是低代码平台中相对复杂的功能:
- 前端 JSON DSL 处理
- 数据查询,对接数据源或实现宽表功能
- JavaScript 引擎,支持自定义后端代码
- 表达式引擎,实现简单的定制逻辑
- API 编排,实现对接外部 API 接口
- 流程引擎,实现流转功能
接下来我们分析这些功能在不同语言下的情况
实现低代码平台功能的语言对比
前端 JSON DSL 处理
低代码前端都是基于 JSON 的自定义 DSL,因此低代码后端需要经常对 JSON 进行分析或二次处理,比如爱速搭在输出 amis 的时会将其中的 api 转换为代理地址,这时需要遍历 JSON 找到这些地址。
在处理 JSON 上动态语言 Node 有天然优势,因为 JSON 就是 JS 对象,还能直接复用前端的类型定义。
而其它语言相对比较麻烦,主要有两种做法:
- 定义数据结构,自动转换为不同语言下的数据结构,好处是后续都是强类型,但对于复杂嵌套定义非常繁琐,还有部分框架在遇到不太规范数据时的容错性不好,可能会直接报错。
- 使用 JSON 库的接口动态遍历和处理,好处是不需要定义类型,但操作起来没有类型保护,容易运行时报错。
早期我很喜欢用第二种方法,因为 amis 语法非常多变,尤其是很多属性是多类型的,导致在 Java 中定义类型就只能用 Object,完全失去了强类型的优势,另外就是有些 JSON 库支持延迟解析,所以如果只修改部分字段时这种做法性能最好。
而如果不是 amis 那么多变的语言,我更推荐第一种用法,在定义字段的时候避免多类型,解析 JSON 在不同语言下的情况如下:
- Rust,Serde 是事实上的标准,由于 Rust 的 Enum 支持嵌套所以还能解决前面提到的多类型问题,使得它甚至还能像做到 Node 那样不需要定义类型,只用 serde_json::Value 就能解析。
- Go,官方有提供 JSON 解析库,但 Go 语法强制要求首字母大写的字段才能公开,而 JSON 中没人会把字段名首字母大写,所以在 Go 中就得写成类似 Name string json:"name"的形式,导致比其它语言麻烦。
- Java,Jackson 是事实上的标准,但官方文档很少,全靠第三方文档学习,每次遇到问题我都靠搜索。
- Kotlin,由于有 data class 且自带 JSON 解析库,因此代码比较简洁。
所以结论是:Node 最好,其次是 Rust,接下来是 Kotlin,然后是 Java,最后是 Go 写起来最麻烦。
数据查询
专业低代码平台通常支持连接用户自己的数据库,这时就需要对应的数据库驱动,在这方面 JDBC 优势明显,所有数据库厂商都会提供 JDBC 驱动,基于 JDBC 可以轻松抹平数据库差异,比如获取表结构信息等不需要查阅各个数据库的 INFORMATION_SCHEMA 结构或特殊的 SQL 语句。
整理了一下目前数据库驱动支持情况,其中国产数据库的选自墨天轮 Top 10 中非 MySQL 和 Postgres 兼容的数据库:
数据库 | Rust | Go | Node |
MySQL | 只有社区驱动,一人开发 | 只有社区驱动,两三人开发 | 有官方驱动,只有一个开发,不如社区的 mysql2 |
Postgres | 只有社区驱动,上个版本是一年前 | 只有社区驱动,已经快一年没更新了 | 只有社区驱动,两个开发 |
Oracle | 只有社区驱动,一人开发 | 只有社区驱动,一人开发 | 有官方驱动,两人开发 |
SQL Server | 只有社区驱动,最近提交频率较低 | 有个社区转官方维护的驱动,一个人开发 | 只有社区驱动,社区相对活跃 |
HANA | 只有社区驱动,只有 32 个 Star | 有官方驱动 | 有官方驱动 |
达梦 | 无 | 有官方驱动 | 有官方驱动 |
GBASE | 无 | 无 | 无 |
可以看到大部分语言都没有官方驱动,而社区驱动通常只有一个人开发,因此在这方面 JDBC 有巨大护城河,接下来稍微好点就是 Node,有许多官方驱动,非官方的驱动我们用过也很稳定。
然而 Node 没有 JDBC 这一层抽象,导致很多驱动表现不一致,比如预编译语句 PreparedStatement,在 JDBC 统一使用 ? 来声明变量,但在 Node 的各种驱动中有五花八门的实现:
- 在 node-postgres 中是用 $1
- 在 oracle 中是用 :1
- 在 mssql 中是用 @
除此之外更上层的基础设施还有连接池管理等,在 Java 下有比较成熟的方案,而其它语言都比较初级。
因此在数据查询这方面 Java/Kotlin 是最好选择,其次是 Node,接下来是 Go 有少数几个官方驱动,而 Rust 没有任何官方驱动,质量难以保证。
另外你可能会想很多数据库都提供了 C 语言驱动,Rust 直接通过 FFI 用不就行了么?答案是没那么简单,因为 Rust 下目前流行 Web 框架都是异步的,而 C 语言驱动是同步的会导致线程卡住,所以 Rust 中比较流行的 SQL 执行器 sqlx 甚至自己实现了 MySQL 和 Postgres 的连接协议,导致开发成本很高,所以他们还打算将 MSSQL 和 Oracle 等重要数据库的支持放在商业版本中。
JavaScript 引擎
在低代码平台中为了让用户实现更灵活的功能,通常需要支持自定义代码,这个代码通常是 JavaScript,因此低代码平台后端需要包含 JavaScript 引擎。
JavaScript 引擎目前能选的就只有四种方案:
- 原生语言实现的 JavaScript 引擎,这里唯一成熟的只有 Java 中的实现。
- 接入基于 C++ 实现的成熟引擎,优点是兼容性最好,缺点是需要语言良好支持 FFI。
- Deno/Node 里实现的沙箱机制,优点是性能最好,缺点是有安全风险。
- 进程沙盒,优点是除了 JavaScript 之外还能支持其它语言,缺点是性能差,强依赖系统内核,比如 NsJail 只能在 Linux 下使用,不同系统下得使用不同方案,开发成本高,后面要支持信创系统可能会坑。
所以不同语言下的情况如下:
- Rust,Rust 的 FFI 虽然没有 Zig 那样直接,但也是这几个语言中支持最好的
- Rusty V8,Deno 团队维护的,活跃度较高。
- javascriptcore,Tauri 团队维护的,活跃度一般。
- Boa,纯 Rust 实现的 JavaScript 解释器,目前还不成熟
- Go,由于 Go 的 CGO 有不小性能损耗,导致这方面的库不多也不怎么活跃
- v8go,两年没怎么更新了。
- otto,纯 Go 实现的 JavaScript 解释器,不支持 ES6,正则使用 re2 导致和 JavaScript 规范不一致,用不了。
- Java,Java 生态下大家为了跨平台都不喜欢用原生库,所以类似 J2V8 这种库很少有人用,但 Java 下有成熟的 JavaScript 引擎实现:
- Nashorn,JDK 8 中内置的 JavaScript 引擎,只支持 ES5 并在 JDK 15 中删除了。
- graaljs,GraalVM 中提供的引擎,也能运行在 JDK 11 中,支持不少最新的 JavaScript 语法,目前最推荐使用这个。
- Rhino,Mozilla 开发的 JavaScript 引擎,从 1999 年开始就有了,支持部分 ES6 语法,市值超过 1400 亿的低代码平台 ServiceNow 就是使用它,如果想支持 JDK8 又想有一些 ES6 语法,这个是唯一选择,但缺点是性能较差。
- Node,前面提到了主要问题是安全风险,比如早起我们使用过 vm2,但它后来遇到一个安全漏洞无法解决,现在推荐用 isolated-vm,但也可能有一天这个也没法用了。
整体来说在 JavaScript 引擎方面 Node 最有优势但有风险,其次是 Java,Rust 也能用,Go 基本没法用。
表达式引擎
在低代码产品中有时需要一些简单的条件判断或计算,比如下面的场景:
- 在流程中判断金额大于多少且级别小于多少。
- 进行公式计算,比如计算毛利率之类的,类似 Excel 中的公式。
第一种情况可以通过可视化界面比如 amis 的条件组合来实现,但第二种用界面就不太合适了,比起可视化,数学公式写起来更直观。
因此低代码产品的后端需要实现一个表达式引擎,这个引擎虽然用前面的 JavaScript 引擎也能部分实现,但为什么不直接使用 JavaScript 引擎?主要有以下几方面原因:
- 性能,初始化 JavaScript 引擎比较至少毫秒级别,而表达式引擎简单计算可以在微秒级别。
- 安全性,内嵌 JavaScript 引擎需要做沙箱处理,有可能出现逃逸等安全问题,相对来说自己实现的表达式引擎会安全得多。
- 语法更容易控制,比如可以忽略大小写方便非研发用户使用。
- 支持大数计算,自己实现的引擎可以默认都使用 BigNumber,可以避免 JavaScript 精度问题。
实现表达式引擎的核心由两部分组成:语法解析、实现内置函数。
其中实现内置函数比较简单,所以主要难点是语法解析,语法解析可以手动实现或使用工具自动生成代码。
手动实现虽然看起来复杂,但如果了解了类似 Top Down Operator Precedence 的原理其实并不难,不过由于大部分人来说有工具还是更方便点,因此接下来主要分析不同语言下使用第三库或工具实现表达式引擎的思路:
- Rust
- 现成的:零星找到几个但看起来都不成熟
- 解析工具:可以用 lalrpop 或 pest,没实际用个不确定成熟度
- Go
- 现成的:Expr 和 Cel 等
- 解析工具:可以使用 ANTLR 或 peg,但不确定成熟度
- Java
- 现成的:非常多,可以参考这里,值得一提的是 Spring 内置的 SpEL,代码质量高,还支持转成字节码来提升性能
- 解析工具:ANTLR,非常成熟,官方示例中有大量实现可供参考
- Node
- 现成的:没有很著名的,大概是因为 Node 这种动态语言可以直接用 eval 执行,用不上
- 解析工具:有很多,除了 ANTLR 这种生成代码的方式,还有 Chevrotain 这种动态解析,成熟度比较高
整体来说如果你知道怎么手写解析,这几个语言区别不大,但如果不知道怎么写解析,用现成的话 Java 最成熟,Go 其次,解析工具的话 Node 和 Rust 也都有,而 Node 中的相对成熟点,不过学 Rust 的人均大神,因此应该写个解析难度不大,可以参考这里。
逻辑编排
逻辑编排可以用来实现简单的后端业务逻辑,使得完全不写代码就能完成业务逻辑开发,因此是低代码平台中的重要功能。
实现逻辑编排需要实现两种类型的节点:
- 控制类节点,比如循环、分支条件、事务等,类似代码中的控制结构
- 执行节点,比如执行数据查询、调用 HTTP 接口等
拥有了这些节点后,许多简单的业务逻辑就能完全通过可视化的方式实现。
在有 GC 的语言中实现这个功能不难,但 Rust 会有点问题,因为它没有 GC,只有引用计数机制,而这些节点底层数据结构通常是树或图,为了方便操作通常会有相互引用的情况,比如引用父级节点,在 Rust 下需要使用 Weak 引用来避免内存无法释放,或者使用 arena 这种古老的内存池技术。
另外这部分涉及到数据库和 HTTP 这些外部请求,如果要想用异步机制来提升并发性能,比如接口可能是下载个大文件,这时 Node 和 Go 就比 Java 更有优势,Java 响应式代码写起来太难懂了,而 Kotlin 有协程相对好点。
整体来说这几个语言我更倾向于用 Node 实现这个功能,不过低代码平台通常不需要太高并发,使用 Java 实现问题也不大,但 Rust 下要实现这个功能会相对更复杂。
流程引擎
流程引擎和逻辑编排看起很相似,有部分低代码产品中还将这两部分合并了,流程引擎和逻辑编排有个最大不同是流程引擎有些特殊的流转功能,比如:
- 如果审批人是自己就自动跳过
- 多人审批是只要一个通过就行还是必须全部
- 回退是要回到上个节点还是最初节点
流程引擎的数据存储更适合用图来表示,还经常要找父节点,因此容易形成循环引用,导致 Rust 下编写起来更加繁琐。
可选方案
综合各个语言下的优缺点,目前可选方案有:
- Java/Kotlin
- 上面所有功能 Java 都能胜任,而且是唯一支持所有国产数据库的方案,主要缺点是内存占用比较大,异步编写麻烦。
- Kotlin 虽然对异步有更好支持,但目前服务端很少人使用。
- Node
- 仅次于 Java 的方案,除了国产数据库之外都能胜任,而且很容易实现异步 IO,主要缺点是性能相对较差。
- Java + Node
- 结合这两个语言的特点同时使用,数据部分用 Java,异步及 JSON 处理用 Node,缺点是有通讯代价。
- Rust + Java
- Rust 在数据库方面还不成熟,需要配合 Java 使用,优点是性能和内存占用都是最好,但 Rust 开发业务逻辑实现成本较高。
- Go + Java
- Go 在数据查询、JavaScript 引擎方面不太适合,需要配合 Java 使用,这个方案整体优点是比 Rust 简单很多。
如果不考虑团队成员熟悉情况,让我选择的话,我个人倾向于 Node+Kotlin 或 Rust+Kotlin。
选择 Kotlin 的主要考虑是国内普遍使用的 JDK 8,而 Java 8 缺少很多重要特性,代码写起来冗余,Kotlin 丰富的语法可以大幅简化,它的缺点是有许多容易导致其他人看不懂的写法,多人开发时需要禁止炫技。
Rust 虽然上手门槛高,但它有个独特优势,就是能轻松嵌入到其它语言中,如果想做低代码平台基础设施,让底层能力可以提供给各种语言使用,除了 C/C++ 之外 Rust 就是目前唯一成熟可靠的语言,其它都差得更远,比如 Zig 虽然语法简洁,但它做不到内存安全,更容易运行时出报错。