diff --git a/Dockerfile b/Dockerfile index ea5b21ca6eb9e0ae1158def8526bbb66feb34e9c..69227cbb7005f664d0f13db663820f8514699bfe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ FROM centos:7 ADD phantomjs /usr/etc/ - +ADD echartsconvert /opt RUN yum install -y fontconfig yum -y install bitmap-fonts bitmap-fonts-cjk chmod 777 /usr/etc/bin/phantomjs \ No newline at end of file diff --git a/echartsconvert/README.md b/echartsconvert/README.md new file mode 100644 index 0000000000000000000000000000000000000000..38988b150f26023c8c810b0cfd6f7b316cc502e7 --- /dev/null +++ b/echartsconvert/README.md @@ -0,0 +1,833 @@ +# PhantomJS在服务端生成ECharts图片 + + **主要功能:给定ECharts的option参数,生成ECharts图片,并以Base64字符串返回。** + +## 1. PhantomJS 介绍 + +[PhantomJS](http://phantomjs.org/)是一个不需要浏览器的富客户端。 + + 官方介绍:PhantomJS是一个基于 WebKit 的服务器端JavaScript API。它全面支持web而不需浏览器支持,支持各种Web标准:DOM处理,CSS选择器, JSON,Canvas,和SVG。 + PhantomJS常用于页面自动化,网络监测,网页截屏,以及无界面测试等。 + + 通常我们使用PhantomJS作为爬虫工具。传统的爬虫只能单纯地爬取html的代码,对于js渲染的页面,就无法爬取,如Echarts统计图。而PhantomJS正可以解决此类问题。 + +>我们可以这么理解PhantomJS,PhantomJS是一个无界面、可运行脚本的谷歌浏览器。 + +### 1.1 PhantomJS下载安装 + +PhantomJS安装非常简单,官网[http://phantomjs.org/download.html](http://phantomjs.org/download.html)下载最新的安装包, +安装包有Windows,Mac OS X, Linux 64/32 bit,选择对应的版本下载解压即可使用,在下载包里有个example文件夹,里面对应了许多示例供参考。 + +为方便使用,我们将phantomjs添加至环境变量中。 + + windows: + 右键我的电脑->属性->高级系统设置->高级->环境变量->用户变量/系统变量->Path=D:\phantomjs\bin; + 或 + cmd->set path=%path%;D:\phantomjs\bin + + linux: + vi /etc/profile + export PATH=$PATH:/usr/phantomjs/bin + +>注:linux虽然不需要其他的依赖包,但仍旧需要GLIBCXX_3.4.9和GLIBC_2.7,当然大多数linux是有这两个依赖包的。 + +### 1.2 PhantomJS运行脚本 + +进入`example`文件夹,里面有个`hello.js`脚本: + +```javascript +"use strict"; +console.log('Hello, world!'); +phantom.exit(); +``` + +通过`phantomjs hello.js`即可运行脚本hello.js + +```bash +phantomjs hello.js +``` + +> 控制台输出:Hello, world! + +### 1.3 PhantomJS脚本参数 + +在`example`文件夹中有`arguments.js`脚本: + +```javascript +"use strict"; +var system = require('system'); +if (system.args.length === 1) { + console.log('Try to pass some args when invoking this script!'); +} else { + system.args.forEach(function (arg, i) { + console.log(i + ': ' + arg); + }); +} +phantom.exit(); +``` + +运行以下命令,其中(first second third)是参数: + +```bash +phantomjs arguments.js first second third +``` + +控制台将输出: + +```bash +0: arguments.js +1: first +2: second +3: third +``` + +### 1.4 PhantomJS页面加载 + +创建`pageload.js`脚本: + +```javascript +"use strict"; +var page = require('webpage').create(); +page.open('https://www.baidu.com/', function () { + page.render('baidu.png'); + phantom.exit(); +}); +``` + +运行`phantomjs pageload.js`即可在同级目录下得到一张`baidu.png`的图片 + +>由于它的这个特性,PhantomJS 可以用来“网页截屏”,截取网页的快照,比如将网页、SVG存成图片,PDF等 + +>导出ECharts图片也是基于page.render此功能 + +## 2. PhantomJS API + +PhantomJS除了有examples外,还有比较全面的API,官方地址[http://phantomjs.org/api/](http://phantomjs.org/api/) + +主要包括: + +1. Command Line Interface(命令行):PhantomJS内置的一些命令行选项,比如设置编码`--output-encoding=encoding `、设置代理`--proxy=address`等等; +2. phantom Object(phantom对象):phantom为实现各种接口,增加一个宿主对象(host object),同时也是window的子对象。主要功能包括运行脚本路径`phantom.libraryPath`,phantom退出`phantom.exit(returnValue)`等等 +3. Web Page Module(网页模块):核心模块,该模块主要提供页面自动化,网页截屏等等功能。 +4. Child Process Module(子进程模块):PhantomJS调用子进程并通过stdin,stdout,stderr进行通信。_(to learn)_ +5. File System Module(文件系统模块):读取和操作服务器系统目录和文件。 +6. System Module(系统模块):该模块可以获取命令行参数、环境变量、操作系统和PID信息。 +7. Web Server Module(Web服务器模块):内置web服务器提供http服务。 + +下面将介绍部分在`echarts-convert.js`中使用到的API,便于方便查看脚本 + +### 2.1 phantom Object(phantom对象) + + phantom.libraryPath:获取运行脚本路径,如该脚本的路径为"E:/phantomjs/examples/hello.js",那么phantom.libraryPath=E:/phantomjs/examples,注意最后没有"/"。 + + phantom.exit():结束phantom进程,脚本不写phantom.exit()时,phantom将不会自己退出,因此如非特殊场景必须增加退出功能。 + + phantom.onError = function (msg, trace) {}:全局异常监听,如果page.onError或其他异常没有捕获,该事件将捕获错误信息。 + +### 2.2 Web Page Module(网页模块) + +首先必须引入此模块`var page = require('webpage').create();` + + page.open(url,callback):打开一个url并且加载页面,页面加载完毕后,执行回调函数callback。简单来说就是在phantom浏览器里中打开一个页面。此方法有几个重载函数,可以去官网查看。 + + page.close():关闭页面,主要为了释放内存。 + + page.evaluate(function, arg1, arg2, ...):评估即沙盒测试,在当前页面建立一个沙盒,在沙盒上做一些操作。该功能比较强大,可以用于无界面测试。 + + page.injectJs(filename):将外部JavaScript文件注入到页面中。主要用于沙盒测试。 + + page.render(filename [, {format, quality}]):渲染也就是截图,生成图片或PDF,filename为路径,format包括PDF、PNG、JPEG、BMP等,quality图片质量为0-100。 + + page.renderBase64(format):渲染生成图片的Base64字符串,format包括PNG、JPEG、GIF。 + + page.clipRect = {top: 0,left: 0,width: 400,height: 300}:截图范围,如果没有设置将截取全部页面。注:phantomjs默认的width为400 + + page.onConsoleMessage = function (msg, lineNum, sourceId){}:页面的控制台监听,用于测试时监听控制台信息。 + + page.onError = function (msg, trace) {}:页面错误监听,如果没有特殊需求,该监听一定要写,否则页面代码错误将无法捕获。 + +### 2.3 File System Module(文件系统模块) + +首先引入`var fs = require('fs');`模块 + + fs.workingDirectory:脚本运行目录,与phantom.libraryPath一样。 + + fs.exists(string):文件是否存在。 + + fs.makeDirectory(string):创建一个目录。 + +### 2.4 System Module(系统模块) + +首先必须引入`var system = require('system');`模块 + + system.args:返回命令行参数数组,第一个参数是脚本名,其他的是各个参数。 + + system.pid:正在运行的进程id。 + +### 2.5 Web Server Module(Web服务器模块) + +首先引入`var server = require('webserver').create();`模块 + + server.listen(port, function (request, response) {}:开启监听服务。 + + request 参数 + request.method:请求方式'GET'或'POST'等等 + request.url:请求地址,包括GET的参数 + request.post:请求体,仅在'POST'和'PUT'时存在 + + response 参数 + response.statusCode:http状态码 + response.headers:http头部信息 + response.write(data):响应体写入数据 + response.close():关闭http连接 + +### 2.6 自定义Module + +在`examples`目录中,有`module.js`和`universe.js`两个脚本: + +`module.js`: + +```javascript +"use strict"; +var universe = require('./universe'); +universe.start(); +console.log('The answer is ' + universe.answer); +phantom.exit(); +``` + +`universe.js`: + +```javascript +"use strict"; +exports.answer = 42; + +exports.start = function () { + console.log('Starting the universe....'); +} +``` + +自定义module跟nodeJs类似,主要是`module.js`通过`require('./universe')`加载universe这个模块,然后就可以直接访问`universe.js`中exports对象的成员函数。 + +## 3. 设计思路 + +### 3.1 初步思路 + + 1. Web项目提供一个http服务,并公开此url地址,访问该地址可以获取Echarts统计图; + 2. Java通过Runtime调用phantomjs脚本,并传入url和filename参数,相当于打开浏览器; + 3. 使用page.open(url)打开该地址,相当于在浏览器中浏览访问; + 4. 使用page.render(filename)生成图片,相当于截图保存; + 5. Java读取filename图片文件File,将其转换成Base64字符串即可。 + +>此思路的流程是按照人的操作流程实现的,phantomjs在这里就是一个浏览器。首先有地址可访问,打开phantomjs,访问地址,截图保存,最后获取图片。 + +>这种设计需要在Web项目开发一个或多个页面,供phantomjs调用。首先增加开发工作量,并导致项目存在无关的页面,最后耦合度较高,不利于维护。 + +### 3.2 中期思路 + + web项目不开发页面,利用Web Page Module模块中的沙盒,生成所需的页面。 + 查看ECharts的文档,统计图的创建主要是通过option(配置项和数据)来控制,因此可以通过option动态生成不同的统计图。 + + 1. Java通过Runtime调用phantomjs脚本,传入ECharts的option和filename参数; + 2. 使用page.open(about:blank)打开空页面; + 3. 调用page.evaluate()在空页面上创建ECharts的Dom层和JavaScript代码; + 4. 使用page.render(filename)生成图片; + 5. Java读取filename图片文件File,将其转换成Base64字符串即可。 + +>这种方式可以解耦合,我们只需关注ECharts的option怎么传入。但是通过Java的Runtime多次打开phantomjs进程,不仅效率慢,而且大量处理时吃服务器资源。 + +### 3.3 改进思路(目前实现) + + 为解决多次开启进程带来的资源消耗提高性能,主要是减少phantomjs进程的多次打开与关闭,目前的解决思路有两种: + 一、利用Child Process Module(子进程模块),开启phantomjs进程时,同时启动Java进程作为子进程。通过共享的stdin,stdout,stderr进行通信。 + 二、利用Web Server Module(Web服务器模块),开启web服务,java通过http请求发送数据并获取结果。 + + 1. 使用server.listen()开启Web服务。 + 2. java通过url请求地址,并传入ECharts的option参数; + 3. 使用page.open(about:blank)打开空页面; + 4. 调用page.evaluate()在空页面上创建ECharts的Dom层和JavaScript代码; + 5. 使用page.renderBase64(format)生成图片的Base64字符串; + 6. response返回Base64数据。 + +### 3.4 后续版本 + + 在实际使用过程中会出现各种问题,待改进。 + +## 4. 代码实现 + +### 4.1 引入module + +```javascript +// 引入module +var system = require('system'), // 获取参数 + path = phantom.libraryPath, + command = require(path + '/module/command.js');// 参数module +``` + +其中system为了获取参数选项,path是当前脚本路径,command是命令行解析模块。 + +### 4.2 设置选项并解析参数 + +```javascript +var commandParams = command + .version('0.0.1') + .option('-s, --server', 'provide echarts convert http server') + .option('-p, --port ', 'change server port when add -s or --server', 9090) + .option('-o, --opt ', 'add the param of echarts method [ eChart.setOption(opt) ]') + .option('-t, --type ', 'provide file/base64 for image, default file', /^(file|base64)$/i, 'base64') + .option('-f, --outfile ', 'add output of the image file path') + .option('-w, --width ', 'change image width', '800') + .option('-h, --height ', 'change image height', '400') + .parse(system.args); +``` + +以port为例,-p是短选项,--port是长选项,是命令后必须跟参数,'change server port when add -s or --server'是描述信息,9090是默认值。 + +参数介绍: + + -s或--server:开启服务监听,如果开启服务端,则-o不生效,这也就意味不能使用脚本命令生成图片; + -p或--port:端口号,只有-s启用时生效,改变监听端口号; + -o或--opt:ECharts的option,这里是json字符串,最外层不加引号; + 示例:{title:{text:'ECharts示例'},tooltip:{},legend:{data:['销量']},xAxis:{data:['衬衫','羊毛衫','雪纺衫','裤子','高跟鞋','袜子']},yAxis:{},series:[{name:'销量',type:'bar',data:[5,20,36,10,10,20]}]} + -t或--type:生成类型file或base64,file是生成图片,base64是生成Base64字符串; + -f或--outfile:文件输出路径,只有--type=file时生效,如果不指定,则在脚本目录下创建一个tmp文件夹,将图片以时间戳为名字,保存到tmp目录下; + -w或--width:生成的图片宽度,默认800像素; + -h或--height:生成的图片高度,默认400像素。 + +服务端参数 + + GET或POST请求时,request的参数主要包括: + + opt:与--opt等同,ECharts的option,这里是json字符串; + type:与--type等同,生成类型file或base64; + width:与--width等同,生成的图片宽度,默认800像素; + height:与--height等同,生成的图片高度,默认400像素。 + +system.args获取命令行参数,parse(system.args)解析参数,之后可以通过commandParams.server,commandParams.port获取相关值。 + +### 4.3 创建对象并初始化 + +```javascript +// *********************************** +// Echarts转换器 +// *********************************** +function Convert(params) { + this.params = params || commandParams; // 参数命令 + this.external = { + JQUERY3: path + '/script/jquery-3.2.1.min.js', + ECHARTS3: path + '/script/echarts.min.js', + ECHARTS_CHINA: path + '/script/china.js' + }; // 外部js +} + +/** + * 初始化 + */ +Convert.prototype.init = function () { + var params = this.params; + this.check(params); + if (params.server) { + this.server(params); + } else { + this.client(params); + } +}; +``` + +创建Convert对象,对象会保存命令行参数选项以及常量如外部依赖js的路径。 + +init()主要是检查参数,并判断开启服务端还是客户端处理,参数检查详见`Convert.prototype.check`。 + +### 4.4 开启服务监听 + +```javascript +/** + * 服务 + * @param params + */ +Convert.prototype.server = function (params) { + var server = require('webserver').create(), // 服务端 + convert = this; + + var listen = server.listen(params.port, function (request, response) { + /** + * 输出 + * @param data + * @param success + */ + function write(data, success, msg) { + response.statusCode = 200; + response.headers = { + 'Cache': 'no-cache', + 'Content-Type': 'application/json;charset=utf-8' + }; + response.write(convert.serverResult(data, success, msg)); + response.close(); + } + + //获取参数 + var args = convert.serverGetArgs(request); + + if (args.opt !== undefined) { + var check = convert.serverCheckAndSet(params, args); + + if (check) { + convert.client(params, write); + } else { + write("", false, "get image error,please check parameter [opt]"); + } + } else { + write("", false, "missing parameter [opt]"); + } + + }); + + // 判断服务是否启动成功 + if (!listen) { + this.error("could not create echarts-convert server listening on port " + params.port); + } else { + console.log("echarts-convert server start success. [pid]=" + system.pid); + } +}; +``` + +`var listen = server.listen(params.port, function (request, response) {}`开启服务,返回值listen如果为false时则启动失败。 + +`Convert.prototype.serverGetArgs`获取并解析request参数,这里只处理了GET和POST请求,解析参数时一定要进行解码处理,尤其是中文。 + +```javascript +/** + * 获取参数 + * @param request + * @returns {{}} + */ +Convert.prototype.serverGetArgs = function (request) { + var args = {}; + if ('GET' === request.method) { + var index = request.url.indexOf('?'); + if (index !== -1) { + var getQuery = request.url.substr(index + 1); + args = this.serverParseArgs(getQuery); + } + } else if ('POST' === request.method) { + var postQuery = request.post; + args = this.serverParseArgs(postQuery); + } + return args; +}; + +/** + * 解析参数 + * @param query 字符串 + * @returns {{}} 对象 + */ +Convert.prototype.serverParseArgs = function (query) { + var args = {}, + pairs = query.split("&"); + for (var i = 0; i < pairs.length; i++) { + var pos = pairs[i].indexOf('='); + if (pos === -1) + continue; + var key = pairs[i].substring(0, pos); + var value = pairs[i].substring(pos + 1); + // 中文解码,必须写两层 + value = decodeURIComponent(decodeURIComponent(value)); + args[key] = value; + } + return args; +}; +``` + +>注:中文解码必须写两层decodeURIComponent + +### 4.5 调用客户端渲染 + +```javascript +/** + * 访问渲染 + * @param params + * @param fn + */ +Convert.prototype.client = function (params, fn) { + var page = require('webpage').create(); // 客户端 + var convert = this, + external = this.external, + render, + output; + + /** + * 渲染 + * @returns {*} + */ + render = function () { + switch (params.type) { + case 'file': + // 渲染图片 + page.render(params.outfile); + return params.outfile; + case 'base64': + default: + var base64 = page.renderBase64('PNG'); + return base64; + + } + }; + + /** + * 输出 + * @param content 内容 + * @param success 是否成功 + */ + output = function (content, success) { + if (params.server) { + fn(content, success); + page.close(); + } else { + console.log(success ? "[SUCCESS]:" : "[ERROR]:" + content); + page.close(); + convert.exit(params);// exit + } + }; + + /** + * 页面console监听 + * @param msg + * @param lineNum + * @param sourceId + */ + page.onConsoleMessage = function (msg, lineNum, sourceId) { + console.log(msg); + }; + + /** + * 页面错误监听 + * @param msg + * @param trace + */ + page.onError = function (msg, trace) { + output(msg, false); // 失败,返回错误信息 + }; + + // 空白页 + page.open("about:blank", function (status) { + // 注入依赖js包 + var hasJquery = page.injectJs(external.JQUERY3); + var hasEchart = page.injectJs(external.ECHARTS3); + var hasEchartChina = page.injectJs(external.ECHARTS_CHINA); + + // 检查js是否引用成功 + if (!hasJquery && !hasEchart) { + output("Could not found " + external.JQUERY3 + " or " + external.ECHARTS3, false); + } + + // 创建echarts + page.evaluate(createEchartsDom, params); + + // 定义剪切范围,如果定义则截取全屏 + page.clipRect = { + top: 0, + left: 0, + width: params.width, + height: params.height + }; + + // 渲染 + var result = render(); + // 成功输出,返回图片或其他信息 + output(result, true); + }); +}; +``` + +调用客户端,主要是为了开启沙盒,进行ECharts统计图创建以及图片渲染工作。 + + 1. page.open("about:blank")一个空页面; + 2. page.injectJs引入外部js,jQuery和ECharts; + 3. page.evaluate页面创建ECharts的Dom,并生成统计图; + 4. page.clipRect定义截图范围; + 5. page.render渲染生成图片; + 6. output输出,命令行直接将结果打印到控制台,Web将输出到response。 + +其中`createEchartsDom`是创建ECharts的Dom对象: + +```javascript +/** + * 创建eCharts Dom层 + * @param params 参数 + */ +function createEchartsDom(params) { + // 动态加载js,获取options数据 + $(' + + + + + +
+ + + +``` + +>这里采用动态加载js来生成options,也可采用JSON.parse(options)将json串解析成对象,后期如果将json的验证完善后可以考虑采用此方式 + +### 4.6 Web服务返回信息 + +```javascript +/** + * 结果返回 + * @param data + * @param success + * @param msg + */ +Convert.prototype.serverResult = function (data, success, msg) { + var result = { + code: success ? 1 : 0, + msg: undefined === msg ? success ? "success" : "failure" : msg, + data: data + }; + + return JSON.stringify(result); +}; +``` + +`response.headers`的`Content-Type`设置为`application/json;charset=utf-8`,将返回json,并且为方便使用,将以统一的格式返回数据 + +其Json格式包括: + + code:成功为1,失败为0; + msg: 成功为'success',失败为'failure'或其他错误信息; + data: 数据,正确返回base64字符串或图片路径,失败为空字符串 + +成功调用的json格式如下: + +```json +{ + "code":1, + "msg":"success", + "data": "base64 string" +} +``` + +### 4.7 异常信息处理 + +PhantomJS异常监听主要包括两种:web page模块异常和phantom对象异常 + +web page异常,此类异常最好手动处理,如果不处理,使用page.evaluate同步命令,如果出现异常PhantomJS线程将被阻止。 + +```javascript +/** + * 页面错误监听 + * @param msg + * @param trace + */ +page.onError = function (msg, trace) { + output("", false, msg); // 失败,返回错误信息 +}; +``` + +全局异常,此类异常在page.onError之后执行,一般发生无法预估异常。 + +```javascript +/** + * phantomJs 全局异常监听 + * @param msg + * @param trace + */ +phantom.onError = function (msg, trace) { + var msgStack = ['Convert ERROR: ' + msg]; + if (trace && trace.length) { + msgStack.push('TRACE:'); + trace.forEach(function (t) { + msgStack.push(' -> ' + (t.file || t.sourceURL) + ': ' + t.line + (t.function ? ' (in function ' + t.function + ')' : '')); + }); + } + console.error(msgStack.join('\n')); + phantom.exit(1); +}; +``` + +## 5. 脚本使用 + + 1. 首先按照(1.1 PhantomJS下载安装)安装好PhantomJS; + 2. 下载phantom.zip并将其解压; + 3. 在`echarts-convert.js`同级目录下,运行命令` phantomjs echarts-convert.js -s `; + 4. 如果控制台出现"echarts-convert server start success. [pid]=xxxx"则表示启动成功,默认端口9090; + 5. Java通过HttpClient或URLConnection请求,url为http://localhost:9090;GET或POST请求,request参数为opt=optJson; + +以HttpClient为例,java调用: + +```java +import java.io.IOException; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; + +import org.apache.http.HttpEntity; +import org.apache.http.NameValuePair; +import org.apache.http.client.entity.UrlEncodedFormEntity; +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.message.BasicNameValuePair; +import org.apache.http.util.EntityUtils; + +public class EChartsConvertTest { + + public static void main(String[] args) { + String url = "http://localhost:9090"; + // 不必要的空格最好删除,字符串请求过程中会将空格转码成+号 + String optJson = "{title:{text:'ECharts 示例'},tooltip:{},legend:{data:['销量']}," + + "xAxis:{data:['衬衫','羊毛衫','雪纺衫','裤子','高跟鞋','袜子']},yAxis:{}," + + "series:[{name:'销量',type:'bar',data:[5,20,36,10,10,20]}]}"; + Map map = new HashMap<>(); + map.put("opt", optJson); + try { + String post = post(url, map, "utf-8"); + System.out.println(post); + } catch (ParseException e) { + e.printStackTrace(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + // post请求 + public static String post(String url, Map map, String encoding) throws ParseException, IOException { + String body = ""; + + // 创建httpclient对象 + CloseableHttpClient client = HttpClients.createDefault(); + // 创建post方式请求对象 + HttpPost httpPost = new HttpPost(url); + + // 装填参数 + List nvps = new ArrayList<>(); + if (map != null) { + for (Entry entry : map.entrySet()) { + nvps.add(new BasicNameValuePair(entry.getKey(), entry.getValue())); + } + } + // 设置参数到请求对象中 + httpPost.setEntity(new UrlEncodedFormEntity(nvps, encoding)); + + // 执行请求操作,并拿到结果(同步阻塞) + CloseableHttpResponse response = client.execute(httpPost); + // 获取结果实体 + HttpEntity entity = response.getEntity(); + if (entity != null) { + // 按指定编码转换结果实体为String类型 + body = EntityUtils.toString(entity, encoding); + } + EntityUtils.consume(entity); + // 释放链接 + response.close(); + return body; + } +} +``` + +## 6. 问题与改进 + +目前整个脚本已经完成初版,但仍有一些已知或未知的问题和缺陷存在,主要包括以下几点: + +1. 命令行自定义模块使用`commander.js`,但因其是为nodejs开发的,所以只抽取了部分代码,有些改动未测试; +2. PhantomJs的Web Server Module(Web服务器模块)仍处于实验性的功能模块,稳定性未知; +3. ECharts的opt目前是人为拼接json字符串,这样会带来一些不必要的问题,后期需要规范传入json的格式; +4. 目前只测试过较为简单的统计图,对于复杂的统计图并没有测试过,使用过程中可能会存在一些问题; +5. 再每次渲染生成图片后执行page.close(),关闭页面,这样会稍微增加性能消耗,后续可以考虑只在沙盒里处理; +6. 目前使用单线程处理图片,高并发下仍需要开发。 + +优化方向 + +1. 增加json字符串即带引号的json支持; +2. 通过java生成ECharts的opt json,而不是人为拼接字符串; +3. 沙盒中处理ECharts图片,不再关闭page。 + +## 7. 参考资料 + +[PhantomJS API](http://phantomjs.org/api/) + +[PhantomJS快速入门教程](http://www.tuicool.com/articles/beeMNj/) + +[Python爬虫利器四之PhantomJS的用法](http://cuiqingcai.com/2577.html) + +[java使用phantomJs抓取动态页面](http://blog.csdn.net/kaka0930/article/details/68941932) + +[JavaScript标准参考教程之PhantomJS](http://javascript.ruanyifeng.com/tool/phantomjs.html#toc1) + +[commander.js](https://github.com/tj/commander.js) + +[ECharts官网](http://echarts.baidu.com/) + +[使用phantomJs服务器端导出Echarts图片](http://blog.csdn.net/zor_chen/article/details/31371501) + +[java调用phantomjs的性能问题](https://segmentfault.com/q/1010000003989521) + + \ No newline at end of file diff --git a/echartsconvert/echarts-convert.js b/echartsconvert/echarts-convert.js new file mode 100644 index 0000000000000000000000000000000000000000..916ffd32f973ead637b2f5fa4671cf708c86637d --- /dev/null +++ b/echartsconvert/echarts-convert.js @@ -0,0 +1,422 @@ +/** + * Created by SaintLee on 2017/6/22. + */ +;(function (window, document, undefined) { + "use strict"; + + // 引入module + var system = require('system'), // 获取参数 + path = phantom.libraryPath, + command = require(path + '/module/command.js');// 参数module + + /** + * phantomJs 全局异常监听 + * @param msg + * @param trace + */ + phantom.onError = function (msg, trace) { + var msgStack = ['Convert ERROR: ' + msg]; + if (trace && trace.length) { + msgStack.push('TRACE:'); + trace.forEach(function (t) { + msgStack.push(' -> ' + (t.file || t.sourceURL) + ': ' + t.line + (t.function ? ' (in function ' + t.function + ')' : '')); + }); + } + console.error(msgStack.join('\n')); + phantom.exit(1); + }; + + /** + * 参数 + * @type {Command} + */ + var commandParams = command + .version('0.0.1') + .option('-s, --server', 'provide echarts convert http server') + .option('-p, --port ', 'change server port when add -s or --server', 9090) + .option('-o, --opt ', 'add the param of echarts method [ eChart.setOption(opt) ]') + .option('-t, --type ', 'provide file/base64 for image, default file', /^(file|base64)$/i, 'base64') + .option('-f, --outfile ', 'add output of the image file path') + .option('-w, --width ', 'change image width', '800') + .option('-h, --height ', 'change image height', '400') + .parse(system.args); + + + // *********************************** + // Echarts转换器 + // *********************************** + function Convert(params) { + this.params = params || commandParams; // 参数命令 + this.external = { + JQUERY3: path + '/script/jquery-3.2.1.min.js', + ECHARTS3: path + '/script/echarts.min.js', + ECHARTS_CHINA: path + '/script/china.js' + }; // 外部js + } + + /** + * 初始化 + */ + Convert.prototype.init = function () { + var params = this.params; + this.check(params); + if (params.server) { + this.server(params); + } else { + this.client(params); + } + }; + + /** + * 参数检查 + * @param params + */ + Convert.prototype.check = function (params) { + if (undefined === params.server && undefined === params.opt) { + this.error("option argument missing -o, --opt "); + } + + if (undefined !== params.opt) { + var isJson = this.checkJson(params.opt); + if (!isJson) { + this.error("--opt args not json string"); + } + } + + if ('file' === params.type && undefined === params.outfile) { + this.createTmpDir(); + } + + }; + + /** + * 检查是否是json字符串 + * @param value + * @returns {boolean} + */ + Convert.prototype.checkJson = function (value) { + var re = /^\{[\s\S]*\}$|^\[[\s\S]*\]$/; + // 类型为string + if (typeof value !== 'string') { + return false; + } + // 正则验证 + if (!re.test(value)) { + return false; + } + // 是否能解析 + try { + value = "\"" + value + "\""; + JSON.parse(value); + } catch (err) { + return false; + } + return true; + }; + + /** + * 创建临时目录,并指定输出路径 + */ + Convert.prototype.createTmpDir = function () { + var fs = require('fs'); // 文件操作 + var tmpDir = fs.workingDirectory + '/tmp'; + // 临时目录是否存在且可写 + if (!fs.exists(tmpDir)) { + if (!fs.makeDirectory(tmpDir)) { + this.error('Cannot make ' + tmpDir + ' directory\n'); + } + } + this.params.outfile = tmpDir + "/" + new Date().getTime() + ".png"; + }; + + /** + * 服务 + * @param params + */ + Convert.prototype.server = function (params) { + var server = require('webserver').create(), // 服务端 + convert = this; + + var listen = server.listen(params.port, function (request, response) { + /** + * 输出 + * @param data + * @param success + */ + function write(data, success, msg) { + response.statusCode = 200; + response.headers = { + 'Cache': 'no-cache', + 'Content-Type': 'application/json;charset=utf-8' + }; + response.write(convert.serverResult(data, success, msg)); + response.close(); + } + + //获取参数 + var args = convert.serverGetArgs(request); + + if (args.opt !== undefined) { + var check = convert.serverCheckAndSet(params, args); + + if (check) { + convert.client(params, write); + } else { + write("", false, "failed to get image, please check parameter [opt] is a JSON"); + } + } else { + write("", false, "failed to get image, missing parameter [opt]"); + } + + }); + + // 判断服务是否启动成功 + if (!listen) { + this.error("could not create echarts-convert server listening on port " + params.port); + } else { + console.log("echarts-convert server start success. [pid]=" + system.pid); + } + }; + + /** + * 服务参数检查和赋值 + * @param params + * @param args + * @returns {boolean} + */ + Convert.prototype.serverCheckAndSet = function (params, args) { + if (this.checkJson(args.opt)) { + params.opt = args.opt; + } else { + return false; + } + + if (/^(file|base64)$/i.exec(args.type)) { + params.type = args.type; + } + + if (!isNaN(args.width)) { + params.width = args.width; + } + + if (!isNaN(args.height)) { + params.height = args.height; + } + return true; + }; + + /** + * 结果返回 + * @param data + * @param success + * @param msg + */ + Convert.prototype.serverResult = function (data, success, msg) { + var result = { + code: success ? 1 : 0, + msg: undefined === msg ? success ? "success" : "failure" : msg, + data: data + }; + + return JSON.stringify(result); + }; + + /** + * 获取参数 + * @param request + * @returns {{}} + */ + Convert.prototype.serverGetArgs = function (request) { + var args = {}; + if ('GET' === request.method) { + var index = request.url.indexOf('?'); + if (index !== -1) { + var getQuery = request.url.substr(index + 1); + args = this.serverParseArgs(getQuery); + } + } else if ('POST' === request.method) { + var postQuery = request.post; + args = this.serverParseArgs(postQuery); + } + return args; + }; + + /** + * 解析参数 + * @param query 字符串 + * @returns {{}} 对象 + */ + Convert.prototype.serverParseArgs = function (query) { + var args = {}, + pairs = query.split("&"); + for (var i = 0; i < pairs.length; i++) { + var pos = pairs[i].indexOf('='); + if (pos === -1) + continue; + var key = pairs[i].substring(0, pos); + var value = pairs[i].substring(pos + 1); + // 中文解码,必须写两层 + value = decodeURIComponent(decodeURIComponent(value)); + args[key] = value; + } + return args; + }; + + /** + * 访问渲染 + * @param params + * @param fn + */ + Convert.prototype.client = function (params, fn) { + var page = require('webpage').create(); // 客户端 + var convert = this, + external = this.external, + render, + output; + + /** + * 渲染 + * @returns {*} + */ + render = function () { + switch (params.type) { + case 'file': + // 渲染图片 + page.render(params.outfile); + return params.outfile; + case 'base64': + default: + var base64 = page.renderBase64('PNG'); + return base64; + + } + }; + + /** + * 输出 + * @param content 内容 + * @param success 是否成功 + */ + output = function (content, success, msg) { + if (params.server) { + fn(content, success, msg); + page.close(); + } else { + console.log(success ? "[SUCCESS]:" : "[ERROR]:" + content); + page.close(); + convert.exit(params);// exit + } + }; + + /** + * 页面console监听 + * @param msg + * @param lineNum + * @param sourceId + */ + page.onConsoleMessage = function (msg, lineNum, sourceId) { + console.log(msg); + }; + + /** + * 页面错误监听 + * @param msg + * @param trace + */ + page.onError = function (msg, trace) { + output("", false, msg); // 失败,返回错误信息 + }; + + // 空白页 + page.open("about:blank", function (status) { + // 注入依赖js包 + var hasJquery = page.injectJs(external.JQUERY3); + var hasEchart = page.injectJs(external.ECHARTS3); + var hasEchartChina = page.injectJs(external.ECHARTS_CHINA); + + // 检查js是否引用成功 + if (!hasJquery && !hasEchart) { + output("Could not found " + external.JQUERY3 + " or " + external.ECHARTS3, false); + } + + // 创建echarts + page.evaluate(createEchartsDom, params); + + // 定义剪切范围,如果定义则截取全屏 + page.clipRect = { + top: 0, + left: 0, + width: params.width, + height: params.height + }; + + // 渲染 + var result = render(); + // 成功输出,返回图片或其他信息 + output(result, true); + }); + }; + + /** + * 创建eCharts Dom层 + * @param params 参数 + */ + function createEchartsDom(params) { + // 动态加载js,获取options数据 + // 解决url的decode错误 + params.opt=params.opt.replace("%25","%").replace("%23","#").replace("%26","&").replace("%3D","="); + $('