memos 源码阅读笔记

memos 源码阅读笔记

技术 5771 字 / 12 分钟
以下AI总结内容由腾讯混元大模型生成

这篇文章是一篇关于开源项目memos的总结,主要介绍了作者对memos项目的理解和心得体会。

  1. 项目介绍:文章开头提到作者寻找一个合适的平台来记录生活中的点滴,最终选择了Self-hosted的笔记应用memos。

  2. 语义化版本:作者解释了Go语言中的语义化版本控制,并指出memos项目中如何使用它来管理数据库迁移。

  3. 避免ORM:memos项目中直接使用database/sql和对应数据库的Driver,而不是ORM,作者对此进行了讨论。

  4. gRPC的使用:文章详细介绍了memos项目中gRPC的使用,包括Buf工具的使用、Protobuf的定义、以及如何通过HTTP实现RESTful API和gRPC Server API。

  5. 定时任务:memos内置了三个定时任务,分别用于处理S3预签名URL、版本更新和备忘录属性提取。

  6. gomark库:作者介绍了gomark库的功能和实现原理,它用于将Markdown格式的文本解析成Go结构体块。

  7. 其他细节:文章还提到了memos的一些小细节,如前端嵌入index.html的处理方式和JWT Token解析的优化。

总的来说,文章通过对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,并且推荐了 sqlcsqlbuilders 两个库。作者的回复是前者 looks a little weird (?),后者 pretty much the same as the existing way,综上所属作者认为保持现状啥也不改!😅

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

玩出花的 gRPC

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

Buf

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

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

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

目录结构

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

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

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

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

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

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

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

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

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

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

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

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

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

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

gRPC Server 和 RESTful API Server

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

重点看下面的代码:

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

	return nil
}

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

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

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

端口复用

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

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

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

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

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

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

梦开始的地方

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

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

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

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

定时任务

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

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

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

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

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

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

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

    BEFORE

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

    AFTER

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

gomark

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

其它的小细节

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

前端 embed index.html

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

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

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

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

JWT Token 解析

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

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

总结

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

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

谢谢老板 Thanks♪(・ω・)ノ


喜欢这篇文章?为什么不打赏一下呢?