上一次重构只是实现了业务流程,还遗留了不少问题没有解决,Telegram Bot 的响应性能也难以让人满意。于是,开发继续。
工欲善其事,必先重下构
这次重构的契机完全出于偶然。
某一天,我和小伙伴们聊起 OG Image 生成服务,我突然想自己做一个,于是调研起相关案例来。
然后,我发现了 Vercel OG 库。
再然后,我发现了 Vercel Satori。
这是一个神奇的东西——利用 Yoga Layout(React Native 的排版引擎),将一段 DOM 片段转换为 SVG,并使用 CSS 定义样式。支持的 CSS 属性虽然不甚丰富,但足够覆盖大多数场景。第一方提供 asm.js、Wasm 和 Node native module 格式,前后通吃。这完美契合贴纸生成器的需要。
我先试着用 Satori 实现贴纸渲染的基本流程,很顺利。而且它比 node-canvas 小多了,这让 Lambda 的体积限制不再是压力。Satori 输出的是 SVG,这也意味着比 Canvas 更高的灵活性。
说干就干。我用 Satori 换掉了 node-canvas,重新整理了工程目录,一个换药不换汤的贴纸生成器就诞生了。
Satori 只能输出 SVG,而我们的目标是 PNG/WebP。Vercel OG 库使用 Resvg 解决 SVG to X 的问题,但不论是使用体验还是性能都不很理想。于是我又找回了 sharp,真是简单又好用。谁能想到,前一天我还在嫌弃 sharp 的 SVG 模块太大浪费部署空间,现在我就依赖上这个 SVG 模块了……
至此,我已经完全忘掉 OG Image 的事儿了……
新引擎,新排版
更换了渲染引擎,必然要重新实现渲染贴纸的逻辑。这有点返璞归真的感觉——第一版贴纸生成器基于 HTML + CSS 实现渲染,就是靠浏览器的排版引擎实现的排版。
对于文字贴纸来说,排版的核心就是文字的定位。使用排版引擎的好处就是,实现定位的过程会轻松许多。之前的基于 Canvas 的渲染引擎其实没有实现什么排版,它的工作原理相当于单独渲染几个字符并绝对定位到特定的位置,对画布多大、其他字符在什么位置并无感知。所以每个字符的渲染位置都需要精确计算,麻烦不说,实现复杂排版也很困难(脑子不够用)。
而有了排版引擎,这些事情就变简单了。只要设定好文本区域和字号等基本参数,描述好位置关系,剩下的排版引擎可以自动搞定,不必再费心一个元素的具体位置是什么了。
在实现渲染逻辑的过程中,我还注意到一个问题:Notion logo 的正面区域并不是一个正方形。

这似乎可以回答我许久以来的疑惑:原版贴纸的文字看上去没有那么「居中」,在垂直方向上更贴近上下边缘。文字越多,这个问题越明显。
考虑到汉字都是方块字,方块字在一个正方形空间里显然是最舒适的。于是我就在思考,按正方形空间排版,然后把排版空间「压缩」到正面区域的尺寸,会不会让观感更好?


经过压缩后,文字无可避免地会变矮胖一点(虽然压缩比例只有 5% 左右,这个变化应该不明显)。但重要的是,文字四周的边距看上去会和谐许多,整体的观感我觉得更好一些。
改变了排版设计,「像素级复刻原版贴纸」的路子也就不能走了,于是我就借助排版引擎的能力重新设计了一套排版参数。同时为了方便容纳更多文字,这套参数不再锁定字号,而是会根据具体的情况自动计算合适的字号和边距,从而实现(理论上的)无限长文本支持。
是的,更多文字。四个字显然不够填满网友的脑洞,就好像 140 个字不够网友发推。

虽然但是,在保证阅读体验的前提下,可以写进贴纸的文字其实并不多。Telegram 客户端显示贴纸的尺寸也就 200 多像素宽(尽管贴纸源文件要求 512px),这个尺寸下 4×4 已经是文字大小的可读极限了。但谁知道网友的脑洞会蹦出什么呢,所以优化更多文字的排版效果还是有意义的。
Telegram 上显示的比这个更小 
虽然没法看,但效果还是不错的


既然有了成熟的排版引擎可用,那只做文字贴纸显然没意思。于是我又做了几个特殊的贴纸类型出来。以后也许会做更多新的贴纸。


直面遗留问题,正视 Unicode
在贴纸生成器第一次上线的时候就有用户就提出了问题,也是贴纸生成器最久远的遗留问题:
「为什么输入的文字显示成了叉叉?」
表面上看,这是 Unicode 支持/实现的问题。但具体的情况也分很多种。
字体不包含那个字符
贴纸使用的字体是 Noto Serif SC。虽然 Noto 系列字体支持的字符已经很丰富了,但架不住人类太能造符号了。一旦输入的字符不在 Noto 字体支持范围内(常见于特殊符号区)就会显示成叉叉。
理论上设定字体 Fallback 规则可以缓解这个问题。但一来很难找到与 Noto Serif 风格相衬的字体,二来这个字体是否可以打包到服务器上也是个问题。所以,这种情况我只能表示无能为力。
使用了多 codeunit 字符
贴纸文字的字数决定排版布局的策略。因此,我们需要知道用户究竟输入了多少个字。
之前贴纸生成器使用
string#split('') 来分割用户输入的字符串为字符序列,这在大多数情况下是工作正常的。直到用户输入了一些「生僻字」,比如「𩽾𩾌」。string#split('') 的作用是将字符串分割为 UTF-16 codeunit 序列。日常使用的大多数字符都是单 codeunit 字符,所以这么分割不会造成问题。而「𩽾𩾌」这两个字都是双 codeunit 字符,强行分割成 4 个 codeunit 就不对了。
所以我们需要一个能正确处理 Unicode 字符的分割方式。选择倒也有一些。
- 手动识别双 codeunit 字符
根据 UTF-16 规范,双 codeunit 字符使用代理对 (Surrogate Pair) 来标记起止 codeunit。第一个 codeunit 的二进制前 6 位应是
110110,第二个 codeunit 的二进制前 6 位应是 110111。因此,只要依次判断用户输入 codeunit 并在发现代理对时将前后两个 codeunit 重新拼在一起按一个字符处理就可以了。- 使用 RegExp 的 u 标记
RegExp 支持一个 unicode 标记来启用若干 Unicode 相关的特性,其中一项就是代理对识别。所以
string#match(/./gu) 就能轻松地处理多 codeunit 字符的问题了。这个方式比上一个方式的优势在于,它还能处理一些 emoji。
- 使用
Intl.Segmenter
这是终极手段。这是一个专为分词设计的 API,它也能用来将字符串分割为一个个视觉意义上的 grapheme,不仅能正确处理代理对,也能正确处理各种复杂的 emoji。它的唯一问题是,Firefox 还不支持,好在有 Polyfill。
最终我选择了第三个方案。一是因为 emoji 支持也是我们要解决的问题(后面会说),二是因为 Satori 本身依赖
Intl.Segmenter,那其实就已经帮我们做选择了。
使用了 Unicode Variation Selector
Unicode Variation Selector 是 Unicode 定义的一组特殊符号,位于 0xFE00-FE0F,总共 16 个。它们用于提示操作系统应该显示上一个字符的哪一个变种(如果该字符有变种的话)。其中,0xFE0E 和 0xFE0F 是与我们有关的两个符号,它们分别表示操作系统应显示上一个字符的单色版本和彩色版本(也就是 Emoji)。
我们来看个例子。

「🈚︎」这个字符本身的 Unicode 码值是 U+1F21A,它同时还有一个 Emoji 变种「 🈚」,也就是说 Unicode 对这个码值定义了两套「皮肤」。那操作系统如何知道要显示哪套皮肤呢?就看它后面紧跟着什么 Variation Selector。如果是 0xFE0E 就显示单色版本,如果是 0xFE0F 就显示 Emoji 版本,如果没有 Variation Selector 就自由发挥。
如果你使用 Safari/Chrome 阅读本文,这个字符可能会被强制显示成 Emoji。
在贴纸生成器还不支持 Emoji 的时候,我们希望至少能显示其单色版本(如果有的话)。但现代操作系统太喜欢 Emoji 了,可能你很难阻止操作系统默认提交 Emoji 版本。于是,我们就需要识别出这种情况,手动移除后面的 Variation Selector。

使用了 Emoji
这是最后一种情况,也是开发贴纸生成器的第一天我就想实现,但拖了两年才得以解决的问题。
一直没能实现的主要原因,是 Emoji 图形资源的缺失。贴纸文字的字体是宋体,是一种偏古典、偏沉稳的字体。能和这种字体和谐共处的,我个人觉得只有苹果 Emoji。但把苹果 Emoji 应用到贴纸生成器上,有几个绕不过去的难题:
- Emoji 的本质也是字体,但其内部格式与普通字体不同。大部分图形处理库都不支持 Emoji 字体。
- 由于贴纸生成器有服务端渲染需要,字体文件必然要部署到服务器上;同时因为还有 Web 版,字体文件又必然需要下载到用户设备上(如果用户使用的是非苹果设备)。这实质上构成了字体分发,是违反苹果字体的使用协议的。
- 苹果 Emoji 的本体 Apple Color Emoji.ttc,文件体积超过 100MB。即便精简掉不需要的图形尺寸,考虑到对清晰度的要求,最终需要保留的体积也不会小,恐怕不能顺利地部署到 Lambda 上。
我也试着找过热心网友制作的导出版本,但并无收获。于是,Emoji 支持就这样被搁置了。
直到我发现了 Satori。Satori 的 demo 演示了其对 Emoji 的支持能力,并提供了多套 Emoji 供选择。其中的 Noto Emoji 引起了我的注意——原来 Noto 项目还有 Emoji 小组。经过了解,Noto Emoji 覆盖全、易接入,且官方提供 SVG 格式,是个不错的 Emoji 资源。加上用户对 Emoji 支持的呼声越来越高,我决定先用 Noto Emoji 凑合一下——总比没得用要好。
于是,贴纸终于可以用上 Emoji 了。

解决了功能问题,再来解决性能问题
贴纸生成器目前面对的性能问题主要在两点:
- Bot 服务器与 Telegram 服务器之间的通讯耗时比较长;
- Telegram 对 bot 发送消息有频率限制;
我们一个个来。
减少 Bot 服务器与 Telegram 服务器的通讯耗时
我们先来回顾一下 bot 的工作过程:
- 用户在 Telegram 中输入贴纸文字。
- Telegram 服务器向 bot 注册的 Webhook 地址发送更新消息。
- Bot 服务器收到用户输入,生成贴纸。
- Bot 向中转群组发送贴纸文件,并获取到 Telegram 服务器返回的文件 ID。
- Bot 使用该文件 ID 向 Telegram 服务器发送 Inline bot 查询结果数据。
- 用户看到贴纸生成结果。
可以看到,生成一次贴纸需要 bot 服务器和 Telegram 服务器来回通讯至少 3 次(第 2、4、5 步)。而测算数据显示,这些通讯才是导致响应迟缓的核心原因。
既然知道了原因,那么解决的办法也就清晰了:让 bot 服务器离 Telegram 服务器近一点就好了。
Bot 服务器一开始应用了 Vercel 的推荐设置,部署在了香港节点。但这对贴纸生成器来说其实没什么意义:用户并不会直接访问 bot 服务器,用户的请求始终是通过 Telegram 服务器「转发」到 bot 服务器上的,所以选择部署节点看的不应该是用户的位置而是 Telegram 服务器的位置。
Vercel 提供了许多节点供选择,但哪个是离 Telegram 服务器最近的呢?我不知道 Telegram 服务器在哪(后来知道了,在中欧),所以干脆把 Vercel 的所有节点都测一遍。我写了一个简化版贴纸生成服务,分别部署到 Vercel 的所有节点上跑了一遍,在生成贴纸的全程耗时上得到了如下结果:

这结果令我十分意外。平常架梯几乎连不上的伦敦居然是连接 Telegram 服务器最快的,而且其他几个欧洲节点的成绩也远好于其他地区(毕竟 Telegram 服务器在中欧嘛)。但不论如何,客观结果如此,我也只能试着把贴纸生成器重新部署到伦敦节点——果然变快了。虽然到不了瞬间响应的程度,但这已经是我能做到的最好了。
绕开 Telegram Bot 的消息发送频率限制
可能是出于防骚扰的原因,Telegram 对 bot 有一个 20 条消息/分钟/会话的频率上限,也就是说一个 bot 在任一会话内平均 3 秒才能发送一条消息。一般来说这对 Inline bot 没有影响,因为用户使用 Inline bot 发送的消息算是用户自己发的。但贴纸生成器的情况比较特殊——还记得上面说到的第 4 步吗,贴纸 bot 是要往中转群组发消息的!这就凭空捏出了一个瓶颈。
用户在输入贴纸文字时,bot 实际上是在不断地生成贴纸的,每生成一个贴纸就会向中转群组发送一条消息。虽然这个过程中会有一层缓存机制减少发送消息的数量,但总归不治本。所以,一旦有多个用户同时使用 bot,中转群组的消息发送频率就很容易撞到上限,反映给用户的结果就是:bot 卡了。
不过原因已知,对策也就清晰了:限制是按会话的,一个会话不够我多开几个会话不就加倍了?于是我又创建了两个群组,总共三个群组,理论上平均频率限制可以提升到 1 条消息/秒,应该是暂时够用的。
接下来的问题是,如何控制 bot 发送消息到这三个中转群组里。
我一开始的想法是摇骰子,摇到哪个群组就发到哪个群组,但实验下来发现效果并不好。要解决这个问题,重要的是均匀度而不是随机度。哪怕放弃随机度,从 1 向上计数依次选择一个群组也是符合需要的,而且完美符合。在外部单独安排一个计数器当然可以解决问题,但这会产生另一次通讯,这是我想避免的。
有没有什么现成的不需要请求的东西可以当作计数器用呢?
还真有,Telegram 的 Inline bot 查询 ID。
Inline bot 查询 ID 的规则是这样的:
- 它是一个正整数,每一次查询的 ID 都是上一个查询 ID +1。
- 如果一周内没有新的查询,下一次查询的 ID 会从一个随机正整数重新递增。
第二条规则的随机数不影响我们:都一周没人用了,考虑秒级限制也没意义。关键的是第一条规则——它是稳定递增的。虽然实际上会因无效查询等情况无法保证完美的均匀度,但对于短时间内并发查询的场景,它够用了。
于是,对查询 ID 取个余数作为选择中转群组的依据,我们就成功绕开了 Telegram 的限制。
至此,bot 响应性能得到了明显的优化,多数情况下生成贴纸的延迟是可以接受的了。
后记
本来这篇文章一个多月前就开始写了,没想到中途遭遇意外,断断续续就写到了现在。又因为耽搁太久,最初的写作思路渐渐消散,导致恢复写作后反复修改却总觉得文笔不顺,一度打算放弃。但毕竟文章里包含了几位小伙伴的贡献,让我觉得不能亏待了他们,终于还是坚持着写完了。如果你读到了这里,我十分感激(4000 多字呢,辛苦了),同时对粗劣的文章质量致以歉意。希望我的文章至少没有让你觉得浪费了宝贵的时间和心情。❤️