去哪儿 Node 生成 1 亿张图片实践 (Satori + Sharp)
一、背景
一图胜千言,图片是信息传递的重要载体。当用户进行社交媒体分享时,图片是比文字更加直观的展示方式。在一些分享的场景可能天然就有图片,比如分享一个商品,商品的图片就是最直观的展示方式。但是在一些场景下,可能没有天然的图片,比如分享一个从北京到上海的火车线路,这时候就需要我们自己生成图片。
二、方案选型
生成图片的方式有很多种, 根据具体的场景可以分为 Web 前端生成图片、 客户端生成图片、后端生成图片。每种方式都有自己的优缺点,我们根据具体的场景选择合适的方式。
1、常见的图片生成方式
Web 前端
Web 前端有很多可以实现页面转图片的工具, 我们拿常用的 html2canvas
来举例,html2canvas
是一个将网页内容渲染成图片的工具,它可以将网页的 DOM 元素转换成 Canvas 元素,然后将 Canvas 元素转换成图片。html2canvas
的使用非常简单,只需要调用 html2canvas
方法传入需要转换的 DOM 元素即可。它很适合用在 Web 页面把页面中的一部分生成图片。这种方式纯前端实现不依赖后端,我们有一些活动页面使用了这种方案。不过 html2canvas
有一些局限性,比如只能在较现代的 Web 环境使用无法一套方案跨多端,不支持跨域资源, 不支持 box-shadow、 filter 等 CSS 特性等。
客户端
如果我们的应用是一个客户端应用,比如一个 Android 或者 iOS 应用,我们可以使用客户端的 API 来生成图片。比如 Android 可以使用 View.draw(canvas)
方法将 View 绘制到 Canvas 上,然后将 Canvas 转换成图片。iOS 可以使用 UIGraphicsBeginImageContextWithOptions
方法将 View 绘制到 Context 上,然后将 Context 转换成图片。这种方式的优点和 html2canvas 类似,纯前端实现,用户所见即所得。局限性也类似,只能用在各自的系统上,无法跨多端使用。
后端
前端生成图片有时会受限于场景无法使用,多端场景也会有重复的开发量。后端生成图片在跨端支持方面有先天优势,可以使用各种语言的图片处理库来生成图片。
Java 后端 awt
我们之前的跨端方案是用 Java 后端方案。使用 Java java.awt
绘图库下的 BufferedImage
类来生成图片。这种生成图片的方式优点是一套方案跨多端,不挑前端环境。缺点是 java.awt
这类库接口比较底层,没有提供丰富的高级绘图能力,开发和维护布局复杂的图片对于平时较少接触界面开发的后端开发者来说非常有挑战。另一方面 Java 画图服务很容易发生内存泄露,给系统带来了很大的不稳定性因素。
下面是一张之前 Java 方案生成的车票图片:
Node 后端
和 Java 后端方案类似, 也可以使用前端同学更为熟悉的 Node 语言来实现图片生成。常见的方案是使用 Headless Chromium 来生成图片。Headless Chromium 是 Chromium 浏览器的无头版本,它可以在不打开浏览器的情况下运行 Chromium 浏览器。我们可以使用 Puppeteer 来控制 Headless Chromium 打开一个网页,然后将网页截图保存成图片。这种方式的优势是一套方案跨多端,不挑前端环境,绘图能力和浏览器一致,前端同学开发图片模板就和开发页面一样, 开发体验也要优于 java.awt 方案。不过现代浏览器带来丰富内置能力的同时也带来了臃肿的体积和较大的运行时内存占用。使用过此方案的 Vercel 总结了以下缺点:
难:该方案需要启动 Chromium,并使用 Puppeteer 对给定的 HTML 页面进行截图。设置这些工具很难实现,而且经常出错。
慢:冷启动速度非常慢(平均约 4 秒),而且这可能会导致图片运行缓慢或损坏。
贵:为了截图而启动整个浏览器并不高效,既昂贵又浪费计算资源。
大:Chromium 越来越大,Vercel 的 Serverless Function 里已经无法容纳了。
2、一种新的 Node 后端生成图片方式 Satori
Satori 是 Vercel 开源的一套将 HTML 和 CSS 转换成 SVG 的工具库。它支持 Node,浏览器,Web Worker 等环境。它的优点是:
容易: 不需要 Headless Chromium,它非常巧妙的把一种文本(HTML ,CSS)转换成另一种文本(SVG), SVG 可以方便的转换成各种图片。开发方式贴近前端页面开发,非常直观且容易上手。
快:整个转换过程非常巧妙的把一种基于文本的描述文档 HTML 转换成另一种基于文本的描述文档 SVG,实际没有真正绘制 HTML, 所以速度非常快。即便加上 SVG 转 PNG 的时间,也比 Headless Chromium 快 5 倍左右。
轻量:Satori 只有 3.9M ,加上图片转换工具也才 23.8M,而 Chromium 安装包就 200M,安装完 500M 起步。
三、实践
基于 Satori + Resvg 实现 HTML 转 PNG。
转换 HTML 到 SVG
参考 Satori 的文档,我们可以使用 Satori 将 HTML(JSX) 转换成 SVG。下面是一个简单的例子,将一个 div
转换成 SVG。
Satori
方法接收一个 jsxTree 和一个配置就能生成一张 SVG 图片。jsxTree 可以由一个类似 React 纯组件的函数封装返回,样式也可以像开发 React Native 一样抽离出来。
生成的 SVG 文件如下:
可以观察到 Satori 把部分 CSS 样式转换成了 rect
元素的属性,文字也被转换成了 path
。
转换 SVG 到 PNG
按照 Satori 文档的推荐, 我们首先使用 Resvg-js 来将 SVG 转换成 PNG。Resvg 是一个 Rust 实现的 SVG 渲染器,它可以将 SVG 渲染成 PNG。Resvg 的优点是速度快,内存占用小,支持大部分 SVG 特性。
执行代码就得到了这样一张 PNG 图片。
把以上简单的转换逻辑和 OSS 存储逻辑封装到一个 Node 服务中,这样我们就得到了一个可以通过 HTTP 请求返回图片 URL 的图片生成服务。
下面就是我们图片生成服务生成的一些分享火车票的图片。
Satori 方案相较于之前的 Java 方案有以下收益:
开发图片模板的效率提升了,新方案前端 1 天就可以搞定原 Java 方案需要开发 3 天的图片模板。
绘制能力提升了,之前不能开发的动态布局图片,新方案也可以轻松应对。
四、优化
Satori + Resvg 实现 HTML 转 PNG 的方案与我们之前基于 java.awt
的方案相比,图片开发速度有了提升但是图片生成速度有一些下降。而且随着服务的长时间在线,内存泄露的问题也逐渐显现出来。下面我们来看看如何优化这个方案。
1、速度优化
原来我们基于 java.awt
的方案生成一张类似的分享火车票图片耗时平均在 500ms, Satori + Resvg 方案刚上线时生成一张图片耗时平均在 900ms, 速度远低于 Java 方案,用户点击分享后需要等待较长的时间才能看到图片,体验较差。
关闭内嵌字体优化
Satori 为了使 SVG 图片在未安装图片中字体的环境中也能正常展示字体,默认启用了内嵌字体优化。这个优化就是上面例子里观察到的字型被转成了 path
。Satori 执行这个优化需要时间,Resvg 也需要时间来解析这些 path
。因为我们的系统字体是可控的,所以我们可以关闭这个优化。
优化后生成一张图片的耗时从 900ms 降低到了 400ms,速度略快于原 Java 方案。
使用 Sharp 替换 Resvg-js
Resvg 只支持输出 PNG 格式的图片,PNG 在某些场景下图片体积要比 JPG 大,在调研支持多格式的图片处理库时发现了 Sharp。Sharp 是用 C++ 实现的。它不仅支持转换到 JPG,WEBP 等近十种图片格式,经测试从 SVG 转 PNG 的速度相较 Resvg 也快了近一倍。使用 Sharp 我们不仅提升了图片生成速度,增加多图片格式的支持,还支持了图片的压缩和优化功能,可以产出更小体积的图片,减少了 OSS 存储时间和用户下载时间。
优化后生成一张图片的耗时从 400ms 降低到了 200ms,速度快了近一倍。
2、内存优化
图片格式转换工具因为在运行时需要频繁的分配和释放内存,所以内存泄露的问题比较常见。我们的服务在刚上线时运行 2 天左右内存占用会到 90%。内存泄露不仅影响内存占用,随之而来的内存碎片化问题会导致分配内存效率降低,从而导致图片生成速度下降。下面我们来看看如何优化内存。
使用 jemalloc 内存管理器
jemalloc 是一个内存管理器,它是用 C 实现的,专门用于优化多线程环境下的内存分配和释放。jemalloc 的优点是内存分配和释放效率高,内存碎片化低。我们可以使用 jemalloc 作为 Node.js 的内存管理器,来优化内存的使用。
jemalloc 的使用使内存泄露的速度得到了降低,运行 30 天左右内存占用才会到 90%。问题还没有得到彻底解决,我们还需要进一步优化内存。
解决第三方库内存泄露问题
经过分析观察,内存泄露发生在 Sharp 转换 SVG 到 PNG 阶段。定位 Sharp 的问题比较困难,我们可以通过进程调度的方式来解决第三方库内存泄露的问题。
为了使主机的负载最大化,我们使用了多进程的方式来运行我们的服务,一台主机上同时运行多个 worker。在保证其他 worker 正常运行的情况下,断开其中目标 worker 使其不再接收新的请求,随后待 worker 处理完未完成的请求后将其关闭,然后启动一个新的 worker 来处理新的请求。这样我们就可以在不影响整体服务稳定性的情况下释放掉 Sharp 产生的内存泄露。
进程调度方案上线后,我们的服务可以一直稳定运行了,内存泄露问题得到了彻底解决。
五、遗憾和惊喜
Satori 方案也有一些缺点,它只支持 HTML 和 CSS 的一个子集。
不支持表单元素,比如
input
,select
等。可是一张静态图片支持这些表单元素又有什么用呢?如果需要完全可以通过div
实现类似的视觉样式。不支持
link
,style
等标签。用 JS 完全可以很好的抽象和复用样式,所以其实也用不到这些标签。只支持 flex 布局,不支持 block,grid,table 等其他布局。因为 Satori 目前使用的是和 React Native 相同的布局引擎 Yoga,所以只支持 flex 布局。不过 React Native 的流行说明 flex 布局在大部分场景下已经足够了。
还有一些其他不支持的特性,但大多在静态图片的场景没有什么用,或者可以通过其他方式解决。
另一方面 Satori 虽然只支持了部分 CSS 样式,但是还难能可贵的支持了很多高级的 CSS 特性:
filter
, boxShadow
, textShadow
, mask
, backgroundClip
, transform
等。这些特性使得我们可以实现一些比较复杂的图片效果。
六、结语
每种图片生成方式都有自己适合的场景,Satori + Sharp 在后端生成图片的各个方案中在开发效率、生成速度、资源占用上都有较大优势。我们的图片生成服务上线以来已经支撑了 20 多个场景的图片生成,生成图片超过 1 亿张,稳定性和性能都得到了验证。
版权声明: 本文为 InfoQ 作者【Qunar技术沙龙】的原创文章。
原文链接:【http://xie.infoq.cn/article/81d837887784f85a780fea430】。文章转载请联系作者。
评论