@windwolf
2021-01-13T09:52:18.000000Z
字数 11536
阅读 2081
Sailing QSS
传统上,现代企业级应用的开发需要多种编程语言的配合,后端需要使用java、c#等,前端需要使用javascript。这些编程语言具有极强的通用性,因此在各个领域都能看到其身影。但对全顺科技这样一家专注于国际贸易、供应链管理等领域的软件开发的企业来说,这类编程语言的通用性并没有发挥作用,而缺乏领域相关特性、对语言使用者的技术背景要求较高等问题,却制约了开发效率的进一步提升。
针对以上问题,需要引入一种脚本语言。该语言应该针对日常经常面对的一系列场景,提供一套优化的数据和逻辑处理特性。应该能屏蔽通用语言复杂的技术细节,让使用者能在问题域,而不是计算机技术域描述问题、解决问题,提高解决领域问题的效率,降低技术门槛,甚至使得领域专家也能参与到软件开发的过程中来。
为此, 我们设计了QSS语言. 它的语法类似于js, 并且也是图灵完备的. 但针对基于全顺科技软件产品线的应用场景拓展了一系列特性, 可以更方便地表达数据处理, 流程控制, 规则定义等方面的语意.
全顺科技地软件产品线都是前后端组合的架构, 因此QSS在前后端(浏览器端, 服务器端)都有完整的实现, 因此同一个脚本可以按需在前端或者后端执行, 并可以与前端的js和后端的java交互.
QSS语言框架还也提供了一系列扩展点, 包括: 外部变量, 外部函数, 类型定义, 成员变量定义, 成员函数定义, 操作符重载.
QSS语言的特点总结如下:
和绝大多数语言引擎类似, QSS语言引擎采用了前端后的架构, 负责解析语言文本的前端和负责执行实际语义的后端. 如下图所示:
前端解析引擎定义了一整套QSS的词法和文法规则, 并通过ANTLR作为主要工具, 使用了LL(*)算法, 将语言文本解析成内部的AST(抽象语法树).
后端执行引擎主要按深度优先的规则遍历AST, 执行AST中各个节点所指定的操作. 最后得出结果. 执行引擎在执行过程中, 需要和上下文以及外部环境交互, 此时由上下文解析系统提供交互渠道, 上下文解析系统还提供了扩展接口. 执行过程中对不同的结果(包括中间结果, 最后结果和上下文对象)类型有不同的处理, 这是由类型系统来提供支撑的, 类型系统也提供了扩展点. QSS还支持操作符的重载, 这是执行引擎也并不直接执行操作符的逻辑, 而是委托给操作符执行系统, 由其完整操作符语义的选择和执行.
QSS作为一种脚本语言, 其主要作用就是和执行上下文和外部环境交互, 而且QSS会用于多种外部环境, 例如: 验证规则检查, 业务逻辑执行等, 因此, 执行引擎需要提供可扩展的上下文解析系统.
上下文解析系统主要分两个模块: 一个负责上下文解析, 一个负责外部环境解析.
可扩展的IdentityContext提供了上下文解析能力. 系统内置了Variable, StatementBlock, Closure, Data四种上下文, 分别用于支持局部变量, 语句块, 闭包和通用数据. 前三种用于支撑QSS中的嵌套作用域和lambda表达式的闭包体系, 最后一种提供了对其他数据的通用访问能力. 此外, 外部系统也可以扩展IdentityContext, 用于支持其他场景, 例如PartitalQuery用于支持动态查询, RuleScope用于前端计算规则引擎中的规则作用域等.
可扩展的ExternalContext提供了与外部环境进行交互的渠道. 外部环境主要包括外部变量和外部函数. QSS不限制使用场景, 所以仅提供扩展点, 不内置具体实现. 外部系统已经扩展了通用函数(Common), 基础代码(BasicCode)等场景特定的实现.
QSS提供了对多种常用类型的支持, 并支持类型扩展, 这个能力由类型系统提供. 类型系统定义了作为一个类型能够支持的操作列表并实现具体操作. 例如, 对String类型, 可以通过length成员变量访问字符串的长度, 可以通过索引获取某个指定位置的字符. 系统内置了对字符串(String), 数值(Number), 布尔(Boolean), 日期/时间(Temporal), 基本对象(Object), 列表(Vector), 字典(Map), 空值(Null), 匿名函数(LambdaClosure)的支持. 此外, 外部系统也可以一系列实现, 例如: 实体对象(Entity), Excel工作表(Workbook)等.
QSS支持操作符重载, 这是由操作符执行系统提供的能力, 事实上, 操作符的基本用法也是通过操作执行系统完成的. 例如: 同样的加号+对数值类型来说, 表示算术加法; 对字符类型来说, 表示字符串连接. 当执行一个支持扩展的操作符时, 操作符执行形式, 首先根据操作数的类型选择需要执行的具体实现, 然后执行具体的实现. 操作符执行系统支持对加法(+), 减法(-), 乘法(*), 除法(/), 取反(-), 相等判断(=, !=), 比较操作(<,<=,>,>=)的扩展.
根据使用场景的需要, QSS可以作为表达式来使用, 也可以作为完整的程序来使用.
一段完整的QSS程序是由一系列QSS语句组成中, 每个语句以分号;结尾. 每种语句都有自己的结构, 常用的语句有赋值, 条件分支, 循环, 返回等.
双斜杠//后面的内容为注释.
// 以下代码摘自真实案例let company = #getRelatedCompanyById(f.明细.first().工厂.id); // #开头的是外部导入的函数if (company.银行 != null && company.银行.count() > 0) {let defbank = company.银行.filter(m=>m.默认);if(defbank != null) {return defbank.fisrt().开户银行;}else {return company.银行.first().银行帐号;}}return '';
虽然QSS运行在其他语言(前端的js或后端的java)之中, 但他有自己的类型系统.
QSS内置的基本类型有:
数值类型包括了整数和浮点数, QSS本身对所有整数和小数同样处理.
数值可以有多种表示方法:
1000 //整数10000x3f25a1 //十六进制整数表示. 以'0x'或'0X'开头-34572316454236 //大整数-34572316454236
在需要和外部环境(js或java)交互的时候, QSS会根据值的内容自动选择合适的类型, 但有些场景中, 外部环境对类型有特别的要求, 此时需要对类型做特别的说明. 例如:
64563L // 长整型
NOTE: 正常情况下, 64563对应java的Integer, 但如果外部环境要求Long类型, 那么加入
L或l后缀, 明确告诉QSS应该使用Long类型.
1234.56 //小数1234.56-1234.0 //小数-1234.0234.123e3 //用科学计数法表示的小数, 数值部分和指数部分的分隔符可以是'e'或'E'-4.54E-5
用单引号或双引号包围的字符. 例如:
'ABCDE' //单引号包围"我是QSS" //双引号包围
布尔值只有两种可能的值: 真或假.
布尔真只有三种表示形式.
TRUE //全大写true //全小写True //大写开头, 后面小写
布尔假也只有三种表示形式.
FALSE //全大写false //全小写False //大写开头, 后面小写
和java不同, QSS把空值作为一种单独的类型. 这个类型只有一种可能的值. 他的表示方法是:
NULL //全大写null //全小写Null //大写开头, 后面小写
QSS的使用场景中, 有大量列表, 数组处理, 因此QSS把列表也作为基础类型对待. 可以使用方括号[]来定义一个列表. 列表中的每一项可以是任意表达式. 例如:
[1, 2] // 定义了一个包含两个元素的列表[1] // 定义了一个包含一个元素的列表[] // 一个空列表[1, '1'] // 列表中的元素可以是多种不同类型[[1, 2], [3, 4]] // 一个二维列表[a, #func1()] // 第一个元素是上下文中的a, 第二个元素是调用外部函数func1的返回值.
可以使用new运算符定义一个新对象. new运算符有两种用法:
new (field1=1, field2="234") // 创建一个自由对象, 并设置两个属性.
对java来说, 一个自由对象就是一个Map. 以上代码创建了一个Map, 并添加了两项, 第一项的key为field1, value为1; 第二项的key为field2, 值为"234".
对js来说, 一个自由对象是一个js对象. 以上代码创建了一个js对象, 并添加了两个属性, 第个属性的名称为field1, 值为1; 第二个属性的名称为field2, 值为"234".
new 出运#明细单(1, "234", 编码 = "ccc") // 创建一个[出运#明细单]对象, 调用有两个参数的构造函数. 并将编码字段赋值为"ccc"
与创建自由对象相比, 最大的区别在于new关键字后增加了标识符, 这个标识符表示要创建的对象的原型名称, 这里表示实体ID, 这种用法主要是对后端java来说. 前端js暂不支持.
此外, 创建预定义对象的时候, 括号中的内容也有所变化, 前两个直接对应构造函数的参数, 后面带有名称的参数这会在对象构件之后, 对对应的字段赋值.
变量使用一个标识符来命名.
_或中英文字母(不包括数字)开头, 后续为_或中英文字母或不包括数字所组成, 中间不能包含空格, 所有的标识符区分大小写. 此外所有的关键字或保留字不能作为标识符.
目前所有的关键字和保留字如下:switchcasedefaultendwithreturnletifelseforinwhilebreakcontinuesyncawaitbeginnewTRUEtrueTrueFALSEfalseFalseNULLnullNullnumberstringStringNumber
以上规则存在一个例外, 如果因为某种原因, 标识符必须使用关键字, 那么可以在标识符前加上@. 例如: @let.
变量可以是外部导入变量, 局部变量或成员变量.
使用以冒号:开头, 后接变量名的方式来访问, 例如:
:abc + :_efg // abc和_efg都有外部导入变量
框架内置了大量的外部导入变量, 一部分外部导入变量是通用的, 还有一部分是针对特定场景的. 具体可用的外部导入变量参考QSS参考手册.
可以使用let关键字定义局部变量. 定义变量时, 可以在变量名后附加类型注解, 类型和变量名之间使用:分隔. 例如:
let var1:number = 1234; //定义了名叫var1的变量, 并进一步说明变量的类型为number, 并赋值为1234let _temp = "1234"; //定义了名称_temp的变量, 并赋值为"1234"
定义变量的同时, 必须对变量赋值. 一个可行的做法是, 仅在需要使用的时候定义变量.
成员变量是定义在对象中的变量, 在下面介绍对象的章节中一起介绍.
QSS提供了各种常用的操作符. 包括:
加法+, 减法-, 乘法*, 除法/, 取相反数(单目)-.
2 - 3 // 1-2 * 3 // -62 + 2 * 3 // 8(2 + 2) * 3 // 12
算术操作符遵循一般的结合特性, 先取反, 再乘除, 然后加减.
算术操作的结果一般来说都是数值, 但也有例外, 比如两个字符串相加的结果是拼接在一起的字符串.
需要特别说明的是算术操作对null值的处理:
由于QSS支持操作符重载, 因此操作符的具体含义是由具体的值类型决定的, 完整的操作语义在语言参考手册中提供.
大于>, 大于等于>=, 小于<, 小于等于<=, 等于==, 不等于!=.
t1 = 1;t1 >= 3; // True减法let t3 = -t3; // 取反t3 = t1 * t2; // 乘法t2 = t3 / t2; // 除法
比较操作符遵循一般的结合性, 先大于/小于/大于等于/小于等于, 再等于/不等于.
比较操作的结果都是布尔值. 尽管QSS支持操作符重载, 但为了保持语义的完整性, 对比较操作符做了限制, 只能返回布尔值或布尔值的兼容类型.
和&&, 或||, 取反!.
a && bt1 || t2!c
布尔操作符也遵循一般的结合性, 先取反, 再和, 最后或.
java和js中都有针对条件选择的三目问号表达式, 但qss中对问号表达式做了加强. 一个问号表达式是由问号?分隔的两部分组成. 问号前面为分支条件, 问号后面是多个分支动作.
分支条件是个表达式. 每个分支动作为用冒号:分隔的两部分: 前面为分支标签, 必须为常量; 后面为分支结果, 可以是任意表达式. 最后一个分支只有分支结果表达式, 且不加;, 类似于java或者js中的default. 例如:
label ? ('A': 1; 'B': 2; 0) // 如果label='A', 则表达式的值为1; 如果label='B', 则表达式的值为2; 其他情况, 表达式的值为0
分支条件可以是任意表达式, 表达式的求知结果会依次与每个分支的标签做比较, 如果匹配则返回分支结果表达式的值. 可以把问号表达式理解成java或js中switch语句的表达式版本.
QSS的if语句和java或者js中的if-elseif-else语句完成一致. 下面是一个例子:
if (i<o) {// TODO: do something...}else if (i==0) {// TODO: do otherthing...}else {// TODO: do otherthing...}
QSS中switch语句基本上和java或者js中的一致, 但做了若干扩展. 来看一个例子:
switch (someStr) {case 'A': // someStr == 'A'#func1();case 'B', 'C': // someStr == 'B' 或者 someStr == 'C'#func2();default: // 其他情况#func0();}
主要有两点不同:
1. 可以不在每个case的最后加入break; 上一个case结束后, 如果没有break, 也不会执行下一个case;
2. 一个case后面可以跟多个label, label之间以逗号,分割. 只要匹配到任意一个label都会执行里面的内容.
QSS中的循环使用for语句, 来看一个简单的例子:
for (let v in list) {#print(v);}
in之后的list表达式为循环的内容. 花括号之间的是循环体, 如果循环体中只有一条语句, 也可以省略花括号.
in之前的v变量为循环中当前迭代的内容, 这部分还有另一种用法:
for (let a, b in list) { //a变量代表迭代的值, b变量代表迭代的序号, 从0开始.#print(b);}
在循环体中可以使用break, continue控制循环流程. 例如:
for (let a in list) {if (a == -1) {break; // 如果a等于-1, 中断循环, 跳出}else if (a == 0) {continue; // 如果a等于0, 则跳过, 执行下一个迭代}else {#print(b); // 其他情况打印出元素}}
QSS中的函数主要由三种, 外部导入的函数, 成员函数
函数名称加井号#前缀, 表示调用的是外部导入的函数, 和java和js类似, 函数名称之后圆括号里面是需要传入的参数, 传入参数支持位置参数和命名参数, 例如:
#externalFunction(1, 'arg2', option1 = 3) // 1和'arg2'是位置参数, option1=3是命名参数
如果有位置参数的话, 命名参数必须跟在位置参数之后.
一般来说, 位置参数都是必须要传的参数, 命名参数都是可选参数. 但这一点并不是硬性要求.
成员数据是定义在对象中的函数, 在下面介绍对象的章节中一起介绍.
QSS作为脚本语言需要和js或java等外部环境交互, 因此, QSS也支持对象操作. 实际上, QSS把基础类型和其他类型都当作对象来处理.
可以通过句点.操作符来访问成员变量.
obj1.memberA // 访问obj1对象中的memberA成员变量obj1.memberA.memberB // 访问obj1对象中的成员变量memberA, 而memberA也是一个对象, 继续访问这个对象的memberB
值得一提的是, 即使第一个表达式中的obj1为null, 表达式也不会出错, 而是返回null; 同理, 第二个表达式中的obj1或者obj1.memberA为空, 表达式也不会报错, 而是返回null. 这个特性可以避免实际使用中大量的null值检查逻辑.
如果希望对象为空时访问成员报错, 这可以使用!.操作符, 例如:
obj1!.memberA // 如果obj1为null的话, 会报错obj1!.memberA!.memberB // 如果obj1为null, 或者obj1不为null而obj1的memberA为null的话, 会报错
也可以使用赋值语句为成员变量赋值.
obj1.memberA = 1; // 将obj1对象中的memberA成员变量赋值为1
同样的, 默认情况下, 如果obj1为空, 那么也不会报错, 什么都不会发生. 如果需要强制报错, 可以使用!.. 例如:
obj1!.memberA = 1; // 如果obj1为空, 则报错.
对于支持索引访问的对象(例如列表对象), 可以通过方括号[]操作符来访问成员索引.
obj1[1] // 访问obj1对象中索引位置为1的值.obj1[1][5] // 访问obj1对象中索引位置为1的值, 并且继续访问该返回对象中索引位置为2的值.
和成员变量访问类似, 默认情况下宿主对象为null时, 直接返回null. 如果希望强制报错, 使用![]方式访问, 例如:
obj1![1] // 如果obj1为null的话, 会报错obj1![1]![2] // 如果obj1为null, 或者obj1不为null而obj1[1]为null的话, 会报错
也可以对索引位置赋值. 例如:
let obj1 = ['A', 'B', 'C']; // ['A', 'B', 'C']obj1[1] = 'D'; // ['A', 'D', 'C']
同样的, 如果宿主对象为null, 索引位置赋值什么都不发生. 如果需要强制报错, 使用![], 例如:
obj1![1] = 'D'; // 如果obj1为null, 则报错
可以使用.访问成员函数, 成员函数的传参规则和外部函数类似. 例如:
obj1.func1(1, opt='A') // 调用obj1对象中的hunc1函数. 传入参数1, 和附加的命名参数opt='A'obj1.func1(1).func2(2) // 调用obj1对象中的hunc1函数, 函数的返回值也是一个对象, 再次调用该对象的func2函数
和其他成员访问类似, 默认情况下宿主对象为null时, 直接返回null. 如果希望强制报错, 使用!.方式访问, 例如:
obj1!.func1(1) // 如果obj1为null, 会报错obj1!.func1(1)!.func2(2) // 如果obj1为null, 或者调用obj1的func1函数的返回值为null, 都会报错.
QSS提供了对lambda表达式的完整支持, 包括: 通过lambda表达式定义函数, 函数闭包, 参数部分绑定等特性.
可以通过箭头=>来定义lambda表达式. 一个典型的lambda表达式如下:
(a, b:number) => { // 可以传入两个参数, 其中第二个参数的的类型为numberif (a > b) {return a;}else {return b;}}(a, b) => a+b // 如果执行体是一个表达式, 那么可以省略两边的花括号
=>前面的部分为形参列表, 形参之间用,分隔. 每个形参包括必须的参数名称和可选的类型注解, 中间用:分隔.
=>后面的部分为执行体, 执行体可以是一个表达式, 表达式的值就是lambda表达式的返回值; 执行体也可以由多条语句组成, 此时需要由花括号{}包围起来, 且需要显式使用return语句提供返回值.
在QSS中, lambda表达式也是first-class值, 可以赋值给变量, 也可以作为参数传递或者作为返回值返回.
let add = (a, b) => a+b; // add为lambda表达式#func1(add); // lambda表达式作为参数传入return add; // lambda表达式作为返回值返回
lambda表达式可以通过.apply()来调用.
let add = (a, b) => a+b; // add为lambda表达式let b = 2;#print(add.apply(1, b)); // 3
QSS的应用场景中经常会出现需要对一个列表的每个元素执行相同的动作. 常规做法如:
let source = [1, 2, 3, 4];let added = [];let normed = [];let sum = #sum(source);for (let element in source) {added.append(element + 1); // 对一个列表中的每项都加上1normed.append(element / sum); // 对每一项都除以合计值}// 或added = source.map(ele => ele + 1); // map函数会作用到source中的每个元素. 对一个列表中的每项都加上1normed = source.map(ele => ele / sum); // map函数会作用到source中的每个元素. 对每一项都除以合计值#print(added); // [2, 3, 4, 5];#print(normed); // [0.1, 0.2, 0.3, 0.4];
向量化的做法:
let source = [1, 2, 3, 4];let added = source + 1; // 对一个列表中的每项都加上1let sum = #sum(source);let normed = source / sum; // 对每一项都除以合计值#print(added); // [2, 3, 4, 5];#print(normed); // [0.1, 0.2, 0.3, 0.4];
以上代码的第3行中, source是一个列表, 而1是一个标量值, 此时加法操作会将标量值1广播到列表中的每一个元素.
第5行也是同样的原理.
此外, 两个列表之间的运算也是向量化的:
let source1 = [1, 2, 3, 4];let source2 = [10, 20, 30, 40];#print(source1 + source2); // [11, 22, 33, 44]
两个列表之间的运算, 事实上会分解成第一个列表的第一个元素和第二个列表的第一个元素运算, 第一个列表的第二个元素和第二个列表的第二个元素运算, 以此类推. 如果两个列表长度不一致, 会报错.
所有的算术运算和比较运算都支持向量化. 布尔运算暂不支持向量化.
我们还经常需要提取某个列表中每个元素的指定字段. 常规做法如下:
let source = [new (a=1, b=2, c=3), new (a=4, b=5, c=6)]; // source是个列表, 列表包含两个元素, 每个元素都包含a, b, c三个字段.let result = [];for (let element in source) {result.append(element.a);}// 或result = source.map(element => element.a); // map函数会作用到source中的每个元素.#print(result); // [1, 4]
向量化的做法:
let source = [new (a=1, b=2, c=3), new (a=4, b=5, c=6)]; // source是个列表, 列表包含两个元素, 每个元素都包含a, b, c三个字段.let result = source.a; // .a的成员访问会广播到列表中的每一个元素#print(result); // [1, 4]
以上代码第三行中, source是一个列表, 那么对source访问成员变量a的操作也会广播到source中的每一个元素.
需要注意的一点是, 向量化的操作会自动对层级嵌套的列表做一个拉平(flat)操作, 例如:
let source = [[new (a=1), new (a=2)], [new (a=3), new (a=4)]];#print(source.a) // [1, 2, 3, 4]
以上代码中, 尽管source有两层列表, 但在向量化操作前, 会首先把source拉平成一个一维列表[new (a=1), new (a=2), new (a=3), new (a=4)].
以下是另外几个拉平的例子:
[[1, 2, 3], [4, 5]]拉平后变成[1, 2, 3, 4, 5], [[1, [2, 3], 4, 5,], [6, 7, 8]]拉平后变成[1, 2, 3, 4, 5, 6, 7, 8]
下面我们来分析一个更实际的案例:
let mxd1 = new 出运#明细单(id = 1,商品明细= [new 出运#明细单_明细(货号='A',数量=1000.00,BOM=[new 出运#明细单_明细_BOM(物料编号='B1', 单耗=1),new 出运#明细单_明细_BOM(物料编号='B2', 单耗=2),]),new 出运#明细单_明细(货号='B',数量=2000.00,BOM=[new 出运#明细单_明细_BOM(物料编号='B3', 单耗=3),new 出运#明细单_明细_BOM(物料编号='B4', 单耗=4),new 出运#明细单_明细_BOM(物料编号='B5', 单耗=4),]),]);let mxd2 = new 出运#明细单(id = 2,商品明细= [new 出运#明细单_明细(货号='A',数量=1000.00,BOM=[new 出运#明细单_明细_BOM(物料编号='B1', 单耗=1),new 出运#明细单_明细_BOM(物料编号='B2', 单耗=2),]),new 出运#明细单_明细(货号='B',数量=2000.00,BOM=[new 出运#明细单_明细_BOM(物料编号='B3', 单耗=3),new 出运#明细单_明细_BOM(物料编号='B4', 单耗=4),new 出运#明细单_明细_BOM(物料编号='B1', 单耗=4),]),]);let source = [mxd1, mxd2];
以上代码定义了两个明细单, 这两个明细单的内容除了id以外都一样. 这两个明细单中都涉及两个货号的商品, 'A'和'B', 'A'由两个子物料组成, 'B'由3个子物料组成. 最后的source为包含这两个明细单的列表.
#print(source.商品明细); // 获取这两个明细中所有的明细#print(source.商品明细.BOM.filter(b => b.物料编号='B1')); // 获取所有物料编号为'B1`的物料信息
我们来分析以上代码的第一个表达式. source.商品明细表示向量化访问source的商品明细成员变量, 最后的结果会将商品明细拉平成一维商品明细列表.
第二个表达式中, 第一部分source.商品明细和之前的一样, 随后第二部分.BOM进一步向量化访问已经拉平的商品明细列表中的BOM, 得到拉平的商品明细BOM. 随后对BOM列表做过滤, 保留下物料编号等于'B1'的元素.
为了不与列表原有的函数混淆, 成员函数访问不支持向量化.
可以通过类型扩展机制让