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更方便,这里既有结构化数据对象更完善的功劳,也有格子代码擅长分步、易于调试的因素。