记 Notion 贴纸生成器的重构历程
date
Oct 14, 2022
slug
refactor-notion-sticker-creator
status
Published
tags
Notion
Dev Log
summary
2021 年年初,我开发了一个仿 Notion 中文社区的 Notion 麻将牌贴纸的粗糙的贴纸生成器。一年多以后,由于对开发和实现质量不忍直视,我决定重构一下。这篇文章记录了这段重构的历程。
type
Post
如果你不知道 Notion 麻将牌贴纸是什么的话,它长这样:(注意断句,是「Notion·麻将牌·贴纸」,不是「Notion 麻将·牌·贴纸」)

初版生成器的工作机制很简单:Notion logo 是现成的,把中间的字母抠掉,填进去
2em
宽的 <div>
,然后 CSS transform 一下(基本上就是 skew 一下),dom-to-image 渲染成图,就可以拿去斗图了。后来由于 dom-to-image 太慢,我改成了 Canvas 渲染。这个实现方案非常简单,除了调字号和 transform 花了点时间(我没问作者具体的参数),其他的基本上能一口气写出来。但同时这个方案也有两个非常大的问题:
- 为了能让手机浏览器可以复制图像,我把 Canvas 内容编译成 Data URI 提供给一个
<img>
,而复制这个<img>
到 Telegram 时,总会自动带上那串长长的的 Data URI,这就给使用者造成了很大的麻烦。
- 由于是复制来的图像,Telegram 会以图片的形式发送到会话中,它的样式和行为跟贴纸是不同的。
于是有一天,我决定解决这些问题。
不要 Data URI 要图片
那串 Data URI 源于复制动作,这个过程我们无法干涉。于是第一个问题就转化成:如何让用户能够不复制还能发图片?
那当然是,Telegram Bot。特别是 Inline bot,可以让用户不离开当前会话就能调用 bot 功能并发送特定消息。
但首先,我们需要先让 bot 能生成贴纸图片才谈得上发送,也就是实现贴纸的服务端渲染。好在有一个 node-canvas,能够实现 Node 环境中的 Canvas 渲染。于是把贴纸生成逻辑抽离出来,实现一个服务端 API,创建一个 bot 并设定 webhook,第一个问题就解决了。
一直到这里还是比较顺利的,没遇到太多难题(除了一个 TypeScript 相关的问题,请朋友发了 issue,但似乎无解)。本以为剩下的就没有难题了,没想到这才是麻烦的开始。
不要图片要贴纸
现在我们已经可以通过 bot 快速地生成并发送一个贴纸图片,但我们的最终目的是发贴纸。Telegram 的贴纸消息本身并不是什么神奇的东西,它只是一种类型为
sticker
的消息。Telegram Bot 也能创建贴纸消息。然而,贴纸消息 API 只允许指定贴纸包的贴纸编号。
大部分 Telegram 贴纸都是以贴纸包的形式事先注册到 Telegram 的,这显然不能满足我们的需要——我们是随用随生成,不可能事先注册,而且也不需要把生成结果一直存在某个贴纸包里。
好在 Telegram 提供了另一种发送贴纸消息的方式,WebP 图像。如果用户发送了一个 WebP 图片,Telegram 会自动按贴纸处理这个文件。这一点对 bot 也适用。不过我们现在开发的是一个 Inline bot,并不能直接发送消息,必须通过 InlineQuery API。
Inline bot 的工作流程是,用户输入参数,bot 根据参数请求 API 并显示一个查询结果清单,用户从清单中选择一项作为待发送消息的正式内容。查询结果有很多种类型,其中两种与目前的情况有关:Photo 图片和 Document 文件。
然而 ×2,它们都不支持 WebP 图像。Photo 类型只支持 JPEG,Document 类型只支持 PDF 和 ZIP。
正当我一筹莫展的时候,我注意到一个特殊的查询结果类型:
InlineQueryResultCachedSticker
。它是唯一一个有 cached 变种而没有非 cached 变种的类型。它的参数 sticker_file_id
接受一个已存在于 Telegram 服务器的贴纸的地址。那么,如果通过某种方式先把 WebP 文件上传,再构建这种查询结果,是不是就可以实现 Inline bot 发送 WebP 贴纸了呢?
答案是可以。我创建了一个只有我和 bot 的私人群组,bot 每次生成贴纸后先发送文件到这个群组里,获取贴纸地址,再响应 inline 请求,整个流程就通了。结合 Telegram 群组的 Auto-delete Messages 功能,还能无成本避免消息堆积,非常舒适。
于是,我着手实现相关功能,包括 Inline bot API 和服务端 WebP 生成。
然而 ×3,服务端并不能顺利地生成 WebP 图像。
不要 PNG 要 WebP
服务端渲染贴纸图像的方案选择了 node-canvas,主要是因为……在 Node 生态里,这其实没什么选择。而 node-canvas 的目标是尽可能还原 Canvas API,这也使前后同构更容易实现。可惜的是,node-canvas 并不支持输出 WebP Data URI。
于是为了能得到 WebP,我又引入了 sharp,专门负责转换 node-canvas 生成的 PNG 为 WebP。
然而 ×4,Vercel Functions 它装不下了。
Vercel Functions 的底层是 AWS Lambda,它有一个部署体积限制,每 lambda 最大 10 MB。而 node-canvas 和 sharp 都是包含原生依赖的库,它们加起来的体积刚好超过了 10 MB,Vercel 拒绝部署。
为了能正常部署服务,我尝试把整个流程分割成两个 endpoint,从而规避单 endpoint 的部署体积限制。但这么做就意味着,我要生成一个 WebP 文件,需要一个 endpoint 内部调用并等待另一个 endpoint 的响应,这导致整个流程的耗时很容易撞上 Inline 查询的 10s 超时限制。我还尝试过精简 sharp 的依赖,因为其中最大的 SVG 处理模块我完全不需要,但我没搞明白如何实现依赖精简。
这迫使我找别的办法。
不要 Vercel 要……
既然 Vercel 有这个限制,那找个没限制的平台不就好了?我首先想到了 Deta(因为免费)。
然而 ×5,node-canvas 无法工作在 Deta 环境中。
node-canvas 的某个原生依赖往往在操作系统中有同名但不同版本的副本,因此需要手动设定
LD_LIBRARY_PATH
来调整引用优先级,而 Deta 拒绝修改这个环境变量。我把这个问题发到 Deta 社区中求助,但似乎无解。不过在 Deta Discord 上被人提示,可以用 RunKit,这是我没想到的一个选项。
然而 ×6,RunKit 它压根儿不能正常引入 node-canvas。
我也在 Twitter 上 @ 了 RunKit 官方咨询了这个问题,然而无回应。
既然部署平台没有更好的选择,那只好对部署内容下手了。node-canvas 的体积是最大的,擒贼先擒王。
不要 node-canvas 要……
除了 node-canvas,还有什么库能让我们在 Node 环境渲染图像呢?倒是有一些。然而……
- Puppeteer
工作正常。但由于 Serverless/Lambda 的特性,对于每个请求都需要重新初始化一个实例,速度太慢。
- PureImage
搜索 node-canvas 替代时的发现,纯 JS 实现的 Canvas for Node。虽然支持的特性还比较少,但画个贴纸还是够的。只可惜它的字体渲染有严重 bug。
- Jimp
只能说支持 WebP,但别的方面基本没法用。
在探索替代方案的时候,我发现 node-canvas 的维护者其实开发了一个 WebP 支持补丁,只不过没有提供预编译文件。在上述尝试都失败后,我决定硬着头皮自己编译一次。还好有 Docker,aws-linux 镜像也是现成的,试着 npm install 一下……
然后就成功了,意外的顺利。
之后取出编译好的二进制文件,引入到贴纸生成器的项目中,部署试试,刚好不超限。至此,我们的 bot 可以正常生成和发送贴纸了,第二个问题解决。
结语
最初重构项目的动机其实并不是这些问题,而是发现还有人在用,同时旧代码遗留了很多问题没处理(毕竟一开始就是写着玩儿),我又懒得重新读一遍旧代码(毕竟一开始没好好写),加上技术栈有更新,于是就想着不如重写一遍。这些问题的难度一开始我都没有纳入考虑,直到着手开始解决才发现自己掉进了坑里。好在最终还是趟出来了。
这个项目到此也并没有结束,还有很多问题和需求没有解决。比如 Emoji 支持,涉及正确计算字符数,至今还没找到一个兼容性良好的方案,目测又会是一个坑。总之,慢慢趟吧。
冷知识
- Notion 麻将牌贴纸的作者是 Craigary,对就是 Nobelium 的作者。