memos 源码阅读笔记
一直想要有一个平台,能够发些碎碎念之类,记录一下在食堂吃到的新菜式,或者分享一下有意思的事情。如果在 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,并且推荐了 sqlc
和 sqlbuilders
两个库。作者的回复是前者 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
指定了 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,
}
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-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 时都会调用。额外加到定时任务中出发,应该是为了兜底。
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.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 纯文本的工作。
其它的小细节
最后再说些自己发现的小细节吧,就不单独分一块了。
前端 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…… 之后这块可以多研究下。
喜欢这篇文章?为什么不打赏一下呢?