当前的互联网数据仓库系统里,数据中心往往存放了大量Cube化或者半Cube化的数据。如果需要将这些数据的内在关系体现出来,需要写大量的程序和SQL来发现数据之间的内在规律,往往会造成用户做非常多的重复性工作;而且由于没有数据校验的机制,还容易出错,无法直观查看各种数据(没有可视化的UI图表)。这时就急需一款基于Cube的报表工具快速为用户提供报表服务,可以完成多维查询、上卷、下钻等各种功能。为此,美团点评酒旅技术团队开发了大圣魔方。
一款好的BI报表工具,需要考虑并能够解决如下问题:
图1 大圣魔方体系架构
提供多数据源查询服务,需要解决的问题主要是两个:
图2 大圣魔方多数据源
大圣魔方上对能够通过SQL查询的数据源,例如MySQL和Kylin都通过统一SQL查询来获取数据;对于ES(Elasticsearch)采用ES提供的API来查询;对于普通文本格式的数据采用自定义API从数据源获取数据。
如图2所示,大圣魔方只是从数据源里面获取基础的数据,之后通过实现自己的计算引擎对数据进行聚合、切割等操作,对此,魔方中设置了四个引擎,用于实现不同的功能。
对于SQL的生成也存在两个问题:
针对第一个问题,我们对SQL模板进行了定义,当选择不同的数据源时,根据数据源的Dialect选择不同的SQL模板,而这就决定了SQL的组成部分(骨架)。
为了解决第二个问题,我们在SQL模板的基础之上做了内容填充和替换操作,选择具体的维度、指标和筛选项的值,再填充到SQL模板的不同地方,最终就会生成能够被数据源执行的SQL。
在SQL生成的时候也考虑过其它的框架,如Apache Calcite Avatica、Alibaba的Druid,但是最终都放弃了,原因也是基于两个方面:
最终,我们采用了SQL模板和字符串填充替换操作来完成。为此我们在Java的正则表达式基础之上做了一个功能很多的字符串操作类库。
一般情况下,同一个数据源的大部分数据源引擎都能够支持多表的join操作,但是也存在不支持的,例如老版本的Kylin就不支持多Cube的join操作,还有一个更重要的问题是数据源引擎无法解决跨数据源的数据聚合问题,必须要自己实现数据的聚合操作,一般的情况下需要自己去实现inner join、left outer join和full outer join的逻辑。
大圣魔方实现了inner join和left outer join两个逻辑,因为full outer join的需求场景不是很多,所以没有实现。下面是大圣魔方的实现代码:
private void join(List<Map<String,String>>[] contents,List<Project> sharedList,final int n,int[] rowsStatus,LinkedList<MatchRow> result){ if(this.cubeJoin==1){ throw new java.lang.IllegalArgumentException("left join call leftJoin method,not call join method"); } if(n<contents.length){ List<Map<String,String>> list = contents[n]; for(int k=0;k<list.size();k++) { boolean equal = true; if(n!=0) { Map<String, String> prev = contents[n - 1].get(rowsStatus[n - 1]); Map<String, String> cur = list.get(k); for (Project proj : sharedList) { String key = proj.fieldName.toUpperCase(); if (key.matches("^\\d+$") || key.equals("*")) { key = "_"; } key = proj.isCompanion() ? key + proj.getFactId() : key; String prevValue = prev.get(key); String curValue = cur.get(key); if (prevValue == curValue) { continue; } if (prevValue == null || curValue == null || !prevValue.equals(curValue)) { equal = false; break; } } } if (equal) { rowsStatus[n] = k; if(n==contents.length-1){//last dataset match MatchRow mr = new MatchRow(); List<MatchRow.DatasetRow> tmp = new ArrayList<>(); for(int i=0;i<rowsStatus.length;i++){ MatchRow.DatasetRow dr = new MatchRow.DatasetRow(); dr.setDatasetIndex(i); dr.setRowIndex(rowsStatus[i]); tmp.add(dr); } mr.addMatchRow(tmp); result.add(mr); }else{ join(contents,sharedList,n+1,rowsStatus,result); } } } } }
上述代码就是通过回溯算法实现inner join的核心逻辑,具体解析如下:
只有当sharedList里面的每个字段都相等的时候,两条记录才满足inner join的条件。这个算法是一个通用算法,因为是通过回溯算法实现的,所以要join的数据源理论上可以有无限个。
private boolean leftJoin(List<Map<String,String>>[] contents,List<Project> sharedList,final int n,int[] rowsStatus,LinkedList<MatchRow> result){ boolean leftJoinMatch = false; if(n<contents.length){ List<Map<String,String>> list = contents[n]; for(int k=0;k<list.size();k++) { boolean equal = true; if(n!=0) { //in left join,compare with the first dataset. Map<String, String> prev = contents[0].get(rowsStatus[0]); Map<String, String> cur = list.get(k); for (Project proj : sharedList) { String key = proj.fieldName.toUpperCase(); if (key.matches("^\\d+$") || key.equals("*")) { key = "_"; } key = proj.isCompanion() ? key + proj.getFactId() : key; String prevValue = prev.get(key); String curValue = cur.get(key); if (prevValue == curValue) { continue; } if (prevValue == null || curValue == null || !prevValue.equals(curValue)) { equal = false; break; } } } if (equal) { leftJoinMatch = true; rowsStatus[n] = k; if(n==contents.length-1){//last dataset match MatchRow mr = new MatchRow(); List<MatchRow.DatasetRow> tmp = new ArrayList<>(); for(int i=0;i<rowsStatus.length;i++){ MatchRow.DatasetRow dr = new MatchRow.DatasetRow(); dr.setDatasetIndex(i); dr.setRowIndex(rowsStatus[i]); tmp.add(dr); } mr.addMatchRow(tmp); result.add(mr); }else{ //if next dataset is not match,use the next's next... for(int loopFlag=n+1;loopFlag<rowsStatus.length;loopFlag++){ boolean match = leftJoin(contents,sharedList,loopFlag,rowsStatus,result); if(match){ break; } rowsStatus[loopFlag]=-1; if(loopFlag==contents.length-1){ MatchRow mr = new MatchRow(); List<MatchRow.DatasetRow> tmp = new ArrayList<>(); for(int i=0;i<rowsStatus.length;i++){ MatchRow.DatasetRow dr = new MatchRow.DatasetRow(); dr.setDatasetIndex(i); dr.setRowIndex(rowsStatus[i]); tmp.add(dr); } mr.addMatchRow(tmp); result.add(mr); } } } } } } return leftJoinMatch; }
上面的代码是left outer join的实现逻辑,同样也是用的回溯算法,它与inner join有2个不同之处:
使用自定义计算的原因,主要是基于下面的两个方面:
对此,我们对大圣魔方做了如下操作:
只要是有数据展示,数据权限问题就无法避免,权限主要是分为报表的可查看权限和维度、指标权限。权限遇到的最主要的问题是构成权限矩阵的数据量太大,参与者有用户和组织,权限的实体有维度和指标,这样大的数据维护起来的成本很高;其次是权限数据配置会占用很多的人力。
对此,我们做了如下操作:
报表上展示数据需要有各种各样的图表,没法为用户只做一个统一的报表,这个时候需要用户能够创建自己想要的报表,这时需要提供一个标准的组件库、布局库和一些常用的模板。用户选择好想要的模板,然后选择布局对报表页面进行布局,接着在每个布局里面填充不同的组件,这样就可以构建一张报表了,也就是我们常说的所见即所得的方式。
大圣魔方就是采用上述的机制提供了一套可视化报表编辑工具。使用它可以快速地创建一个报表,管理人员只需要维护对应的组件、布局和模板就行了。
上述几点就是大圣魔方的一个总纲,其中大部分功能已经实现了,还有一小部分处于开发之中(标准化UI组件、自助生成可视化报表)。目前大圣魔方已经上线将近一年了,支持了内部众多业务,后续我们还会在UI易用性、星型模型、配置简化、元数据同步等方面做一些提高。
最后插播一个招聘广告,有对BI工具开发感兴趣的可以发邮件给 fuyishan@meituan.com
回答“思考题”、发现文章有错误、对内容有疑问,都可以来微信公众号(美团点评技术团队)后台给我们留言。我们每周会挑选出一位“优秀回答者”,赠送一份精美的小礼品。快来扫码关注我们吧!