一. 办法摸索

后盾生成图片的办法不多,依据我在网上的查找,有如下几种办法:

  1. 前台服务提供接口,联合图表提供的生成图片,申请后返回图片数据。
  2. 搭建服务,与第一点相似,同样是发送数据。
  3. 若有配合的前端服务,能够在前端发动下载时生成图片数据,传送回后盾。
  4. 利用 phantomjs,将图表数据整顿成 html,再联合对应的 javascript 脚本,即可生成图片,这种办法也是本篇文章要介绍的办法。

这几种办法我通过比拟,发现还是应用 phantomjs 这种形式劣势比拟大。

第一,它不须要依赖额定的服务。

第二,生成的形式自主可控,你可能灵活处理数据,也可能管制生成图片的品质和大小。

第三,实用的范畴更加宽泛,你不仅能够应用 echarts,也能够应用 highchart,并且包含不限于图表,只有你能找到图片化的办法。

惟一的毛病就是你须要装置它。

二. 灵感起源

本篇文章的灵感来源于 ECharts-Java issuse,在寻找后端如何生成前端图表图片办法的过程中,我找到了这个 issuse,并由作者之一的 incandescentxxc 指引,找到了 Snapshot-PhantomJS 这个我的项目。

然而我没有间接应用 Snapshot-PhantomJS,因为它自身源码不多,因而我抉择排汇其中的外围源码,并针对性的进行了缩减与优化。

三. 所需工具

Echarts-Java

<dependency>  <groupId>org.icepear.echarts</groupId>  <artifactId>echarts-java</artifactId>  <version>1.0.7</version></dependency>

该工具的作用有两点:

  1. 不便的将数据整顿成 echarts 所需的 option。
  2. 可能将 option 转化成所须要的 html,可能间接在浏览器中关上看到图表。

如果你应用了 slf4j,最好移除掉所有的 org.slf4j,否则会有抵触问题。(这问题我认为不应该呈现,第三方 jar 自身应该思考到这个问题)

phantomjs

作用有点相当于一个运行在后盾的浏览器,在后盾运行 html 界面。

javascript 脚本

var page = require("webpage").create();var system = require("system");var file_type = system.args[1];var delay = system.args[2];var pixel_ratio = system.args[3];var snapshot =    "    function(){" +    "        var ele = document.querySelector('div[_echarts_instance_]');" +    "        var mychart = echarts.getInstanceByDom(ele);" +    "        return mychart.getDataURL({type:'" + file_type + "', pixelRatio: " + pixel_ratio + ", excludeComponents: ['toolbox']});" +    "    }";var file_content = system.stdin.read();page.setContent(file_content, "");window.setTimeout(function () {    var content = page.evaluateJavaScript(snapshot);    phantom.exit();}, delay);

该脚本的作用是在外部生成一个 webpage,用来加载你传递的含有图表数据的 html,期待一段时间加载实现后,获取图片的 dataURL,实际上也就是图片的 base64 数据。

四. 演示代码

因为我感觉 java 的代码写演示太过繁琐,因而都应用 kotlin 演示。
val bar = Bar()        .setLegend()        .setTooltip("item")        .addXAxis(arrayOf("Matcha Latte", "Milk Tea", "Cheese Cocoa", "Walnut Brownie"))        .addYAxis()        .addSeries("2015", arrayOf<Number>(43.3, 83.1, 86.4, 72.4))        .addSeries("2016", arrayOf<Number>(85.8, 73.4, 65.2, 53.9))        .addSeries("2017", arrayOf<Number>(93.7, 55.1, 82.5, 39.1))    val engine = Engine()    val html = engine.renderHtml(bar)    val process = ProcessBuilder("phantomjs", "generate-images.js", "jpg", "10000", "10").start()    BufferedWriter(OutputStreamWriter(process.outputStream)).use {        it.write(html)    }    val result = process.inputStream.use { IoUtil.read(it, Charset.defaultCharset()) }    val contentArray = result.split(",".toRegex()).dropLastWhile { it.isEmpty() }    if (contentArray.size != 2) {        throw RuntimeException("wrong image data")    }    val imageData = contentArray[1]    FileUtil.writeBytes(Base64.decode(imageData), File("test.jpg"))
IoUtil 与 FileUtil 都来源于 hutool

解释一下命令行参数:

  1. phantomjs,phantomjs 的执行门路。
  2. generate-images.js,就是上述提到的 javascript 脚本。
  3. jpg 为你须要的图片格式,svg 须要本人批改 javascript 脚本。
  4. 10000,为延迟时间,这个工夫为了留给 html 加载的,耗时蕴含的有下载 echarts 脚本与图片生成。
  5. 10,图片品质,越大品质越高,图片体积越大。

你能够看到,通过我的精简,整体的代码是比较简单的。

五. 优化过程

下面的演示代码并不能称得上是最终版本。

你要面临两个问题:

  1. 生成的 html,是须要联网下载 echarts 的,这部分耗时不说,有些环境也面临着无奈联网的状况。
  2. 品质为 10 的图片,体积能来到 40M 以上,这必定是无奈承受的。

应用本地 echarts 库

这里只须要你下载好文件即可,针对性的做替换。

val html = engine.renderHtml(bar)        .replace(Regex("(?is)<script src.+?/script>"), """<script src="file://echart.min.js"></script>""")

压缩 jpg

因为生成的图片是很简略的,这也意味着压缩的空间十分微小,通过我本人的测试,40M 左右的图片,通过压缩,体积能放大到几百 K,并且图片品质根本不会受到影响。

我间接给出实现代码。

fun removeAlpha(img: BufferedImage): BufferedImage {    if (!img.colorModel.hasAlpha()) {        return img    }    val target = BufferedImage(img.width, img.height, BufferedImage.TYPE_INT_RGB)    val g = target.createGraphics()    g.fillRect(0, 0, img.width, img.height)    g.drawImage(img, 0, 0, null)    g.dispose()    return target}fun compress(imageData: ByteArray): ByteArray {    return ByteArrayOutputStream().use { compressed ->        // 压缩图像,原有图像体积过大,压缩后体积放大并且品质不会有太大损失        ImageIO.createImageOutputStream(compressed).use {            val jpgWriter = ImageIO.getImageWritersByFormatName("JPEG").next()            jpgWriter.output = it            val jpgWriteParam = jpgWriter.defaultWriteParam            jpgWriteParam.compressionMode = ImageWriteParam.MODE_EXPLICIT            jpgWriteParam.compressionQuality = 0.7f            val img = ByteArrayInputStream(imageData).use {                // 移除原来的 alpha 通道                IIOImage(removeAlpha(ImageIO.read(it)), null, null)            }            jpgWriter.write(null, img, jpgWriteParam)            jpgWriter.dispose()            compressed.toByteArray()        }    }}

因为压缩图片的前提是要求图片不能含有 alpha 通道,因而我在网上找到了移除通道的方法。

优化耗时

其实本来写到下面就截止了,不过因为灵感来了,顺道就把这个问题也解决了。

如果你残缺的了解了下面的例子之后,你会发现在这个例子在耗时解决上有很大的问题:

  1. 耗时不可控,无奈晓得图表是何时渲染完的。
  2. 耗时只能是固定的,即便图片早于你设定的工夫渲染实现,同样须要等很久。
  3. 图表渲染过程有一个动画,若你在下面的根底上,缩短工夫,你可能会取得动画运行两头的图片,咱们在后端应用,齐全能够省掉这部分工夫。

因而,我针对这些问题又做了进一步的优化。

在此之前,你须要晓得 phantomjs 可能监控 webpage 页面的一些事件,其中一个事件就是 [onConsoleMessage](https://phantomjs.org/api/webpage/handler/on-console-message.html),它可能捕捉到 webpage 的打印事件,并获取打印信息。

与此同时,echarts 也提供了渲染完结事件 finished

这样,就可能齐全自主的掌控渲染所带来的耗时问题了。

优化后的脚本如下,同时我也对脚本设置了一个最长的超时工夫,如果在这个工夫内还没渲染实现,就会强制完结,避免卡死,我也舍弃了品质与图片格式的配置,将它们放在 echarts 的 finished 事件中。

var page = require("webpage").create();var system = require("system");var delay = system.args[1];var file_content = system.stdin.read();page.setContent(file_content, "");page.onConsoleMessage = function (msg) {    console.log(msg);    phantom.exit();};window.setTimeout(function () {    phantom.exit();}, delay);

此时,咱们再应用自定的 script,将之前 html 缺失的 finished 加上。

val script = """        <script type="text/javascript">        var chart = echarts.init(document.getElementById("display-container"));        var option = ${engine.renderJsonOption(bar)};        chart.on("finished", function () { console.log(chart.getDataURL({type: "jpg", pixelRatio: "10", excludeComponents: ["toolbox"]})) });        chart.setOption(option);        </script>    """.trimIndent().replace("\n", "")

最初,再设置勾销动画,进一步缩短生成工夫。

bar.option.animation = false

至此,本来须要十几秒才可能实现的动作,当初只须要 6 秒即可(MacBook Pro m1上测试)。

kotlin 的残缺代码

import cn.hutool.core.codec.Base64import cn.hutool.core.io.FileUtilimport cn.hutool.core.io.IoUtilimport org.icepear.echarts.Barimport org.icepear.echarts.render.Engineimport java.awt.image.BufferedImageimport java.io.BufferedWriterimport java.io.ByteArrayInputStreamimport java.io.ByteArrayOutputStreamimport java.io.Fileimport java.io.OutputStreamWriterimport java.nio.charset.Charsetimport javax.imageio.IIOImageimport javax.imageio.ImageIOimport javax.imageio.ImageWriteParamfun removeAlpha(img: BufferedImage): BufferedImage {    if (!img.colorModel.hasAlpha()) {        return img    }    val target = BufferedImage(img.width, img.height, BufferedImage.TYPE_INT_RGB)    val g = target.createGraphics()    g.fillRect(0, 0, img.width, img.height)    g.drawImage(img, 0, 0, null)    g.dispose()    return target}fun compress(imageData: ByteArray): ByteArray {    return ByteArrayOutputStream().use { compressed ->        // 压缩图像,原有图像体积过大,压缩后体积放大并且品质不会有太大损失        ImageIO.createImageOutputStream(compressed).use {            val jpgWriter = ImageIO.getImageWritersByFormatName("JPEG").next()            jpgWriter.output = it            val jpgWriteParam = jpgWriter.defaultWriteParam            jpgWriteParam.compressionMode = ImageWriteParam.MODE_EXPLICIT            jpgWriteParam.compressionQuality = 0.7f            val img = ByteArrayInputStream(imageData).use {                // 移除原来的 alpha 通道                IIOImage(removeAlpha(ImageIO.read(it)), null, null)            }            jpgWriter.write(null, img, jpgWriteParam)            jpgWriter.dispose()            compressed.toByteArray()        }    }}fun main() {    val bar = Bar()        .setLegend()        .setTooltip("item")        .addXAxis(arrayOf("Matcha Latte", "Milk Tea", "Cheese Cocoa", "Walnut Brownie"))        .addYAxis()        .addSeries("2015", arrayOf<Number>(43.3, 83.1, 86.4, 72.4))        .addSeries("2016", arrayOf<Number>(85.8, 73.4, 65.2, 53.9))        .addSeries("2017", arrayOf<Number>(93.7, 55.1, 82.5, 39.1))    bar.option.animation = false    val engine = Engine()    val script = """        <script type="text/javascript">        var chart = echarts.init(document.getElementById("display-container"));        var option = ${engine.renderJsonOption(bar)};        chart.on("finished", function () { console.log(chart.getDataURL({type: "jpg", pixelRatio: "10", excludeComponents: ["toolbox"]})) });        chart.setOption(option);        </script>    """.trimIndent().replace("\n", "")    val html = engine.renderHtml(bar)        .replace(Regex("(?is)<script src.+?</script>"), """<script src="file://echart.min.js"></script>""")        .replace(Regex("(?is)<script type.+?</script>"), script)    println(html)    val processBuilder = ProcessBuilder("phantomjs", "generate-images.js", "10000")    val process = processBuilder.start()    BufferedWriter(OutputStreamWriter(process.outputStream)).use {        it.write(html)    }    val result = process.inputStream.use { IoUtil.read(it, Charset.defaultCharset()) }    val contentArray = result.split(",".toRegex()).dropLastWhile { it.isEmpty() }    if (contentArray.size != 2) {        throw RuntimeException("wrong image data")    }    val imageData = contentArray[1]    val compressImageData = compress(Base64.decode(imageData))    FileUtil.writeBytes(compressImageData, File("test.jpg"))}