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
进行区分,这也是一种简易的多租户实现了。
当我们开发一个博客评论后端时,功能上需要支持回复他人的评论,即数据之间会存在引用关系,我们一般会在 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
的谐音,这其实也代表着我对这个项目未来的规划。嘻嘻😝
2024-04-29 17:44:04
文章封面使用 DALL·E 3 生成
从三月底开始一直比较忙,最近一切尘埃落定,自己在家也休息了几天,这才能做点自己的事情。
由于一些原因 (是的,我要入职腾讯了),我准备将之前部署在 Cloudflare Pages 上的博客,也就是你现在看到的这个站点,迁移到国内腾讯云上。本以为是很简单的一个操作,完全没有必要大费周章地专门写一篇文章来记录,但现实是我在腾讯云上来来回回试了好几个产品,最终才勉强将这整套的持续集成方案给搭起来。
我以前一直是阿里云的忠实用户。但我对阿里云是又爱又恨,没少骂过阿里云残缺的产品功能和听不懂人话的弱智客服。甚至以前在 EFC 上班的时候,路过英国中心楼下想到阿里云就气不打一处来。但即使是这样,阿里云还是全中国排名第一的云,这说明什么?说明其他家的云更是草台班子!
说回腾讯云,我大一的时候,曾在腾讯云上开过学生机,后面毕业了优惠没了也就销毁了。腾讯云给我的第一感觉是他的 UI 做得很舒服,操作反馈颇有点 Azure 的感觉。但除开 UI 之外,产品的功能设计还有很大的提升空间。
我感觉国内做云的,都是先拿类 OpenStack 做一套管控机房物理资源的系统,然后开始卖 ECS 这样的云主机,卖了一阵子后觉得我可以在一台 ECS 上装点数据库软件、监控软件、消息队列中间件等东西,然后单独拆成如 RDS 这样的服务来卖。卖了一阵子后,发现又可以把好多台 ECS 合起来卖 Kubernetes 集群托管,Kubernetes 托管卖了之后又发现可以在上面二开跑点容器卖 Serverless 服务……
就这样在之前的产品的能力上糊一层然后演化成新的产品。
我不好评价这样的做法是对还是错。我认为复用已有能力做新产品前,对于新产品的定位以及将具备的核心功能,必须要想清楚。倘若底层的功能过于局限,或者必要配置项比较“狭窄”,则应该考虑另起炉灶而不是在上面糊一层兼容的 Shim。
我一开始是无脑选择腾讯云的 Webify 来部署我的静态页面。从名字就可以看出它是借鉴的 Netlify,产品形态上跟 Netlify、Vercel、Cloudflare Pages 等页面托管产品差不多。
但问题就出在——腾讯云没有将 Webify 作为的一个单独的产品进行研发,它是属于腾讯云 Cloudbase 云开发产品下的一个子功能!这个 Cloudbase 是啥?是一个类似于 LeanCloud 或者 Heroku 一样的东西,用户在上面托管 Serverless 应用,同时使用 Cloudbase 提供的存储、数据库、云函数等功能。
Webify 作为 Cloudbase 产品的一个子功能,复用了 Cloudbase 部署应用时的 CI/CD 工作流。对于 Cloudbase 而言这个 Webify 实例是一个按量计费的 WebifyPackage
”环境“,在控制台上就莫名其妙地将 Cloudbase 的“环境“这个概念集成进了 Webify 产品中,但是这个“环境”是系统创建的,你控制台点进去还会报错说无权限!
在产品计费上,Webify 有自己的一套按月付费的包,包含 CDN 流量、静态存储容量等内容。但这些用量又和底层的 Cloudbase 的用量藕断丝连。以至于我发工单问客服 CDN 流量用完了是怎么计费,他先是说流量用完后直接回原,跟 CDN 服务无关,一会又给我发 CDN 的计费文档,我指出他说得前后矛盾之后,过了一会直接电话打过来跟我解释才讲明白。(我发现现在阿里云和腾讯云的客服水平都变差了,动不动就一个电话过来解释,为啥不能线上消息或者文档说明白?)
但以上种种也都只是控制台操作上有些不合理,让我来试下实际产品怎么样。
首先是 Weblify 不支持 Hugo 站点的自动构建,不像 Cloudflare Pages 或者 Vercel 那样,选择好仓库后能自动推断出技术栈,并补全构建命令。Weblify 只支持常见的 JavaScript 框架编写的项目。
解决的办法也不难,我稍微拐个弯,在 GitHub 上建一个仓库,存放构建好的 Hugo 站点文件即可。只需在原 Hugo 项目的 GitHub Actions 流水线中加条 Hugo 构建并推送到仓库即可。
在 Weblify 上配置 GitHub OAuth 授权后,选择存放构建后静态资源的仓库,直接静态托管该仓库的内容。然后 Webify 构建又报错了……
根据构建日志,我发现这垃圾玩意是把 git pull
下来的仓库内容,打成 ZIP 压缩包,再用 Cloudbase CLI 推送上去,然后这 Cloudbase CLI 不支持推送超过 100MB 的文件!发工单问客服,答曰:
Webify目前限制构建产物的体积在100MB内,建议客户减少部署包的体积。 图片、音视频等大体积的资源,可以使用CLI工具手动上传到环境内的某个固定目录。
哈???我站点超过 100MB 还不能自动构建还得手动上传???本来用 Weblify 就是图个方便,最后还要我自己上传?
没办法,我打算把 CLI 手动上传的步骤放到 GitHub Actions 的工作流里,即 Hugo 构建完后直接上传至 Weblify。搞了半天成功了,结果 Webify 访问网页直接显示 NO ROUTE
报错,且在控制台上也完全没有找到默认主页、404 页面的配置项。我想就算我解决了 NO ROUTE
的问题,后面默认主页和 404 页面配置不了也还是残废,索性直接申请退款,放弃!
那只能回到传统的静态网站部署方案:将静态文件上传至 COS(腾讯云的对象存储),然后前面套个 CDN。
继续改 GitHub Actions 流水线,将构建好的产物上传至 COS Bucket。然后我发现官方提供的 COS Action 就是个 Bug 百出的垃圾!这里我要实名 diss 这个仓库的原作者 mingshun 我不知道你是不是鹅厂的,但我知道你肯定没认真测试过你写的代码!
例如以下代码 TencentCloud/[email protected]#L110:
} while (data.IsTruncated === 'true');
这个 IsTruncated
传进来只能是 Boolean 类型的 true
或者 false
,你拿他跟一个字符串类型的'true'
强比较,这里恒为 false
,导致这个 while
循环永远也跳不出来,一直卡着。我睡一觉醒来后发现我的 Workflow 跑了六个小时,然后被 GitHub 因为超时干掉了。
除了上面的这位原作者,还有 Shirasawa 这位,因为我有朋友也关注了这位老哥,因此我就不喷了。我只能说老哥你多看下 COS SDK 的源码吧,明明就有 accelerate
这个加速域名参数的,你非得自己实现个:
Domain: core.getInput('accelerate') === 'true' ? '{Bucket}.cos.accelerate.myqcloud.com' : undefined,
搞得后面不开accelerate
那就是直接 Domain 为 undefined
然后报错。
没办法,鉴于官方的 Actions 质量如此之差,我索性 Fork 改了个自己用:wuhan005/tencent-cos-action。然后我惊讶的发现,从 GitHub Actions 的美国节点,即使走 accelerate 加速域名上传文件到位于上海的 COS Bucket,也是 1-2 秒上传一个文件,我每次部署都要上传 1000+ 个文件,直接大半个小时过去了,这个部署上传的时间是我无法接受的。
那我得想办法让 Hugo 在境内的节点进行构建,然后从境内传到 COS Bucket 中。这次,我盯上了腾讯云自己搞的代码托管平台 CODING,本质上就是个啥都有的缝合怪。
好在他可以添加外部的 GitHub 仓库,并通过 GitHub OAuth 授权后,在仓库中安装 CODING 的 GitHub App,配置 WebHook。GitHub 仓库有新的推送后,触发 CODING 的流水线进行构建。经过数次调试后,最终可用的 CODING 流水线文件内容如下:
pipeline {
agent any
stages {
stage('检出') {
steps {
checkout([$class: 'GitSCM',
branches: [[name: GIT_BUILD_REF]],
userRemoteConfigs: [[
url: GIT_REPO_URL,
credentialsId: CREDENTIALS_ID
]]])
}
}
stage('安装 Hugo') {
steps {
sh 'apt install snapd'
sh 'snap install hugo dart-sass'
}
}
stage('构建') {
steps {
sh 'hugo --minify'
}
}
stage('上传到 COS Bucket') {
steps {
sh "coscmd config -a ${COS_SECRET_ID} -s ${COS_SECRET_KEY} -b ${COS_BUCKET_NAME} -r ${COS_BUCKET_REGION}"
sh "coscmd upload -rfs --delete public/ /"
}
}
stage("刷新 CDN 缓存") {
steps {
sh "pip install --upgrade tencentcloud-sdk-python"
sh "python ./dev/refresh-tencent-cdn.py -i ${COS_SECRET_ID} -k ${COS_SECRET_KEY}"
}
}
}
}
我在 Hugo 仓库中加了个刷新腾讯云 CDN 缓存的 Python 脚本,上传成功后再执行这个脚本刷新 CDN 缓存。现在完整构建并部署一次的时间大约在 3-4 分钟。
勉强能接受吧,要知道在 Cloudflare Pages 上可是 1-2 分钟就能完成,并且还不需要我自己做这个多的配置!!
费劲周折,我总算是成功的将博客部署到了腾讯云上。
之前迁移至 Cloudflare 的原因是我七牛云和阿里云都因为 CDN 被盗刷,导致一夜之间账单欠了 ¥600+。我也不知道互联网上为什么会有这么多干着这些损人不利己的蠢事的人。
因此在迁移之前,我十分谨慎地调研过腾讯云的 CDN 防盗刷功能,最后的结论是发现他们做得居然还不错,可以说是已经相当尽力了。在 COS 对象存储的「安全管理」菜单下,居然有一个「盗刷风险监测」功能!从各个维度评估了是否有盗刷风险,真的让人眼前一亮!建议阿里云赶紧跟进下。
我总结了下,具体是这几个方面的配置,以及我自己的配置值。
所属产品 | 配置项 | 备注 |
---|---|---|
对象存储 COS | 存储桶权限 | 配置为私有读写 ,授权 CDN 子用户访问,其余公网请求全部 ban 掉 |
内容分发网络 CDN | 防盗链配置 | 配置白名单 Referer(治标不治本,CC攻击加个头就行) |
内容分发网络 CDN | IP访问限频配置 | 10QPS(单个 IP 限制,有一定效果) |
内容分发网络 CDN | 下行限速配置 | 全部内容,限速 1024KB/s(这个值我感觉还可以再低,防止被刷流量) |
内容分发网络 CDN | 用量封顶配置 | 流量每五分钟瞬时用量超过 2GB、HTTPS 请求数每五分钟超过 100 万次、当天 24 点前累计流量超过 10GB。(触发后会直接停掉 CDN 服务,防止一觉醒来账单爆炸) |
以上配置是否能真的防住 CC 攻击,还得看腾讯云的用量封顶配置多久生效。虽然官方说是 10 分钟左右,这个时间我觉得还是有些长,万一对面 10 分钟打出了 1 TB 流量呢?但同时腾讯云官方又给出了一种通过定时 Serverless 函数,请求腾讯云 API 检测 CDN 用量,超过用量后使用 API 关闭 CDN 服务的方法。由于是自建 Serverless 定时函数,时间周期可以设的更短,这个后续我可以尝试下。
后续我可能会把阿里云集群上的业务也迁到腾讯云上来。
最近一个多月以来自己得睡眠质量不是很好,总是忧心忡忡。好在现在都已尘埃落定,我如愿拿到了腾讯的 Offer,自己这波“金三银四”还算顺利。这过程中的怀疑、悔恨、不甘,现在回想起来也都不重要了。
站在人生的又一个起点,我还依旧觉得没什么实感。对于后面匆匆收拾东西,搬去上海,我也不确定自己是否准备好了。但我可以肯定的是,自己已经跳出了原来的舒适圈,面前的是另一个更舒适的舒适圈还是更艰难的挑战,这还尚不可知。