SpringBoot系列 - 集成Echarts导出图片

Echarts是百度一款开源可视化图表库,基于html5 Canvas的。能够快速让你看到漂亮的效果。也是百度开源产品中的良心之作。

有时候在Java程序中也需要导出好看的图表,比如我经常会基于JMH做各种微基准测试,想将测试结果可视化导出为图表形式。 试用了一下JFreeChart,跟Echarts导出的图比起来还是弱了不少。但是Echarts是基于js的,只能在浏览器中解析和导出图片,怎么办呢?

后来我想到一个方法,就是基于WebSocket技术,服务器将图表数据推送到页面,然后页面再触发导出动作。 本篇将介绍如何通过SpringBoot、SocketIO、Echarts技术来实现这个图表导出功能。

大致步骤是这样的:

  1. 编写一个RESTful API接口,用来接收生成图表需要的数据,然后向页面推送导出图片的消息请求。
  2. 编写一个WebSocket接口,接收Base64格式的图片编码,然后将其转换为图片保存到磁盘上
  3. 启动应用后打开websocket服务端,并监听页面发送的Echarts图片导出消息
  4. 打开页面,自动连接上websocket服务器,并监听导出图片的消息请求,收到消息后发送Echarts图片导出消息
  5. 后面就可以调用这个RESTful API接口来讲数据可视化为Echarts图片并保存了。

我曾经尝试过,Java程序通过phantomjs启动浏览器打开页面,最后导出来的图片有1.5M,而直接用浏览器打开后导出大小只有50K, 所以放弃了phantomjs方案。

maven依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<netty.version>4.1.19.Final</netty.version>
<thymeleaf.version>3.0.7.RELEASE</thymeleaf.version>
<thymeleaf-layout-dialect.version>2.2.2</thymeleaf-layout-dialect.version>
<jmh.version>1.20</jmh.version>
</properties>

<dependencies>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.7</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.5</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.11</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.hamcrest</groupId>
<artifactId>hamcrest-all</artifactId>
<version>1.3</version>
<scope>test</scope>
</dependency>

<!-- netty-socketio-->
<dependency>
<groupId>com.corundumstudio.socketio</groupId>
<artifactId>netty-socketio</artifactId>
<version>1.7.13</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-buffer</artifactId>
<version>${netty.version}</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec</artifactId>
<version>${netty.version}</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-codec-http</artifactId>
<version>${netty.version}</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-common</artifactId>
<version>${netty.version}</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-handler</artifactId>
<version>${netty.version}</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-resolver</artifactId>
<version>${netty.version}</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport</artifactId>
<version>${netty.version}</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport-native-epoll</artifactId>
<version>${netty.version}</version>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-transport-native-unix-common</artifactId>
<version>${netty.version}</version>
</dependency>
<dependency>
<groupId>io.socket</groupId>
<artifactId>socket.io-client</artifactId>
<version>1.0.0</version>
</dependency>

</dependencies>

配置文件

修改application.yml,配置web端口、socket服务端口、图片保存路径等:

1
2
3
4
5
6
7
8
9
10
11
12
###################  自定义项目配置 ###################
xncoding:
socket-port: 9076 #socket端口
ping-interval: 60000 #Ping消息间隔(毫秒)
ping-timeout: 180000 #Ping消息超时时间(毫秒)
image-dir: E:/pics/

################### 项目启动端口 ###################
server:
port: 9075
jetty:
max-http-post-size: 20000000

WebSocket服务

基于Socket.IO来实现WebSocket服务,关于这个我专门写了一篇博客:集成SocketIO实时通信

这里我不再多讲怎么集成,我只贴一下监听方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 保存客户端传来的图片数据
*
* @param client 客户端
* @param ackRequest 回执消息
* @param imgData Base64的图形数据
*/
@OnEvent(value = "savePic")
public void onSavePic(SocketIOClient client, AckRequest ackRequest, String imgData) {
logger.info("保存客户端传来的图片数据 start, sessionId=" + client.getSessionId().toString());
String r = apiService.saveBase64Pic(imgData);
logger.info("保存客户端传来的图片 = {}", r);
ackRequest.sendAckData("图片保存结果=" + r);
}

这里WebSocket服务器会监听消息savePic,参数为Base64的图形数据,然后将其保存到磁盘上面。

Echarts配置类

Echarts可以到处非常多类型的图表,每个图表初始化需要一个特定json对象,里面的配置不一样。我使用了其中的一种用来显示性能测试对比的纵向柱状图。 然后需要编写这个配置对象,包括各个内嵌对象,具体我就不贴出来了,参考最后面的github源码。

图片导出接口

接下来编写对外开放的图片导出RESTful API接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 对外API接口类
*/
@RestController
@RequestMapping(value = "/api/v1")
public class PublicController {

@Resource
private ApiService apiService;

private static final Logger _logger = LoggerFactory.getLogger(PublicController.class);

@RequestMapping(value = "/data", method = RequestMethod.POST)
public ResponseEntity<BaseResponse> doJoin(HttpServletRequest request) throws Exception {
_logger.info("数据上传消息push接口 start....");
String jsonBody = IOUtils.toString(request.getInputStream(), Charset.forName("UTF-8"));
EchartsData echartsData = new EchartsData("", jsonBody);
String jsonString = new ObjectMapper().writeValueAsString(echartsData);
apiService.pushMsg("notify", jsonString);
BaseResponse result = new BaseResponse<>(true, "数据上传消息push成功", null);
return new ResponseEntity<>(result, HttpStatus.OK);
}
}

上面的pushMsg方法定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 服务器主动推送消息
*
* @param msgType 消息类型
* @param jsonData echarts图表数据
*/
public void pushMsg(String msgType, String jsonData) {
SocketIOClient targetClient = this.server.getClient(UUID.fromString(sessionId));
if (targetClient == null) {
logger.error("sessionId=" + sessionId + "在server中获取不到client");
} else {
targetClient.sendEvent(msgType, jsonData);
}
}

服务器将数据转换成echarts的配置json字符串后,就给浏览器推送一个notify的消息。

页面客户端

接下来编写js客户端来连接WebSocket服务,并监听notify的消息,在页面上生成Echarts图表后, 再给服务器发送一个savePic的消息,并把图表的Base64编码数据传过去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="renderer" content="webkit">
<title>Echarts Demo</title>
<link rel="shortcut icon" th:href="@{/favicon.ico}"/>
<link th:href="@{/static/css/bootstrap.css}" rel="stylesheet"/>
<style>
body {
padding: 20px;
}

#console {
height: 100px;
overflow: auto;
}

.connect-msg {
color: green;
}

.disconnect-msg {
color: red;
}

.send-msg {
color: #888
}
</style>
</head>
<body>
<h1>Netty-socketio Demo Chat</h1>
<br/>
<div id="console" class="well"></div>

<!-- 为 ECharts 准备一个具备大小(宽高)的 DOM -->
<div id="main" style="width:860px; height:470px;"></div>

<form class="well form-inline" onsubmit="return false;">
<input id="msg" class="input-xlarge" type="text" placeholder="Type something..."/>
<button type="button" onClick="sendDisconnect()" class="btn">Disconnect</button>
<button type="button" onClick="sendSavePic()" class="btn">Save Picture</button>
</form>

<script th:src="@{/static/js/jquery-1.10.1.min.js}"></script>
<script th:src="@{/static/js/socket.io.js}"></script>
<script th:src="@{/static/js/moment.min.js}"></script>
<script th:src="@{/static/js/echarts.common.min.js}" charset="utf-8"></script>

<script>
var socket;
var myChart;

function sendDisconnect() {
socket.disconnect();
}

function output(message) {
var currentTime = "<span class='time'>" + moment().format('HH:mm:ss.SSS') + "</span>";
var element = $("<div>" + currentTime + " " + message + "</div>");
$('#console').prepend(element);
}

function sendSavePic() {
socket.emit('savePic', myChart.getDataURL(), function (result) {
output('<span class="connect-msg">' + result + '</span>');
});
}

function initPage() {
console.log('this is index.html log...');

var userName = 'user' + Math.floor((Math.random() * 1000) + 1);
socket = io.connect('http://localhost:9076');

console.log('socket = ' + socket);

socket.on('connect', function () {
console.log('connect successful');
output('<span class="connect-msg">Client has connected to the server!</span>');
});

socket.on('disconnect', function () {
console.log('disconnect successful');
output('<span class="disconnect-msg">The client has disconnected!</span>');
});

socket.on('notify', function (jsonBody) {
console.log('get notify message from server...');
var msg = JSON.parse(jsonBody);
output('<span class="connect-msg">接收到notify消息, 去给我保存图片</span>');
var option = msg.option;
// 使用刚指定的配置项和数据显示图表。
myChart.setOption(JSON.parse(option));
// 渲染完成之后再给服务器发送保存图片消息
sendSavePic();
});

/**************************************分割线***********************************/
// 基于准备好的dom,初始化echarts实例
myChart = echarts.init(document.getElementById('main'));
console.log('index initPage finished!')
}

$(function () {
initPage();
});

</script>

</body>

</html>

测试

编写测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ApplicationTests {
@Test
public void testOption() throws Exception {
String titleStr = "对象序列化为JSON字符串";
// 几个测试对象
List<String> objects = Arrays.asList("FastJson", "Jackson", "Gson", "Json-lib");
// 测试维度,输入值n
List<String> dimensions = Arrays.asList("10000次", "100000次", "1000000次");
// 有几个测试对象,就有几组测试数据,每组测试数据中对应几个维度的结果
List<List<Double>> allData = new ArrayList<List<Double>>(){
{
add(Arrays.asList(2.17, 9.10, 21.70));
add(Arrays.asList(1.94, 8.94, 19.43));
add(Arrays.asList(4.88, 22.88, 48.89));
add(Arrays.asList(9.11, 58.14, 108.44));
}
};
String optionStr = generateOption(titleStr, objects, dimensions, allData, "秒");
// POST到接口上
postOption(optionStr, "http://localhost:9075/api/v1/data");
}
}

一切准备就绪后,就可以开始测试了,步骤如下:

  1. 启动应用
  2. 打开首页 http://localhost:9075/
  3. 运行测试类ApplicationTests.java

结果首页显示如下:

同时去E:/pics/目录去看看,图片也成功保存。

GitHub源码

springboot-echarts