2025-03-30 23:12:57
我在 2021 年时就开始用 GitHub Copilot 写代码了,2022 年 12 月初刷推特时看到了 ChatGPT,立刻注册了个号玩了下。大模型的这波风口我看到的很早,但却没有做什么行动。那个时候的自己感觉不管做什么起步都已经晚了,套壳站已经满天飞了,OpenAI 的 API Key 也被人卖的差不多了,已经没有什么新的玩法了。
今年过年的时候 DeepSeek 火了,我才惊讶地发现,几年过去了, 豆包、混元、千问虽然在业内打得不可开交,但还是有太多的人至今没有接触过这些大模型应用。我在推特上看到个喷子,喷 DeepSeek 的点居然是问今天天气怎么样,它回答不出来。很多人对这种对话式 AI 的概念还停留在 10 年前的 Siri 等手机语音助手上。换句话说,下沉市场还是一片蓝海。
刚好之前看到腾讯混元大模型的最低配模型 hunyuan-lite
居然是免费的!那我们不如也来试试当一回二道贩子,尝试自己做一个大模型套壳站,会不会有人用我不知道,但开发的过程一定很有意思。
排除掉写了一万遍的用户注册登录和一堆 CRUD,我对以下功能的实现原理很感兴趣:
SSE 代理:怎样将腾讯云大模型的 SSE 和自己的对话接口接起来?
SSE 断点续传:对话生成过程中如果页面刷新了,重新进入时怎样继续生成当前回答?(⚠️ 实践后发现这是最难实现的功能,边缘情况很多)
怎样生成对话标题?
每次对话的 Token 如何计算?单次对话的 Token 数如何限制?
开始逐个分析上述功能之前,我们先来看看社区做得怎么样了。我按 stars 排序随便挑了几个感兴趣的项目,简单读了下他们的代码后,我信心大增哈哈哈。🤣
这应该是大家最初自建套壳站时使用的了,使用 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 注入,只是没什么危害罢了。
open-webui 的前端做出了高仿 OpenAI 的风格。使用 Python Web 异步库 starlette
返回 SteamingResponse
来代理 SSE 接口。它也实现了对话标题生成的功能,Prompt 上比 NextChat 长很多,并且要求以 JSON 格式返回。
我担心的点是,标题生成本身用的就是小模型,这么长的 Prompt 以及限定 JSON 格式输出,对小模型而言会不会不稳定。🤔
至于并发限流、以及对话的 Token 吞吐量限制,open-webui 写了一个路由中间件解决,这里就不再赘述了。
因为我使用 Go 来编写后端,所以找了个 Stars 数很多的 Go 项目。作者应该是 PHP 转 Go 没多久,或者说是刚学编程没多久,这代码质量真的不敢恭维。
好好的 SSE 不用,画蛇添足用了 WebSocket,可从头至尾就没有需要客户端发送消息的场景。甚至这项目背后还接了个 xxl-job。😅 他能获得这么多 stars 只是因为把支付那块也给做完了,小白可以即开即用拿去做套壳。但从代码的可维护性和整洁度上来说,真是一团糟。我都想做个《鉴定网络奇葩代码》短视频了。
这个故事告诉我们,技术好不好不重要,能把事情做完最重要。
同样是 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,去除后三位。
消息表中的 ParentID
和 ChildrenIDs
字段,用于记录父子消息关系。就像豆包可以点击重新生成,进而再生成一条回复。
更复杂的像 ChatGPT,可以点击上文任意一条消息,新建一个分支重新生成对话。为了实现这样的效果,我们在创建一条新的消息记录时,需要 ParentID
指定它的父消息,并更新它父消息的 ChildrenIDs
字段,这俩包在一个数据库事务里做就行。
在需要构造大模型接口 JSON messages
参数时,只需从最后一条消息开始,沿着 ParentID
依次向上遍历,一直到 ParentID
为 0,即可拿到当前对话分支的消息列表。 前端实现像上图中豆包的“上一条”“下一条”翻页的效果,也只需取 ChildrenIDs
构造翻页即可。
这里再补充一些小细节,我发现腾讯元宝的消息 ID 使用 <对话ID>_<自增索引的格式>
表示,如 <对话ID>_1
<对话ID>_2
等,这从设计上使得元宝的对话只能是线性的。 用户只能重新生成最新一轮对话的消息,且不能在历史对话中重新生成创建分支。
关于 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)
}
}
}
混元大模型本身提供了 GetTokenCount
接口用于计算消息中的 Token 数,20 QPS 的限制还不收费,足够我们使用了。
从处理流程上来说,用户发起提问时,调用 GetTokenCount
计算提问的 Token 数;回答生成完毕后,计算并更新回答所消耗的 Token 数。为未来可能要做的 Token 付费功能铺垫。进一步,如果还要做不同套餐的上下文长度的限制,提问的长度在开始提问的时就进行判断,而对于大模型回答的长度,则是在调大模型接口时使用 max_tokens
参数限制。
然而混元的 SDK 好像不能指定这个参数,只有走 OpenAI 兼容接口调用时才支持。
我画了一张流程图来梳理目前的整个过程,带 🚀 小火箭图标的意味着这一步可以开个 goroutine 异步进行。如果上述流程没问题,那就请做紧抓稳了,我们后面要引入 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
Tako(たこ)是日文章鱼🐙的意思。起这个名字只是我单纯觉得微软 Teams 下的章鱼动态 Emoji 很可爱。背后接的是免费版的混元大模型,所以你可以注册体验下,只是目前的功能还很基础。(不清楚阿里云那边短信验证码备案的问题是否解决了,可能会遇到部分运营商收不到短信验证码的问题,可以换不同运营商的号试试)
你可以看到左侧有「实验室」一栏,我是打算在这里动手做做像 MCP 和 Agent 这样的小玩意。(先把坑开了,填不填再说。
呼~ 总算把这篇写完了。我还挺自我感动的,没蹭热度,仅仅只是分享一些自己总结的心得体会,比那些营销号不知道高到哪里去了。
至此,周末也要结束了,明天又可以上班继续修 bug 了 🤤
文章头图来自 @极道寂 PixivID: 69237248
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 文件会被放置在以版本号命名的文件夹中,当执行数据库迁移时,会将这些版本号文件名进行排序,并与当前的版本号进行对比,从而选择要执行的迁移脚本。
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,并且推荐了 sqlc
和 sqlbuilders
两个库。作者的回复是前者 looks a little weird
(?),后者 pretty much the same as the existing way
,综上所属作者认为保持现状啥也不改!😅
FYI:https://github.com/usememos/memos/issues/2517
memos 项目中对 gRPC 的写法可谓是教科书级别的。我也算是对着它的代码入门了下 gRPC。说来惭愧,我以前除了拿 Protobuf 写过 Hello World 的 demo,就没有更深入的应用了。
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
指定了 memo
和 update_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,
}
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-Type
为 application/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 时都会调用。额外加到定时任务中出发,应该是为了兜底。
对于用户每一篇文本笔记,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.go
和 block.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 纯文本的工作。
最后再说些自己发现的小细节吧,就不单独分一块了。
随着 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 好些。
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站、小红书、公众号,他们会把自己发的一条帖子在多个平台都一模一样地发一遍,还会根据不同平台的用户属性,修改帖子的措辞。我也有想过将自己平时在空间动态发的一些有意思的信息或者抖机灵的段子,在不同的平台发发,好恰一波流量。但这也都只是想想,我不是很喜欢对外高调宣传自己。以前有尝试过给我的开源项目拉过一个交流群,但进群的大多都是技术和人品都不在一个层次的伸手党,这让我备受打击。我很想多结识一些圈子外的人,但是又害怕遇到蠢货。(因为我上周就遇到了个蠢货,但我又碍于面子不好直接喷,只能自己生闷气)
过去的一年,我在闲暇时间写了不少有意思的小东西:
除了上面列举的这些,还有几个因为各种原因不方便透露的。但它们都有一个特点,那就是:
它们都不开源。
要说不开源的理由嘛,一是我觉得这些都是玩具性质的项目,开源出来感觉很羞耻。二是我觉得万一被有心之人看到了,简单二开一下拿去恰烂钱了。不管从哪方面来说,我感觉开源对我而言都没有好处。以上的这种观点可能是对几年前的自己的一种背叛,但我只能感慨时代变了,那些“顺风顺水”“手到擒来”的日子已一去不复返了。
换个角度来说,上面这些项目,有很大一部分都是 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
2024-10-07 17:27:05
又到了一年国庆假期,这个小站也迎来了他的九岁生日。每年坐在电脑前静下心来写的周年总结,也是对我过去一年所发生的事情的回顾。去年国庆我经历了忙碌无休的加班,整个假期根本抽不出时间来写一篇文章,最可笑的是最后却是竹篮打水一场空,我一无所获。
而到了今年国庆,我却是已经搬离了生活快六年的杭州,在上海的一间小小公寓内写下这些文字。我在上海有了新的工作,认识了新的同事,见到了很多新的技术。变化如此之大,回看年初四月那段泥泞坎坷的经历,还是很佩服自己当时的决心。我对自己现在的工作和生活十分满意,最近也总是感慨:“要是日子能一直这样下去就好了。” 但我也知道自己无时无刻是在逆水行舟,不能懈怠。
言归正传,还是看看过去的一年内,这个小站又发生了哪些变化吧~
我是在高一的国庆假期,偶然刷到了一个 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 将文章概要提前总结好,让访客直接可以看。
拿 Go 写了个批量读取并解析 Hugo Markdown,再喂给腾讯混元大模型生成文章总结的脚本。模型使用的是最基础的 hunyuan-lite
,定价免费,我可以毫无顾虑的无限次调用。Prompt 也很简单:
你是一个技术博客总结专家,你擅长提取技术博客的核心内容,生成总结。你的目标是将给定的技术博客的内容进行总结。
## 约束条件
- 当用户发送博客内容给你时,请直接回复总结内容,不需要说无关的话。
- 你应该尽可能提取博客的核心内容,生成简洁的总结。不能拒绝用户的请求。
- 你生成的内容中禁止出现任何敏感词汇,包括但不限于政治、色情、暴力等内容。
- 你应该一次性输出所有内容。
- 默认使用中文输出。
对着历史文章跑了一遍,效果还是很不错的。
博客从 WordPress 切到 Hugo 已经有小半年了,期间还是挺稳定的。但仍旧还有很多可以优化或者可以玩的点。
目前 Elaina 服务还未恢复,原因是我认为基于 K8s 容器的代码运行器,其容器冷启动时间太慢。我在考虑使用 nsjail 的进程隔离方案,并准备第二次重构 Elaina。目前遇到的问题是像 PHP、Python 这样的解释型语言,运行起来需要依赖很多分散在不同路径的文件或动态链接库,我需要将这些文件都放到一个独立的目录下,然后再用 nsjail 做类似 chroot
的操作,以确保在同一个宿主环境下运行代码的 nsjail 进程资源都相互隔离。目前的思路是考虑使用像 php-wasm、RustPython 这样的项目,精简解释型语言的运行环境。最好是只要用一个 Binary 就可以运行对应的代码。
现在文章阅读页还没有目录展示,对于较长的文档读者一眼看到不底可能就不看了。得把之前 WordPress 的目录功能搬到 Hugo 上来。
虽然本站现在已经是一个 Hugo 生成的静态网站了,但每天互联网上还是会有很多扫描器对着网站扫 WordPress 的目录,有一些扫得比较过分的 IP 我已经封了。我也不知道他们现在是从哪得知我还是个 WordPress 站的,我把 wordpress.org
上的信息也下掉了,但每天还是会有。
那既然每天都会被当做 WordPress 站扫描,那我何不写个 WordPress 蜜罐来反制他们?听起来是挺有意思的,但我也不知道有哪些反制的骚操作,以及如果要在腾讯云 CDN 中配置规则转发流量到蜜罐后端的话,需要升级 CDN 服务到 “边缘安全加速平台 EdgeOne”。这东西一个月套餐起步价就 30 块,比我一个月 CDN 流量费还高。因此目前还一直停留在 TODO……
嘛,大概就是这些。明年的今天就是十周年啦~ 也不知道那时的自己会在何处?虽说确实该整个大的,但是现在暂时还没想法。
今天也是国庆假期的最后一天,我挺期待明天第一天去新大楼上班。😋
2024-09-08 01:42:49
我在腾讯云上有一台 4C8G 的 LightHouse 轻量云服务器,服务器上使用 k3s 搭了个小集群部署自己开发的小玩意,以及一些常见的基础组件。如 Grafana 做仪表盘展示、Uptrace 记录 Go 程序的链路、Metabase 用作 NekoBox 的数据库 BI。这些服务通过 Helm Charts 部署至集群,配置 Ingress 后直接通过公网域名即可以访问。
我时常在想这些第三方应用会不会哪天爆出个 0day 被打穿。进而导致我存在里面的数据库配置、云 AK SK 之类的凭证泄露。因而在想能否在集群的 Ingress 反代层面做统一的权限认证,就像公司内的某统一认证系统一样 —— 具体名字我不知道能不能说,不过你应该可以在公网上找到它的痕迹。
我一直觉得,这种架设在反代上的统一认证,比那些跳第三方 OAuth 的验证方式安全多了。
经常能看到一些企业内部的 Web 站,做的前后端分离的架构。第一次访问时加载前端页面,前端逻辑判断用户未登录,跳转到第三方 SSO 做统一登录。登录成功后 callback 一个 SSO Token 回原站点。然后后端 API 签一个自己业务的 Token 发给前端,前端把业务 Token 放 Local Storage 里存着。由于网站是前后端分离的,攻击者在未登录的时候就可以访问前端,他就可以从前端打包后的 JavaScript 里把后端接口全提取出来去 Fuzz。(更别说还有些不关 Sourcemap 的)后端在实现上万一漏了个路由,鉴权中间件没包到(往往还是些上传下载文件的接口),然后就接口越权一把梭了。
因此我觉得供内部使用的服务,不管是基于第三方的还是自建的,都应该在网关层面做一套统一的鉴权。
那么说干就干!在查阅了相关资料后,站在前人的肩膀上,我造了个小轮子 —— ikD。
由于使用 k3s 搭建的集群会内置一个 Traefik 做为默认的 Ingress Class,我也就围绕 Traefik 来展开了。ikD 这个名字,其实也就是取自 Traefik ID 中的三个字母。一开始想叫 ikID
的,但是仔细一读像是什么儿童品牌……?遂改名。
我的想法是先找找看 Traefik 有没有类似 K8s Mutating Webhook 的特性,当准备代理一个集群内的 Service 时,先去调用一下我写得“WebHook”,由我来指挥它后续的行为。找了一圈发现 Traefik 里还真有这样一个中间件:ForwardAuth,同时还找到了前人开发的 traefik-forward-auth 项目。该项目利用 ForwardAuth 中间件让 Traefik 反代支持前置使用 Google 账号或 OpenID 服务进行身份认证。然而我很少用 Google 账号登录,OAuth、OpenID、SAML 那些玩意更是傻傻分不清,总不能为了用这玩意我再去注册个 Auth0 吧?!
因此我在阅读了 traefik-forward-auth 的源码后,写了 ikD 这一版拥有更简洁更适合我自己使用的 Traefik ForwardAuth 认证服务。
Traefik 本身不支持用户编写自定义逻辑的中间件,只能将官方文档中给的内置中间件简单配置后使用。比如官方给你提供了个 Errors
错误中间件,那你可以自己配置哪些状态码要报错,以及报错页面的地址是啥。
ForwardAuth 就是官方提供的用于转发请求到外部服务进行验证的中间件。这里直接贴文档里的图,方便后文介绍。
对于使用了 ForwardAuth 中间件的路由,Traefik 会先请求 address
中配置的第三方服务地址,并使用 X-Forwarded-*
请求头传递上游请求的请求方式、协议、主机名、URL、源 IP 地址给第三方服务。第三方服务就可以根据这些信息来执行自定义的验证逻辑了,若第三方服务返回 2XX 响应码,则代表验证通过;否则验证不通过,Traefik 将把第三方服务的响应传给上游。
这个设计十分简洁。验证不通过时返回第三方服务的响应,可以方便我们将未验证用户 302 跳转到登录页面。
值得一提的是,我十分好奇 Traefik 源码中关于 2XX 响应码的判断方式,我以为会是 statusCode / 100 == 2
这样的写法,但实际是:
// https://github.com/traefik/traefik/blob/9dc2155e637318c347b8b00e084c3dd0c75f18e4/pkg/middlewares/auth/forward.go#L187-L189
// Pass the forward response's body and selected headers if it
// didn't return a response within the range of [200, 300).
if forwardResponse.StatusCode < http.StatusOK || forwardResponse.StatusCode >= http.StatusMultipleChoices {
它是判断状态码的数字是否落在 [200, 300)
这个区间内,我感觉这样的写法可以规避掉 statusCode / 100 == 2
中出现的 2
这个 Magic Number。在 Lint 上会更好一些。
画了张图来梳理 ikD 是怎样处理用户登录的。
https://hello.example.com/index.php
网站,集群内 Traefik 请求 ikD 服务,ikD 发现用户未登录,返回 302 跳转到 https://ikd.example.com/?redirect=https://hello.example.com/index.php
。https://ikd.example.com/
是单独做的 Web 服务,用户在这里提交凭证登录成功,后端接口会在来源 URL 中加上一个 ikdcode
Query 参数,如:https://hello.example.com/index.php?ikdcode=a1b2c3d4e5f6g7
前端控制用户浏览器跳转到该地址。hello.example.com
域下后,又被 ikD ForwardAuth 中间件拦了,但它发现这次多了个 ikdcode
参数,会去验证这个参数是否有效。如果有效,则会在返回 302 跳转到去除 ikdcode
的地址:https://hello.example.com/index.php
,并 Set-Cookie。这里是整个登录过程中我认为最巧妙的地方:ForwardAuth 中间件劫持了目标站的响应,返回 Set-Cookie
头让它可以在目标站的域名下写一个 ikD 的 Cookie。
https://hello.example.com/index.php
,会带上之前一步设置的 Cookie。此时再被 ikD 拦截,ikD 认出了这个 Cookie 并验证通过,返回状态码 200 OK
,至此请求终于能够被转发到后面的 hello.example.com
服务的 Service 上了。具体到代码实现上,有一些细节需要注意:
https://ikd.example.com/
Session 存个登录态,下次再跳过来,发现之前已经登录过了,直接跳走就行。ikdcode
拼接后作为 Redis 的 Key,Value 存储目的站点的 Proto + Host。实际登录时,用户拿到带 ikdcode
的 URL 几秒不到就跳转去验证了,所以 Key 的有效期可以设置的短一点,如一分钟。像上面的例子在 Redis 中存储的就是:redis.SetEx("ikd:authcode:a1b2c3d4e5f6g7", "https://hello.example.com", 1*time.Minute)
ikdcode
时,使用 GETDEL
来获取 Redis Key,确保 ikdcode
仅可使用一次。还要将 Value 中存储的目的站点和实际要跳转的站点进行比对,防止一开始使用恶意站点 A 获取到的 ikdcode
可以用来登录站点 B。Set-Cookie
的值可以签一个存储了目的站点 Proto + Host 和有效期的 JWT。因为访问目的站点的每个请求都要先打到 ikD 上,这个请求量比较大,验证 JWT 比查 Redis 验证 SessionID 快多了。你会发现 ikD 的登录页并没有要求输入用户名和密码,而是一个 发送登录凭证
的按钮。这里的登录方式和 Notion 类似 —— 随机发送由三个英文单词组成的字符串到我的手机上,我输入字符串登录。
在 macOS 环境下可以读取 /usr/share/dict/words
文件来获得英文单词,这个 words
文件是软链接到同目录下的 web2
文件。线上基于 Alpine 打包的 Docker 镜像,可以从苹果开源 https://opensource.apple.com/source/files/files-473/usr/share/dict/web2 下载到这份单词表。GitHub Actions 打镜像的时候丢进去就行。
发送字符串是后端请求我手机 Bark App 的 WebHook URL 发送推送消息。收到推送后手机上复制,iCloud 剪贴板同步粘贴到电脑浏览器即可登录。由于是直接复制的内容,几乎不可能出错。所以每次发送的字符串凭证的验证仅有一次机会,输入错误了就得再重新下发一个新的。嗯,感觉十分的安全呢。后续其实可以做个 App 来弹出个框让我点确认的。
现在我已将集群内的 Metabase、Grafana、Uptrace、以及自己开发的自用服务接上了 ikD 做统一认证。好好好,这下 ikD 被打穿了就全部完蛋!
但目前还只是个刚好能用的状态,对于各种操作还需要记录行为日志,后续可以考虑下把集群里搭的 Loki 用起来。
我一开始是想用 WebAuthn 来做一个帅到爆的 TouchID 刷指纹登录的。但尝试了下 WebAuthn 单独拆出来做成单用户调用还挺复杂的。真要做的话只能老老实实地按照 SDK 文档先做注册生成公私钥,公钥还得分用户存数据库,登录的时候发送 Challenge 挑战给客户端,解完后还得查库找到对应的用户。那就又回归到了朴实无华的 Go 写一套用户账号的 CRUD 了,已经不想再写 CRUD 了!放弃!😖
以及最后那个问题,ikD 开源吗?很遗憾,依旧不想开源。如果你对此有兴趣,可以找我讨论。😋
2024-07-14 21:50:24
记得我 18 岁那年高考完在家,还没放松几天就被我爸催着去找份暑假工作。当时我对工作一点概念也没有,糊了份简历就在 58 同城上乱投,投完第二天跟一家公司约了线下聊聊,结果还真让我聊到个在家兼职的工作。(后来发现其实巨不靠谱)
工作内容大致是开发微信小程序,我当时仅有一点自学的微信小程序的开发经验和 PHP CodeIgniter 后端经验,差不多能 Hold 住对面的需求,甚至还在 GitHub 上给一个小程序前端组件库提了 PR。(现在回过头看当初写的代码,真的是“满目疮痍”——前端 UI 没对齐,后端 SQL 注入满天飞,黑历史了属于是)
直到大学开学前,暑假的两个月里我给那边开发了两个微信小程序。因为每次都要用 CodeIgniter 框架写功能类似的后端,年少的我在想能否把 MVC 的 Model 操作数据库,Controller 处理逻辑,View 返回响应给封装成一个线上的服务,我在图形化的 Web 页面上点点点就可以实现建表、验证表单、定义 API 接口等操作。
我被自己这个天才般的点子所鼓舞,用 PHP 写了 WeBake ,当时的想法是用来快速构建微信小程序后端。年少的我以为自己在做前人从来没做过的东西,沉浸其中并暗自窃喜。直到进入大学的前一天夜里,我在知乎上偶然看到了一家同类型的 SaaS 应用推广,也是在跟我做相同的东西,并且已经开始了商业化,我才知道业内有很多公司都已经在做了。那天晚上我直接心态爆炸。关于 WeBake 这个项目后面也就理所当然的弃坑了。
后来发生的事,大家也都知道了:微信后面发布了「微信云开发」的一站式后端解决方案,直接官方必死同人。再后来 “LowCode 低代码”的概念开始流行,LeanCloud 被心动游戏收购,国外 AirTable、国内黑帕云、维格表 Vika 等产品开始流行起来…… 而那个当时让我心态爆炸的做小程序后端的 SaaS 产品,在互联网上几乎找不到它的痕迹了。
我在 2021 年的时候看到了 Hooopo 的文章 Let’s clone a Leancloud,里面介绍了使用 Postgres 实现类似 LeanCloud 的 Schemaless Table 的特性。我直呼好家伙,没想到 Postgres 的视图和 JSON 数据类型还可以这样玩出花来。我当时对着文章用 Go 实现了个小 Demo,感觉确实有意思。但是因为没有具体的需求,那个 Demo 一直躺在我的 GitHub 里。
今年我放弃 WordPress 使用 Hugo 重构了本博客,一直没找到个能满足我需求的静态博客评论组件,便想自己造轮子写一个。但是评论服务的后端,不就跟留言板一样,都是些很基础很无脑的 CRUD 吗?我已经不想再用 Go 无脑写 CRUD 了!要不我把需求抽象一层,直接写个“低代码数据中台”出来?好像有点意思哦……?
就这样,Sayrud 诞生了。
Schemaless,中文机翻为「无模式」,让人听得云里雾里的,让我们一步步来。
首先,数据库语境的 Schema
可以简单的理解为是数据库的表结构定义,我有一张学生表,表里有学号、姓名、班级三列,然后学号是主键…… 这些就是 Schema
。在关系型数据库中,我们得写 SQL 语句来定义这张表:
CREATE TABLE students (no TEXT, name TEXT, class TEXT);
后面需求改了,要再新增一列记录“出生日期”,那我们得写 SQL 修改表结构:
ALTER TABLE students ADD COLUMN birth_date DATE;
如果改得多了,那这就有点烦了。况且在实际的项目里我们还得去编写数据库迁移的 SQL 并在线上运行迁移的 Migration 程序。聪明的你估计想到了我们可以用 MongoDB 来做呀!要新增一列直接在 JSON 中加一个字段就行,无所谓什么“表结构”的概念。表结构的概念没了,也就是 Schema
没了。英文中形容词 -less
后缀指 without
,这就有了 Schemaless
这个词。简单来说就是跟 MongoDB 一样不受表结构定义的条条框框,想加字段就加字段。
市面上的很多 Schemaless 特性的产品,其后端大多都使用 MongoDB 实现。但我前文中提到了 Hooopo 那篇文章,再加上我对 Postgres 的热爱,我决定另辟蹊径使用 Postgres 来实现。
我们平时写后端,需要先建表,定义表里有哪些字段,最后往表里插数据,对应到 Sayrud 使用 sl_tables
sl_fields
sl_records
三张表来存储。(以下列出的表结构精简了项目分组、gorm.Model
里包含的字段)
sl_tables
: Schemaless 表字段名 | 类型(Go) | 说明 |
---|---|---|
name | string |
表名,给程序看的 |
desc | string |
表备注名,前端给人看的 |
increment_index | int64 |
记录当前自增 ID |
sl_fields
:Schemaless 字段字段名 | 类型(Go) | 说明 |
---|---|---|
sl_table_id | int64 |
属于哪张表 |
name | string |
字段名 |
label | string |
字段备注,前端给人看的 |
type | string |
字段类型,包括 int text bool float timestamp reference generated 等 |
options | json.RawMessage |
字段额外的属性,如默认值、约束条件等 |
position | int |
字段在表中的顺序 |
sl_records
:Schemaless 数据字段名 | 类型(Go) | 说明 |
---|---|---|
sl_table_id | int64 |
属于哪张表 |
data | json.RawMessage |
JSON 存数据,Key 为字段的 ID,Value 为字段的值 |
然后神奇的事情就来了~ 我们按照 Hooopo 上述文章里所介绍的,为每一个 Schemaless 表当创建一张视图。以下是一个视图的 SQL 定义示例:
得益于 Postgres 对 JSON 类型的强大支持,我们可以从 sl_records
表中提取 JSON 字段的值作为内容,构建出一张“表”,效果如下:
当用户需要查询 Schemaless 表中的数据时,我们直接查询这张视图就行。对于 GORM 而言,这就跟查询一张普通的表一样!它都不会意识到这是由三张表拼凑提取出来的数据。更神奇的是,当你对着这张视图删除一条记录时,对应的 sl_records
原始表中的记录行也会被删除!Postgres 居然能把这俩关联起来。
具体到代码实现上,我们需要动态构造创建视图的 SQL 语句。而像字段、表名这类关键字在 SQL 语句中是不支持 SQL 预编译传入的,为了避免潜在的 SQL 注入风险,我使用了 github.com/tj/go-pg-escape 库来对字段名和表名进行转义。
正如 Hooopo 文章中所提到的,我将这个视图创建在了另一个 Postgres Schema 下,与默认的 public
进行区分,这也是一种简易的多租户实现了。
有坑注意!
之前看到过这篇文章: 《我们使用 Postgres 构建多租户 SaaS 服务时踩的坑》,文中提到使用 Postgres Schema 构建多租户时,如果每个 Postgres Schema 下都是同样的表结构,同时对所有 Postgres Schema 中的表结构变更会有性能问题。但上述场景在我们这里不存在,可以忽略该问题。
当我们开发一个博客评论后端时,功能上需要支持回复他人的评论,即数据之间会存在引用关系,我们一般会在 comments
表中加一列 parent_comment_id
来存储父评论的 ID。对应到 Schemaless 的字段类型里,就需要有 reference
这样一种引用类型。
我的设计是,当字段类型为 reference
时,其字段值存储的是所引用记录的 UID,字段额外属性 options
里记录它实际展示的列,如下图所示:
在生成视图时,使用 Postgres json_build_object
来构造 reference
类型字段展示的 JSON。(再次感叹 Postgres 真是太强大了!)JSON 中的字段 u
为关联记录的唯一 UID,方便前端处理时找到这一条记录。v
为关联记录的展示字段,用于在前端 Table 表格上展示给用户看。
在实际的博客评论记录中,一条评论是不能将自己作为自己的父级评论的。即我们要对 reference
字段的引用值进行约束。我给 reference
字段加了一个 constraint
属性,用户可以输入 JavaScript 表达式来自定义约束行为。JavaScript 表达式返回 true
/ false
,来表示数据校验是否通过。背后的实现是接了 goja 这个 Go 的 JavaScript Engine 库。我将当前记录传入 JavaScript 运行时的 $this
变量中,将被关联的记录传入 $that
变量中,对于上述需求,我们只需要写 $this.uid !== $that.uid
就可以约束一条评论的父评论不能是它自身。
除了能引用他人的评论,在博客评论中还需要展示评论者的头像,通常的做法是使用评论者的电子邮箱去获取其 Gravatar 头像进行展示。即将评论者的电子邮箱地址全部转换为小写后,再做 MD5 哈希,拼接到 https://gravatar.com/avatar/
或者其他镜像站地址之后。在 Postgres 里我们可以使用生成列(Generated Columns)来很轻松的做到这一点:
CREATE TABLE comments (
email TEXT,
email_md5 TEXT GENERATED ALWAYS AS (md5(lower(email))) STORED
);
但在 Schemaless Table 里呢?一开始我的想法是像上面做字段约束一样接 JavaScript Engine,在添加数据时跑一遍 JavaScript 表达式计算出生成列的值就行。但这存在一个问题:如果 JavaScript 表达式被修改了,那就得全表重新跑重新更新刷一遍数据,这是无法接受的。
最后还是选择让用户编写 Postgres SQL 语句片段,用作创建视图时生成列的定义,就像前面视图的 SQL 定义那张图里的:
md5(lower(sl_records.data ->> 'YXSQhESl'::text)) AS email_md5,
但既然用户能直接编写原生 SQL,SQL 还会被拼接进来创建视图,那我这不直接 SQL 注入被注烂了!就算用黑名单来过滤字符串特殊字符与关键字,保不齐后面出来个我不知道的方法给绕了。这里我使用了 auxten/postgresql-parser 这个库(Bytebase 也在用)来将用户输入的 SQL 语句解析成 AST,然后 Walk 遍历树上的每个节点,发现有 UNION
JOIN
以及白名单外的函数调用就直接禁止提交。如果有人 bypass 了这个库的解析规则绕过了我的检验,那也就等同于他找到了 CockroachDB 的洞(这个 AST 解析库是从 CockroachDB 源码中拆出来的),那我直接拿去水个 CVE。😂
在具体代码实现中,由于 postgresql-parser 这个库只能解析完整的 SQL 语句,而用户输入的是 md5(lower(email))
这样的 SQL 片段,我会在用户输入前拼一个 SELECT
再解析。而像 email
这种字段名,由于提供没有上下文,会被解析成 *tree.UnresolvedName
节点。我需要将这些 *tree.UnresolvedName
节点的值替换成 sl_records.data ->> 'YXSQhESl'::text
这样的 JSON 取值语句,直接修改节点的话出来的语句会是:
md5(lower("sl_records.data ->> 'YXSQhESl'::text"))
它将这整一块用双引号包裹,会被 Postgres 一整个当做列名去解析。我也没能找到在 Walk 里修改节点属性的方法,最后只能用一个比较丑陋的 HACK:替换节点内容时前后加上一段分隔符,在最后生成的 SQL 语句中找到这个分隔符,将分隔符和它前面的 "
引号去掉。(不由得想起 PHP 反序列化字符逃逸……)
最终实现大致如下,目前函数白名单仅放开了极少数的哈希函数和字符串处理函数。我还写了不少单元测试来测这个函数的安全性,希望没洞吧……
var whiteFunctions = []string{
"md5", "sha1", "sha256", "sha512",
"concat", "substring", "substr", "length", "lower", "upper",
}
func SterilizeExpression(ctx context.Context, input string, allowFields map[string]string) (string, error) {
w := &walk.AstWalker{
Fn: func(ctx interface{}, node interface{}) (stop bool) {
switch v := node.(type) {
...
case *tree.UnresolvedName:
inputFields = append(inputFields, v.String())
// HACK: We add separator to get the field name.
v.Parts[0] = "!<----!" + allowFields[v.Parts[0]] + "!---->!"
...
return false
},
}
...
// Remove the separator.
sql = strings.ReplaceAll(sql, `"!<----!`, "")
sql = strings.ReplaceAll(sql, `!---->!"`, "")
return sql, nil
}
聊完了 Schemaless 特性的实现,我们再来看下自定义 API 接口的实现。这里直接上前端的操作页面,方便我来逐一介绍。
参考之前用过的 Pocketbase,我将接口分为 LIST
VIEW
CREATE
UPDATE
DELETE
五种类型。注意这与 HTTP 请求动词或数据库 DDL 操作并无关系,是偏业务上的定义。LIST
返回多条数据、VIEW
查询单条数据、CREATE
添加数据、UPDATE
修改数据、DELETE
删除数据。
就像我们写后端需要定义路由一样,每个 API 接口会有它请求方法和路径。以及会定义每个接口它从 GET Query 和 POST Body 处接收的字段。这些字段除了要有英文的参数名外,还需要有给人看的标签名,用于展示在数据校验的报错信息里。
然后我们会选择一张 Schemaless 数据表作为数据源(记得在 Dreamweaver 里叫“记录集”),把传入参数与数据表中的字段做映射,这样就完成了对数据的操作流程。而就整个请求而言,在请求开始前我们可能会想做一层限流或者验证码,请求结束后需要发送通知邮件或触发 WebHook,因此还需要支持配置路由中间件。
这里有两个值得拿来讨论的部分:数据源的筛选规则与前端拖拽配置路由中间件。
我们的接口经常会有传入 ?id=1
来筛选指定一条数据的需求,确切的说是在 LIST
VIEW
UPDATE
DELETE
四种类型下都会遇到。Schemaless 表的增删改查在代码上最终都是用 GORM 来构造 SQL 并执行的,“筛选”对应查询中的 WHERE
,对应 GORM 中的 Where
方法。用户在前端编辑好筛选条件后,需要能“翻译”成 GORM 的 Where 查询条件(一个 clause.Expression
类型的变量)。
我在这里设计了一种使用 JSON 格式来表示 Where 查询条件的方法。一个查询条件分为两种类型,一种是单操作符,仅接收一个或零个参数,如字面量 true
、「非」操作 NOT xxxx
;另一种是常见的双操作符的,如「与」操作 xxx AND xxx
、xxx LIKE xxx
,它们接收两个参数。
我们定义一个 Operator
结构体,它记录了当前 WHERE 查询的操作类型 Type
、单操作符的参数 Value
、双操作符的左值 Left
和右值 Right
。注意左值和右值又可以是一个查询条件,构造 WHERE 条件的时候需要递归解析下去。
type Operator struct {
Type OperatorType `json:"t"`
Value json.RawMessage `json:"v,omitempty"`
Left *Operator `json:"l,omitempty"`
Right *Operator `json:"r,omitempty"`
}
对应的操作符有以下这些,你可以看到上方的双操作符都是对应着 SQL 语句中的操作,下面单操作符中有两个特殊的操作 FIELD
和 LITERAL
。其中 FIELD
会被解析为 Schemaless 表中的字段,而 LITERAL
的内容将被放到 JavaScript Engine 中运行,请求的 Query 和 Body 会被解析后注入到 JavaScript Runtime 中。你可以通过一个值为 $request.query.id
的 LITERAL
操作拿到 id
这个 Query 参数的值。
const (
// Binary operators
OperatorTypeAnd OperatorType = "AND"
OperatorTypeOr OperatorType = "OR"
OperatorTypeNotEqual OperatorType = "<>"
OperatorTypeEqual OperatorType = "="
OperatorTypeGreater OperatorType = ">"
OperatorTypeLess OperatorType = "<"
OperatorTypeGreaterEqual OperatorType = ">="
OperatorTypeLessEqual OperatorType = "<="
OperatorTypeLike OperatorType = "LIKE"
OperatorTypeIn OperatorType = "IN"
// Unary operators
OperatorTypeNot OperatorType = "NOT"
OperatorTypeField OperatorType = "FIELD"
OperatorTypeLiteral OperatorType = "LITERAL"
)
形如上面前端图中的那段 Filter:
{
"l": {
"t": "FIELD",
"v": "raw"
},
"r": {
"t": "LITERAL",
"v": "$request.query.raw"
},
"t": "="
}
我们从最外层开始解析,就是将左值和右值做 =
操作,左值是数据表的 raw
字段,右值是 $request.query.raw
即 Query 参数 raw
,所以上述这么一长串到最后的 Go 代码里形如:
query.Where("raw = ?", ctx.Query["raw"])
十分优雅,又十分安全。只是目前前端这个 Filter 还是给你个文本框自己填 Filter JSON,后续会做成纯图形化点点点的组件。(因为评估了下不太好写,所以先咕着🕊)
路由的中间件,我一开始就想把常用的功能封装成模块,然后前端直接拖拽着使用。其中对数据操作的主逻辑为 main
中间件,这个不可删除,其它的可以自由编排。
后端的实现很简单,相信看过任意 Go Web 框架源码的小伙伴都知道,又是些被说烂了的“洋葱模型”之类的东西。说穿了就是对整个中间件的 Slice for
遍历一下,判断发现其中的某个中间件返回响应(ctx.ResponseWriter().Written()
为 true
),就直接整个返回了,这里就不贴代码水字数了。
前端我使用了 vue3-smooth-dnd 这个库,我对比了 Vue 多个拖拽库,貌似只有这一家的动画最为丝滑,并且还带自动吸附。最后实现的效果我也是十分满意:
这个中间件模块的节点是我自己画的,背景设置为灰色, 然后后面放一个细长的 div
作为流程的直线。鼠标放在中间件节点上时会有一个 popup 配置中间件的具体参数。这里是直接用的 TDesign 的 Popup 弹出层组件,里面再放一个 Card 卡片组件把弹出层空间撑开即可。
目前 Sayrud 已经初步开发完并部署到了线上,它已经完美支持了我想要一个静态博客评论后端的需求,后面只需要接上我写得前端就可以用了!(目前我开发的博客评论组件还没上,你现在看到的还是又丑又难用的 Waline)
你可能也注意到了编辑接口前端有一个「响应格式」的 Textarea,这块空着是因为我还没有找到一个能够简洁定义 JSON 数据结构的方式。所以目前接口的返回结构也是固定写死的,这块如果你有好的想法,欢迎告诉我。
这个项目的开发差不多花了一个月的时间,我平时下班后如果有空就会稍微写点。(注意是下班哦,我上班可是兢兢业业干满 8 小时+,恨不得住在鹅厂)由于开发时间不连贯,再加上有时回到家里比较困脑子不清醒,经常会出现后一天否定前一天的设计的情况。最后磕磕绊绊总算是完成了!由于是纯属为满足自己的需求,再加上我对它后端字段的校验还没统一梳理测试过,我目前并不会把这个站向公众开放。而像这种二开一下就能拿去恰烂钱的东西,我当然也更不会开源。
总的来说,Sayrud 也算是圆了自己当年 18 岁时的梦,将自己当时想得东西给做出来了。你可能注意到这个项目的名字也颇耐人寻味,Say - RUD
是 CRUD
的谐音,这其实也代表着我对这个项目未来的规划。嘻嘻😝