MoreRSS

site iconE99p1ant修改

博客名:Light Cube。腾讯安全玄武实验室。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

E99p1ant的 RSS 预览

LightCube 十周年

2025-10-06 21:13:12

十年前的午后,我在b站刷到了一个视频, 视频介绍了如何在 OpenShift 平台上搭建 WordPress 博客。

很多年后,我才意识到那是一个多么生机勃勃的时代:Docker、K8s、Vue 才刚起步,字节才刚开始融资,AS3 还没凉,自然语言对话服务还是谷歌 DialogFlow,微软小冰,IBM Waston。

可惜我找不到那个视频了,估计是被删了吧。但好在我跟着视频一步步搭建的博客,陪我记录了这十年。

我在 9 月 28 日凌晨发了这条朋友圈。原本是想等到 10 月 4 日再写些东西叙叙旧,奈何一想到 10 周年就心潮澎湃,就提前开始“预热”了。😂

此刻,我正坐在同样的沙发上,同样面朝阳台,写下这段文字,和十年前一样。

回看黑历史

你可以结合 文章归档 页面,和我一起回忆我的“黑历史”。

我很少有能一直坚持下来的事情,很多 Side Project 都是轰轰烈烈开个头,三分钟热度一过,就再也不管了。写博客最初的动力源于 WordPress 后台给了我种打扮 QQ 空间的感觉,我可以换好看的主题,装一堆插件。但它比 QQ 空间的可定制程度更高,我可以通过自己的域名访问,可以在页面底部加自己的 Copyright 版权信息,一切都是自己的东西。

在按自己的想法装扮完页面后,我想着得写点东西挂上去充数。那会儿我刚上高中,身上的“中二”气息还没褪去,再加上高中开局不利,考试成绩接连爆炸,所以写了些很丧又很幼稚的文章。现在看来真是黑历史。到了 2016 年的高二,我因为接触 WordPress 而开始学习 PHP 语言,但那时同样很难找到东西写,便把自己发的 QQ 空间说说转载到博客来,这样“滥竽充数”也就成为一篇文章了。文章大多很短,有些还很意识流,我已经看不懂当时的自己想表达什么了。

2016 年下半年,我关注了「差评」公众号,在那之后的文章,会不自觉地去模仿差评公众号文章的标题和文笔。并且都是先发表在个人公众号,再顺带转载到博客。到了 2017 年,我终于是能写点正经技术文章了,我分享了如何给 WordPress 全站开启 CDN、写 C# 时踩得坑、用 CodeIgniter 框架写得小项目、用 PHP 写得微博爬虫…… 直到这里,我才算真正产出了能帮助别人文章。

2018 年高考结束后的暑假,我分享了自己开发的微信小程序的前后端实现,如何实现树莓派的内网穿透,初识 Jenkins 等。上了大学后,大学的自由让我能自主规划去学很多新东西,博客文章也是一篇接着一篇。从 CTF 到 Docker、PHP Swoole、PHP 内核(虽然只开了头)、CI/CD、Serverless 函数计算、Redis、Vue,再到现在混饭吃用的 Go。我在那时开了 Apicon 这个坑,把我学到的这些东西融入到了这个项目里,就当是自娱自乐。

时间来到 2020 年的疫情,那年我主要是在开发 CTF 平台 Cardinal,博客文章记录了我运营这个开源项目的感受,技术上和心理上的都有,虽然都比较“稚嫩”。2020 年下半年,我将重心投入到了在 ForkAI 的工作中,博客更新频率大不如从前。我在工作中接触到了 Macaron 框架和依赖注入,还被安利了《黑客与画家》这本书,我也总结了篇读书小记。

2021 年我开了很多坑,比如 EggMD 协作文档、Elaina 代码运行器、mebeats 小米手环心率采集、asoul.video 视频站等。每个项目都有可以分享的内容,都是一篇独立的文章。(虽然很多项目后来我就没维护过了)

这里我想重点表扬下 《Your Soul, Your Beats! —— 小米手环实时心率采集》 这篇文章,这是所有文章中访问量最多的一篇,直到文章发布 4 年后的现在,每天都还有人阅读。抛开文章内容的实用性不谈,更重要的是这篇文章详细描述了我当时一步步解决问题的思路和方法技巧。 我首先使用软件检测电脑蓝牙,再逐步扩展到编写代码操作蓝牙;在遇到依赖库年久失修无法使用的情况时,我又是如何成功找到还在维护且可用的库;最后照应前文一步步的软件操作,将功能编写为代码。直到今天,我都认为这篇文章写得真的真的很好!

2022-2023 年,又是 allin 工作的一年,文章产出更是大幅减少。这段时间的工作内容主要集中在 Kubernetes 集群,所以抽空写得文章都是些集群相关的骚操作。

2024 年中,我入职了鹅厂。工作强度相比前几年小了很多,我有更多的时间去思考,去动手做一些新东西。刚入职的那一个月,几乎每个周末都能写一个新项目出来。(虽然很多都还没开源) 我开发了 Sayrud,它现在也被我用来搭建博客的评论后端。我基于 Traefik ForwardAuth 开发了自己的集群统一认证 ikD,现在我服务器集群对外暴露的所有服务,都已经接入了;甚至该 Side Project 还被我成功引入到了公司团队内,稍作修改后作为团队成员登录各服务的统一认证。😄 之后又自己实现了个大模型套壳站,这段关于大模型应用的开发经验也被我用在了公司的项目中。我发现 2024 年后,我在闲暇时间自己研究的事情——无论是开发的 Side Project,还是在自己的 Kubernetes 集群或者腾讯云运维中积累的知识,在未来的某一天都能反哺到我的工作中。颇有种我提前预判了我的工作,提前就给做完了的感觉。(叠甲:这并不是说我之后就开始摸鱼了,当然是在追求更加精益求精 😛)我很喜欢无心插柳柳成荫的意外收获,希望这样的日子能永远永远地继续下去。

未来

我是一个很在意他人看法的人,不止是他人对我口头评价的看法,也体现在比如 Twitter 粉丝,博客文章评论量这些事情上。我会因为 GitHub Follower 数 -1 或者博客一直没人评论而烦恼,会因为日常工作中他人对我态度不友好而内耗一整天,即使这很有可能是我听错了或者想多了。我时常会在睡前突然想起白天尴尬的事情,然后在床上缩成一团。我会评判自己白天是不是哪句话说得不对,给别人留下了不好的印象。我时常会将自己的成就归结于百年难遇的运气爆棚,进而陷入自我怀疑,会有种不配得感。

反过来也是同样,我对收到来自别人的反馈或者肯定可以兴奋地睡不着觉。之前很长一段时间没有维护过 NekoBox,偶然收到了来自用户的打赏和鼓励,那天晚上就跟打了鸡血一样写新功能肝到凌晨三四点。在工作中也同样,一旦收到了正反馈,我就会感觉这是自我价值得到了实现,自愿加班到 11 点后,开始抱怨为什么空调关了只能被迫下班。

能让我坚持将一件事情做下去的动力有两个。一个是我能从中持续得到反馈,让我觉得自己的所作所为是被看见了的。另一个是我能“吃自己的狗粮”,我自己也会作为用户,会去不断使用我所创造的东西。 NekoBox 是前者,ikD 是后者。

站在十年这个时间点,我觉得得立个 Flag 做点什么。我在年初注册了 nekobase.com 这个域名,并备了案。我想开个新坑,将我博客中用到的服务组件作为 SaaS 开放出来,供大家使用(例如评论后端、代码运行器服务等),顺带继续拓宽技术栈,去做些“更高级的 CRUD”。我也不知道这个服务会不会有人来用,但至少我自己的博客会迁移过去,能 dogfooding 的话,就不会半路弃坑吧。(应该吧)

我不知道下个十年的自己会身在何处,但当下,我发自内心地十分满意现在的工作和生活,希望这样的日子能永远永远地继续下去。

再看 NekoBox:迁移、重构、展望

2025-09-13 23:12:57

NekoBox 匿名提问箱于 2020 年 3 月上线以来,至今已有五位数的注册用户并产生了六位数的提问。

这个数据大大的出乎了我的意料,要知道 NekoBox 从未对外公开宣传过,纯靠用户间口口相传。我很喜欢这种无心插柳柳成荫的事情,自己默默做得事情能够被看见,对我来说是很幸福的事。

说来惭愧,一直以来我都是“放养式”运营,每次只有自己手头的工作不忙了,才会登录上兔小巢看一下用户的反馈,然后将一些恶性 Bug 或实现起来简单的需求给做了。很感谢使用 NekoBox 的各位能包容我的懒惰,依旧不离不弃。🧎🏻

本文记录了近期 NekoBox 迁移与重构时踩过得坑,以及我对 NekoBox 的定位与后续展望。技术向的内容会有些多,不感兴趣可以直接跳到文末。

2023.02.23 那天发生了什么?

NekoBox 最开始是我 2020 年花三天时间写出来的,作为一个小玩具部署在我的国内服务器上,并且使用我个人的备案域名。网站可以使用任意的邮箱注册,并不需要用户输入手机号并验证实名,这其实是不符合我国《互联网信息服务管理办法》的。

但当时抱有一定的侥幸心理,想着提问和回答都接了云服务商的文本内容审核 API,违规评论都会被拦截,再加上自己也没对外宣传这个站,应该不会有问题。

但这在无形中给我埋下了一个大雷。

2023 年 2 月 23 日上午 11 点左右,我在家接到了网信办的电话,对方说有人在 NekoBox 发布违法言论,我作为站长,需要配合调查。当时我吓坏了,马上光速注销备案 + 关站,并认真配合警察叔叔的工作。

最后好在我没有利用 NekoBox 进行盈利,且我事先也接了相关文本审核的功能,在配合工作提供了相关材料后,这件事便告一段落了。还好没留下什么案底,已经是万幸了。

事后复盘发现,那名用户使用谐音和表情符号绕过了文本内容审核功能。这让我意识到机器审核 API 也会有严重的漏报,但一方面因为成本原因又无法做到每条信息都接人工审核服务。

互联网不是法外之地!

这件事给我的打击挺大的,我最初的想法是将网站代码开源出来,大家能够一起共建,可惜 GitHub 上一直没有多少贡献者,还被炸弹人给爆破了。原先的国内网站下线后,我收到了很多用户的反馈,纷纷询问站点怎么无法访问了,甚至还有一位网友因为 NekoBox 了解到了我的技术博客,受到触动也开始尝试建站写博客。能成为他人的光真的是很开心的事。

因此后续 NekoBox 便迁移到了境外服务器,并且没再使用备案域名了 —— 正如 v2ex、Go 语言中文网等站点那样。希望它能在广袤互联网的一角,继续安静地存在下去。

云原生迁移

抒情的话聊完了,该聊点技术了。

NekoBox 部署在境外的 2C2G 轻量服务器上,由于配置的原因,只能使用 Docker Swarm 进行粗糙的服务编排调度。每次需要更新线上版本时,都是 GitHub Actions 通过 SSH 连上服务器,再执行 docker service update 命令。

我想将 NekoBox 接入现有的 K3s 集群,使用 GitOps 实现更好的版本管理和平滑更新。我的 K3s Master 节点位于腾讯云上海区域,经测试发现腾讯云东京区域的线路比中国香港区域好些,因此买了台 2C4G 的境外东京区域的机器,作为 NekoBox 新的部署机器。

关于如何跨地域甚至是跨云组件 K3s 集群,这篇文章介绍的很详细:《基于K3S和zerotier-planet实现跨云搭建K8S集群》

首先使用 ZeroTier 将不同可用区的机器加入到同一 ZeroTier 网络中,这样在 K3s 看来它们就在同一内网里了。

zerotier-node-list

节点均开启 IPv4 Forwarding 后,修改 Master 节点 /etc/systemd/system/k3s.service 中的 K3s 参数,显示指定 node-ip 为节点在 ZeroTier 中的 IP,并设置使用 ZeroTier 的网卡:

ExecStart=/usr/local/bin/k3s \
 server --node-ip 10.243.xxx.xxx --flannel-iface ztcxxxxxxx --flannel-backend=host-gw \

同理,修改 Worker 节点 /etc/systemd/system/k3s-agent.service 的 K3s 参数:

ExecStart=/usr/local/bin/k3s \
 agent --node-ip 10.243.xxx.xxx --flannel-iface ztcxxxxxxx \

重启各节点上的 K3s 服务,Lens 连上 Master 节点能在 Worker 节点启动 Node Shell 并访问 Worker 上的 Pod 日志,Worker 节点能请求到其他节点上的 Service,说明就配置成功啦~

我们再给 NekoBox 的节点加个 region=jp 的污点,防止集群里的其它服务被调度过来。

goldpinger

后续还推荐装上 goldpinger,它会建一个 Daemon Sets,在每个节点上放一个 Pod 来监测节点间的连接状态。

goldpinger-web

官方仓库里虽然提供了 Helm Charts,但比较敷衍,可扩展性差,建议是自己把有用的部分扒出来直接 GitOps 写 YAML 创建资源。官方仓库里还提供了 Grafana Dashboard 定义,导入 Grafana 后可以很直观的看到节点之间的连接延迟:

goldpinger-grafana-dashboard

数据库 MySQL -> Postgres

2020 年写 NekoBox 那会,我还很菜(虽然现在也很菜),数据库只会用 MySQL。

尝试过 Postgres 后发现真香,就想着把 NekoBox 从 MySQL 迁移到 Postgres。但 NekoBox 在线上跑着,随时会有用户访问,发布新的提问和回答往数据库插入新的数据,此举无疑是在边开飞机边换引擎。

社区的 pgloader 是一个很好用的 Postgres 数据迁移工具,但它只支持全量迁移,并不支持增量同步。换句话说我需要先给线上的 NekoBox 停机防止有新的数据写入,迁移数据,再将后端数据库配置改到新的库上。受限于老的 2C2G 服务器的性能,我需要对 pgloader 进行限速,停机迁移全量数据的时间可能会很长。

阿里云 DTS

如果要实现不停机迁移,则需要在完成全量迁移后,再将全量迁移这段时间内的增量数据,也同步到新库中。在调研了市面上几个数据库同步产品后,最后我选择使用阿里云 DTS 来完成。(这里不得不吐槽下我司,腾讯云的 DTS 产品只支持 MySQL 系之间的数据同步,不支持异构数据库,还得加强呀!)

将服务器添加为阿里云数据库网关 DG 节点后,即可在 DTS 控制台选择使用数据库网关接入非阿里云的源库与目标库,配置完后启动任务即可。

aliyun-dts

等全量迁移完了就会开始一直跑增量写入任务了,此时可以在线上写一些数据,来检查数据同步的情况。

阿里云 DTS 的坑

但是阿里云在让我失望这件事上从来没有让我失望过。

我发现阿里云 DTS 居然把 MySQL tinyint(2) 类型迁移成了 Postgres smallint 类型,而非 bool 类型!这导致 GORM 在 AutoMigrate 时直接报错了!这一点在 pgloader 中专门有一条 tinyint-to-boolean 规则进行适配:

As MySQL lacks a proper boolean type, tinyint is often used to implement that. This function transforms 0 to ‘false’ and anything else to ’true’.

提工单问了客服,客服只会照本宣科给我发产品文档链接…… 我要的是怎样解决问题,不是你告诉我产品该怎么用。

更抽象的是,阿里云 DTS 怕不是根本没什么人用,产品文档中记录的“库表列名单个映射”功能,前端的树形组件下拉是有 Bug 的,如果直接全选了整个库,则无法再细化各表的字段映射配置。

这导致不看文档,用户自己是不会知道还有这功能的。但这个字段映射也只是配置目标字段的名称,并不能修改映射类型。

我的解决办法是在迁移完后,观测到线上流量低后,关闭 DTS 同步,线上服务停机,Postgres 数据库执行 SQL 修改字段类型。

ALTER TABLE "nekobox"."questions"
ALTER COLUMN is_private TYPE BOOLEAN
USING CASE
 WHEN is_private = 0 THEN FALSE
 ELSE TRUE
END;

ALTER TABLE "nekobox"."censor_logs"
ALTER COLUMN pass TYPE BOOLEAN
USING CASE
 WHEN pass = 0 THEN FALSE
 ELSE TRUE
END;

修改类型的 SQL 执行的很快,就当我以为已经全部搞定的时候,我发现阿里云 DTS 这垃圾东西居然不会迁移 Postgres 自增序列! 这意味着每一张表的ID 字段都不会自增并自动赋值,插入数据就会报错说 ID 字段为 NULL。

没办法,赶紧执行 SQL 手动加序列……

-- 1. 查看当前最大 ID(先确认数据)
SELECT MAX(id) FROM "nekobox".users;
-- 2. 创建序列并设置起始值(假设最大 id 是 1000)
CREATE SEQUENCE "nekobox".users_id_seq START WITH 1001;
-- 3. 将序列绑定到 id 列
ALTER TABLE "nekobox".users
ALTER COLUMN id SET DEFAULT nextval('nekobox.users_id_seq');
-- 4. 将序列的所有权给表(表删除时序列也删除)
ALTER SEQUENCE "nekobox".users_id_seq OWNED BY "nekobox".users.id;

还得是阿里云,能整出这种狠活来,真牛!😅

如果再给我一次机会,我会选择自己写一个工具,先记录 MySQL 数据库 BinlogID 或 GTID,然后调用 pgloader 进行全量数据迁移,再从记录的 BinlogID/GTID 处开始增量同步添加数据。

现在 NekoBox 已经迁移到了 Postgres,凌晨 3:30 开始迁移,4:00 完成。线上运行了一天 Trace 里没看到有报错,感觉是没问题了。

前端重构

NekoBox 的前端使用 UIKit 组件库。这个组件库的风格我十分喜欢,扁平简单,美中不足的是它真的就只是一个 CSS + 一点点 JavaScript 的组件库。社区里有人开发了 vuikit 组件来将其接入 Vue 生态,但这个项目的最后一次 commit 已经是五年前了,并且还未适配 Vue3。

因此 NekoBox 的前端一直是以服务端渲染的形式呈现,稍微复杂一点的交互或者异步加载,则会使用 Alpine.js 实现。渐渐的,我发现它已经无法支撑起后续复杂的前端需求了。我写前端的经常会想:“这些响应式交互,Vue 来了可以全秒了。”

我开始尝试将 UIKit 的 CSS 引入 Vue3 项目中,发现它比我想象中的好用。由于 UIKit 大部分情况下只是在原生 HTML 标签上加上了 CSS 样式,因此我大可不必像 vuikit 那样将按钮、文本框之类的封装为 Vue 组件,直接在原生 HTML 标签用 class 指定样式即可。页面也比我想象中的少很多,因此只花了一个周末的时间就完成了 80% 的前端 Vue3 + 后端 RESTful API 的重构工作。

骨架屏

前后端分离后,由于后端部署在境外,请求 API 难免会慢一些。这里我用了 vue-loading-skeleton 来给页面加载时加上骨架屏,防止页面未加载完时布局塌陷。这个组件做得还行,可以自动识别插槽里的元素自适应调整加载的骨架元素大小。

灰度

新版的前端我不敢直接全量上线,想先小部分用户测试下。最简单的办法是将前端部署在例如 next.n3ko.cc 这样的子域下,但会导致后续主站全量上线时,子域的链接还得做重定向兼容。

复杂一点的话,在集群里搭个 Istio 服务网格来实现细粒度的流量转发,但看了下机器的配置,还是算了…… 最后简单粗暴的在 Cloudflare 上配置了回源规则:当请求 Cookies 里带 next-beta=1 时,则将请求转发到源站新版前端的端口上。后续在线上加个按钮,点一下就给 Set-Cookie 即可切换到新版前端,去掉 Cookie 就切回来。

展望

我有问过自己,NekoBox 对我而言意味着什么?

我并不指望靠着它能够发家致富,我认为 NekoBox 是一块让我实践产品运营的“试验田”。参加工作以来,我基本没有做过对性能和服务可用性有很强要求的东西,更没有什么 To C 的经验。这既是好事,好在我不用随时 on call;也是坏事,坏在我没有那些项目经验和教训。

因此我想借 NekoBox 这个用户量还算不少的平台,亲身去实践开发和运营一个产品,去踩那些前人踩过的坑,去体验边开飞机边换引擎的惊险。因此 NekoBox 的项目经历其实也一直被写在我的简历里,作为一个还算成功的 Side Project 被我拿来跟面试官吹逼。😂

对于 NekoBox 的用户,很感谢他们能包容我的“放养式”运营,更是感谢那些还会不定期支付宝打赏的朋友们。我仅通过兔小巢这个渠道接收用户的反馈,并没有尝试组建 QQ 群之类的方式,是因为我认为每个人的圈子不同,年龄和性格也不同,求同存异会比较困难。我也很害怕跟别人起纷争或者冲突,所以还是继续维持现状吧。

关于之后的更新计划嘛,等新版前端稳定后,我想先完善一下基建方面,例如 Tracing 由 Uptrace 切到更专业的腾讯云 APM,完善项目的开发和部署文档,之后就可以开始做用户反馈中提到的暗色主题、多语言、表情评价,甚至是聊天等功能。先把 flag 立了,后面慢慢填坑哈哈哈。

如何设计并实现一个好用的大模型套壳站?

2025-03-30 23:12:57

我在 2021 年时就开始用 GitHub Copilot 写代码了,2022 年 12 月初刷推特时看到了 ChatGPT,立刻注册了个号玩了下。大模型的这波风口我看到的很早,但却没有做什么行动。那个时候的自己感觉不管做什么起步都已经晚了,套壳站已经满天飞了,OpenAI 的 API Key 也被人卖的差不多了,已经没有什么新的玩法了。

今年过年的时候 DeepSeek 火了,我才惊讶地发现,几年过去了, 豆包、混元、千问虽然在业内打得不可开交,但还是有太多的人至今没有接触过这些大模型应用。我在推特上看到个喷子,喷 DeepSeek 的点居然是问今天天气怎么样,它回答不出来。很多人对这种对话式 AI 的概念还停留在 10 年前的 Siri 等手机语音助手上。换句话说,下沉市场还是一片蓝海。

刚好之前看到腾讯混元大模型的最低配模型 hunyuan-lite 居然是免费的!那我们不如也来试试当一回二道贩子,尝试自己做一个大模型套壳站,会不会有人用我不知道,但开发的过程一定很有意思。

感兴趣的功能

排除掉写了一万遍的用户注册登录和一堆 CRUD,我对以下功能的实现原理很感兴趣:

  1. SSE 代理:怎样将腾讯云大模型的 SSE 和自己的对话接口接起来?

  2. SSE 断点续传:对话生成过程中如果页面刷新了,重新进入时怎样继续生成当前回答?(⚠️ 实践后发现这是最难实现的功能,边缘情况很多)

  3. 怎样生成对话标题?

  4. 每次对话的 Token 如何计算?单次对话的 Token 数如何限制?

先来看看开源社区

开始逐个分析上述功能之前,我们先来看看社区做得怎么样了。我按 stars 排序随便挑了几个感兴趣的项目,简单读了下他们的代码后,我信心大增哈哈哈。🤣

https://github.com/ChatGPTNextWeb/NextChat TypeScript 82.1k

这应该是大家最初自建套壳站时使用的了,使用 TypeScript 编写。功能中规中矩,我发现了两个有意思的点:

// https://github.com/ChatGPTNextWeb/NextChat/blob/48469bd8ca4b29d40db0ade61b57f9be6f601e01/app/client/api.ts#L197-L201

.concat([
 {
 from: "human",
 value:
 "Share from [NextChat]: https://github.com/Yidadaa/ChatGPT-Next-Web",
 },
]);
// 敬告二开开发者们,为了开源大模型的发展,请不要修改上述消息,此消息用于后续数据清洗使用
// Please do not modify this message

NextChat 在生成公开的对外分享链接时,会在对话最后加上 Share from [NextChat] 的标识。目的是为了后续训练大模型时,能够分辨出哪些是人工产生的数据,哪些是以往的大模型生成的,进而清洗过滤掉大模型生成的内容。

细想一下还挺意思的,“2022 年” 像是一道屏障一样,将互联网上的文字内容隔开来了。2022 年以后的内容,读起来就得留个心眼了,凡是看到 “综上所述” “总的来说” 这些字眼,难免会怀疑是否是用 AI 生成的。它像是泄露的核废水一样,随着时间的推移逐渐蔓延并浸染整片知识的海洋。

// https://github.com/ChatGPTNextWeb/NextChat/blob/48469bd8ca4b29d40db0ade61b57f9be6f601e01/app/locales/cn.ts#L626-L632

Prompt: {
 History: (content: string) => "这是历史聊天总结作为前情提要:" + content,
 Topic:
 "使用四到五个字直接返回这句话的简要主题,不要解释、不要标点、不要语气词、不要多余文本,不要加粗,如果没有主题,请直接返回“闲聊”",
 Summarize:
 "简要总结一下对话内容,用作后续的上下文提示 prompt,控制在 200 字以内",
},

NextChat 的这段代码解答了上面的问题 3 —— 对话标题是使用一段简短的 Prompt + 一个较小的模型生成的。转而一想,这里其实可能存在 Prompt 注入,只是没什么危害罢了。

https://github.com/open-webui/open-webui Python 84.8k

open-webui 的前端做出了高仿 OpenAI 的风格。使用 Python Web 异步库 starlette 返回 SteamingResponse 来代理 SSE 接口。它也实现了对话标题生成的功能,Prompt 上比 NextChat 长很多,并且要求以 JSON 格式返回。

https://github.com/open-webui/open-webui/blob/b03fc97e287f31ad07bda896143959bc4413f7d2/backend/open_webui/config.py#L1149-L1168

我担心的点是,标题生成本身用的就是小模型,这么长的 Prompt 以及限定 JSON 格式输出,对小模型而言会不会不稳定。🤔

至于并发限流、以及对话的 Token 吞吐量限制,open-webui 写了一个路由中间件解决,这里就不再赘述了。

https://github.com/yangjian102621/geekai Go 4.1k

因为我使用 Go 来编写后端,所以找了个 Stars 数很多的 Go 项目。作者应该是 PHP 转 Go 没多久,或者说是刚学编程没多久,这代码质量真的不敢恭维。

好好的 SSE 不用,画蛇添足用了 WebSocket,可从头至尾就没有需要客户端发送消息的场景。甚至这项目背后还接了个 xxl-job。😅 他能获得这么多 stars 只是因为把支付那块也给做完了,小白可以即开即用拿去做套壳。但从代码的可维护性和整洁度上来说,真是一团糟。我都想做个《鉴定网络奇葩代码》短视频了。

这个故事告诉我们,技术好不好不重要,能把事情做完最重要。

https://github.com/swuecho/chat Go 538

同样是 Go 项目,这个国外老哥写得代码就好多了。他使用了 langchaingo 来构造拼接对话。说实话我内心觉得这些库用起来挺花里胡哨的,又是什么模板,什么占位符,什么对话链,但最终做的事还是在拼字符串,拼出一个 Prompt 发给大模型。😁

老哥使用了 langchaingo 自带的 summarization 来做对话总结,本质上也是 langchaingo 内置了一段 Prompt。

而关于问题 4,如何计算 Token 数量,由于这个项目支持的模型都是 OpenAI 家的,因此直接使用的 OpenAI 开源的 tiktoken 来进行计算(国会听证会警告)。tiktoken 有 Go 封装的开源实现:github.com/pkoukk/tiktoken-go


其余的一些项目我有点看不下去了,不如直接开写吧!

数据结构

回忆一下,我们是怎样用豆包或元宝的,在页面左侧有一个对话列表,点开对话后可以看到我们发送的和 AI 回复的消息。因此需要创建 Chat (对话)和 Message (消息)两张表。

  • Chat 对话表
字段名 类型 说明
ID int64 生成的自增 ID
UserUID string 用户 UID,用来对应这个对话属于哪个用户
Title string 对话标题,后面由大模型总结生成
CreatedAt time.Time 对话创建时间
  • Message 消息表
字段名 类型 说明
ID int64 生成的自增 ID
ChatID int64 Chat 对话表 ID,表示这条消息属于哪个对话
ParentID int64 父消息的 ID
ChildrenIDs pq.Int64Array 当前消息所有子消息的 ID 集合
Role MessageRole 这条消息是谁发的,user / assistant
Content string 消息正文
Model string 对话使用的模型,目前还没做多模型切换,先预留
TokenCount int64 为消息正文的 Token 数
CreatedAt time.Time 对话创建时间

有坑注意!

这里的 ID 均使用 Snowflake 算法生成,Snowflake 生成的是 19 位数字,这在 Go int64 下没问题,但在前端 JavaScript 下会丢失最后 4 位的精度。即 1906281281029672960 在前端会变成 1906281281029673000

我用了一个简单粗暴且不靠谱的 HACK,将数字除以 1000,去除后三位。

消息表中的 ParentIDChildrenIDs 字段,用于记录父子消息关系。就像豆包可以点击重新生成,进而再生成一条回复。

豆包子消息

更复杂的像 ChatGPT,可以点击上文任意一条消息,新建一个分支重新生成对话。为了实现这样的效果,我们在创建一条新的消息记录时,需要 ParentID 指定它的父消息,并更新它父消息的 ChildrenIDs 字段,这俩包在一个数据库事务里做就行。

在需要构造大模型接口 JSON messages 参数时,只需从最后一条消息开始,沿着 ParentID 依次向上遍历,一直到 ParentID 为 0,即可拿到当前对话分支的消息列表。 前端实现像上图中豆包的“上一条”“下一条”翻页的效果,也只需取 ChildrenIDs 构造翻页即可。

这里再补充一些小细节,我发现腾讯元宝的消息 ID 使用 <对话ID>_<自增索引的格式> 表示,如 <对话ID>_1 <对话ID>_2 等,这从设计上使得元宝的对话只能是线性的。 用户只能重新生成最新一轮对话的消息,且不能在历史对话中重新生成创建分支。

实现最简单的 SSE

关于 SSE 的简单介绍,可以去阅读我五年前写得 《聊聊 EventStream 服务器端推送》 这篇文章。大模型活了之后每个月都会有人在 Google 上搜 EventStream 搜到这篇。

腾讯云官方的 Go SDK 调用混元大模型时,客户端可以使用 SendOctetStream 方法,接收流式响应,此时 response 中返回的是 channel 类型的 SSEvent。我们可以先对混元大模型做简单的函数封装,从 SDK 的 channel 中提出大模型对话返回的 Content 正文,再打到函数返回值的 channel 中,精简后的代码如下:

func (h *Hunyuan) TextCompletions(ctx context.Context, input TextCompletionsInput) (chan string, error) {
// ...
eventsCh := response.BaseSSEResponse.Events // 腾讯云 SDK 输出

go func() {
 for event := range eventsCh {
 if event.Err != nil {
 logrus.WithContext(ctx).WithError(event.Err).Error("Failed to get event")
 break
 }

 eventData := event.Data
 var respParams hunyuan.ChatCompletionsResponseParams
 if err := json.Unmarshal(eventData, &respParams); err != nil {
 logrus.WithContext(ctx).WithError(err).Error("Failed to unmarshal event data")
 continue
 }

 if len(respParams.Choices) == 0 {
 break
 }

 choice := respParams.Choices[0] // 默认取第一个结果,貌似我从没见过会有第二个
 outputChan <- *choice.Delta.Content // 打到函数返回值的 channel 里
 }

 close(outputChan)
}()
// ...
}

我这里直接默认选第一个 Choices ,将 Content 正文放到 channel 里。JSON 反序列化那块,硬要扣的话也可以改用 sonic。

具体到对话接口的设计上,与那些自用的套壳站不同,我们是要给第三方用户使用的,在接口的入参上不能像那些自用站一样每次都将整个对话完整的 messages 发给后端处理,应该尽可能缩减用户前端可控的参数范围。前端只能传入对话 ID、父消息 ID、提问消息正文;历史消息链的拼接和 messages 参数的构造全都应该在后端完成。

对话接口先响应 Content-Type: text/event-stream 头,然后发送一条类型 event:metadata 的消息告诉前端当前对话 ID 和消息 ID,之后就从大模型的 channel 里读消息,写入 ResponseWriter 即可。

大模型接口返回的是逐 Token 生成的内容,这里其实又有一个抉择,SSE 的每条消息,是返回当下完整的消息内容,还是返回新增的 Token 内容呢?

// 返回当下完整的内容
{"v":"你好,很"}
{"v":"你好,很高兴认识你"}
// 返回新增内容
{"v":"你好,很"}
{"v":"高兴"}
{"v":"认识你"}

现在大家都是选择后者。我担心的点是如果选择后者,前端拼接字符串时会不会有概率乱掉。我在不断测试豆包的时候遇到过一次,但这也是极端情况,实际后端文本是正常的,刷新一下就好了。因此我也随大流选择了返回每次新增的内容。😁

豆包生成文本错位

由于我前端处理 SSE 使用的是 eventsource-client 这个库,它在传入对话接口的 URL 后,就只能处理 SSE 格式的响应了。因此这个对话接口的报错,也只能以写入单条 SSE 消息的形式返回,使用 event: error 来区分。

对话标题生成

在对话生成结束后,需要判断当前是否为新对话,是的话则需要再调用大模型,让其生成对话标题。生成的对话标题入库存储,同时 SSE 发送一条 event: title 类型的消息,通知前端更新页面上的对话标题。

我这里的 Prompt 写得比较粗糙,你可以根据上文中提到的 NextChat 和 open-webui 的 Prompt 自己再改改。以及是将提问内容放在单独的 user 消息中,还是直接拼在 System Prompt 中,这里也可以再钻研下。

if isNewChat {
 // Summarize the conversation title from LLM in a new conversation.
 summaryOutput, err := llmChat.TextCompletions(ctx.Request().Context(), llm.TextCompletionsInput{
 Messages: []*llm.TextCompletionsMessage{
 {Role: "system", Content: "请根据给出的提问内容,总结生成一个不超过 10 个字的对话标题,尽可能是陈述句,仅输出对话标题,不要有任何其他的内容。"},
 {Role: "user", Content: "提问内容:`" + content + "`"},
 },
 SSE: false,
 })
 if err != nil {
 logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to get text completions of summary title")
 } else {
 for title := range summaryOutput {
 if err := db.Chats.Update(ctx.Request().Context(), chat.ID, db.UpdateChatOptions{Title: title}); err != nil {
 logrus.WithContext(ctx.Request().Context()).WithError(err).Error("Failed to update chat title")
 }

 // Set the title to the SSE response if the context is not canceled.
 _ = ctx.SSEResponse("title", title)
 }
 }
}

对话 Token 计算

混元大模型本身提供了 GetTokenCount 接口用于计算消息中的 Token 数,20 QPS 的限制还不收费,足够我们使用了。

从处理流程上来说,用户发起提问时,调用 GetTokenCount 计算提问的 Token 数;回答生成完毕后,计算并更新回答所消耗的 Token 数。为未来可能要做的 Token 付费功能铺垫。进一步,如果还要做不同套餐的上下文长度的限制,提问的长度在开始提问的时就进行判断,而对于大模型回答的长度,则是在调大模型接口时使用 max_tokens 参数限制。

然而混元的 SDK 好像不能指定这个参数,只有走 OpenAI 兼容接口调用时才支持。

简单的对话实现

我画了一张流程图来梳理目前的整个过程,带 🚀 小火箭图标的意味着这一步可以开个 goroutine 异步进行。如果上述流程没问题,那就请做紧抓稳了,我们后面要引入 SSE 断点续传功能了。

SSE 断点续传

这是一个各家大厂都支持的功能,但网上好像还没人讨论应该如何实现,我在相关的大模型套壳开源项目中也没有看到。

具体来说就是,在用户提问后,前端调用了上述对话接口,页面开始逐字打出大模型的回答。就在这时用户突然刷新了页面,或者在新的浏览器标签页中打开了网页,页面上应该要接着之前的回答继续生成完。我称之为“SSE 断点续传”。

我们拆解一下这个需求,最终的效果应该是:

  • 用户提问后,刷新页面,页面要能继续接着之前的回答内容生成。
  • 用户提问后,点击「停止」按钮,生成停止;刷新页面,要停在之前的回答内容上。
  • 用户提问后,刷新页面,页面继续生成;点击「停止」按钮,生成停止;再刷新页面,要停在之前的回答内容上。
  • 用户提问后,又在新浏览器窗口打开对话,此时两个窗口要同步生成;点击「停止」按钮,两个窗口要近乎同时停止。

在前文中,我们直接将大模型的 Channel 和当前请求的 Response Channel 接在一起,一旦 SSE 请求被中断,HTTP 请求的 Context Cancel 后,会连带着混元大模型 SDK 生成请求的 Context 一起停止。因此,我们第一步应该是将大模型生成请求独立到一个 goroutine 中进行,且 Context 与外部 HTTP Context 隔离。

不管是刷新还是新开多个浏览器页面,都要能获取到之前已生成的回答内容,那么生成的内容就得找个地方存下来。这个“存下来”还不是持久化存储,因为回答生成完毕后,就会入库存到 Messages 表的 Content 字段中。我们要的是一个性能好的临时存储,它最好还自带过期功能,还支持多个浏览器接收的这种消息订阅分发模式,这里很容易能想到用 Redis。

Redis 关于消息订阅的功能有 PubSub 和 Stream。前者用于实现消息的发送与广播,但消息不会被持久化,发完就忘了;后者引入了消费组的概念,不同的消费组有单独的 position 来消费历史消息,甚至还支持 ACK 机制。那么结果就很明确了,我最终选择了 Redis Stream 来实现这个功能。

大模型生成请求在单独的 goroutine 中进行,生成的内容打到 Redis Stream 中,Stream 的 Key 使用 chat:message-stream:<message_id> 表示。每一个前端的 SSE 请求,都是从 chat:message-stream:<message_id> 中从头开始(游标为 0)间接读取消息并返回。

那么前端在进入页面后,又该如何知道当前对话还在生成中呢?我在每次调用大模型生成时都会在 Redis 里设置一个 Key,生成结束后删除。

set chat:conversation-status:<chatID> <messageID> 5*time.Minute

前端获取对话基本信息的 HTTP API,会通过查看这个 Key 是否存在来判断当前对话是否正在生成。如果正在生成,就直接调对话接口,发送空提问消息来开启 SSE 开始拉取回答消息。

这个 chat:conversation-status:<chatID> 我还设置了 5 分钟的过期时间用于兜底,如果因为意外后端重启了,对话不至于一直卡在生成中的状态。

在对话结束后,chat:conversation-status:<chatID> chat:message-stream:<message_id> 这两个 Key 都会被删除,这里会存在一个 race 的极端情况:那就是前端通过 conversation-status 得知对话正在生成,这个时刻之后刚好对话生成结束,前端启动 SSE 后发现 message-stream 被删了,这样就拉不到历史消息了。因此我在删除 conversation-status 后延迟了 5 秒再删除 message-stream

// Delete the conversation status in redis.
if err := redis.Get().Del(ctx, conversationStatusFlagKey).Err(); err != nil {
 logrus.WithContext(ctx).WithError(err).WithField("key", conversationStatusFlagKey).Error("Failed to delete redis key")
}

time.Sleep(5 * time.Second)

// Delete the redis stream after the chat message completion is done.
if err := redis.Get().Del(ctx, messageStreamKey).Err(); err != nil {
 logrus.WithContext(ctx).WithError(err).WithField("stream", messageStreamKey).Error("Failed to delete redis stream")
}

以上,我们就实现了 SSE 消息的断点续传了。但还有一个问题:用户点击前端的「停止」按钮后,我们要能够停掉 goroutine 里正在跑的大模型请求,确保生成的消息内容就停在当下。这里我单独加了个 POST /stop 接口,前端调用后会将 chat:conversation-status:<chatID> 从 Redis 中直接删掉。大模型生成的 goroutine 里再开一个 goroutine 来循环查看这个 Key 是否存在,如果不存在了,就直接关掉大模型请求的 Context:

// Scan for `conversationStatusFlagKey`
// If the conversation status is not set, which means the conversation is stopped by the user.
go func() {
 for {
 select {
 case <-ctx.Done():
 return
 case <-llmCtx.Done():
 return
 default:
 _, err := redis.Get().Get(ctx, conversationStatusFlagKey).Result()
 if errors.Is(err, redispkg.Nil) {
 llmCancel()
 return
 }
 time.Sleep(1 * time.Second)
 }
 }
}()

至此,我们就完成了上述 SSE 断点续传的 4 个需求,属实不容易,这里的设计我斟酌思考了很久。我也不知道大厂们是怎么做的,如果你有更好的设计或者想法,欢迎留言和我讨论。

带断点续传的对话实现

具体落实到代码上会复杂些,因为还有更新对话标题、计算 Token 等流程,很多步骤又是可以异步进行的,但互相之间又会用不同的 Context 来同步状态,然后 goroutine 中还有一堆的 defer ,这块的代码我打算后续梳理下流程好好美化下,现在只是停留在可用的状态。

来看看豆包和元宝

整个开发过程中,我时常会去看豆包和元宝是怎么做的,参考他们的设计是怎样的(期间要不停地抓包和翻压缩后的 JS 文件),也和大家分享下。

豆包的实现比较复杂,用户发送的消息在浏览器本地的 IndexDB 会存一份。当用户开启新对话提问后,由于这时新对话还没发送到后端,前端会给这个对话和消息生成一个本地的 Local ID。带着 Local ID 将请求发给后端。但正如我前面提到的,Local ID 这种由用户本地生成的数据,后端不应该给予过多的信任,因而对话 Local ID 仅被用在第一次后端生成对话 ID 前,当后端生成并返回了对话 ID 后,后续都用该对话 ID 进行查询;消息 ID 也是同理。最后接口参数会传 本地/后端 的 对话/消息 ID 共 4 个参数,但后端会优先使用后端生成的 ID。我对着这个接口排列组合测了多种情况,发现豆包都能很好的 handle 住。

由于豆包会把消息在本地存一份,因此在页面刷新后,它是知道上次 SSE 断在哪里的。观察豆包的 SSE 返回消息,它的 JSON 中有一个自增的 event_id 游标字段,断点续传时会带上这个 event_id,SSE 接口就只会返回在这之后的消息。这样做是为了省一点传输的流量吗(?

相对而言元宝就大道至简很多。除了我们上面提到的,元宝使用 <对话ID>_<自增索引的格式> 格式的消息 ID 记录线性的消息记录。关于断点续传,元宝是拿着对话 ID + 消息 ID 请求 /continue 接口,后端 SSE 返回全部历史消息和正在生成的消息。但如果再重放 /continue 接口请求,会直接 hang 住,可能这是个 Bug 吧。

再聊聊前端

我之前总结过 BAT 三家大厂的 AI 组件库建设情况:

公司 组件库 评价
字节跳动 Semi Design 豆包同款,Semi Design 还支持搭配 Tailwind CSS 使用。缺点是只支持 React,很难受。然后开发团队还说提供了接口,社区可以自己实现 Vue 版本。呃呃,社区实现了,但又没完全实现,居然还要在 Vue 里写 JSX。😅
阿里巴巴(蚂蚁) Ant DesIgn X 打开官网给我浏览器卡得半死。相比其它家有欢迎栏、提示集这类独特组件。还没深入使用过。
腾讯 TDesign 我司这个有点一言难尽。元宝前端虽然用了 TDesign 但 AI 对话那块看起来是自己写的。组件库提供的 ChatInput 占得空间太大了,样式还不好调,我在司内的项目是拿 TDesign 的 Input 组件自己撸了一个。(以上仅代表个人观点,我爱公司😘)

因此前端的部分我选择了 Semi Design 组件库,因为我感觉它是真的经历了 Dogfooding 做出来的,实打实的豆包同款前端。我在写的时候前端想去仿豆包的风格,然后发现现成的组件库实现不了同样的样式,便去翻豆包的前端,惊讶地发现我踩的坑他居然都踩过一遍了! 我按照豆包前端强行加 CSS style 和 class 之后,真就搞好了。

这也是我写得第一个 React 项目,不出意外地踩了 StrictMode 下请求会发两次用来检查副作用的坑。🤣 这个过程跟我刚开始写 Vue 一样,一开始是很痛苦的,但写着写着突然就顿悟了,发现 React 把各种东西和功能定义成组件嵌套包起来的设计,还真有点妙。我也理解为什么 Vue 能火了,这俩入门难度确实不一样。

最后再聊聊

我的大模型套壳站现已部署至线上:TakoChat - https://tako.chat

TakoChat

Tako(たこ)是日文章鱼🐙的意思。起这个名字只是我单纯觉得微软 Teams 下的章鱼动态 Emoji 很可爱。背后接的是免费版的混元大模型,所以你可以注册体验下,只是目前的功能还很基础。(不清楚阿里云那边短信验证码备案的问题是否解决了,可能会遇到部分运营商收不到短信验证码的问题,可以换不同运营商的号试试)

你可以看到左侧有「实验室」一栏,我是打算在这里动手做做像 MCP 和 Agent 这样的小玩意。(先把坑开了,填不填再说。

呼~ 总算把这篇写完了。我还挺自我感动的,没蹭热度,仅仅只是分享一些自己总结的心得体会,比那些营销号不知道高到哪里去了。

至此,周末也要结束了,明天又可以上班继续修 bug 了 🤤

文章头图来自 @极道寂 PixivID: 69237248

memos 源码阅读笔记

2025-01-17 23:01:23

一直想要有一个平台,能够发些碎碎念之类,记录一下在食堂吃到的新菜式,或者分享一下有意思的事情。如果在 QQ 空间动态发,未免有些扰民了;如果在 Telegram 发,因为网络问题不是很方便;在知识星球发,很不幸我的知识星球账号莫名其妙地被停用了。

之前刷推特时偶然发现了 memos 这个项目,定位是一个 Self-hosted 的笔记应用,但看页面很像是一个精简版的 Twitter。memos 的功能很简单,令我感到惊讶的是,它的 Repo 居然有 36000+ 的 Stars 数,确实厉害。

碰巧 memos 也是用 Go 写,功能又这么简单,我便抽空阅读了下它的源码,也还算是小有收获,用这篇文章分享下我的心得体会。文中提到的内容可能你很早以前就知道了,还请多多包涵。

本文使用 commit edc3f1d 的代码进行演示。

语义化版本

语义化版本(Semantic Versioning)在 Go 里面应该是用得很多了。几年前参加 GopherChina 的时候,就有人专门分享了这个。

memos 在 server/version/version.go 下记录了当前的版本号,并为使用 golang.org/x/mod/semver 实现了排序逻辑。值得注意的是,这里的版本号会被用于在数据库迁移(migration)中。每一个版本的数据库迁移 SQL 文件会被放置在以版本号命名的文件夹中,当执行数据库迁移时,会将这些版本号文件名进行排序,并与当前的版本号进行对比,从而选择要执行的迁移脚本。

打死都不用 ORM

memos 支持 MySQL、Postgres、SQLite 三种数据库。遇到这种需要支持多种数据库的场景,我们往往会使用 ORM,就算对 ORM 存在的副作用不信任,也会选择 SQL 查询构造器(SQL Query Builder)的库来辅助我们构造 SQL。但 memos 不知道在坚持什么,硬生生地对着三套数据库后端写了三套代码!他甚至只用 database/sql 和对应数据库的 Driver!他甚至手写 SQL!他甚至还各种拼 SQL 查询条件的字段!

各位可以体会下 store/db/mysql/activity.go#L23-L27

fields := []string{"`creator_id`", "`type`", "`level`", "`payload`"}
placeholder := []string{"?", "?", "?", "?"}
args := []any{create.CreatorID, create.Type.String(), create.Level.String(), payloadString}

stmt := "INSERT INTO `activity` (" + strings.Join(fields, ", ") + ") VALUES (" + strings.Join(placeholder, ", ") + ")"

这段 INSERT 真就硬生生地拼字段,硬生生的写死预编译占位符。

当然,有人提了 issue 问为什么不用 ORM,并且推荐了 sqlcsqlbuilders 两个库。作者的回复是前者 looks a little weird (?),后者 pretty much the same as the existing way,综上所属作者认为保持现状啥也不改!😅

FYI:https://github.com/usememos/memos/issues/2517

玩出花的 gRPC

memos 项目中对 gRPC 的写法可谓是教科书级别的。我也算是对着它的代码入门了下 gRPC。说来惭愧,我以前除了拿 Protobuf 写过 Hello World 的 demo,就没有更深入的应用了。

Buf

Buf 是一个用来辅助使用 Protobuf 的工具。它相当于为 Protobuf 实现了“包管理”的功能,你可以使用 buf.yaml 来定义需要引用的第三方 Proto,还可以配置 Lint 之类的规则。运行 buf generate 后便会自动去帮我们完成运行 protoc-gen-go 等一切操作。memos 中就使用到了 Buf,可以在 proto/buf.yaml 找到。Buf 还会生成一个 buf.lock 文件,也就是包管理中常见的签名文件。

我们可以观察到 Buf 的 dep 依赖形如 buf.build/googleapis/googleapis 这样的 URL,访问便可跳转到 Buf Schema Registry 上对应 Package 的页面。

感觉用 Buf 来处理 Protobuf,操作简便,逼格一下就上去了,学到了。

目录结构

memos 的 /proto 目录下,store 目录与数据库的表结构对应,为每张表对应的实例的 proto 定义。api/v1 目录中则是 service 的定义,这里则对应了 Web API 的路由。

service AuthService {
 // GetAuthStatus returns the current auth status of the user.
 rpc GetAuthStatus(GetAuthStatusRequest) returns (User) {
 option (google.api.http) = {post: "/api/v1/auth/status"};
 }
 // SignIn signs in the user with the given username and password.
 rpc SignIn(SignInRequest) returns (User) {
 option (google.api.http) = {post: "/api/v1/auth/signin"};
 }
 // SignInWithSSO signs in the user with the given SSO code.
 rpc SignInWithSSO(SignInWithSSORequest) returns (User) {
 option (google.api.http) = {post: "/api/v1/auth/signin/sso"};
 }
 // SignUp signs up the user with the given username and password.
 rpc SignUp(SignUpRequest) returns (User) {
 option (google.api.http) = {post: "/api/v1/auth/signup"};
 }
 // SignOut signs out the user.
 rpc SignOut(SignOutRequest) returns (google.protobuf.Empty) {
 option (google.api.http) = {post: "/api/v1/auth/signout"};
 }
}

例如上述代码,service 中的每个 rpc 可以看作与一个 API 相对应。

例如 GetAuthStatusRequest 这些是在下面定义的 message ,相当于是接口的入参表单,returns 指定了返回值。没有返回值的接口则使用了 google.protobuf.Empty

option 指定了 HTTP 下的请求路由和请求方法。

对于动态路由,感觉会有些复杂:

rpc GetMemo(GetMemoRequest) returns (Memo) {
 option (google.api.http) = {get: "/api/v1/{name=memos/*}"};
 option (google.api.method_signature) = "name";
}

rpc UpdateMemo(UpdateMemoRequest) returns (Memo) {
 option (google.api.http) = {
 patch: "/api/v1/{memo.name=memos/*}"
 body: "memo"
 };
 option (google.api.method_signature) = "memo,update_mask";
}

第一个 GetMemo 中,限制了路由的必须要匹配到 /api/v1/memos/* ,后面的 method_signature 指定了必须要传 name 参数。

第二个 UpdateMemo 中,限制了路由必须匹配 /api/v1/memos/* 。大括号里有个很怪的 memo.name=,因为 proto 里参数都是在 rpc 的入参传入的(即 UpdateMemoRequest ),只是我们在通过 HTTP API 访问时才有 Path、Header、Query、Body 这些传参的方式。因此在 rpc 的定义里,路由中通配符的值来自于 UpdateMemoRequest 中的 memo.name 。而后面的 method_signature 指定了 memoupdate_mask 为必须要传的参数。

Service 的具体实现上,其实跟正常写 HTTP 接口差不多,Service 结构体实现对应 interface 里定义的方法即可。我注意到方法的错误处理,使用的是 google.golang.org/grpc/status 构造的 error,状态码也是 grpc 包里自带的。

func (s *APIV1Service) GetMemo(ctx context.Context, request *v1pb.GetMemoRequest) (*v1pb.Memo, error) {
 ...
 return nil, status.Errorf(codes.PermissionDenied, "permission denied")
}

codes 包里定义了 17 种状态码,我开始还怀疑就这么点状态码类型真的能给所有的错误分类吗?事实证明还真可以。像 RESTful API 里常常表示的 403 没权限、404 不存在、400 格式不对、5xx 服务寄了 等状态,都可以找到状态码进行对应。

var strToCode = map[string]Code{
 `"OK"`: OK,
 `"CANCELLED"`:/* [sic] */ Canceled,
 `"UNKNOWN"`: Unknown,
 `"INVALID_ARGUMENT"`: InvalidArgument,
 `"DEADLINE_EXCEEDED"`: DeadlineExceeded,
 `"NOT_FOUND"`: NotFound,
 `"ALREADY_EXISTS"`: AlreadyExists,
 `"PERMISSION_DENIED"`: PermissionDenied,
 `"RESOURCE_EXHAUSTED"`: ResourceExhausted,
 `"FAILED_PRECONDITION"`: FailedPrecondition,
 `"ABORTED"`: Aborted,
 `"OUT_OF_RANGE"`: OutOfRange,
 `"UNIMPLEMENTED"`: Unimplemented,
 `"INTERNAL"`: Internal,
 `"UNAVAILABLE"`: Unavailable,
 `"DATA_LOSS"`: DataLoss,
 `"UNAUTHENTICATED"`: Unauthenticated,
}

gRPC Server 和 RESTful API Server

memos 的 server/server.go 文件定义了 HTTP 服务。它的 HTTP 服务使用 echo 框架。

重点看下面的代码:

grpcServer := grpc.NewServer(
 // Override the maximum receiving message size to math.MaxInt32 for uploading large resources.
 grpc.MaxRecvMsgSize(math.MaxInt32),
 grpc.ChainUnaryInterceptor(
 apiv1.NewLoggerInterceptor().LoggerInterceptor,
 grpcrecovery.UnaryServerInterceptor(),
 apiv1.NewGRPCAuthInterceptor(store, secret).AuthenticationInterceptor,
 ))
s.grpcServer = grpcServer

apiV1Service := apiv1.NewAPIV1Service(s.Secret, profile, store, grpcServer)
// Register gRPC gateway as api v1.
if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil {
 return nil, errors.Wrap(err, "failed to register gRPC gateway")
}

这里首先声明了一个 gRPC Server,并加了些常见的 Recover 中间件、Logger 拦截器、ACL 鉴权拦截器等。

后面的 NewAPIV1Service 创建每一块接口的 ServiceServer。跟进去可以看到,它会向上述定义的 gRPC Server 注册所支持的服务。这些注册服务的 v1pb.RegisterXXXServiceServer 就是用 proto 文件自动生成的了。

func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store, grpcServer *grpc.Server) *APIV1Service {
 grpc.EnableTracing = true
 apiv1Service := &APIV1Service{
 Secret: secret,
 Profile: profile,
 Store: store,
 grpcServer: grpcServer,
 }
 v1pb.RegisterWorkspaceServiceServer(grpcServer, apiv1Service)
 v1pb.RegisterWorkspaceSettingServiceServer(grpcServer, apiv1Service)
 v1pb.RegisterAuthServiceServer(grpcServer, apiv1Service)
 v1pb.RegisterUserServiceServer(grpcServer, apiv1Service)
 v1pb.RegisterMemoServiceServer(grpcServer, apiv1Service)
 v1pb.RegisterResourceServiceServer(grpcServer, apiv1Service)
 v1pb.RegisterInboxServiceServer(grpcServer, apiv1Service)
 v1pb.RegisterActivityServiceServer(grpcServer, apiv1Service)
 v1pb.RegisterWebhookServiceServer(grpcServer, apiv1Service)
 v1pb.RegisterMarkdownServiceServer(grpcServer, apiv1Service)
 v1pb.RegisterIdentityProviderServiceServer(grpcServer, apiv1Service)
 reflection.Register(grpcServer)
 return apiv1Service
}

最后的 reflection.Register(grpcServer) 用于注册 gRPC 的反射功能,让客户端在运行时能动态获取 gRPC 服务的相关信息,如服务列表、方法列表、方法的输入输出参数类型等,而不需要事先知道服务的具体定义。


向 gRPC Server 注册完服务后,下面是将 Echo 框架启动的 HTTP Server 作为 Gateway,以实现通过 HTTP 的方式来访问 gRPC Service。(echoServer 就是 echo.New() 出来的实例)

// Register gRPC gateway as api v1.
if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil {
 return nil, errors.Wrap(err, "failed to register gRPC gateway")
}

跟进去看定义。这里居然新建了一个 gRPC 的客户端!

runtime.NewServeMux()grpc-gateway 下的包,用于返回一个 HTTP Mux,后续就可以交给任意的 Go HTTP 框架去调用。下面自动生成的 v1pb.RegisterXXXServiceHandler 这些路由 Handler,就是来自于上文 proto 文件里的 google.api.http 注解。

最后将这个 HTTP Mux 包起来交给 echo 框架的 handler,放在了 /api/v1/* 路由下。这样我们就实现了 RESTful 风格的 API。

// RegisterGateway registers the gRPC-Gateway with the given Echo instance.
func (s *APIV1Service) RegisterGateway(ctx context.Context, echoServer *echo.Echo) error {
 conn, err := grpc.NewClient(
 fmt.Sprintf("%s:%d", s.Profile.Addr, s.Profile.Port),
 grpc.WithTransportCredentials(insecure.NewCredentials()),
 grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(math.MaxInt32)),
 )
 if err != nil {
 return err
 }

 gwMux := runtime.NewServeMux()
 if err := v1pb.RegisterWorkspaceServiceHandler(ctx, gwMux, conn); err != nil {
 return err
 }
 // ...
 if err := v1pb.RegisterIdentityProviderServiceHandler(ctx, gwMux, conn); err != nil {
 return err
 }
 gwGroup := echoServer.Group("")
 gwGroup.Use(middleware.CORS())
 handler := echo.WrapHandler(gwMux)

 gwGroup.Any("/api/v1/*", handler)
 gwGroup.Any("/file/*", handler)

 // GRPC web proxy.
 options := []grpcweb.Option{
 grpcweb.WithCorsForRegisteredEndpointsOnly(false),
 grpcweb.WithOriginFunc(func(_ string) bool {
 return true
 }),
 }
 wrappedGrpc := grpcweb.WrapServer(s.grpcServer, options...)
 echoServer.Any("/memos.api.v1.*", echo.WrapHandler(wrappedGrpc))

 return nil
}

下面还声明了一个 gRPC Web Proxy,这个是用 HTTP 的方式来调 gRPC。使用的 grpcweb 包,调用接口传参并不是用的 Query 或者 Body,而是 protobuf 将参数序列化后再发送那套。跟走纯 TCP 相比,仅仅只是这里走的是 HTTP 请求而已。换句话说,就是让浏览器能跟 gRPC Server 通信了。

而浏览器中调用会有同源跨域的问题,所以可以看到这里的 grpcweb.Option 也是逐重解决 CORS 和 Origin。

希望看到这里你没被绕晕。你会发现,memos 其实是用 HTTP 实现了两套服务:RESTful API 和 gRPC Server API。这两套背后的业务逻辑都是一样的,且都是使用 HTTP 协议,不同点在于路由和传参的方式不一样。

端口复用

有个比较抽象的小细节不知道你发现了没有,gRPC Server -> gRPC Server API 只需要用 grpcweb 包一下就行了,但 RESTful API 需要再本地建一个 gRPC Client,然后这个 Client 自己请求本地的 Server。整条链路是 HTTP Mux -> Handler Func -> gRPC Client -> gRPC Server。而这个 gRPC Client 监听的端口,居然与对外的 HTTP 服务的端口是一样的!

换句话说,就是 gRPC Server 和 echo HTTP Server 复用了同一个端口

这里是使用了 github.com/soheilhy/cmux 这个库来实现。这个库支持定义 Matcher 条件,哪个匹配上了就走哪个的 Serve。

像 gRPC Server 在通过 HTTP 调用时,通过 Body 发送 Protobuf 报文,Content-Typeapplication/grpc;而 RESTful API 则是常规的 HTTP 请求,除了 PATCH 方法外都会命中。

muxServer := cmux.New(listener)
go func() {
 grpcListener := muxServer.MatchWithWriters(cmux.HTTP2MatchHeaderFieldSendSettings("content-type", "application/grpc"))
 if err := s.grpcServer.Serve(grpcListener); err != nil {
 slog.Error("failed to serve gRPC", "error", err)
 }
}()
go func() {
 httpListener := muxServer.Match(cmux.HTTP1Fast(http.MethodPatch))
 s.echoServer.Listener = httpListener
 if err := s.echoServer.Start(address); err != nil {
 slog.Error("failed to start echo server", "error", err)
 }
}()
go func() {
 if err := muxServer.Serve(); err != nil {
 slog.Error("mux server listen error", "error", err)
 }
}()

这里对 gRPC 的操作属实妙哉!端口复用的操作更是一绝。想起我之前有个 Side Project,既需要跑对外的 Web Server 后端,又需要跑对内的 API Server 后端,当时的做法是监听两个不同端口,现在想来可以用 cmux 来实现端口复用了。

梦开始的地方

那么请问,上述这种教科书级别的 Protobuf 和 gRPC 的用法,是来自于哪里的呢?

我观察到 memos 的作者居然也给 Bytebase 提交过代码,好家伙,老熟人啊。同时,我在 Bytebase 的仓库里,找到了 #3751 这个 PR。(万恶之源)

在 2022 年 12 月(好像就是 DevJoy 结束后一个月),Bytebase 仓库引入了第一个 proto 文件。从此便一发不可收拾,原先的 Web API 全都变成了 gRPC Server 的写法,同时也开始使用 Buf 来管理 proto 文件。memos 的作者作为后面加入 Bytebase 的员工,也是将 Bytebase 对于 gRPC 的最佳实践,用在了他的 Side Project,也就是 memos 中。

我想大概是这么个故事情节吧。😁

定时任务

memos 内部自行实现了三个很基础的定时任务。为什么说很基础呢,因为就是使用 time.NewTicker 来做的。每个定时任务的 Runner 都会实现 Run()RunOnce() 两个方法,这里可能可以定义成一个接口?

func (r *Runner) Run(ctx context.Context) {
 ticker := time.NewTicker(runnerInterval)
 defer ticker.Stop()

 for {
 select {
 case <-ticker.C:
 r.RunOnce(ctx)
 case <-ctx.Done():
 return
 }
 }
}

三个定时任务分别是 s3presign version memopreperty

  • s3presign 每 12 个小时遍历一波数据库中存储的上传到 S3 的资源,将临时 URL 有效期不到一天的资源,重新调用 S3 SDK 中的 PreSign 签一个五天的临时 URL。memos 在数据库中存储图片等资源的临时 URL,感觉是为了防止私有笔记中的资源 URL 泄露。使用 PreSign URL 后,即使将公开笔记转为私有,之前的链接在五天后也就过期了。

  • version 每 8 个小时请求 memos 自己的 API 获取当前 memos 的最新版本。判断版本落后并且数据库中之前还没有过版本更新提醒的话,就新增一条 Activity 记录,并将该 Activity 加到管理员账号的 Inbox 收件箱中。让管理员收到版本更新的消息。

    其中 GetLatestVersion 获取最新版本的函数,解析请求体这里,感觉可以进一步精简成一行。

    BEFORE

    buf := &bytes.Buffer{}
    _, err = buf.ReadFrom(response.Body)
    if err != nil {
     return "", errors.Wrap(err, "fail to read response body")
    }
    
    version := ""
    if err = json.Unmarshal(buf.Bytes(), &version); err != nil {
     return "", errors.Wrap(err, "fail to unmarshal get version response")
    }
    

    AFTER

    json.NewDecoder(response.Body).Decode(&version)
    
  • memopreperty 每 12 小时遍历一遍所有 Payload 为空的 memos 笔记,从它的内容中解析出 Tag、链接、代码块等属性,保存到 memos 的 Property 中。这个函数在创建、修改、更新 MemoTag 时都会调用。额外加到定时任务中出发,应该是为了兜底。

gomark

对于用户每一篇文本笔记,memos 都会使用 github.com/usememos/gomark 库来做结构化的解析。将文本内容解析成不同类型的 Go 结构体块,以实现将 Markdown 格式转纯文本、笔记 Tag 提取等功能。

这里简单拆解一下这个包的结构和原理,本质上又是把文本进行词法分析转换为 Tokens,构建 AST 抽象语法树,然后通过遍历 AST 实现上述提到的功能。gomark 好就好在他功能简单但全面,很适合像我这种从没学过编译原理的菜鸡。

parser/tokenizer/tokenizers.go 中定义了各种 Token 的类型,如下划线、星号、井号、空格、换行等,基本上就是在 Markdown 中含有语义成分的字符,都会作为一个 Token 类型。正文内容分为 Number 数字和 Text 文本两种 Token 类型。

Tokenize(text string) []*Token 函数就是很标准的传入 text 字符串,挨个字符 switch-case,然后转换为 Token 结构体添加到切片中。

var prevToken *Token
if len(tokens) > 0 {
 prevToken = tokens[len(tokens)-1]
}

isNumber := c >= '0' && c <= '9'
if prevToken != nil {
 if (prevToken.Type == Text && !isNumber) || (prevToken.Type == Number && isNumber) {
 prevToken.Value += string(c)
 continue
 }
}

if isNumber {
 tokens = append(tokens, NewToken(Number, string(c)))
} else {
 tokens = append(tokens, NewToken(Text, string(c)))
}

对于不在上述 Markdown 语义中的字符,则判断是否为数字 0-9,如果是的话说明是一个 Number 数字 Token,同时还需要看下上一个 Token 是不是也是数字,如果是的话他俩就是挨一起的,共同组成了一个 Number Token。Text 文本 Token 也是一样的逻辑,将挨着的文本字符统一为一个 Text Token。

Token 拆分完后,就开始构建 AST 了。

ast 目录下有 inline.goblock.go 两个文件。前者定义了单个节点类型,如普通的文本节点、加粗、斜体、链接、井号标签等;后者定义了多个普通节点组成的集合节点,如段落、代码块、标题、有序无需列表、复选框等。

parser/parser.go 里定义的 ParseXXX 函数将第一步的 []*tokenizer.Token 解析成 []ast.Node

nodes := []ast.Node{}
for len(tokens) > 0 {
 for _, blockParser := range blockParsers {
 node, size := blockParser.Match(tokens)
 if node != nil && size != 0 {
 // Consume matched tokens.
 tokens = tokens[size:]
 nodes = append(nodes, node)
 break
 }
 }
}

本质上也还是将 Tokens 丢给所有的 BlockParser 在 for 循环里过一遍, BlockParser 接口实现 Match() 方法,不同的 Node 会一次性读取不同数量的 Tokens,判断格式是否满足 Node 的要求,来确定这些 Tokens 是否组成了这个 Node。Match 上了则会返回生成的 Node 和匹配上的 Tokens 长度,截去这个 Node 匹配的 Tokens,剩下的 Tokens 继续轮一遍所有的 BlockParser

var defaultInlineParsers = []InlineParser{
 NewEscapingCharacterParser(),
 NewHTMLElementParser(),
 NewBoldItalicParser(),
 NewImageParser(),
 ...
 NewReferencedContentParser(),
 NewTagParser(),
 NewStrikethroughParser(),
 NewLineBreakParser(),
 NewTextParser(),
}

值得注意的是,这些 BlockParser 的顺序应该是有讲究的。像最普通的、最容易匹配上的 Text 纯文本类型,应该放在最后。当前面所有的 Parser 都没匹配上时,才说明这个 Token 是文本类型的 Node。如果把 TextParser 放最前面,那估计所有的 Tokens 都会被匹配成文本 Node。

将 Tokens 转换为 AST 上的 Nodes 后,最后还有个 mergeListItemNodes 函数,是用来特殊处理 List 列表节点的。如在列表的最后加上换行符,判断列表项是要拆成两个列表节点还是添加到末尾。

renderer 目录则是遍历上述 AST 中的节点,来将 AST 转换成 HTML 或者 String 纯文本。这里就很简单了,不同的节点调不同的函数 WriteString 即可。

综上,gomark 就完成了将 Markdown 格式文本,解析转换成 HTML 或 String 纯文本的工作。

其它的小细节

最后再说些自己发现的小细节吧,就不单独分一块了。

前端 embed index.html

随着 Go Embed 功能加入后,我很喜欢将 Vue 编译后的前端打包进 Go Binary 中。往往是会在 web 或者 frontend 前端代码路径下,保留放编译产物的 dist 目录,在里面放个 gitkeep 文件啥的。

memos 的做法是放置了一个 frontend/dist/index.html 文件:

<!DOCTYPE html>
<html lang="en">
 <head>
 <meta charset="UTF-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
 <title>Memos</title>
 </head>
 <body>
 No embeddable frontend found.
 </body>
</html>

直接在 Body 中写明了前端嵌入文件不存在。这样既可以通过编译,如若用户访问时,前端真没有被打包进来,在 index.html 也会有一个错误提示,比我只放一个不会被读到的 gitkeep 好些。

JWT Token 解析

memos 使用 JWT Token 鉴权。因此需要解析通过 Authorization 头传进来的形如 Bearer xxxx 内容。问题是用户可能在 Bearer 和 Token 之间传入不定数量的空格,甚至在 Bearer 前或者 xxx 后也会有空格。

要是我的话,可能就先 strings.TrimSpace ,再 strings.Split 按空格分隔,然后再取判断长度,取第一个元素和最后一个元素,即为 Bearer 和 Token。memos 里直接使用了 strings.Fields 包来做到这一点,直接解决了上述可能存在的问题。后面要做的仅仅只有判断切片长度是否为 2 即可。

总结

以上便是我之前阅读 memos 源码的一些心得体会。由于时间关系,我并没有很仔细的去阅读每一个文件的每一行代码,也没去审是否有潜在的安全漏洞。memos 的前端是使用 React 编写的,由于我平时不怎么写 React,所以前端这块也只是粗略的翻了翻。

memos 还是有很多可圈可点之处的,学到很多。貌似作者其它的开源项目也都有使用 memos 这种黑白动物风格的 Logo,相当于是一套统一的品牌。我对 AI 生成产品 Logo 这方面也挺感兴趣的,因为自己实在设计不来一个好看的 Logo…… 之后这块可以多研究下。

深夜随笔

2025-01-10 02:24:05

原本是打算写一篇技术文章来记录之前阅读某个项目源码的心得体会。但由于今天是工作日,白天还要上班,要是真当一篇技术文章来写,估计就要凌晨四五点才睡了。

我以前写过那些颇有创意的文章,往往是从半个月前就有了点子,然后找一整个空闲的周末给它一口气写完。至于文章有没有技术含量,有多少阅读量,我也不关心,自己享受的是那洋洋洒洒几千字后的成就感。我感觉在如今这个时代,搭建个人网站写点文字性质的东西颇有点孤芳自赏的意味。在我读高中那会,是有在运营一个自己的微信公众号的。当时我的重心都放在公众号那边,这个博客里早些年的文章,也是从公众号那边复制过来的。

后来觉得微信公众号的文字排版不好看,布局也不自由,我更喜欢个人网站这种像 QQ 空间一样可以随意装扮的形式,遂放弃了公众号,开始专心往博客里填东西,也开始注重每篇博客的标题和头图,好让整个页面看起来显得内容丰满。我感觉未来很长一段时间还是会保持现在这种状态,我在互联网的一个孤岛上自娱自乐,几乎不会有陌生人发现这个岛屿。

我认识的朋友有在运营自己的B站、小红书、公众号,他们会把自己发的一条帖子在多个平台都一模一样地发一遍,还会根据不同平台的用户属性,修改帖子的措辞。我也有想过将自己平时在空间动态发的一些有意思的信息或者抖机灵的段子,在不同的平台发发,好恰一波流量。但这也都只是想想,我不是很喜欢对外高调宣传自己。以前有尝试过给我的开源项目拉过一个交流群,但进群的大多都是技术和人品都不在一个层次的伸手党,这让我备受打击。我很想多结识一些圈子外的人,但是又害怕遇到蠢货。(因为我上周就遇到了个蠢货,但我又碍于面子不好直接喷,只能自己生闷气)

过去的一年,我在闲暇时间写了不少有意思的小东西:

  • toma:iOS 设备端拖微信小程序并自动分析
  • odoc:降本不增效的 CMS
  • echo:用 daisyui 开发的博客评论前端
  • Sayrud:不用写代码只要点点点就可以实现 RESTful API
  • ikD:基于 Traefik 的集群服务统一认证插件
  • fusion:集合了短信推送、邮件推送、滑动验证码、支付的中台服务
  • TakoChat:使用 Go + Semi Design 做的 LLM 套壳站,背后套的腾讯混元大模型
  • db-carry:只需配置三行 URL 快速实现 SSH 隧道连接数据库并备份到对象存储

除了上面列举的这些,还有几个因为各种原因不方便透露的。但它们都有一个特点,那就是:

它们都不开源。

要说不开源的理由嘛,一是我觉得这些都是玩具性质的项目,开源出来感觉很羞耻。二是我觉得万一被有心之人看到了,简单二开一下拿去恰烂钱了。不管从哪方面来说,我感觉开源对我而言都没有好处。以上的这种观点可能是对几年前的自己的一种背叛,但我只能感慨时代变了,那些“顺风顺水”“手到擒来”的日子已一去不复返了。

换个角度来说,上面这些项目,有很大一部分都是 CRUD,顶多的是在 CRUD 的基础上,再辅佐一点额外的技术。我也在怀疑自己的优势是不是仅仅是我写的 CRUD 代码质量比别人好。别人写得代码丑陋,连 Lint 都过不了,但是我有注释会换行,命名统一封装得当。是不是仅此而已呢?那要是这样,别人是不是认真钻研一下,也就能替代我了?这是我时常自我怀疑和 emo 的一个点。

当下,大模型的发展也让这一层差距变得更加模糊。我在网上看到了太多人宣称用 GitHub Copilot Chat、Cursor、Windsurf 等工具可以不用谢代码快速开发出一个 xxx。但令我感到不解的是,我自己使用的时候,怎么就没这么神了?

我猜测应该是那些人在使用这些工具时,都是从零开始新建一个文件夹,然后指挥大模型在这个空白的画布上尽情绘画。大模型会用它熟悉的方式和写法,来替你出色地完成需求。你让它写前端,如果你不说太详细,它就真只给你写个 HTML 和 JavaScript 文件。它不大会考虑到用现代的前端工具链。我感觉大模型编码在对项目的宏观把控,以及是对项目未来可能产生的需求,它的理解是不够的。它第一次可以给你想要的东西,而当你索取更多的东西时,它会在已有的代码上尝试修改,你提出更多的需求,它就继续修改。这个重复的过程通常来说是没问题的。但我相信未来总会到一个点,你发现大模型无论怎么给你修改代码,都没法再实现你新的需求了,或者是它给你实现了新需求 B,但上次提出的需求 A 又被改没了。

这就是我在尝试使用大模型帮我开发 App 时遇到的问题。我对开发 App 一窍不通,很多次想要从零开始学习,刚跑起来 Hello World 就干别的去了。准备跟风让大模型帮我写个 App,第一版出来确实效果还行,但是我对页面有洁癖,但凡有操作不顺或者特效样式感觉不舒服不流畅的地方,都会让大模型帮我改。这就导致了改好了 B,又改好了 C,之前的 A 又不行了。最终只能我自己沉下心来看代码,手动将代码的大方向调整了下,这才让上述重复的过程能得以持续。但过了几轮对话下来,它又不行了。这导致我用了整整一个下午加一个晚上的时间,才终于写出了第一个符合我想法的页面。这个过程一点也不轻松,反倒是给我气得不行。那些在 Twitter 或者小红书上吹嘘无脑指挥大模型完成整个项目的人,你们一开始在脑子里就没有一个具体的标准,大模型给你写个勉强 80 分的东西,你也就凑合着用了。至于什么配色不对,区块没对齐,组件太宽或太窄,项目结构不合理,这些问题统统就被你们给无视了!反正又不是不能用。

可悲的是,我心里想得是 100 分,我忍受不了大模型的 80 分,我自己写却只有 0 分(总是中途就放弃了)。所以如果你能反驳我并指出我的错误,甚至能向我展示大模型确实能做到 100 分,我感激不尽。

大模型的概念被炒的正火,什么牛鬼蛇神就都出来了,现在也正是最浮躁的时候。有人风口捞钱,有人辞职创业,有人狂蹭热点,有人不要颜面。这个时候去争去辩去骂没什么用处,待到潮水退去,谁没穿裤子一目了然。当然我也叠个甲,这并不是在自命清高,只是我作为非既得利益者的嫉妒罢了。😁

我发现之前写的挺多东西,后面基本都不常维护了,究其原因是我自己平时也不会去用这些东西。我在探索如何做一款 dogfooding 的产品,我日常会去用它,这样自己就能提一些新需求并持续迭代完善了。自己还是太容易被一些风吹草动给影响了,总会想些有的没的,然后陷入自我否定和怀疑。但有时得到正反馈以后又会感觉自己牛逼炸了,是天选之子。

希望今后能更 Focus 一些,以上确实是些没什么逻辑的随笔,现在也已是深夜两点了,差不多就写到这吧。

文章头图来自 @Novelance PixivID: 85842369

我还是放弃了 WordPress · LightCube 九周年总结

2024-10-07 17:27:05

又到了一年国庆假期,这个小站也迎来了他的九岁生日。每年坐在电脑前静下心来写的周年总结,也是对我过去一年所发生的事情的回顾。去年国庆我经历了忙碌无休的加班,整个假期根本抽不出时间来写一篇文章,最可笑的是最后却是竹篮打水一场空,我一无所获。

而到了今年国庆,我却是已经搬离了生活快六年的杭州,在上海的一间小小公寓内写下这些文字。我在上海有了新的工作,认识了新的同事,见到了很多新的技术。变化如此之大,回看年初四月那段泥泞坎坷的经历,还是很佩服自己当时的决心。我对自己现在的工作和生活十分满意,最近也总是感慨:“要是日子能一直这样下去就好了。” 但我也知道自己无时无刻是在逆水行舟,不能懈怠。

言归正传,还是看看过去的一年内,这个小站又发生了哪些变化吧~

WordPress -> Hugo

我是在高一的国庆假期,偶然刷到了一个 b 站视频,视频介绍了如何在 Redhat OpenShift 上搭建自己的 WordPress 博客。这也是我第一次接触 WordPress、PHP、MySQL 这些东西,用了一个下午时间,在 OpenShift 上搭建了 WordPress 站点。后续因不满足于 OpenShift 海外美国节点的访问速度,陆陆续续换了很多家网站托管商。因为域名没有备案,所以当时都还是用得香港节点。

上大学后,开通了阿里云学生机,又自学了 Docker,我便将网站迁到了学生机的 Docker 里。但由于使用的是 Apache、PHP、MySQL 官方镜像,没有调节任何参数,整个网站即使在国内学生机上,前台访问也总是卡卡的。WordPress 后台就更别说了,后台首页加载要七八秒。本想自己造轮子写一套博客系统的,在 2020 年的时候尝试把容器镜像换成了 WordPress 官方镜像,居然不卡了。造博客系统轮子的计划也随之弃坑。

大学毕业后,学生机无法续费的,便开始玩上了竞价实例 + K8s 集群,博客也从原来学生机上的 Docker,迁移到了集群内。但我为了省钱,竞价实例节点出价总是比最低价格多一分钱,导致隔段时间实例就会因为市场价格变化而被回收。然后我的阿里云账号余额又总是维持在 90 - 100 附近,余额低于 100 就开不出新的实例。每次都是节点被销毁了,站点告警提醒我博客挂了,我再赶紧拿出手机充钱。(甚至在谈恋爱第一次约会请吃饭的时候,突然收到告警说实例被销毁了,我只能假装是在拿手机点餐,实则在给阿里云充钱)

而压倒骆驼的最后一根稻草,是我发现用了这么多年的 WordPress 主题,居然不支持 PHP 8。切到 PHP 8 后,会提示满屏的方法已弃用,完全跑不起来。这套主题是我 2018 年高考后花钱购买的主题,早已不维护了,主题作者的网站现在都已经变成下载站了。

因此,我决定放弃用了 9 年的 WordPress,转向静态网站。

我在今年二月开始,花了大概一个月的的时间,将原 WordPress 主题搬到了 Hugo 上。搬的方法也是很简单粗暴,大批大批地复制 HTML、CSS,再按 Hugo 模板的结构一点点拆。期间舍去了很多看起来很炫,但实则没什么用的功能。(纯属因为太麻烦了不想做)例如页面滑到最底可以自动加载下一页,被改成了只能通过导航器翻页;去掉了移动端的下拉导航,做成了将导航菜单放到 Logo 下面;删除了以前在 WordPress 中乱七八糟的 Tag 和文章分类,统一成 “随笔”、“技术”、“创意”、“安全”、“分享” 五个分类。

换成 Hugo 静态网站后,得到的速度提升也是很明显的。目前网站部署在腾讯云 COS 对象存储中,前面套了一层腾讯云的 CDN。对于文章头图这类比较耗 CDN 流量的资源,我找了个京东某系统的上传,将图片上传到京东 360buyimg.com 的全球 CDN 上。京东这 CDN 还挺强大,还支持图片裁剪、缩放、格式转换等处理参数。详情可以查看官方文档:京东图片调用详解

像一些简单的前端交互或者数据双向绑定,我就直接拿 AlpineJS 来做了。像这些主流的 JavaScript 公共库,可以直接走字节的 CDN:字节跳动静态资源公共库,在 URL 路径中还可以设置缓存的时长。(之前用七牛的 staticfile.net ,这垃圾玩意的所有响应都带 no-cache 头,这本地缓存个寂寞 😅)

静态网站的评论系统

迁移到静态网站后,“评论系统” 总是绕不开的一个话题。其本质还是持久化数据存哪的问题。

像开源的一些基于 GitHub 账号的评论,数据存 GitHub Issues,但国内的访问速度不佳,且留言者必须登录自己的 GitHub 账号。或者是接一些第三方的 SaaS,如 DISQUS,这类系统会要求使用第三方账号登录,或者注册一个 DISQUS 账号。我对这种收集留言者信息或者引流到第三方平台注册的行为,挺精神洁癖的。另一些基于 Serverless 服务的评论系统,则是存储在类 LeanCloud SaaS 或者 Self-hosted 的数据库中,这类在设计上没有问题,但开源的那几个不论是样式还是性能,都挺拉胯的。

我一开始选择的是 Waline,后端部署在阿里云的 Serverless 云函数上,背后接的内网 MySQL 数据库。首先遇到的是如何从 WordPress 迁移评论数据,GitHub 上发了帖 #2348 ,得到回复说要先迁移到 DISQUS,再转 Waline。好家伙,我还得把我博客的评论用户 IP 和 Email 数据提供给第三方服务是吧?果断拒绝,自己糊了个迁移脚本。

迁移完成后,加载评论咋还有点卡,这样式咋还是细细的边框跟我博客主题一点都不搭…… 真的太丑太垃圾了!不如自己写一个好了。

于是则有了你现在看到的博客评论系统,后端是基于之前介绍过的 Sayrud,前端是自己使用 daisyUI 糊的。相比 Waline 的留言框更加的轻巧大气。构建时还是老一套的 UMD 打包输出一个 .js.css,通过 window 变量来将当前的页面 URL 传递进 Vue 实例内。

值得一提的是,我这个评论系统还支持在评论内容中添加表情。这些表情图标都来自于字节系的产品(因为我很喜欢里面那个可爱的狼头)。

只需打开飞书网页版的聊天页面,将飞书聊天表情的精灵图与 CSS 扒下来即可。

https://sf3-cn.feishucdn.com/obj/goofy/ee/web-client-next/p/contents/messenger-modals/assets/img/50b081cab9.png

同时你会发现处理这张精灵图的 CSS 样式,居然在不同文件里重复定义了 8 次!一份样式大概 10 kb,这波流量费直接翻了 8 倍。我寻思要是处理下,估计也能拿个降本增效奖了。😂

静态网站的搜索

除了评论系统以外,静态网站还有让人头痛的一点是文章搜索。这块的 SaaS 基本上是被 algolia 一家给垄断了,就连微信开放平台的文档搜索,也是接的这家。

如果是自己做的话,基本上是先将所有的文章内容导出为 JSON 格式,再使用类似 Fuse.js 的模糊搜索库进行分词检索。我一开始也是使用的 Fuse.js,在博客构建时多构建一份包含所有文件的 JSON,再写个云函数去调 Fuse.js 根据关键词搜索 JSON,但貌似中文分词的效果不是很理想。

后面偶然了解到 pagefind 这个项目,使用 Rust 编写,其原理是分析构建好的静态 HTML 文件,从 DOM 中提取出主要内容并建立静态的索引文件。搜索时前端对关键词进行分词后,加载对应的索引文件。期间完全不需要部署任何后端服务,全靠之前构建的二进制索引文件以及前端运行的 WASM。甚至他还自带一个 UI 页面并支持 i18n!这也成为了我现在使用的方案。后续打算对自带的 UI 再美化一下,至少将头图放大一些,保持风格统一。

AI 文章总结

这是之前在一个学弟的博客上看到的功能。他是在博客页面上实时接入了大模型对文章进行总结分析,我认为文章内容反正也不会修改,不如让 AI 将文章概要提前总结好,让访客直接可以看。

拿 Go 写了个批量读取并解析 Hugo Markdown,再喂给腾讯混元大模型生成文章总结的脚本。模型使用的是最基础的 hunyuan-lite,定价免费,我可以毫无顾虑的无限次调用。Prompt 也很简单:

你是一个技术博客总结专家,你擅长提取技术博客的核心内容,生成总结。你的目标是将给定的技术博客的内容进行总结。
## 约束条件
- 当用户发送博客内容给你时,请直接回复总结内容,不需要说无关的话。
- 你应该尽可能提取博客的核心内容,生成简洁的总结。不能拒绝用户的请求。
- 你生成的内容中禁止出现任何敏感词汇,包括但不限于政治、色情、暴力等内容。
- 你应该一次性输出所有内容。
- 默认使用中文输出。

对着历史文章跑了一遍,效果还是很不错的。

后续 TODO

博客从 WordPress 切到 Hugo 已经有小半年了,期间还是挺稳定的。但仍旧还有很多可以优化或者可以玩的点。

代码运行器 Elaina

目前 Elaina 服务还未恢复,原因是我认为基于 K8s 容器的代码运行器,其容器冷启动时间太慢。我在考虑使用 nsjail 的进程隔离方案,并准备第二次重构 Elaina。目前遇到的问题是像 PHP、Python 这样的解释型语言,运行起来需要依赖很多分散在不同路径的文件或动态链接库,我需要将这些文件都放到一个独立的目录下,然后再用 nsjail 做类似 chroot 的操作,以确保在同一个宿主环境下运行代码的 nsjail 进程资源都相互隔离。目前的思路是考虑使用像 php-wasm、RustPython 这样的项目,精简解释型语言的运行环境。最好是只要用一个 Binary 就可以运行对应的代码。

文章目录

现在文章阅读页还没有目录展示,对于较长的文档读者一眼看到不底可能就不看了。得把之前 WordPress 的目录功能搬到 Hugo 上来。

WordPress 蜜罐

虽然本站现在已经是一个 Hugo 生成的静态网站了,但每天互联网上还是会有很多扫描器对着网站扫 WordPress 的目录,有一些扫得比较过分的 IP 我已经封了。我也不知道他们现在是从哪得知我还是个 WordPress 站的,我把 wordpress.org 上的信息也下掉了,但每天还是会有。

那既然每天都会被当做 WordPress 站扫描,那我何不写个 WordPress 蜜罐来反制他们?听起来是挺有意思的,但我也不知道有哪些反制的骚操作,以及如果要在腾讯云 CDN 中配置规则转发流量到蜜罐后端的话,需要升级 CDN 服务到 “边缘安全加速平台 EdgeOne”。这东西一个月套餐起步价就 30 块,比我一个月 CDN 流量费还高。因此目前还一直停留在 TODO……

嘛,大概就是这些。明年的今天就是十周年啦~ 也不知道那时的自己会在何处?虽说确实该整个大的,但是现在暂时还没想法。

今天也是国庆假期的最后一天,我挺期待明天第一天去新大楼上班。😋