现如今,互联网上充斥着各式各样的图表产品,有基于Java实现的(如JfreeChart)、有基于Javascript实现的(如highcharts和国内的ichartjs)、还有基于Flash实现的(如fusioncharts)。那么不同公司根据自己的需求,会选择不同类型的图表产品,甚至有的公司感觉当前的图表产品已经不适合公司长远发展,就会考虑选择一套新的图表组件,但是期间最大的问题不是挑选新的图表组件,而是如何减少因为替换图表而造成的代码修改工作量问题。
本文不是介绍如何开发一套独立的图表,而是介绍如何通过Java开发设计一套抽象图接口,通过抽象接口最大程度地减少替换图表组件所耗费的工作量。
注:本文适合用了非常多图表的项目,也适合图表扩展非常大的项目。至于一个项目用了一两个图表,那就大材小用了。
- 问题分析
要做一个通用图表接口,首先必须了解市面诸多图表的调用原理。我抽取了Fusioncharts、AnyChart、Highcharts、amCharts、open flash chart、jfreeChart这类比较出名的图表进行分析,总体发现他们获取图表参数的方式有以下几种。
1、以JSON为参数,JSON是Javascript非常流行的一种数据结构,它可以像JavaBean一样表示一个复杂的数据集合。有的图表是用Javascript参数来配置图的样式,JSON只保存图的数据(比如这个);而有的图表是将图样式和图数据均以JSON保存(比如这个)。
2、以XML为参数,XML是一种应用非常广泛的数据交换格式,被很多编程语言接纳。很多图表都支持XML格式的参数,而且往往图表允许图的样式和图的数据都以XML传递给它(比如这个)。
3、以Java对象为参数,这是通过Java语言实现的图表绘制方式,代表产品是JfreeChart,它的实现方式是通过JavaBean的基本属性来表示图表的样式和图数据,最后通过图组件对JavaBean进行分析并生成图。
举两例子,均以伪代码的形式说明不同图表组件是怎么运行的。
[JfreeChart]
1、DefaultCategoryDataset dataset = new DefaultCategoryDataset();
2、dataset.addValue(440, "数据", "类型1"); //封数据
3、JFreeChart chart = ChartFactory.createBarChart3D("XXX统计图", "类型","数据额", dataset, PlotOrientation.VERTICAL, true, false, false);
4、String filename = ServletUtilities.saveChartAsPNG(chart,600,420, null, session);//返回图片名称
//为图片生成url访问地址
5、String graphURL = request.getContextPath() + "/DisplayChart?filename=" + filename;
6、将 图片名称filename 和图片地址 graphURL 保存到作用域中,最后返回
7、页面中 图片链接方式 <img src="${graphURL}" width=630 height=450 border=0 usemap="#${filename}"> 。
[FusionChart]
后台Java生成JSON:String json = xxx.generatJSON(xxx);
前端获取json并创建图对象
<script type="text/javascript">
var myChart = new FusionCharts( "FusionCharts/Column3D.swf","myChartId", "400", "300", "0" );
myChart.setJSONUrl(${json});
myChart.render("chartContainer");
</script>
从以上几种情况可以想象,在没有做好图表抽象接口的前提下,假如目前正在使用open flash chart,而公司打算用fusionChart做替换会花多大的代价:open flash chart有自己独立的JSON格式,而fusionChart就得写一套类似的javascript代码,或者在后台编写一套满足fusionChart格式的JSON/XML数据。从很大程度上说原来的那套代码基本没用了,甚至还会造成第三方的影响----比如有其它模块调用了open flash chart的接口,如果一变为FusionChart,肯定会出错。这就是所谓的耦合度问题,耦合度越高,改动起来越难,牵一发而动全身!
本文的目的就是想办法解决上面场景的问题,尽量减少图表耦合度问题,力图打到足够细的粒度,让图表后期维护更加轻松。
- 共同点分析
有的人从代码的角度去看,会觉得不同图表根本没有共同点,完全没有复用性,即使是两套图表都支持XML,但是双方对XML格式的定义差别非常大,所以也没法重用。开发人员往往注重太多细节而被真相蒙蔽,其实如果跳出代码这个棋局,你会发现不同图表组件是有共同点的,那就是它们都是图表,它们有图表所必须具备的元素:标题、X轴、Y轴、数据区、图例等等。
在我看来,图表完全可以抽离出一个基于Java的抽象图表对象,这个对象通过Java的数据结构来表示一个图表所具备的所有元素。然后我们想要出一个图表的时候就先实例化这个抽象图对象,并且往里面设置各种图元素,最后再通过某个“转换器”将抽象对象转换为真实图表组件认识的数据。这样在进行数据传递的时候,抽象图对象它其实就是一个标准,无论到哪儿都能识别的标准。
- 架构分析
定义抽象图对象只是接口其中的一部分,要做好一整套组件,需要在某些关键节点定义好接口进行分割,然后分成几块各自完成各自的工作,这个可以说是面向对象的封装。
上图是图表组件的运行逻辑,下面依次说明各步骤的功能和关系。
一般动态页面的图表数据都会来自一个或多个数据源,在获得图表所必须的数据源后就将其放入抽象图对象中,然后根据不同要求给抽象图设置各自的增值功能(标题、图例等),这时我们假想的一个图表就完成了。
在完成抽象图对象之后,还不能满足真正图表的接口要求,比如JfreeChart需要定义JfreeChart对象,并且往里面设置参数才能出图。那“图表转换器”就诞生了,图表转换器是实现抽象对象与真正图表所需数据进行映射转换的工具。一种图表组件就对应一种图表转换器,这个是开发人员逃不了的工作,比如JfreeChart就是将抽象图对象的值get出来再set到JfreeChart指定方法中;如果是FusionChart则是将抽象图对象的值get出来并拼接成JSON字符串或XML。
通过转换器最终转换出来的就是图表组件能识别的图表数据,如JfreeChart的JfreeChart对象,FusionChart的JSON,AnyChart的XML,最后再将数据传到页面进行对应的图表数据处理,最终一个图表就出来了。
当然页面后面可能还要绑定一个基于javascript的接口组件,将其它图表组件包装起来,这个后面再说。
- 抽象图表
要将一个图表所有元素用一个Java对象表示非常困难,不同图表、不同图类型有各自的特点,我们只能做一个大体上的,以后在用到特殊元素再加上去也不晚。
首先要了解图表的元素,通过上图可以看到一个常规图形的大部分元素,简单介绍一下图表。
从整体来看,图表分为两部分:绘图区和注释区,绘图区就是展示图核心数据的部分,柱状图的柱子、饼图的圆饼都在绘图区内,而注释区可以说是给绘图区元素进行注释说明的部分,比如图例、标题等等。(注:很多专业术语我着实分不清,因为就随便命名了,命名尽量让大家能理解)
细化来说,一个图由很多元素组成。
标题,顾名思义不解释了,有的图表组件还支持副标题和脚注。
图例,用百度百科解释就是集中于图的一角或一侧的图上各种符号和颜色所代表内容与指标的说明,有助于更好的认识图。
Labels文字,在图上进行文字描述以直观显示图的值,做好好的图表支持文字在图内、图外等任意位置。
Tooltip冒泡提示,在鼠标移动到图元素上时,弹出一个层显示该元素的详细信息。
Zoom滚动条,在数据过多的时候,滚动条用于部分放大,便于查看图数据。
当然很多图表还支持菜单功能,以方便导出、打印等。
现在开始定义Java对象,我打算用自上而下(从总到细)的形式来构造抽象的Java对象。
首先我会考虑一个父类,父类可以定义最终返回给真实图表组件的参数,比如xml、json等。
public class BaseChartObject implements Serializable,Cloneable{ private String xml; private String json; private Object object; }
然后就是最关键的抽象图表类,抽象类继承上一步的父类,并且定义创建各种图表所必须的元素,每一个元素又对应一个Java类。
public class AbstractChart extends BaseChartObject{ //基本设置-如图类型 private BasicSetting basicSetting; //图数据 private DataSet dataSet; //轴元素 private Axes axes; //图边距 private Margin margin; //图例 private Legend legend; //右键菜单 private ContextMenu contextMenu; //标题 key对应Title.MAIN_TITLE/Title.SUB_TITLE/Title.FOOT_TITLE private Map<String,Title> title; //冒泡提示 private Tooltip tooltip; //文字提示 private Labels label; }
再往下层走,就是审视各个图元素,因为篇幅问题,这里就只介绍DataSet的结构,其它基本都是依葫芦画瓢。其实看DataSet的定义还没有结束,它也只有三个List类型的属性。这三个属性中attributes看起来比较难理解,它其实类似于Map,以key/value的形式来定义属性,比如我定义一个key为’maxValue’,value为’100’,那么在series中我就可以引用关键字’maxValue’,这时这条数据的值其实就是指’100’。这是AnyChart图表具有的特殊功能,如果其它图表不支持该功能就可以不定义。
public class DataSet extends BaseChartObject { //数据集,一个series对应一组List数据 private List<Series> series; //跳转动作,用于下钻、穿透操作 private List<Action> actions; //自定义属性,用于定义快捷值 private List<Attribute> attributes; }
public class Attribute extends BaseChartObject { private String name; private Object value; }
public class Action extends BaseChartObject { // 动作类型 private ChartEnums.ActionType type; // 跳转地址 private String url; // 跳转目标 private ChartEnums.URLTarget urlTarget; private String function; private ChartEnums.SourceMode sourceMode; private String source; private String viewName; }
然后看Series的代码就非常大头了,series是一组数据,它下面还有一个List<Point>,而series还具体自己本身的一些属性,比如id、name,然后是可以自定义颜色、定义多轴信息、定义label和冒泡提示格式等等,所以会有这么多属性。
public class Series extends BaseChartObject{ private List<Point> pointList; private String id; private String name; private ChartEnums.ChartTypeEnum type; private Color color; // 柱状图的不同显示效果 private ChartEnums.ShapeType shapeType; // 多轴使用,对应extra yAxis的name,表示该series隶属于该轴 private String yAxis; // 饼状图点击后是否突出显示 private Boolean pieExploded; private Labels label; private Tooltip toolTip; private Marker marker; private List<Action> actions; private List<Attribute> attributes; private Animation animation; private List<Labels> extraLabel; private List<Tooltip> extraToolTip; private List<Marker> extraMarker; }
再往下层走是Point,Point与Series是非常相似的,但是一定要明确它们的关系,这个在下面说明。
public class Point extends BaseChartObject{ private String id; private String name; private String value; // 气泡大小 private String size; // 颜色配置 private Color color; // 是否被选中 private Boolean selected; // 是否允许被选中 private Boolean allowSelect; // 饼图是否突出 private Boolean pieExploded; private Labels label; private Tooltip toolTip; private Marker marker; private List<Action> actions; private List<Attribute> attributes; private Animation animation; private List<Labels> extraLabel; private List<Tooltip> extraToolTip; private List<Marker> extraMarker; }
关于Series和Point的关系,也许很多人都是蒙的,这个我也是熟悉了很久才理清楚。
首先一个Series对应多个Point,Point属于元数据,而Series属于一组元数据的集合,假如一个Series下包含5个Point,如果编写代码就可以这样做。
List<Point> points = new ArrayList<Point>(); points.add(new Point(“John”,“10000”)); points.add(new Point(“Jake”,“12000”)); points.add(new Point(“Peter”,“18000”)); points.add(new Point(“James”,“11000”)); points.add(new Point(“Mary”,“9000”)); List<Series> series = new ArrayList<Series>(); series.add(new Series(points)); DataSet dataSet = new DataSet(series); AbstractChart chart = new AbstractChart(dataSet);
那么如果有两组及以上Series是什么效果呢,看下面代码及效果图。
List<Point> points = new ArrayList<Point>(); points.add(new Point(“John”,“10000”)); points.add(new Point(“Jake”,“12000”)); points.add(new Point(“Peter”,“18000”)); points.add(new Point(“James”,“11000”)); points.add(new Point(“Mary”,“9000”)); List<Point> points2 = new ArrayList<Point>(); points2.add(new Point(“John”,“12000”)); points2.add(new Point(“Jake”,“15000”)); points2.add(new Point(“Peter”,“16000”)); points2.add(new Point(“James”,“13000”)); points2.add(new Point(“Mary”,“19000”)); List<Series> series = new ArrayList<Series>(); series.add(new Series(points)); series.add(new Series(points2)); DataSet dataSet = new DataSet(series); AbstractChart chart = new AbstractChart(dataSet);
另外,混合图也经常出现,比如一个series是柱子,一个series是折线,则只需要在指定series对象中设置type(图类型)属性即可。type对应一个图类型枚举。
public static enum ChartTypeEnum implements EnumsCode{ column("column","柱状图"), bar("bar","条形图"), line_vertical("line_vertical","折线图-竖直"), line_horizontal("line_horizontal","折线图-水平"), spline_vertical("spline_vertical","曲线图-竖直"), spline_horizontal("spline_horizontal","曲线图-水平"), pie("pie","饼状图"), doughnut("doughnut","圆环图"), area_vertical("area_vertical","面积图-竖直"), area_horizontal("area_horizontal","面积图-水平"), radar("radar","雷达图"), marker_vertical("marker_vertical","标记图(气泡)-竖直"), marker_horizontal("marker_horizontal","标记图(气泡)-水平"), column_cylinder("column_cylinder","圆柱柱状图"), bar_cylinder("bar_cylinder","圆柱条形图"), gauges_circular("gauges_circular","圆型仪表图"); ...... }
其它的属性就不过多介绍了,我之所以这样设计这个抽象对象,主要就是尽量将粒度细化,然后抽取某些可重复利用的元素成一个对象(比如Font文字,Color颜色都是很多元素都在使用)。
- 转换器
前面已经提到,转换器是将抽象对象转换为图表认识的真实数据,所以首先要有一个转换器接口,如下代码。
public interface ChartManager { /** * 图表转换器接口 * @param chart 抽象对象 * @param product 图表产品 * @return AbstractChart */ public AbstractChart convertChart(AbstractChart chart,String... product); }
然后是对应的实现方法,无非是判断需要被转换的是哪款图表产品,然后调用对应的转换器获取返回结果即可。
public AbstractChart convertChart(AbstractChart chart,String... product) { if(ArrayUtils.isEmpty(product)){ product = new String[]{DEFAULT_CHART}; } for (String string : product) { if("JfreeChart".equals(string)){//基于JfreeChart的转换器 chart.setObject(this.convert2JFreeChart(chart)); }else if("AnyChart".equals(string)){//基于AnyChart的转换器 chart.setXml(this.convert2AnyChart(chart)); }else if("FusionChart".equals(string)){//基于FusionChart的转换器 chart.setJson(this.convert2FusionChart(chart)); } } return chart; }
比如如果调用的是JFreeChart,下面代码简单地写了几句转换代码,非常不详细,只起到参考作用。
private JFreeChart convert2JFreeChart(AbstractChart chart){ if(SeeyonChartEnums.ChartTypeEnum.pie.equals(chart.getBasicSetting().getChartType())){ //DataSet转换为JFreeChart DefaultPieDataset DefaultPieDataset dpd=new DefaultPieDataset(); for (Point point : chart.getDataSet().getSeries().get(0).getPointList()) { dpd.setValue(point.getName(), Double.valueOf(point.getValue())); } //可以查具体的API文档,第一个参数是标题,第二个参数是一个数据集,第三个参数表示是否显示Legend,第四个参数表示是否显示label提示,第五个参数表示图中是否存在URL JFreeChart jfChart = ChartFactory.createPieChart(chart.getTitle().get(Title.MAIN_TITLE).getValue(),dpd,true,true,false); return jfChart; } return null; }
那么AnyChart只认识XML,所以转换器的工作是将抽象对象转换为XML字符串,这里也是非常简单的参考代码,一个复杂的XML绝不是这么简单的。
private String convert2AnyChart(AbstractChart chart){ StringBuilder xml = new StringBuilder(); xml.append("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"); xml.append("<anychart>"); xml.append("<charts>"); //设置图类型 xml.append("<chart plot_type=\"").append(chart.getBasicSetting().getChartType().getValue()).append("\">"); xml.append("<data>"); //设置数据集合 for (Series series : chart.getDataSet().getSeries()) { xml.append("<series name=\"").append(series.getName()).append("\" >"); for (Point point : series.getPointList()) { xml.append("<point name=\""+point.getName()+"\" y=\""+point.getValue()+"\" />"); } xml.append("</series>"); } xml.append("</data>"); xml.append("<chart_settings>"); //设置标题 xml.append("<title>"); xml.append("<text><![CDATA["+chart.getTitle().get(Title.MAIN_TITLE).getValue()+"]]></text>"); xml.append("</title>"); xml.append("</chart_settings>"); xml.append("</chart>"); xml.append("</charts>"); xml.append("</anychart>"); return xml.toString(); }
注:至于转换器我说得非常简单,而且代码也写得不好,如果新增一套图表产品就必须在原来的实现方法中加if判断,这种方式其实不适合开发扩展,我的建议是采用某些设计模式,让每个产品转换器以“插件”的形式与核心方法绑定。我心里有一个插件化的设想,就是类似于spring的xml配置:每开发一套转换器,就配置一个xml,说明转换器的入口类和方法是什么,转换器的产品标识是什么,然后我的convertChart实现类就先读取XML,然后以反射的形式去实例化转换器,这个我下来再去考虑如何细化吧。
- 图表数据和页面
图表数据其实就是通过转换器返回的值,比如JfreeChart就是JfreeChart对象,AnyChart就是XML字符串,这时就可以通过后台将信息传递到前端了。我比较推荐将整个抽象对象推送到前端,因为AbstractChart继承了BaseChartObject,而BaseChartObject可以随身携带XML、JSON和Object类型的图表数据,所以非常简单方便。而之所以将整个抽象对象传到前端,是因为有时候前端需要判断图表的值信息来安排图表长宽和滚动条问题。
request.setAttribute("chart", AbstractChart);
至于页面怎么创建图表可根据项目本身情况决定,有的项目可能就直接调用图表组件的js再传入数据值就出图,而我比较推荐的是做一套js组件,将图表封装再js组件中,这个可以说是一套接口了。
那么针对这套接口我只简单写下伪代码,下面代码只是js调用接口,而真正执行代码就自然在Chart对象中了,这个大家自己去考虑如何实现。
<script> Chart chart = new Chart(); chart.setProduct(x); //调用哪个产品的图表 chart.setWidth(x); //设置显示宽度 chart.setHeight(x); //设置显示高度 chart.setData(AbstractChart); //传入抽象对象及图数据 chart.setHtmlId(x); //设置图表在哪个html元素中显示 chart.write(); //调用接口出图 </script>
- 总结
自此,一套图表组件就完成了,这套组件的关键是抽象对象,在迁移图表的时候,开发人员不用关抽象对象,只需要写一套新的转换器(如果在页面使用的是组件,则还需要写一套基于js的组件封装)即可展示新的图表,希望这种方法能给大家带来帮助。