Stream 能在 Java 中取代 SQL 吗

Stream是JAVA 8开始提供的重要类库,提供了更丰富流畅的Lambda语法,能够较方便地实现很多集合运算。基于这个原因,很多程序员尝试用Stream取代SQL。但实际上,Stream的专业程度还远不如SQL。

当集合的成员是简单数据类型时(整数、浮点、字符串、日期),Stream可方便地实现集合计算,比如过滤整数数组:

IntStream iStream=IntStream.of(1,3,5,2,3,6);
IntStream  r1=iStream.filter(m->m>2);

或排序:

Stream r2=iStream.boxed().sorted();

或汇总:

int r3=iStream.sum();

上面的代码简短易懂,程序员能快速掌握。其它像去重distinct \合并concat \包含contain等运算也可以简单地完成。Stream在集合的计算方面的确比较方便。

但结构化计算的数据对象不是简单数据类型,而是记录(Map\ entity\ record)。一旦数据对象变成记录,Stream就不那么方便了。比如对订单表的Client字段逆序排序,对Amount字段顺序排序,代码如下:

// Orders是Stream<Order>类型,Order是记录类型,定义如下:
//record Order(int OrderID, String Client, int   SellerId, double Amount, Date OrderDate) {}
Stream<Order>    result=Orders
.sorted((sAmount1,sAmount2)->Double.compare(sAmount1.Amount,sAmount2.Amount))
.sorted((sClient1,sClient2)->CharSequence.compare(sClient2.Client,sClient1.Client));

代码明显比SQL复杂,排序字段还要前后颠倒,有点不习惯。另外,代码中的数据对象record会自动实现equals函数,如果采用entity,还需手工实现该函数才能支持排序计算,代码长度又会增加。

排序虽然麻烦些,但逻辑结构还算类似SQL。还有一些计算,逻辑结构上就和SQL不同,对SQL程序员来说就有点陌生了。比如对订单表的SellerId分组,对Amount求和,代码如下:

…
Map<Object, DoubleSummaryStatistics>   c=Orders.collect(Collectors.groupingBy(r->r.SellerId,Collectors.summarizingDouble(r->r.Amount)));
  for(Object   sellerid:c.keySet()){
    DoubleSummaryStatistics r =c.get(sellerid);
  System.out.println("group  is:"+sellerid+"   sum  is:"+r.getSum());
}

分组汇总不止用到groupingBy,还要用collect、Collectors、summarizingDouble、DoubleSummaryStatistics等多个类和函数才能实现。而且,分组汇总的结果不再是Stream<Order>之类的常规记录集合,而是Map类型,要用循环才能获取分组汇总的结果,数据类型不如SQL那样一致。

单字段分组汇总还算简单,多字段分组汇总就更麻烦了。比如按年份和Client分组:

Calendar cal=Calendar.getInstance();
  Map<Object, DoubleSummaryStatistics>   c=Orders.collect(Collectors.groupingBy(
    r->{
      cal.setTime(r.OrderDate);
      return   cal.get(Calendar.YEAR)+"_"+r.SellerId;
      },
         Collectors.summarizingDouble(r->{
           return r.Amount;
      })
    )
);
  for(Object sellerid:c.keySet()){
     DoubleSummaryStatistics r   =c.get(sellerid);
    String   year_sellerid[]=((String)sellerid).split("_");
    System.out.println("group   is (year):"+year_sellerid[0]+"\t (sellerid):"+year_sellerid[1]+"\t   sum is:"+r.getSum());
  }

Stream要先用分隔符(比如下划线)将多个字段合并成一个,转为单字段的分组汇总,计算后再将字段拆开,如此才能实现多字段的分组汇总。这个结构不仅和SQL不同,和本身的单字段汇总比也是差异甚大,不仅SQL程序员感到陌生,恐怕JAVA程序员也有障碍。

上面都是针对单个数据对象的计算,代码已经很复杂了。如果进行多个数据对象之间的计算,代码就会更复杂。比如对两个结构相同的订单表进行交集运算:

Stream<Order>   result=Stream.concat(Orders1, Orders2)
  .collect(Collectors.collectingAndThen(
     Collectors.toMap(Order::OrderID,
       Function.identity(),
      (l, r) -> l,
       LinkedHashMap::new),
     f ->   f.values().stream()));

前面无论排序还是分组汇总,都可以用名字直观的函数进行计算(sorted\groupingBy),但计算交集(包括差集、并集)用的却是concat,而concat应该是合集的意思,这就很不直观了。这是因为Stream不支持数据对象之间的计算,只能硬编码实现。这需要一定的技巧和优化能力,对普通程序员要求过高。

Stream不直接支持关联计算。比如对Orders表和Employee表进行内关联,然后对Employee.Dept进行分组,对Orders.Amount求和:

Map<Integer, Employee> EIds =   Employees.collect(Collectors.toMap(Employee::EId, Function.identity()));
  //创建新的OrderRelation类,里面SellerId是单值,指向对应的那个Employee对象。
  record OrderRelation(int OrderID, String Client, Employee SellerId, double   Amount, Date OrderDate){}
  Stream<OrderRelation> ORS=Orders.map(r -> {
    Employee e=EIds.get(r.SellerId);
    OrderRelation or=new   OrderRelation(r.OrderID,r.Client,e,r.Amount,r.OrderDate);
    return or;
  }).filter(e->e.SellerId!=null);
  Map<String, DoubleSummaryStatistics>   c=ORS.collect(Collectors.groupingBy(r->r.SellerId.Dept,Collectors.summarizingDouble(r->r.Amount)));
  for(String dept:c.keySet()){
    DoubleSummaryStatistics r   =c.get(dept);
    System.out.println("group(dept):"+dept+"     sum(Amount):"+r.getSum());
  }

硬编码实现的关联计算不仅冗长,而且逻辑复杂。左关联和外关联的代码同样要硬编码,关键的代码逻辑还不一样,编写起来难度更大,这对专业JAVA程序员来说都是个挑战。

关联在结构化数据计算中很重要,Stream对关联计算支持得不好,它在结构化数据计算方面很不专业,远不如SQL。

在 Stream出现之前,Java实现集合类的运算都非常麻烦。Stream的出现改善了JAVA在结构化计算方面不专业的状况,比如,Stream有基本的集合运算,对lambda语法也支持良好。但Stream缺乏专业的结构化数据对象,仍然要使用基于JAVA的数据类型完成运算,再怎么改善也只是动动皮毛。

事实上,直接在JAVA中实现的计算类库都不够专业,根本问题就在于JAVA缺乏专业的结构化数据对象,缺少来自底层的有力支持。结构化计算的返回值的结构随计算过程而变,大量的中间结果同样是动态结构,这些都难以事先定义,而JAVA是强类型语言,又必须事先定义数据对象的结构(否则只能用map这类操作繁琐的数据对象),这就使JAVA的结构化计算僵化死板, lambda语法的能力严重受限。解释性语言可以简化参数的定义,函数本身就可指定参数表达式应解释成值参数还是函数参数,而JAVA是编译型语言,难以区分不同类型的参数,必须设计复杂难懂的接口才能实现匿名函数(主要指lambda语法),这连SQL程序员都不易掌握。省略数据对象而直接引用字段(比如写成“单价*数量”),可显著简化结构化计算,但JAVA缺乏专业的结构化数据对象,目前还无法支持此类表面简单实则巧妙的语法,这就使JAVA代码冗长且不直观(只能写成“x.单价*x. 数量”)。

Stream缺乏专业的数据对象,在结构化计算方面远不如SQL专业,SQL虽然足够专业,但必须依赖数据库,两者都有其短板。有时候我们既需要SQL专业的结构化计算语法,又需要Stream的库外计算能力,这种情况应该怎么办?

集算器 SPL可以解决这个矛盾。

SPL提供了不依赖于数据库的结构化数据计算能力,它有完善的结构化数据对象,在结构化计算方面和SQL同样专业。对于前面列出的运算,SPL比Stream写起来简单多了。

比如双字段排序

A
1 =Orders=file("Orders.txt").import@t()
2 =Orders.sort(-Client,   Amount)

上面的@t表示首行读为字段名,后续可以脱离数据对象,直接用字段名进行计算,其中-Client表示逆序。

SPL代码写在单元中,单元格名可以代替变量名,上面代码也可以写作:

A
1 =file("Orders.txt").import@t()
2 =A1.sort(-Client,   Amount)

不影响阅读的情况下,代码也可以写在一行,这样更加简短:

A
1 =file("Orders.txt").import@t().sort(-Client,   Amount)

再比如单字段分组汇总

=Orders.groups(SellerId; sum(Amount))

SPL的语法结构简单一致,扩展成双字段分组汇总时,程序员无需刻意学习即可自然掌握:

=Orders.groups(year(OrderDate),Client; sum(Amount))

SPL是专业的结构化计算语言,不仅可以简化单个数据对象的计算,也很容易进行多数据对象之间的计算。比如两个订单表的子集进行交集运算

=Orders1 ^ Orders2

上面代码中的^是交集运算符,等价于SPL函数isect,类似的集合运算符还有并集&、差集\,以及专门的合集运算符|。

同样的内关联计算,SPL代码简单多了:

=join(Orders:o,SellerId ;   Employees:e,EId).groups(e.Dept; sum(o.Amount))

类似SQL,只需稍作改动就可以切换关联类型,而无需改动其他代码,比如join@1表示左关联,join@f表示全关联。

SPL提供了通用的JDBC接口,这些SPL代码很容易像嵌入JAVA中执行(类似SQL),或以脚本文件的形式被JAVA调用(类似存储过程)。

        …
Class.forName("com.esproc.jdbc.InternalDriver");
Connection connection   =DriverManager.getConnection("jdbc:esproc:local://");
Statement statement =   connection.createStatement();
ResultSet   result = statement.executeQuery("=file(\"Orders.txt\").import@t().sort(-Client,   Amount)");
//result   = statement.executeQuery("call splFileName(?)");
...

更多详情参考官方文档,这里不再详细展开

实际上,SPL的计算能力还远远超过SQL。比如下面这些较复杂的例子,用SQL很麻烦,但用SPL就容易多了。

例1:Duty.xlsx记录着每日值班情况,一个人通常会持续值班几个工作日,之后再换人,现在要根据duty依次计算出每个人连续的值班情况。处理前后的部分数据如下:

处理前(Duty.xlsx)

Date Name
2018-03-01 Emily
2018-03-02 Emily
2018-03-04 Emily
2018-03-04 Johnson
2018-04-05 Ashley
2018-03-06 Emily
2018-03-07 Emily

处理后

Name Begin End
Emily 2018-03-01 2018-03-03
Johnson 2018-03-04 2018-03-04
Ashley 2018-03-05 2018-03-05
Emily 2018-03-06 2018-03-07

SQL不擅长处理有序分组问题,要用窗口函数做嵌套子查询,很困难。而SPL提供了有序分组函数,关键代码只要一句。

A
1 =T("D:/data/Duty.xlsx")
2 =A1.group@o(name)
3 =A2.new(name,~.m(1).date:begin,~.m(-1).date:end)

 

例2:库表sales存储客户的销售额数据,主要字段有客户client、销售额amount,请找出销售额累计占到一半的前n个大客户,并按销售额从大到小排序。SPL代码如下:

A B
1 =demo.query(“select client,amount from sales”).sort(amount:-1)  取数并逆序排序
2 =A1.cumulate(amount) 计算累计序列
3 =A2.m(-1)/2 最后的累计值即是总和
4 =A2.pselect(~>=A3) 超过一半的位置
5 =A1(to(A4)) 按位置取值

如果遇到较复杂的计算,SPL通常比SQL更方便,这里既有结构化数据对象更完善的功劳,也有格子代码擅长分步、易于调试的因素。