记 Notion 贴纸生成器的重构历程 #2

date
Jan 17, 2023
slug
refactor-notion-sticker-creator-2
status
Published
tags
Notion
Dev Log
summary
其实严格地说,这一篇算「开发历程」而不是「重构历程」,因为重构的比重并不大,主要还是新功能的开发。但毕竟本意是想强调那个「#2」,所以标题的细节就不纠结啦,哈哈哈(
type
Post
上一次重构只是实现了业务流程,还遗留了不少问题没有解决,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 上显示的比这个更小
Telegram 上显示的比这个更小
虽然没法看,但效果还是不错的
虽然没法看,但效果还是不错的
既然有了成熟的排版引擎可用,那只做文字贴纸显然没意思。于是我又做了几个特殊的贴纸类型出来。以后也许会做更多新的贴纸。
日历贴纸
日历贴纸
CSS IS AWESOME
CSS IS AWESOME

直面遗留问题,正视 Unicode

在贴纸生成器第一次上线的时候就有用户就提出了问题,也是贴纸生成器最久远的遗留问题:
「为什么输入的文字显示成了叉叉?」
表面上看,这是 Unicode 支持/实现的问题。但具体的情况也分很多种。

字体不包含那个字符

贴纸使用的字体是 Noto Serif SC。虽然 Noto 系列字体支持的字符已经很丰富了,但架不住人类太能造符号了。一旦输入的字符不在 Noto 字体支持范围内(常见于特殊符号区)就会显示成叉叉。
理论上设定字体 Fallback 规则可以缓解这个问题。但一来很难找到与 Noto Serif 风格相衬的字体,二来这个字体是否可以打包到服务器上也是个问题。所以,这种情况我只能表示无能为力。

使用了多 codeunit 字符

贴纸文字的字数决定排版布局的策略。因此,我们需要知道用户究竟输入了多少个字。
之前贴纸生成器使用 string#split('') 来分割用户输入的字符串为字符序列,这在大多数情况下是工作正常的。直到用户输入了一些「生僻字」,比如「𩽾𩾌」。
string#split('') 的作用是将字符串分割为 UTF-16 codeunit 序列。日常使用的大多数字符都是单 codeunit 字符,所以这么分割不会造成问题。而「𩽾𩾌」这两个字都是双 codeunit 字符,强行分割成 4 个 codeunit 就不对了。
「鱼安鱼康」叉叉版
「鱼安鱼康」叉叉版
所以我们需要一个能正确处理 Unicode 字符的分割方式。选择倒也有一些。
  1. 手动识别双 codeunit 字符
    1. 根据 UTF-16 规范,双 codeunit 字符使用代理对 (Surrogate Pair) 来标记起止 codeunit。第一个 codeunit 的二进制前 6 位应是 110110,第二个 codeunit 的二进制前 6 位应是 110111。因此,只要依次判断用户输入 codeunit 并在发现代理对时将前后两个 codeunit 重新拼在一起按一个字符处理就可以了。
  1. 使用 RegExp 的 u 标记
    1. RegExp 支持一个 unicode 标记来启用若干 Unicode 相关的特性,其中一项就是代理对识别。所以 string#match(/./gu) 就能轻松地处理多 codeunit 字符的问题了。
      这个方式比上一个方式的优势在于,它还能处理一些 emoji。
  1. 使用 Intl.Segmenter
    1. 这是终极手段。这是一个专为分词设计的 API,它也能用来将字符串分割为一个个视觉意义上的 grapheme,不仅能正确处理代理对,也能正确处理各种复杂的 emoji。它的唯一问题是,Firefox 还不支持,好在有 Polyfill
最终我选择了第三个方案。一是因为 emoji 支持也是我们要解决的问题(后面会说),二是因为 Satori 本身依赖 Intl.Segmenter,那其实就已经帮我们做选择了。
notion image

使用了 Unicode Variation Selector

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

使用了 Emoji

这是最后一种情况,也是开发贴纸生成器的第一天我就想实现,但拖了两年才得以解决的问题。
一直没能实现的主要原因,是 Emoji 图形资源的缺失。贴纸文字的字体是宋体,是一种偏古典、偏沉稳的字体。能和这种字体和谐共处的,我个人觉得只有苹果 Emoji。但把苹果 Emoji 应用到贴纸生成器上,有几个绕不过去的难题:
  1. Emoji 的本质是也是字体,但其内部格式与普通字体不同。大部分图形处理库都不支持 Emoji 字体。
  1. 由于贴纸生成器有服务端渲染需要,字体文件必然要部署到服务器上;同时因为还有 Web 版,字体文件又必然需要下载到用户设备上(如果用户使用的是非苹果设备)。这实质上构成了字体分发,是违反苹果字体的使用协议的。
  1. 苹果 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 了。
不是 Notion Enhancer
不是 Notion Enhancer

解决了功能问题,再来解决性能问题

贴纸生成器目前面对的性能问题主要在两点:
  1. Bot 服务器与 Telegram 服务器之间的通讯耗时比较长;
  1. Telegram 对 bot 发送消息有频率限制;
我们一个个来。

减少 Bot 服务器与 Telegram 服务器的通讯耗时

我们先来回顾一下 bot 的工作过程:
  1. 用户在 Telegram 中输入贴纸文字。
  1. Telegram 服务器向 bot 注册的 Webhook 地址发送更新消息。
  1. Bot 服务器收到用户输入,生成贴纸。
  1. Bot 向中转群组发送贴纸文件,并获取到 Telegram 服务器返回的文件 ID。
  1. Bot 使用该文件 ID 向 Telegram 服务器发送 Inline bot 查询结果数据。
  1. 用户看到贴纸生成结果。
可以看到,生成一次贴纸需要 bot 服务器和 Telegram 服务器来回通讯至少 3 次(第 2、4、5 步)。而测算数据显示,这些通讯才是导致响应迟缓的核心原因。
既然知道了原因,那么解决的办法也就清晰了:让 bot 服务器离 Telegram 服务器近一点就好了。
Bot 服务器一开始应用了 Vercel 的推荐设置,部署在了香港节点。但这对贴纸生成器来说其实没什么意义:用户并不会直接访问 bot 服务器,用户的请求始终是通过 Telegram 服务器「转发」到 bot 服务器上的,所以选择部署节点看的不应该是用户的位置而是 Telegram 服务器的位置。
Vercel 提供了许多节点供选择,但哪个是离 Telegram 服务器最近的呢?我不知道 Telegram 服务器在哪(后来知道了,在中欧),所以干脆把 Vercel 的所有节点都测一遍。我写了一个简化版贴纸生成服务,分别部署到 Vercel 的所有节点上跑了一遍,在生成贴纸的全程耗时上得到了如下结果:
顺便测了一下 Deta Space,结果果然不理想
顺便测了一下 Deta Space,结果果然不理想
这结果令我十分意外。平常架梯几乎连不上的伦敦居然是连接 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 多字呢,辛苦了),同时对粗劣的文章质量致以歉意。希望我的文章至少没有让你觉得浪费了宝贵的时间和心情。❤️

© SilentDepth 2022 - 2023