This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,305 @@
<audio id="audio" title="14 | 前端技术应用(一):如何透明地支持数据库分库分表?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b7/cf/b727f5d6d8bf782382f6a05b089e30cf.mp3"></audio>
从今天开始,我们正式进入了应用篇,我会用两节课的时间,带你应用编译器的前端技术。这样,你会把学到的编译技术和应用领域更好地结合起来,学以致用,让技术发挥应有的价值。还能通过实践加深对原理的理解,形成一个良好的循环。
这节课,我们主要讨论,一个分布式数据库领域的需求。我会带你设计一个中间层,让应用逻辑不必关心数据库的物理分布。这样,无论把数据库拆成多少个分库,编程时都会像面对一个物理库似的没什么区别。
接下来,我们先了解一下分布式数据库的需求和带来的挑战。
## 分布式数据库解决了什么问题,又带来了哪些挑战
随着技术的进步,我们编写的应用所采集、处理的数据越来越多,处理的访问请求也越来越多。而单一数据库服务器的处理能力是有限的,当数据量和访问量超过一定级别以后,就要开始做分库分表的操作。比如,把一个数据库的大表拆成几张表,把一个库拆成几个库,把读和写的操作分离开等等。**我们把这类技术统称为分布式数据库技术。**
分库分表Sharding有时也翻译成“数据库分片”。分片可以依据各种不同的策略比如我开发过一个与社区有关的应用系统这个系统的很多业务逻辑都是围绕小区展开的。对于这样的系统按照**地理分布的维度**来分片就很合适,因为每次对数据库的操作基本上只会涉及其中一个分库。
假设我们有一个订单表那么就可以依据一定的规则对订单或客户进行编号编号中就包含地理编码。比如SDYT代表山东烟台BJHD代表北京海淀不同区域的数据放在不同的分库中
<img src="https://static001.geekbang.org/resource/image/37/85/376bf6f25970caf6250e9a4cd768de85.jpg" alt="">
通过数据库分片,我们可以提高数据库服务的性能和可伸缩性。当数据量和访问量增加时,增加数据库节点的数量就行了。不过,虽然数据库的分片带来了性能和伸缩性的好处,但它也带来了一些挑战。
**最明显的一个挑战,是数据库分片逻辑侵入到业务逻辑中。**过去,应用逻辑只访问一个数据库,现在需要根据分片的规则,判断要去访问哪个数据库,再去跟这个数据库服务器连接。如果增加数据库分片,或者对分片策略进行调整,访问数据库的所有应用模块都要修改。这会让软件的维护变得更复杂,显然也不太符合软件工程中模块低耦合、把变化隔离的理念。
所以如果有一种技术,能让我们访问很多数据库分片时,像访问一个数据库那样就好了。**数据库的物理分布,对应用是透明的。**
可是,“理想很吸引人,现实很骨感”。要实现这个技术,需要解决很多问题:
**首先是跨库查询的难题。**如果SQL操作都针对一个库还好但如果某个业务需求恰好要跨多个库比如上面的例子中如果要查询多个小区的住户信息那么就要在多个库中都执行查询然后把查询结果合并一般还要排序。
如果我们前端显示的时候需要分页每页显示一百行那就更麻烦了。我们不可能从10个分库中各查出10行合并成100行这100行不一定排在前面最差的情况可能这100行恰好都在其中一个分库里。所以你可能要从每个分库查出100行来合并、排序后再取出前100行。如果涉及数据库表跨库做连接你想象一下那就更麻烦了。
**其次就是跨库做写入的难题。**如果对数据库写入时遇到了跨库的情况,那么就必须实现分布式事务。所以,虽然分布式数据库的愿景很吸引人,但我们必须解决一系列技术问题。
这一讲,我们先解决最简单的问题,**也就是当每次数据操作仅针对一个分库的时候,能否自动确定是哪个分库的问题。**解决这个问题我们不需要依据别的信息只需要提供SQL就行了。这就涉及对SQL语句的解析了自然要用到编译技术。
## 解析SQL语句判断访问哪个数据库
我画了一张简化版的示意图假设有两张表分别是订单表和客户表它们的主键是order_id和cust_id
<img src="https://static001.geekbang.org/resource/image/9b/e0/9bb05d9ccff18746b275a765567c4de0.jpg" alt="">
我们采用的分片策略是依据这两个主键的前4位的编码来确定数据库分片的逻辑比如前四位是SDYT那就使用山东烟台的分片如果是BJHD就使用北京海淀的分片等等。
在我们的应用中会对订单表进行一些增删改查的操作比如会执行下面的SQL语句
```
//查询
select * from orders where order_id = 'SDYT20190805XXXX'
select * from orders where cust_id = 'SDYT987645'
//插入
insert into orders (order_id...其他字段) values( &quot;BJHD20190805XXXX&quot;,...)
//修改
update orders set price=298.00 where order_id='FJXM20190805XXXX'
//删除
delete from orders where order_id='SZLG20190805XXXX'
```
我们要能够解析这样的SQL语句根据主键字段的值决定去访问哪个分库或者分表。这就需要用到编译器前端技术包括**词法分析、语法分析和语义分析。**
听到这儿你可能会质疑“解析SQL语句是在开玩笑吗”你可能觉得这个任务太棘手犹豫着是否要忍受业务逻辑和技术逻辑混杂的缺陷把判断分片的逻辑写到应用代码里或者想解决这个问题又或者想自己写一个开源项目帮到更多的人。
无论你的内心活动如何应用编译技术能让你有更强的信心解决这个问题。那么如何去做呢要想完成解析SQL的任务在词法分析和语法分析这两个阶段我建议你采用工具快速落地比如Antlr。你要找一个现成的SQL语句的语法规则文件。
GitHub中那个收集了很多示例Antlr规则文件的[项目](https://github.com/antlr/grammars-v4)里,[有两个可以参考的规则](https://github.com/antlr/grammars-v4):一个是[PLSQL](https://github.com/antlr/grammars-v4/tree/master/plsql)的它是Oracle数据库的SQL语法一个是[SQLite](https://github.com/antlr/grammars-v4/tree/master/sqlite)的(这是一个嵌入式数据库)。
实际上我还找到MySQL workbench所使用的一个产品级的[规则文件](https://github.com/mysql/mysql-workbench/tree/8.0/library/parsers/grammars)。MySQL workbench是一个图形化工具用于管理和访问MySQL。这个规则文件还是很靠谱的不过它里面嵌了很多属性计算规则而且是C++语言写的,我嫌处理起来麻烦,就先弃之不用,**暂且采用SQLite的规则文件来做示范。**
先来看一下这个文件里的一些规则例如select语句相关的语法
```
factored_select_stmt
: ( K_WITH K_RECURSIVE? common_table_expression ( ',' common_table_expression )* )?
select_core ( compound_operator select_core )*
( K_ORDER K_BY ordering_term ( ',' ordering_term )* )?
( K_LIMIT expr ( ( K_OFFSET | ',' ) expr )? )?
;
common_table_expression
: table_name ( '(' column_name ( ',' column_name )* ')' )? K_AS '(' select_stmt ')'
;
select_core
: K_SELECT ( K_DISTINCT | K_ALL )? result_column ( ',' result_column )*
( K_FROM ( table_or_subquery ( ',' table_or_subquery )* | join_clause ) )?
( K_WHERE expr )?
( K_GROUP K_BY expr ( ',' expr )* ( K_HAVING expr )? )?
| K_VALUES '(' expr ( ',' expr )* ')' ( ',' '(' expr ( ',' expr )* ')' )*
;
result_column
: '*'
| table_name '.' '*'
| expr ( K_AS? column_alias )?
;
```
我们可以一边看这个语法规则一边想几个select语句做一做验证。你可以思考一下这个规则是怎么把select语句拆成不同的部分的。
SQL里面也有表达式我们研究一下它的表达式的规则
```
expr
: literal_value
| BIND_PARAMETER
| ( ( database_name '.' )? table_name '.' )? column_name
| unary_operator expr
| expr '||' expr
| expr ( '*' | '/' | '%' ) expr
| expr ( '+' | '-' ) expr
| expr ( '&lt;&lt;' | '&gt;&gt;' | '&amp;' | '|' ) expr
| expr ( '&lt;' | '&lt;=' | '&gt;' | '&gt;=' ) expr
| expr ( '=' | '==' | '!=' | '&lt;&gt;' | K_IS | K_IS K_NOT | K_IN | K_LIKE | K_GLOB | K_MATCH | K_REGEXP ) expr
| expr K_AND expr
| expr K_OR expr
| function_name '(' ( K_DISTINCT? expr ( ',' expr )* | '*' )? ')'
| '(' expr ')'
| K_CAST '(' expr K_AS type_name ')'
| expr K_COLLATE collation_name
| expr K_NOT? ( K_LIKE | K_GLOB | K_REGEXP | K_MATCH ) expr ( K_ESCAPE expr )?
| expr ( K_ISNULL | K_NOTNULL | K_NOT K_NULL )
| expr K_IS K_NOT? expr
| expr K_NOT? K_BETWEEN expr K_AND expr
| expr K_NOT? K_IN ( '(' ( select_stmt
| expr ( ',' expr )*
)?
')'
| ( database_name '.' )? table_name )
| ( ( K_NOT )? K_EXISTS )? '(' select_stmt ')'
| K_CASE expr? ( K_WHEN expr K_THEN expr )+ ( K_ELSE expr )? K_END
| raise_function
;
```
你可能会觉得SQL的表达式的规则跟其他语言的表达式规则很像。比如都支持加减乘除、关系比较、逻辑运算等等。而且从这个规则文件里你一下子就能看出各种运算的优先级比如你会注意到字符串连接操作“||”比乘法和除法的优先级更高。**所以,研究一门语言时积累的经验,在研究下一门语言时仍然有用。**
有了规则文件之后接下来我们用Antlr生成词法分析器和语法分析器
```
antlr -visitor -package dsql.parser SQLite.g4
```
在这个命令里,我用-package参数指定了生成的Java代码的包是dsql.parser。dsql是分布式SQL的意思。接着我们可以写一点儿程序测试一下所生成的词法分析器和语法分析器
```
String sql = &quot;select order_id from orders where cust_id = 'SDYT987645'&quot;;
//词法分析
SQLiteLexer lexer = new SQLiteLexer(CharStreams.fromString(sql));
CommonTokenStream tokens = new CommonTokenStream(lexer);
//语法分析
SQLiteParser parser = new SQLiteParser(tokens);
ParseTree tree = parser.sql_stmt();
//输出lisp格式的AST
System.out.println(tree.toStringTree(parser));
```
这段程序的输出是LISP格式的AST我调整了一下缩进让它显得更像一棵树
```
(sql_stmt
(factored_select_stmt
(select_core select
(result_column
(expr
(column_name
(any_name order_id))))
from (table_or_subquery
(table_name
(any_name orders)))
where (expr
(expr
(column_name
(any_name cust_id)))
=
(expr
(literal_value
('SDYT987645'))))))
```
从AST中我们可以清晰地看出这个select语句是如何被解析成结构化数据的再继续写点儿代码就能获得想要的信息了。
接下来的任务是对于访问订单表的select语句要在where子句里找出cust_id="客户编号"或order_id="订单编号"这样的条件,从而能够根据客户编号或订单编号确定采用哪个分库。
怎么实现呢很简单我们用visitor模式遍历一下AST就可以了
```
public String getDBName(String sql) {
//词法解析
SQLiteLexer lexer = new SQLiteLexer(CharStreams.fromString(sql));
CommonTokenStream tokens = new CommonTokenStream(lexer);
//语法解析
SQLiteParser parser = new SQLiteParser(tokens);
ParseTree tree = parser.sql_stmt();
//以lisp格式打印AST
System.out.println(tree.toStringTree(parser));
//获得select语句的要素,包括表名和where条件
SQLVisitor visitor = new SQLVisitor();
SelectStmt select = (SelectStmt) visitor.visit(tree);
String dbName = null;
if (select.tableName.equals(&quot;orders&quot;)) {
if (select.whereExprs != null) {
for (WhereExpr expr : select.whereExprs) {
//根据cust_id或order_id来确定库的名称
if (expr.columnName.equals(&quot;cust_id&quot;) || expr.columnName.equals(&quot;order_id&quot;)) {
//取编号的前4位即区域编码
String region = expr.value.substring(1, 5);
//根据区域编码,获取库名称
dbName = region2DB.get(region);
break;
}
}
}
}
return dbName;
}
```
获取表名和where子句条件的代码在SQLVisitor.java中。因为已经有了AST抽取这些信息是不难的。你可以点开我在文稿中提供的链接查看示例代码。
## 我们的示例离实用还有多大差距?
目前,我们已经初步解决了数据库访问透明化的问题。当然,这只是一个示例,如果要做得严密、实用,我们还要补充一些工作。
**我们需要做一些语义分析工作确保SQL语句的合法性。**语法分析并不能保证程序代码完全合法,我们必须进行很多语义的检查才行。
我给订单表起的名字是orders。如果你把表名称改为order那么必须用引号引起来写成order不带引号的order会被认为是一个关键字。因为在SQL中我们可以使用order by这样的子句这时候order这个表名就会被混淆进而被解析错误。这个语法解析程序会在表名的地方出现一个order节点这在语义上是不合法的需要被检查出来并报错。
**如果要检查语义的正确性,我们还必须了解数据库的元数据。**否则,就没有办法判断在SQL语句中是否使用了正确的字段以及正确的数据类型。除此之外我们还需要扩展到能够识别跨库操作比如下面这样一个where条件
```
order_id = 'FJXM20190805XXXX' or order_id = 'SZLG20190805XXXX'
```
分析这个查询条件,可以知道数据是存在两个不同的数据库中的。但是我们要让解析程序分析出这个结果,甚至让它针对更加复杂的条件,也能分析出来。这就需要更加深入的语义分析功能了。
**最后,解析器的速度也是一个需要考虑的因素。**因为执行每个SQL都需要做一次解析而这个时间就加在了每一次数据库访问上。所以SQL解析的时间越少越好。因此有的项目就会尽量提升解析效率。**阿里有一个开源项目Druid是一个数据库连接池。**这个项目强调性能因此他们纯手写了一个SQL解析器尽可能地提升性能。
总之,要实现一个完善的工具,让工具达到产品级的质量,有不少工作要做。如果要支持更强的分布式数据库功能,还要做更多的工作。不过,你应该不会觉得这事儿有多么难办了吧?至少在编译技术这部分你是有信心的。
在这里我还想讲一讲SQL防注入这个问题。SQL注入攻击是一种常见的攻击手段。你向服务器请求一个url的时候可以把恶意的SQL嵌入到参数里面这样形成的SQL就是不安全的。
以前面的SQL语句为例这个select语句本来只是查询一个订单订单编号“SDYT20190805XXXX”作为参数传递给服务端的一个接口服务端收到参数以后用单引号把这个参数引起来并加上其他部分就组装成下面的SQL并执行
```
//原来的SQL
select * from orders where order_id = 'SDYT20190805XXXX'
```
如果我们遇到了一个恶意攻击者他可能把参数写成“SDYT20190805XXXXdrop table customers; --”。服务器接到这个参数以后仍然把它拿单引号引起来并组装成SQL组装完毕以后就是下面的语句
```
//被注入恶意SQL后
select * from orders where order_id = 'SDYT20190805XXXX'; drop table customers; --'
```
如果你看不清楚,我分行写一下,这样你就知道它是怎么把你宝贵的客户信息全都删掉的:
```
//被注入恶意SQL后
select * from orders where order_id = 'SDYT20190805XXXX';
drop table customers; // 把顾客表给删了
--' //把你加的单引号变成了注释这样SQL不会出错
```
**所以SQL注入有很大的危害。**而我们一般用检查客户端传过来的参数的方法看看有没有SQL语句中的关键字来防止SQL注入。不过这是比较浅的防御有时还会漏过一些非法参数所以要在SQL执行之前做最后一遍检查。而这个时候就要运用编译器前端技术来做SQL的解析了。借此我们能检查出来异常**明明这个功能是做查询的为什么形成的SQL会有删除表的操作**
通过这个例子我们又分析了一种场景开发一个安全可靠的系统用编译技术做SQL分析是必须做的一件事情。
## 课程小结
今天我带你利用学到的编译器前端技术解析了SQL语句并针对分布式数据库透明查询的功能做了一次概念证明。
SQL是程序员经常打交道的语言。有时我们会遇到需要解析SQL语言的需求除了分布式数据库场景的需求以外Hibernate对HQL的解析也跟解析SQL差不多。而且最近有一种技术能够通过RESTful这样的接口做通用的查询其实也是一种类SQL的子语言。
当然了今天我们只是基于工具做解析。一方面有时候我们就是需要做个原型系统或者最小的能用的系统有时间有资源了再追求完美也不为过比如追求编译速度的提升。另一方面你能看到MySQL workbench也是用Antlr来作帮手的在很多情况下Antlr这样的工具生成的解析器足够用甚至比你手写的还要好所以我们大可以节省时间用工具做解析。
可能你会觉得,实际应用的难度似乎要低于学习原理的难度。如果你有这个感觉,那就对了,这说明你已经掌握了原理篇的内容,所以日常的一些应用根本不是问题,你可以找出更多的应用场景来练练手。
## 一课一思
你在工作中是否遇到过其他需要解析SQL的场景另外当你阅读了SQL的规则文件之后是否发现了它跟Java这样的语言规则的不同之处是更加简单还是更复杂欢迎在留言区写下你的发现。
最后,感谢你的阅读,如果这篇文章让你有所收获,也欢迎你将它分享给更多的朋友。

View File

@@ -0,0 +1,357 @@
<audio id="audio" title="15 | 前端技术应用(二):如何设计一个报表工具?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/0e/0fbaeae10443da810564b874eb46e20e.mp3"></audio>
众所周知,很多软件都需要面向开发者甚至最终用户提供自定义功能,在[开篇词](https://time.geekbang.org/column/article/118016)里,我提到自己曾经做过工作流软件和电子表单软件,它们都需要提供自定义功能,报表软件也是其中的典型代表。
在每个应用系统中我们对数据的处理大致会分成两类一类是在线交易叫做OLTP比如在网上下订单一类是在线分析叫做OLAP它是对应用中积累的数据进行进一步分析利用。而报表工具就是最简单但也是最常用的数据分析和利用的工具。
本节课,我们就来分析一下,如果我们要做一个通用的报表工具,需要用到哪些编译技术,又该怎样去实现。
## 报表工具所需要的编译技术
如果要做一个报表软件我们要想清楚软件面对的用户是谁。有一类报表工具面向的用户是程序员那么这种软件可以暴露更多技术细节。比如如果报表要从数据库获取数据你可以写一个SQL语句作为数据源。
还有一类软件是给业务级的用户使用的很多BI软件包都是这种类型。带有IT背景的顾问给用户做一些基础配置然后用户就可以用这个软件包了。Excel可以看做是这种报表工具IT人员建立Excel与数据库之间的连接剩下的就是业务人员自己去操作了。
这些业务人员可以采用一个图形化的界面设计报表,对数据进行加工处理。我们来看看几个场景。
**第一个场景是计算字段。**计算字段的意思是原始数据里没有这个数据我们需要基于原始数据通过一个自定义的公式来把它计算出来。比如在某个CRM系统中保存着销售数据我们有每个部门的总销售额也有每个部门的人数要想在报表中展示每个部门的人均销售额这个时候就可以用到计算公式功能计算公式如下
```
人均销售额=部门销售额/部门人数
```
得到的结果如下图所示:
<img src="https://static001.geekbang.org/resource/image/f6/b8/f6abaebc36fc515e8cf1dd7ec3b5cdb8.jpg" alt="">
**进一步,我们可以在计算字段中支持函数。**比如我们可以把各个部门按照人均销售额排名次。这可以用一个函数来计算:
```
=rank(人均销售额)
```
rank就是排名次的意思其他统计函数还包括
- min(),求最小值。
- max(),求最大值。
- avg(),求平均值。
- sum(),求和。
还有一些更有意思的函数,比如:
- runningsum(),累计汇总值。
- runningavg(),累计平均值。
这些有意思的函数是什么意思呢?因为很多明细性的报表,都是逐行显示的,累计汇总值和累计平均值,就是累计到当前行的计算结果。当然了,我们还可以支持更多的函数,比如当前日期、当前页数等等。更有意思的是,上述字段也好、函数也好,都可以用来组合成计算字段的公式,比如:
```
=部门销售额/sum(部门销售额) //本部门的销售额在全部销售额的占比
=max(部门销售额)-部门销售额 //本部门的销售额与最高部门的差距
=max(部门销售额/部门人数)-部门销售额/部门人数 //本部门人均销售额与最高的那个部门的差
=sum(部门销售额)/sum(人数)-部门销售额/部门人数 //本部门的人均销售额与全公司人均销售额的差
```
怎么样,是不是越来越有意思了呢?现在你已经知道了在报表中会用到普通字段和各种各样的计算公式,那么,我们如何用这样的字段和公式来定义一张报表呢?
## 如何设计报表
假设我们的报表是一行一行地展现数据也就是最简单的那种。那我们将报表的定义做成一个XML文件可能是下面这样的它定义了表格中每一列的标题和所采用字段或公式
```
&lt;playreport title=&quot;Report 1&quot;&gt;
&lt;section&gt;
&lt;column&gt;
&lt;title&gt;部门&lt;/title&gt;
&lt;field&gt;dept&lt;/field&gt;
&lt;/column&gt;
&lt;column&gt;
&lt;title&gt;人数&lt;/title&gt;
&lt;field&gt;num_person&lt;/field&gt;
&lt;/column&gt;
&lt;column&gt;
&lt;title&gt;销售额&lt;/title&gt;
&lt;field&gt;sales_amount&lt;/field&gt;
&lt;/column&gt;
&lt;column&gt;
&lt;title&gt;人均销售额&lt;/title&gt;
&lt;field&gt;sales_amount/num_person&lt;/field&gt;
&lt;/column&gt;
&lt;/section&gt;
&lt;datasource&gt;
&lt;connection&gt;数据库连接信息...&lt;/connection&gt;
&lt;sql&gt;select dept, num_person, sales_amount from sales&lt;/sql&gt;
&lt;/datasource&gt;
&lt;/playreport&gt;
```
这个报表定义文件还是蛮简单的它主要表达的是数据逻辑忽略了表现层的信息。如果我们想要优先表达表现层的信息例如字体大小、界面布局等可以采用HTML模板的方式来定义报表其实就是在一个HTML中嵌入了公式比如
```
&lt;html&gt;
&lt;body&gt;
&lt;div class=&quot;report&quot; datasource=&quot;这里放入数据源信息&quot;&gt;
&lt;div class=&quot;table_header&quot;&gt;
&lt;div class=&quot;column_header&quot;&gt;部门&lt;/div&gt;
&lt;div class=&quot;column_header&quot;&gt;人数&lt;/div&gt;
&lt;div class=&quot;column_header&quot;&gt;销售额&lt;/div&gt;
&lt;div class=&quot;column_header&quot;&gt;人均销售额&lt;/div&gt;
&lt;/div&gt;
&lt;div class=&quot;table_body&quot;&gt;
&lt;div class=&quot;field&quot;&gt;{=dept}&lt;/div&gt;
&lt;div class=&quot;field&quot;&gt;{=num_person}&lt;/div&gt;
&lt;div class=&quot;field&quot;&gt;{=sales_amount}&lt;/div&gt;
&lt;div class=&quot;field&quot;&gt;{=sales_amount/num_person}&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/body&gt;
&lt;/html&gt;
```
这样的HTML模板看上去是不是很熟悉其实在很多语言里比如PHP都提供模板引擎功能实现界面设计和应用代码的分离。这样一个模板可以直接解释执行或者先翻译成PHP或Java代码然后再执行。只要运用我们学到的编译技术这些都可以实现。
我想你应该会发现这样的一个模板文件其实就是一个特定领域语言也就是我们常说的DSL。DSL可以屏蔽掉实现细节让我们专注于领域问题像上面这样的DSL哪怕没有技术背景的工作人员也可以迅速地编写出来。
而这个简单的报表,在报表设计界面上可能是下图这样的形式:
<img src="https://static001.geekbang.org/resource/image/59/fe/59f1162ec7a1e76f0cb20457db34adfe.jpg" alt="">
分析完如何设计报表之后,接下来,我们看看如何定义报表所需要的公式规则。
## 编写所需要的语法规则
我们设计了PlayReport.g4规则文件这里面的很多规则是把PlayScript.g4里的规则拿过来改一改用的
```
bracedExpression
: '{' '=' expression '}'
;
expression
: primary
| functionCall
| expression bop=('*'|'/'|'%') expression
| expression bop=('+'|'-') expression
| expression bop=('&lt;=' | '&gt;=' | '&gt;' | '&lt;') expression
| expression bop=('==' | '!=') expression
| expression bop='&amp;&amp;' expression
| expression bop='||' expression
;
primary
: '(' expression ')'
| literal
| IDENTIFIER
;
expressionList
: expression (',' expression)*
;
functionCall
: IDENTIFIER '(' expressionList? ')'
;
literal
: integerLiteral
| floatLiteral
| CHAR_LITERAL
| STRING_LITERAL
| BOOL_LITERAL
| NULL_LITERAL
;
integerLiteral
: DECIMAL_LITERAL
| HEX_LITERAL
| OCT_LITERAL
| BINARY_LITERAL
;
floatLiteral
: FLOAT_LITERAL
| HEX_FLOAT_LITERAL
;
```
这里面其实就是用了表达式的语法包括支持加减乘除等各种运算用来书写公式。我们还特意支持functionCall功能也就是能够调用函数。因为我们内部实现了很多内置函数比如求最大值、平均值等可以在公式里调用这些函数。
现在呢,我们已经做好了一个最简单的报表定义,接下来,就一起实现一个简单的报表引擎,这样就能实际生成报表了!
## 实现一个简单的报表引擎
报表引擎的工作,是要根据报表的定义和数据源中的数据,生成最后报表的呈现格式。具体来说,可以分为以下几步:
- **解析报表的定义。**我们首先要把报表定义形成Java对象。这里只是简单地生成了一个测试用的报表模板。
- **从数据源获取数据。**我们设计了一个TabularData类用来保存类似数据库表那样的数据。
- **实现一个FieldEvaluator类能够在运行时对字段和公式进行计算。**这个类是playscript中ASTEvaluator类的简化版。我们甚至连语义分析都简化了。数据类型信息作为S属性在求值的同时自底向上地进行类型推导。当然如果做的完善一点儿我们还需要多做一点儿语义分析比如公式里的字段是不是数据源中能够提供的而这时需要用到报表数据的元数据。
- **渲染报表。**我们要把上面几个功能组合在一起,对每一行、每一列求值,获得最后的报表输出。
主控程序我放在了下面,用一个示例报表模板和报表数据来生成报表:
```
public static void main(String args[]) {
System.out.println(&quot;Play Report!&quot;);
PlayReport report = new PlayReport();
//打印报表1
String reportString = report.renderReport(ReportTemplate.sampleReport1(), TabularData.sampleData());
System.out.println(reportString);
}
```
renderReport方法用来渲染报表它会调用解析器和报表数据的计算器
```
public String renderReport(ReportTemplate template, TabularData data){
StringBuffer sb = new StringBuffer();
//输出表格头
for (String columnHeader: template.columnHeaders){
sb.append(columnHeader).append('\t');
}
sb.append(&quot;\n&quot;);
//编译报表的每个字段
List&lt;BracedExpressionContext&gt; fieldASTs = new LinkedList&lt;BracedExpressionContext&gt;();
for (String fieldExpr : template.fields){
//这里会调用解析器
BracedExpressionContext tree = parse(fieldExpr);
fieldASTs.add(tree);
}
//计算报表字段
FieldEvaluator evaluator = new FieldEvaluator(data);
List&lt;String&gt; fieldNames = new LinkedList&lt;String&gt;();
for (BracedExpressionContext fieldAST: fieldASTs){
String fieldName = fieldAST.expression().getText();
fieldNames.add(fieldName);
if (!data.hasField(fieldName)){
Object field = evaluator.visit(fieldAST);
data.setField(fieldName, field);
}
}
//显示每一行数据
for (int row = 0; row&lt; data.getNumRows(); row++){
for (String fieldName: fieldNames){
Object value = data.getFieldValue(fieldName, row);
sb.append(value).append(&quot;\t&quot;);
}
sb.append(&quot;\n&quot;);
}
return sb.toString();
}
```
程序的运行结果如下,它首先打印输出了每个公式的解析结果,然后输出报表:
```
Play Report!
(bracedExpression { = (expression (primary dept)) })
(bracedExpression { = (expression (primary num_person)) })
(bracedExpression { = (expression (primary sales_amount)) })
(bracedExpression { = (expression (expression (primary sales_amount)) / (expression (primary num_person))) })
部门 人数 销售额 人均销售额
电话销售部 10 2345.0 234.5
现场销售部 20 5860.0 293.0
电子商务部 15 3045.0 203.0
渠道销售部 20 5500.0 275.0
微商销售部 12 3624.0 302.0
```
你可以看到,报表工具准确地得出了计算字段的数据。接下来,我再讲一讲报表数据计算的细节。
如果你看一看FieldEvaluator.java这个类就会发现我实际上实现了一个简单的向量数据的计算器。在计算机科学里向量是数据的有序列表可以看做一个数组。相对应的标量只是一个单独的数据。运用向量计算我们在计算人均销售额的时候会把“销售额”和“人数”作为两个向量每个向量都有5个数据。把这两个向量相除会得到第三个向量就是“人均销售额”。这样就不需要为每行数据运行一次计算器会提高性能也会简化程序。
其实,这个向量计算器还能够把向量和标量做混合运算。因为我们的报表里有时候确实会用到标量,比如对销售额求最大值{=max(sales_amount)},就是一个标量。而如果计算销售额与最大销售额的差距{=max(sales_amount)-sales_amount},就是标量和向量的混合运算,返回结果是一个向量。
TabularData.java这个类是用来做报表数据的存储的。我简单地用了一个Map把字段的名称对应到一个向量或标量上其中字段的名称可以是公式
<img src="https://static001.geekbang.org/resource/image/8a/d0/8a8d4640fa0b9e4e5db147952bd33ad0.jpg" alt="">
在报表数据计算过程中我们还做了一个优化。公式计算的中间结果会被存起来如果下一个公式刚好用到这个数据可以复用。比如在计算rank(sales_amount/num_person)这个公式的时候它会查一下括号中的sales_amount/num_person这个子公式的值是不是以前已经计算过如果计算过就复用否则就计算一下并且把这个中间结果也存起来。
我们把这个报表再复杂化一点,形成下面一个报表模板。这个报表模板用到了好几个函数,包括排序、汇总值、累计汇总值和最大值,并通过公式定义出一些相对复杂的计算字段,包括最高销售额、销售额的差距、销售额排序、人均销售额排序、销售额累计汇总、部门销售额在总销售额中的占比,等等。
```
public static ReportTemplate sampleReport2(){
ReportTemplate template = new ReportTemplate();
template.columnHeaders.add(&quot;部门&quot;);
template.columnHeaders.add(&quot;人数&quot;);
template.columnHeaders.add(&quot;销售额&quot;);
template.columnHeaders.add(&quot;最高额&quot;);
template.columnHeaders.add(&quot;差距&quot;);
template.columnHeaders.add(&quot;排序&quot;);
template.columnHeaders.add(&quot;人均&quot;);
template.columnHeaders.add(&quot;人均排序&quot;);
template.columnHeaders.add(&quot;累计汇总&quot;);
template.columnHeaders.add(&quot;占比%&quot;);
template.fields.add(&quot;{=dept}&quot;);
template.fields.add(&quot;{=num_person}&quot;);
template.fields.add(&quot;{=sales_amount}&quot;);
template.fields.add(&quot;{=max(sales_amount)}&quot;);
template.fields.add(&quot;{=max(sales_amount)-sales_amount}&quot;);
template.fields.add(&quot;{=rank(sales_amount)}&quot;);
template.fields.add(&quot;{=sales_amount/num_person}&quot;);
template.fields.add(&quot;{=rank(sales_amount/num_person)}&quot;);
template.fields.add(&quot;{=runningsum(sales_amount)}&quot;);
template.fields.add(&quot;{=sales_amount/sum(sales_amount)*100}&quot;);
return template;
}
```
最后输出的报表截屏如下,怎么样,现在看起来功能还是挺强的吧!
<img src="https://static001.geekbang.org/resource/image/8f/1b/8f83b528da89e0620deea388086d321b.jpg" alt="">
当然了这个程序只是拿很短的时间写的一个Demo如果要变成一个成熟的产品还要在很多地方做工作。比如
- 可以把字段名称用中文显示,这样更便于非技术人员使用;
- 除了支持行列报表,还要支持交叉表,用于统计分析;
- 支持多维数据计算。
- ……
在报表工具中,编译技术除了用来做字段的计算,还可以用于其他功能,比如条件格式。我们可以在人均销售额低于某个数值时,给这行显示成红色,其中的判断条件,也是一个公式。
甚至你还可以为报表工具添加自定义公式功能。我们给用户提供脚本功能,用户可以自己做一个函数,实现某个领域的一个专业功能。我十分建议你在这个示例程序的基础上进一步加工,看看能做否做出一些让自己惊喜的功能。
## 课程小结
本节课我们做了一个示例性的报表工具。你能在这个过程中看到,像报表工具这样的软件,如果有编译技术的支持,真的可以做得很灵活、很强大。你完全可以借鉴本节课的思路,去尝试做一下其他需要自定义功能的软件工具或产品。
与此同时我们能看到编译技术可以跟某个应用领域结合在一起内置在产品中同时形成领域的DSL比如报表的模板文件。这样我们就相当于赋予了普通用户在某个领域内的编程能力比如用户只需要编写一个报表模板就可以生成报表了。了解这些内容之后我来带你回顾一下这个应用是怎么运用编译器前端技术的。
词法分析和语法分析都很简单,我们就是简单地用了表达式和函数调用的功能。而语义分析除了需要检查类型以外,还要检查所用到的字段和函数是否合法,这是另一种意义上的引用消解。而且这个例子中的运算的含义是向量运算,同样是加减乘除,每个操作都会处理一组数据,这也是一种语义上的区别。
我希望在学习了这两节课之后,你能对如何在某个应用领域应用编译技术有更直观的了解,甚至有了很多的启发。
## 一课一思
你在自己的工作领域中,是否发现有哪些需要用户自定义功能的需求?你又是怎么实现这些需求的?编译技术会不会在这些地方帮助到你?欢迎在留言区分享你的发现。
最后,感谢你的阅读,如果这篇文章让你有所收获,欢迎你将它分享给更多的朋友。
本节课的示例代码我放在文末,供你参考。
- lab/report报表项目示例代码 [码云](https://gitee.com/richard-gong/PlayWithCompiler/tree/master/lab/report) [GitHub](https://github.com/RichardGong/PlayWithCompiler/tree/master/lab/report)
- PlayReport.java主程序入口 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/report/src/main/report/PlayReport.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/report/src/main/report/PlayReport.java)
- FieldEvaluator.java做报表计算的代码 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/report/src/main/report/FieldEvaluator.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/report/src/main/report/FieldEvaluator.java)
- ReportTemplate.java报表模板 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/report/src/main/report/ReportTemplate.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/report/src/main/report/ReportTemplate.java)
- TabularData.java报表数据 [码云](https://gitee.com/richard-gong/PlayWithCompiler/blob/master/lab/report/src/main/report/TabularData.java) [GitHub](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/report/src/main/report/TabularData.java)