面向初学者的 SPL 概念

作为程序语言,SPL 有一些自有的特点,不了解这些并不影响上手学习,但在架构设计或代码编写上都难以充分发挥其功能。这里试图为初步者建立一套 SPL 的基本概念骨架,其它更多的内容再根据实际需求参考相关文档就可以了。
这些内容也不太少,需要花数小时时间仔细研读理解。

一. 应用结构

首先要了解 SPL 的运行环境及应用结构。

1. 脚本与开发

SPL 是一种解释执行的程序语言,写出来的代码通常不大,一般称为脚本
SPL 脚本的提交形式一般是文件,扩展名是 **.splx**,不是文本格式。SPL 也支持文本格式的脚本文件,扩展名是.spl。
SPL 提供了专门的 IDE 用于编写 SPL 脚本,IDE 支持常规的调试功能。IDE 是个客户端程序,可以运行在 Windows,Linux 以及 Mac 上

2. 环境与集成

SPL 软件可以分为三个部分:IDE、JDBC 和 Server。全部是纯 Java 开发,可以在任何有 JDK1.8 以上版本的 JVM 的环境下运行。

SPL JDBC 不是独立进程,它由一批 jar 组成,可以被引入到 Java 应用中,向应用提供标准 JDBC 接口。
SPL JDBC 和传统 RDB 的 JDBC 驱动对比:
1) SPL JDBC 包含了完整的运算库,单独就能提供计算服务,不再依赖于某个服务器工作
2)接受的语句是 SPL 而不是 SQL,执行某个.splx 脚本类似于使用 SQL 执行 RDB 中的存储过程

SPL Server 是个独立进程,可对外提供基于 HTTP 协议的服务,可供非 Java 应用调用。也可被 SPL JDBC 访问调用。
多个 SPL Server 可以构成集群。很多大数据方案都会强调集群的作用,但 SPL 的多次实践表明单机能够解决绝大部分任务,除非高并发需求,否则很少用到集群,初学者不必关注集群。

3. 数据源和外部库

SPL 支持多样性数据源,只要有 Java 接口的数据源均可访问和计算。
SPL 内置有文本文件、Excel 文件、有 JDBC 驱动的关系数据库、HTTP/Restful 访问接口,可以直接访问这些数据源。
SPL 为其它数据源提供了各种外部库,引入相应的外部库后即可在 SPL 脚本中访问相应的数据源,具体的读写能力由数据源本身决定。业界内常见的数据源都有对应的外部库。

SPL 将数据源抽象成两种形式:序表游标,序表将被一次性读入内存处理;如果数据源支持,SPL 还可以使用游标逐步读入数据并处理。
SPL 并不要求把所有外部数据读入内存才能计算。不要把 esProc 理解为内存计算引擎。当然它可以保持一定的内存数据以用作内存计算引擎。

SPL 没有“库”的概念,任何能访问到的数据源在逻辑地位上是等同的,仅仅是数据源本身的功能以及访问的性能不同。esProc 也不负责数据本身的管理及其安全性,只负责计算。

二. 脚本和语法

SPL 代码的基本逻辑和概念和大多数程序语言类似,对于有经验的程序员非常简单,但还有些 SPL 特有的内容需要关注。

1. 变量

SPL 可以使用常规的变量名,但更推荐直接使用单元格作为变量名。
表达式中引用的单元格名在编辑过程中会自动变迁,但字符串中的不会,用 $[…] 表示字符串时,其中涉及到单元格也会在编辑过程被变迁。
SPL 的变量具有泛型性,不需要声明数据类型,计算出什么就是什么。
在 SPL 表达式中也可以临时使用变量。

2. 函数

SPL 中有大量函数选项的语法以区分某个函数可能的轻微不同运行状态,选项可以组合使用,了解函数功能时要特别注意相关的选项。

replace( s, s1, s2 ) 替换子串
replace@1( s, s1, s2 ) 只替换第一个
replace@c( s, s1, s2 ) 匹配时大小写不敏感
replace@c1( s, s1, s2 ) 匹配时大小写不敏感且只替换第一个

SPL 使用层次参数来描述有结构的复杂参数,各层次的参数分别用冒号、逗号、分号来分隔,这和常规语言中仅用逗号分隔参数有较大不同,在了解函数定义时要注意参数层次。

if(a,b,c) 常规一层参数,逗号
between( x, a:b) 两层参数,逗号和冒号
hash(xi,...;n) 两层参数,逗号和分号
case(x1:y1,x2:y2,...; y ) 三层参数

3. 对象

SPL 有对象的概念,许多函数是封装在对象上的。
SPL 没有继承和重载这些面向对象的重要机制,严格地说,SPL 不算是个面向对象的语言,从这个意义上讲 SPL 要比 Java 甚至 Python 都简单。SPL 对象的主要作用在于封装一系列相关的函数。

4. Lambdba 语法

SPL 支持 Lambda 语法,从某种意义上可以说是函数式语言(它也支持过程)。
SPL 不支持为 Lambda 语法定义变量名,而是使用固定符号来表示 Lambda 语法中的变量,~ 表示当前成员,# 表示当前成员的序号,还可以用 ~[i] 和 ~[a:b] 表示相邻成员及子集。

A.sum( ~*~ ) 计算平方和
A.sum( if(#%2==0,~) ) 计算偶数位置的成员的和
A.max( ~-~[-1] ) 计算出最大差分值
A.( ~[-1,1].avg() ) 计算移动平均

某个参数是否可以写成 Lamdba 语法,由函数本身决定,在了解函数定义时需要注意。

参考 SPL 看 Lambda 语法

5. 宏

SPL 是动态语言,可以在执行过程中使用宏来临时改变语句

像 SQL 一样,SPL 语句中涉及到表名和字段名时大部分情况下都可以直接书写,不必写在字符串中,比如:

file("employee.txt").import@t( ID, Name, Gender, Department )

可以从 employee.txt 读出 ID,Name,Gender,Department 字段构成数据表,这样书写更为方便,可读性也好。
但是,如果希望 import 的参数变动的(可能根据代码上下文改变要读出的字段),就不太方便描述了,写成

file("employee.txt").import@t( "ID, Name, Gender, Department" )

会被 import 函数将 "ID, Name, Gender, Department" 整个作为一个字段名处理,显然不是我们想要的。

使用宏可以解决这个问题:

S="ID,Name,Gender,Department"
file("employee.txt").import@t(${S})

宏的本质是一个字符串,将这个字符串拼入待执行语句中,即可获得新的可执行语句。

参考 SPL 的宏

三. 数据对象

SPL 中最重要的数据对象有三种,这是用 SPL 处理数据的基础。

1. 序列

序列是内存中的有序集合。
SPL 中的集合都是有序的,也就是可以有序号来访问其成员,成员在集合中明确的前后次序,这和 Java 中的数组是一致的。

SPL 的序列有泛型性,不要求序列成员数据类型相同,序列的成员可以是任何数据类型,以下都是合法的序列:

[1,2,3]
["1","2","3"]
[1,"2",3]
[1,[2,3],4,5]

特别地,针对集合成员循环时可以直接写

for A
    在循环体中可以直接引用A的成员

而不推荐写成

for i,1,A.len()
    在循环体内用A(i)引用A的成员

更不要写成

i=1
for i<=A.len()
    在循环体内用A(i)引用A的成员
    i+=1

和 SQL 不同,SPL 提供了序列这种无数据结构的数据对象,在 SQL 中只能表示成单个字段的表。
SPL 的集合化函数常常是针对序列设计的,比如过滤、聚合、分组等运算,而不必针对有结构的数据表。
序列是比数据表更基础的数据对象,学习 SPL 要习惯于使用更轻量级的序列,而不是总是生成有结构的数据表。

2. 序表

序表是内存中的数据表。
SPL 延用了 SQL 中记录、字段以及数据表的概念,序表可以理解为有相同数据结构的记录构成的集合。
SPL 中的集合总是有序,序表表作为记录的集合也不例外,故称为序表。
序表也是个序列,成员也可以序号访问,针对序表成员的循环也推荐使用前述的语法。

SPL 的序表也有泛型性,序表中记录的数据结构要求相同,但对字段的取值并没有限制。序表中不同记录的同一字段的取值可以是不同数据类型的(尽管并不常见)。
特别地,字段取值甚至可以是另一个序表。这样,在 SPL 中可以很容易表示 json 式的多层数据结构。

SPL 的记录也是一种数据对象,可以从序表中取出再参与运算。这和 SQL 的概念不同,SQL 并没有记录数据类型,单条记录事实上一个只有一条记录的数据表。

SPL 还允许序表中的记录再构成新集合,由记录构成的序列称为排列
特别地,从某个序表中按某种条件过滤出来(SQL 中的 WHERE 运算,SPL 中的 select 函数)的子集就是一个排列。而构成这个排列的成员记录和原来序表中的记录是同一个对象,改变这些记录中的字段值会导致原序表中中记录相应的记录值也改变,SPL 并不会复制这些记录,这样能获得更好的计算性能以及占用更小的内存空间。这一点和 SQL 非常不同,SQL 中 WHERE 运算的结果集和原数据表没有关系,是复制出来的新记录。
排列也支持泛型性,构成排列的记录可以来自不同序表,即数据结构不同的记录可能拼到一起运算,当然这种情况并不常见。

在 SPL 中,记录从序表中游离出来单独存在或再构成集合后参与运算的情况很常见,称为离散性,这是 SPL 和 SQL 的本质差别之一。这一点其实和 Java 的对象引用机制是一致的,Java 程序员很容易理解这种数据类型。

SPL 中,记录的字段取值允许是另一个序表的记录,这样可以实现多表之间的关联引用。比如

D=file("department.txt").import@t()
E=file("employee.txt").import@t()
E.switch( DeptID, D:ID) 将E表的DeptID字段值改成D表中对应的记录
D.switch( Manager, E:ID ) 将D表中Manager字段取值改成E表中对应的记录
E.select( DeptId.Manager.Gender=="Female" ) 现在可以直接引用字段值存储的记录的字段值

类似地,字段取值还可以另一个排列,这样可以实现主子表的结构。

D=file("department.txt").import@t()
E=file("employee.txt").import@t()
D.derive( E.select( DeptID==D.ID ):Employee ) 在D表增加Employee字段取值为该部门的员工记录构成的排列
D.select( Employee.len()>10 ) 使用这个取值为排列的字段

这种通过记录和排列实现的表间引用关系在 SPL 中很常见,也是 SQL 中没有的内容,而在 Java 中很容易实现这种效果。

3. 游标

外存不像内存那样适合随机访问,经常只能提供流式的访问机制,需要在这种前提下设计计算方案。也有很多运算并不需要将源数据全部装入内存,只要逐步读入数据进行累积计算就可以,比如求和。这样可以用较小的内存处理较大的数据量。
SPL 中,可以流式读入的数据对象被抽象为游标,SPL 游标的基本概念特征和数据库游标类似。
和数据库游标一样,SPL 游标也是单向性,只能向后读出数据直到取完所有数据,不能倒退回去。

SPL 游标读出的数据通常会被组织成序表或序列(序表更常见),和 SQL 每次只能读出一条记录不同,SPL 提供了从游标中批量读出数据的方法

cs=file("Orders.txt").cursor@t()
cs.fetch( 100 ) 读出100记录构成序表
cs.fetch( ;TradeDate<=date("2022-12-31") ) 读出一直能使条件满足的记录(注意不是按条件过滤所有记录)
cs.fetch( ;UserID ) 读出一直到UserID字段发生改变之前的记录

后一种读数方式在很多场景非常有用,是 SPL 特有的机制。

为了调试方便,SPL 还提供了选项

cs.fetch@0( 1 ) 读出1条记录,但游标位置并不移动,下次再读还能将这条记录读出

这样可以先看看游标中的数据是否正常,而不会破坏整体代码的执行。

SPL 的游标不仅有读数功能,还可以在上面附加运算,比如

cs.select( ... ) 过滤
cs.groups( ... ) 分组汇总

游标上的运算函数在语法上和序表(排列)上的相应函数非常相似,保证内外存运算的代码尽量统一。但仍要注意这两者是不等同的。

游标上的运算函数可以分为两大类,一类称为延迟计算,执行此类函数时仅仅是做个标记,并不会实质性地遍历游标。另一类称为立即计算,会实质地遍历游标并计算出结果。

cs2=cs.select( ... ) 延迟计算,仅仅在游标上登记会有个过滤动作,不会立即遍历游标,返回一个新游标
cs2.groups(...) 立即计算,马上实施对游标的遍历和计算返回计算结果,之前在游标上登记过的延迟计算也将被执行

这一点和序表(排列)不同,序表(排列)上的运算函数都会立即执行并返回相应的结果。

游标只能遍历一次,完成遍历后就失效,不像内存的序表可以反复运算。有些基于游标的运算要求数据有序,如集合的交、并、差运算。
SPL 为游标提供了遍历复用的机制,可以在一次遍历过程中计算出多种结果。
SPL 还提供特有的多路游标,可以将数据拆分成(和存储方案有关)多段后并行遍历,充分利用多核 CPU 和 SSD 的并发能力。数据库游标没有直接的并行能力,写出并行代码非常困难。

四. 运算理解

SPL 提供了丰富的结构化数据计算类库,包括常规的集合运算如交、并、差,以及对结构化数据的过滤、分组、连接等运算。其中有些运算有 SPL 特有的理解和风格。

1. 循环函数

大部分针对集合(序列 / 序表 / 排列)的运算甚至处理动作,都可以使用循环函数解决,而不必使用复杂的循环语句。这样书写简单且性能也更好

p=directory("*.csv") 列出目录下所有csv文件
p.conj( file(~).import@tc() ) 将这些文件读出后合并

养成这种好习惯。

用公式 e=1+1/1!+1/2!+1/3!+…计算自然对数的底数 e(使用前 20 项)

=1+20.run(~=~*if(#>1,~[-1],1)).sum(1/~)

读懂这个语句,对于理解 SPL 的循环计算逻辑有很大的帮助。

在循环函数可以使用前述的 Lambda 语法。序表和排列上的 Lamdba 语法中可以直接引用字段名而不必写 ~。

2. 位置与对齐

SPL 提供了许多位置相关的函数。

T.pmax(age) 返回序表T中age字段值最大的记录的序号
T.pselect@a( age>50 ) 返回序表T中age>50的所有记录 的序号

善加利用可以让代码更为简洁

T=file("stock.txt").import@t().sort(Date)
S=T.ptop( 10; -Price ) 股价最高的10天所在的位置
T.calc( S, Price-Pirce[-1] ) 这10天的涨幅

以及按位置对齐的函数,相当于把数据按指定次序排序

T.align( 31, Day ) 把交易记录分到一月的31天,没有交易的日期填空,保证结果是31个成员
T.align( ["Sun","Mon","Tue","Web","Thu","Fri","Sat"], WeekDay )

3. 聚合理解

与 SQL 不同,SPL 中的聚合运算并不限于 SUM/COUNT/MAX/MIN 这些返回单个数值的运算。任何一种从集合计算出来的规模更小的值都可以认为是聚合计算。
聚合运算的结果有可能是个小集合,比如 top,返回前 N 名成员。
聚合运算的结果还可能是一个对象,比如 maxp,返回最大值所在的记录。
所有的聚合计算都可以用于分组。

一些例子:

T.top( -3, salary ) 最高工资的前三名
T.top( -3; salary ) 工资最高的前三名员工
T.maxp( age ) 年龄最大的员工
T.groups( Dept; maxp( age ) ) 每个部门年龄最大的员工

这些聚合运算也都可用应用在游标的分组汇总上。
程序员还可以使用 iterate 函数自定义聚合运算。

4. 分组

SQL 的分组后面会强制伴随针对分组子集的聚合,SPL 没有这个要求,分组和聚合可以分成两步,分组子集可以保留住继续参与计算。

T.group( Birthday ).select( ~.len()>1 ).conj() 有生日和其他人相同的人

SQL 只有一种等值分组,即把键值相同的成员分到同一个组中。SPL 除了等值分组还提供有更多的分组方法:

有序分组

T.sort( Date ).group@i( Price<Price[-1] ).max( ~.len() ) 按次序扫描数据,价格下跌时分组,计算最长连涨天数

序号分组

T.groups@n( Day; sum( Amount) ) 计算每日期的销售额

对位分组

T.align@a( ["Male","Female"], Gender ) 按性别分组,保证结果有两条

等值分组是一种完全划分,即所有原成员都会被分且只被分到一个分组子集中,不会出现空的分组子集。而 SPL 能支持不完全划分的分组,允许某些原成员被丢弃,允许分组子集出现空集,甚至可以实现可重复分组,即某些原成员被分到多个分组子集中。
去重运算(DISTINCT)本质上也是分组(没有聚合运算),要用分组的思维来解决。

5. 连接

SQL 把连接定义为笛卡尔积后再过滤,并不区分等值和不等值连接,且不要求和主键相关。SPL 也提供了这种自由灵活的连接运算,但很少用到。

等值连接,即以关联表的对应字段相等为过滤条件的连接,是最常见的连接运算形式。SPL 将等值连接再分成两种类型:

1) 外链连接:事实表和某个字段和维表的主键等值关联

2) 主键连接:A 表的主键和 B 表的主键或部分主键等值关联

两种连接都会有主键参与。没有主键参与的等值连接,绝大多数情况是业务逻辑和数据发生了错误。

两种连接运算要使用完全不同的函数实现,应用连接运算时事先要明确区分连接的类型,并找到参与的(逻辑)主键。

T.switch( C, D:K ) T表的C字段和D表的主键K进行外键连接,计算结果将K转换成D表中记录的引用
T.join( C, D:K, x:F ) T表的C字段和D表的主键K进行外键连接,将针对D表的关联记录计算x作为F字段拼到T上
join(T1,K1; T2,K2 ) T1表的K1字段和T2表的K2字段进行主键关联,返回以关联的T1,T2记录为字段的序表

外键连接时的维表需要被随机访问,原则上只能存储在内存中。SPL 提供了专门的函数针对内存装不下的巨大维表。
基于游标的主键连接运算要求参与游标都对主键有序。