一. 办法摸索
后盾生成图片的办法不多,依据我在网上的查找,有如下几种办法:
- 前台服务提供接口,联合图表提供的生成图片,申请后返回图片数据。
- 搭建服务,与第一点相似,同样是发送数据。
- 若有配合的前端服务,能够在前端发动下载时生成图片数据,传送回后盾。
- 利用
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>
该工具的作用有两点:
- 不便的将数据整顿成 echarts 所需的 option。
- 可能将 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
解释一下命令行参数:
- phantomjs,phantomjs 的执行门路。
- generate-images.js,就是上述提到的 javascript 脚本。
- jpg 为你须要的图片格式,svg 须要本人批改 javascript 脚本。
- 10000,为延迟时间,这个工夫为了留给 html 加载的,耗时蕴含的有下载 echarts 脚本与图片生成。
- 10,图片品质,越大品质越高,图片体积越大。
你能够看到,通过我的精简,整体的代码是比较简单的。
五. 优化过程
下面的演示代码并不能称得上是最终版本。
你要面临两个问题:
- 生成的 html,是须要联网下载 echarts 的,这部分耗时不说,有些环境也面临着无奈联网的状况。
- 品质为 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 通道,因而我在网上找到了移除通道的方法。
优化耗时
其实本来写到下面就截止了,不过因为灵感来了,顺道就把这个问题也解决了。
如果你残缺的了解了下面的例子之后,你会发现在这个例子在耗时解决上有很大的问题:
- 耗时不可控,无奈晓得图表是何时渲染完的。
- 耗时只能是固定的,即便图片早于你设定的工夫渲染实现,同样须要等很久。
- 图表渲染过程有一个动画,若你在下面的根底上,缩短工夫,你可能会取得动画运行两头的图片,咱们在后端应用,齐全能够省掉这部分工夫。
因而,我针对这些问题又做了进一步的优化。
在此之前,你须要晓得 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.Base64
import cn.hutool.core.io.FileUtil
import cn.hutool.core.io.IoUtil
import org.icepear.echarts.Bar
import org.icepear.echarts.render.Engine
import java.awt.image.BufferedImage
import java.io.BufferedWriter
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.OutputStreamWriter
import java.nio.charset.Charset
import javax.imageio.IIOImage
import javax.imageio.ImageIO
import javax.imageio.ImageWriteParam
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()}
}
}
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"))
}