写点什么

低代码平台中的元编程 (Meta Programming)

作者:canonical
  • 2023-11-29
    北京
  • 本文字数:5461 字

    阅读完需:约 18 分钟

在众多的编程语言中,爷爷辈的 Lisp 语言一直是一个独特的存在,这种独特性有人把它总结为"Lisp 是可编程的编程语言"。这指的是 Lisp 具有强大的元编程能力,可以由程序员自主创造新的语法抽象。编程通俗的说就是写代码, 而所谓的元编程指的是写生成代码的代码。Lisp 通过宏提供了元编程的能力,而 Lisp 宏本质上就是一种内嵌在语言中的代码生成器。除了 Lisp 语言之外,Scala 和 Rust 这些比较现代的程序语言也提供了所谓的宏的设计,但是宏一般被看作是非常复杂的底层技术,很少进入普通程序员的工具箱。


Nop 平台的 XLang 语言是实现可逆计算原理的核心技术之一,为了落实可逆计算理论所提出的 App = Biz x-extends Generator<DSL>这样一种面向 DSL 和差量编程的新的编程范式,XLang 定义了一整套系统化的、覆盖应用系统开发方方面面的 Generator 方案。Lisp 的宏仅仅是提供了生成 LispAST 的元编程机制,而 XLang 除了引入宏函数用于生成 XLang AST 之外,还提供了面向代码生成的 Xpl 模板语法,生成的范围从局部的函数实现体,到单个模型文件,再到整个模块目录。特别是 Nop 平台中定义的所有 DSL 语言都内置了x:gen-extends这样的差量生成机制,可以在模型解析、加载的过程中动态生成模型差量再自动实现差量合并,从而创造了一种新的软件结构复用手段,解决了很多在传统编程范式下难以处理的技术问题。在本文中,我将简单介绍一下 Nop 平台中所内置的这些元编程机制。

宏函数

XLang 语言中也定义了类似 Lisp 宏的宏函数。所谓宏函数是在编译期执行,自动生成 Expression 抽象语法树节点的函数。


宏函数具有特殊的参数要求,并且需要增加@Macro注解。具体示例可以参见GlobalFunctions


EvalGlobalRegistry.instance().registerStaticFunctions(GlobalFunctions.class) 会将类中的所有静态函数注册为 XScript 脚本语言中可用的全局函数


    @Macro    public static Expression xpl(@Name("scope") IXLangCompileScope scope, @Name("expr") CallExpression expr) {        return TemplateMacroImpls.xpl(scope, expr);    }
复制代码


宏函数的第一个参数必须是 IXLangCompileScope 类型,第二个参数必须是 CallExpression 类型,返回值必须是 Expression 类型。


编译宏函数的时候,会把函数调用所对应的 AST 作为 CallExpression 传入。例如


let result = xpl `<c:if test="${x}">aaa</c:if>`
复制代码


编译 xpl 宏函数的时候 CallExpression 的第一个参数是 TemplateStringLiteral,也就是上面调用中的 XML 文本 <c:if test="${x}">aaa</c:if>。在宏函数中我们可以自行解析这个 XML 文本,然后构造出新的 Expression 对象返回。


利用宏函数机制,结合 XScript 语言中的 TemplateStringLiteral,我们可以很容易的将不同语法格式的 DSL 嵌入到 XScript 语言中。例如,*提供类似 C# LinQ 的 SQL 查询语法*。


let result = linq `select ff from myObject o  where o.value > 3`
复制代码


目前在 Nop 平台中,内置了如下宏函数



因为宏函数在编译期执行,因此用宏函数来实现解析功能可以优化系统执行性能。例如从 XNode 中读取子节点 a 的 b 属性时


  node.selectOne(xpath `a/@b`) 
复制代码


因为 xpath 是一个宏函数,所以它在编译期就会完成解析,在运行期相当于是传送一个常量对象给 selectOne 函数。


通过宏函数可以实现自定义的语法结构,例如 IF(X,Y,Z)会被转换为 if 语句。

面向代码生成的 Xpl 模板语言

Xpl 模板语言是 XLang 语言的一部分,它采用 XML 格式,包含<c:if><c:for>等图灵完备的逻辑运算语法规则。XML 格式的模板语言可以实现 Lisp 同像性,即代码的格式与生成的数据的格式相同


一般的模板语言(例如 Freemarker 或者 Velocity)并不具有同像性,而且它们都只是用于文本生成,并不是真正的支持代码生成。Xpl 模板语言为了支持代码生成,它提供了多种输出模式:


  1. node 模式:输出 XNode 节点。这种方式会保留源代码位置信息,即在最终得到的结果中我们可以知道每个属性和节点到底是那一段源码生成的。

  2. xml 模式:输出 XML 文本,自动对属性和文本内容进行 XML 转义。

  3. html 模式:输出 XHTML 文本,除<br/>等少数标签之外,大部分标签都采用完整格式输出,即总是输出<div></div>而不会输出<div/>

  4. text 模式:不允许输出节点和属性,只允许输出文本内容,而且不需要进行 XML 转义。

  5. xjson 模式:输出 XNode 节点自动按照固定规则转换为 JSON 对象。

  6. sql 模式:输出 SQL 语句,对于表达式输出结果,自动变换为 SQL 参数


例如对于以下 SQL 输出,


<filter:sql>  o.id in (select o.id from MyTable o where o.id = ${entity.id}) </filter:sql> 
复制代码


实际会生成 o.id in (select o.id from MyTable o where o.id = ? ),表达式的值不会直接拼接到 SQL 文本中,而是会被替换为 SQL 参数。

编译期表达式

Xpl 模板语言内置了<macro:gen><macro:script>等标签,它们会在编译期自动执行。


  • <macro:script>表示在编译期执行表达式,比如可以在编译期动态解析 Excel 模型文件得到一个模型对象等



<macro:script> import test.MyModelHelper;
const myModel = MyModelHelper.loadModel('/nop/test/test.my-model.xlsx');</macro:script>
复制代码


得到编译期变量之后,后续表达式可以使用编译期表达式来访问该对象,例如 #{myModel.myFunc(3)}


  • 编译期表达式采用 #{expr}这种形式。编译期表达式会在编译到该表达式的时候立刻执行,直接保留到运行期的是它的返回结果。

  • 在普通的表达式中可以使用编译期表达式,例如 ${ x > #{MyConstants.MIN_VALUE} }

  • Xpl 模板语言在编译期时会自动执行编译期表达式,并根据执行结果进行优化,例如<div xpl:if="#{false}> 在编译期可以获知 xpl:if 的值是 false,此节点会被自动删除。


<macro:gen>的内容是 Xpl 模板语法,它会先编译 body,再执行 body,收集输出结果,然后再编译生成的结果。而<macro:script>的内容是 XScript 语法,并且它会丢弃返回结果

自定义宏标签

Xpl 模板语言中的标签库中可以定义宏标签。宏标签与普通标签的区别在于,宏标签的 source 段在编译之后会立刻执行,然后再收集执行过程中输出的内容进行编译。


比如,我们可以定义一个宏标签<sql:filter>,它可以实现如下结构变换



<sql:filter>and o.fld = :param</sql:filter> 变换为<c:if test="${!_.isEmpty(param)}">and o.fld = ${param}</c:if>
复制代码


具体实现在sql.xlib标签库中



<filter macro="true" outputMode="node"> <slot name="default" slotType="node"/>
<source> <c:script> import io.nop.core.lang.sql.SqlHelper; import io.nop.core.lang.sql.SQL;
const sb = SqlHelper.markNamedParam(slot_default.contentValue); const cond = sb.markers.map(marker=> "!_.isEmpty("+marker.name+")").join(" and "); const sqlText = sb.renderText(marker =>{ return "${" + marker.name + "}"; }); </c:script>
<c:if xpl:ignoreTag="true" test="${'$'}{${cond}}"> ${sqlText} </c:if> </source></filter>
复制代码


上述的宏标签会对节点内容进行结构变换,生成<c:if>节点,然后模板引擎会再对输出的<c:if>节点进行编译,效果等价于手工编写对应节点。


  • 通过slotType="node"的 slot 来直接读取节点内容。slotType=node 时表示不解析 slot 的内容,直接把它作为 XNode 类型的变量。

  • xpl:ignoreTag表示不将当前节点以及子节点识别为 xpl 标签,将<c:if>直接作为普通 XML 节点输出。

  • test="${'$'}{$cond}"中的表达式会被识别,执行表达式后生成test="${cond}"


** 宏标签类似于 Lisp 语言中的宏,它提供了一种简易的 AST 语法树变换机制,相当于是一种内嵌的代码生成器 **

编译得到 AST

通过<c:ast>标签可以得到内容部分所对应的抽象语法树(Expression 类型)。



<Validator ignoreUnknownAttrs="true" macro="true">
<!--runtime标识是运行期存在的变量。这个属性仅当标签是宏标签的时候起作用--> <attr name="obj" defaultValue="$scope" runtime="true" optional="true"/>
<!--slotType=node表示保持XNode节点内容传入到source段中。如果不设置这个属性,则会编译后传入--> <slot name="default" slotType="node"/> <description> 利用宏标签机制将XNode按照Validator模型解析,并转化对ModelBasedValidator调用。 宏标签的source段在编译期执行,它的输出结果才是最终要编译的内容 </description> <source>
<!--在编译期解析标签体得到ValidatorModel, 保存为编译期的变量validatorModel--> <c:script><![CDATA[ import io.nop.biz.lib.BizValidatorHelper;
let validatorModel = BizValidatorHelper.parseValidator(slot_default); // 得到<c:script>对应的抽象语法树 let ast = xpl ` <c:ast> <c:script> import io.nop.biz.lib.BizValidatorHelper; if(obj == '$scope') obj = $scope; BizValidatorHelper.runValidatorModel(validatorModel,obj,svcCtx); </c:script> </c:ast> ` // 将抽象语法树中的标识名称替换为编译期解析得到的模型对象。这样在运行期就不需要动态加载模型并解析 return ast.replaceIdentifier("validatorModel",validatorModel); ]]></c:script> </source></Validator>
复制代码


  • 宏标签的 source 段在编译的时候执行, BizValidatorHelper.parseValidator(slot_default)表示解析标签节点得到 ValidatorModel 对象(这个对象是在编译期存在)。

  • 在 XScript 脚本语言(语法类似 TypeScript)中,可以通过 xpl 模板函数来嵌入 XML 格式的 Xpl 模板代码。

  • ast = xpl <c:ast>...</c:ast> 表示执行 xpl 模板函数,<c:ast>表示仅仅是得到它的子节点所对应的 AST 语法树,而不是执行其中的内容

  • ast.replaceIdentifier("validatorModel",validatorModel) 表示将 ast 语法树中的名称为 validatorModel 的标识符替换为编译期变量 ValidatorModel。这相当于是一种常量替换,将变量名替换为变量所代表的具体的值。因为 validatorModel 是在编译期解析得到的模型对象,所以在运行期完全不需要再进行任何动态解析过程。

  • source 段可以直接返回 AST 语法树节点(对应于 Expression 类型),而不一定需要通过输出 XNode 来动态生成 AST 语法树。(上一节的例子是通过输出来构造 AST 语法树)

  • <attr name="obj" runtime="true">表示 obj 属性为运行时属性,在 source 段中它对应于一个 Expression,而不是它的值。如果没有标记 runtime=true,则在 source 段中可以使用,但是因为宏标签的 source 段是在编译期运行,所以调用时属性值只能是固定值或者编译期表达式。



<biz:Validator obj="${entity}"/>
复制代码

XDSL 的差量生成与合并机制

Nop 平台中所有的 DSL 都支持 x-extends 差量合并机制,通过它实现了可逆计算理论所要求的计算模式


App = Delta x-extends Generator<DSL>


具体来说,所有的 DSL 都支持x:gen-extendsx:post-extends配置段,它们是编译期执行的 Generator,利用 XPL 模板语言来动态生成模型节点,允许一次性生成多个节点,然后依次进行合并,具体合并顺序定义如下:


<model x:extends="A,B">    <x:gen-extends>        <C/>        <D/>    </x:gen-extends>
<x:post-extends> <E/> <F/> </x:post-extends></model>
复制代码


合并结果为


F x-extends E x-extends model x-extends D x-extends C x-extends B x-extends A 
复制代码


当前模型会覆盖x:gen-extendsx:extends的结果,而x:post-extends会覆盖当前模型。


借助于x:extendsx:gen-extends我们可以有效的实现 DSL 的分解和组合。具体介绍参见 XDSL:通用的领域特定语言设计

数据驱动的差量化代码生成器

为了在系统级别实现可逆计算理论所要求的软件构造模式,Nop 平台提供了一个数据驱动的差量化代码生成器 XCodeGenerator。


一般的代码生成器都是针对某个特定目的定制的,比如常见的 MyBatis 的代码生成器,它的控制逻辑由一个特定的 CodeGenerator 类来实现,它负责读取模板,构造生成文件路径,并初始化上下文模型变量,执行循环逻辑。如果我们希望调整代码生成的细节,则一般需要修改这个 CodeGenerator 类。


XCodeGenerator 的做法与传统的代码生成器不同,它将模板路径看作是一种微格式的 DSL,把判断和循环逻辑编码在路径格式中,从而由模板自身的组织结构来控制代码生成过程。例如


/src/{package.name}/{model.webEnabled}{model.name}Controller.java.xgen
复制代码


以上模式可以表示遍历 package 下的每个 model,对每个 webEnabled 属性设置为 true 的 Model 都生成一个 Controller.java 类。


基于这种设计,我们只需要调整模板文件的目录结构,就可以控制目标代码的目录结构和生成时机。


具体介绍参见 数据驱动的差量化代码生成器


XCodeGenerator 可以与 maven 打包工具集成在一起,在 Java 代码编译前和编译后执行代码生成动作,从而起到某种类似 Java 注解处理器(APT)技术的作用。只是它的使用远比 APT 要简单、直观。


具体集成方式可以参见 如何集成Nop平台的代码生成器

基于可逆计算理论设计的低代码平台 NopPlatform 已开源:


发布于: 刚刚阅读数: 4
用户头像

canonical

关注

还未添加个人签名 2022-08-30 加入

还未添加个人简介

评论

发布
暂无评论
低代码平台中的元编程(Meta Programming)_低代码_canonical_InfoQ写作社区