简介

Memos是一个开源的支持自托管部署的知识库,类似flomo。作为一个go+react实现的产品,功能齐全,且活跃度一直不错,所以找了个时间阅读了一下代码。
本次仅记录后端及DB设计部分,前端部分后续有时间了再继续。

安装方法

方法一:可能是出于部署便捷的考虑,代码中仅支持docker安装,下面解析Dockerfile中镜像构建步骤,属于典型的多阶构建过程:

  1. 协议代码生成:安装buf,buf generate生成协议代码。包括后端所需的pb、grpc、grpc-gateway代码以及前端所需的ts代码。详见buf.yml配置
  2. 使用pnpm build编译前端文件,详见前端文件夹(web/)下的package.json
  3. 将前端打包结果(web/dist)copy到server目录,build后端服务。
    注意:项目中使用了go embed,将前端资源目录一并打包到了二进制memos文件中,并在代码中提供了静态文件的读服务。所以部署时不再需要copy前端目录文件,也无需其他反向代理。
//go:embed dist
var embeddedFiles embed.FS

func getFileSystem(path string) http.FileSystem {
fs, err := fs.Sub(embeddedFiles, path)
if err != nil {
panic(err)
}

return http.FS(fs)
}

func embedFrontend(e *echo.Echo) {
// Use echo static middleware to serve the built dist folder
// refer: https://github.com/labstack/echo/blob/master/middleware/static.go
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
Skipper: defaultAPIRequestSkipper,
HTML5: true,
Filesystem: getFileSystem("dist"),
}))
//...
}
  1. 构建服务运行环境

注意在docker-compose.yml中采用的从远程拉取镜像的方式。如果想使用本地构建,将image改为build指令即可。

方法二:本地安装
动手能力强的可以直接本地编译安装,无需依赖docker。但是需要手工安装各类工具。

  1. 安装pb、buf等工具,生成协议代码。
  2. 前端编译。并将编译结果copy到server/dist目录。
  3. go build,编译可执行文件。然后直接执行即可。

更新:源码下已经有个现成的编译脚本(scripts/build.sh)了。自己安装好编译工具即可。

DB存储结构

后台代码分析之前,可以对DB结构有个大致了解,因为其接口大部分是基于DB的CRUD操作。

system_setting

存储系统设置。详见系统设置页面配置项(仅ROLE为HOST用户有权限)

CREATE TABLE system_setting (
name TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
UNIQUE(name)
);

【用户】 user

注册用户表。

-- user
CREATE TABLE user (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
username TEXT NOT NULL UNIQUE,
role TEXT NOT NULL CHECK (role IN ('HOST', 'ADMIN', 'USER')) DEFAULT 'USER',
email TEXT NOT NULL DEFAULT '',
nickname TEXT NOT NULL DEFAULT '',
password_hash TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT ''
);

【用户】 user_setting

用户设置表,存放用户个性化配置。以key-value方式存储,所以部分用户类数据也存放于此,比如用户token。

-- user_setting
CREATE TABLE user_setting (
user_id INTEGER NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
UNIQUE(user_id, key)
);

【发表】 memo

发表memo记录表,类似tweet、微博记录。

CREATE TABLE memo (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL, -- 发表人,关联user.id
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
row_status TEXT NOT NULL CHECK (row_status IN ('NORMAL', 'ARCHIVED')) DEFAULT 'NORMAL',
content TEXT NOT NULL DEFAULT '',
visibility TEXT NOT NULL CHECK (visibility IN ('PUBLIC', 'PROTECTED', 'PRIVATE')) DEFAULT 'PRIVATE'
);

【发表】 memo_organizer

目前仅记录pin状态的memos记录,后续应该有更多场景。

CREATE TABLE memo_organizer (
memo_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
pinned INTEGER NOT NULL CHECK (pinned IN (0, 1)) DEFAULT 0,
UNIQUE(memo_id, user_id)
);

【发表】 memo_relation

记录memo记录间关联关系。关系类型:REFERENCE、ADDITIONAL

-- memo_relation
CREATE TABLE memo_relation (
memo_id INTEGER NOT NULL,
related_memo_id INTEGER NOT NULL,
type TEXT NOT NULL,
UNIQUE(memo_id, related_memo_id, type)
);

【资源】 resource

资源存放记录。可以包括资源内容本身,也可能为外部链接、本地文件名等。

CREATE TABLE resource (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
filename TEXT NOT NULL DEFAULT '',
blob BLOB DEFAULT NULL,
external_link TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
internal_path TEXT NOT NULL DEFAULT ''
);

【资源】 memo_resource

memos记录与资源的关联关系(1:N)。

-- memo_resource
CREATE TABLE memo_resource (
memo_id INTEGER NOT NULL,
resource_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
updated_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
UNIQUE(memo_id, resource_id)
);

【标签】 tag

跟tweet、微博之类的不同,memos使用上需要先创建标签之后才能在发表时使用标签。所以标签有单独的一套接口。

-- tag
CREATE TABLE tag (
name TEXT NOT NULL,
creator_id INTEGER NOT NULL,
UNIQUE(name, creator_id)
);

idp

没有具体研究,感觉应该是跟外部应用关联时使用。

CREATE TABLE idp (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
identifier_filter TEXT NOT NULL DEFAULT '',
config TEXT NOT NULL DEFAULT '{}'
);

activity

操作流水日志

CREATE TABLE activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
creator_id INTEGER NOT NULL,
created_ts BIGINT NOT NULL DEFAULT (strftime('%s', 'now')),
type TEXT NOT NULL DEFAULT '',
level TEXT NOT NULL CHECK (level IN ('INFO', 'WARN', 'ERROR')) DEFAULT 'INFO',
payload TEXT NOT NULL DEFAULT '{}'
);

storage

S3存储配置列表,用于资源文件的存储。注意local、database配置不在此内。

-- storage
CREATE TABLE storage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL,
config TEXT NOT NULL DEFAULT '{}'
);

后端源码分析

入口文件:cmd/memos.go

  • 项目采用了cobra框架,支持几个子命令。此处仅关注root command的主流程。其他子命令参见mvrss、setup。
  • 初始化init():项目使用了viper来实现配置的解析,包括mode(dev/prod/demo)、addr、port、data(数据存储目录)参数。相关参数在启动时会输出到console。
    • 注意:db文件名:”memos_+$mode.db”
  • open DB。
    • 使用了sqlite作为db driver。为何不将DSN放在配置中支持多个db类型?
    • Migrate DB:db初始化,包括表构建,新版本db老数据迁移。

构造echo server对象。核心数据结构包括:

  • echo server:项目使用echo作为服务器框架。
  • 读取、生成 Server ID(唯一,如果只支持单机的话server ID有啥用?)
  • 读取、生成 secret-session
  • embedFrontend:为前端资源目录(server/dist、server/dist/asserts)启用静态文件中间件
  • demo、dev模式时支持echoSwagger,方便查看API。URL:http://ip:port/api/index.html。// swagger确实好用,查看、调试API很方便。
  • 使用到的echo中间件
    • Logger(日志)、CORS(防cors)、Timeout(30s)、RateLimiter(频控,防止ip恶意访问)
    • JWT鉴权:token校验通过会在context中写入用户id(key: “user-id”)

路由

RSS路由,包括

  • /explore/rss.xml:提供rss接口,展示所有(max:100条)公开的memos记录
  • /u/:id/rss.xml:与上面类似,不过进列出该用户的memos记录。

前端REST接口路由apiv1(root path: “/api/v1”)

  • system routes

    • /ping:return HTTP 200 code.
    • /status:some information from system_setting table
    • /system/vacuum:各表垃圾类记录清理操作,包括memos、resouce、tag表等。
  • system setting:system_setting,仅限于role类型为HOST的用户

    • 【GET】/system/setting:拉取配置
    • 【POST】/system/setting:写配置
  • auth:登录鉴权

    • /auth/signin:登录
      • 系统设置为disablePasswordLoginSystemSetting、用户状态为Archived、(加密)密码不匹配,返回错误
      • 生成JWT token,参数包括:username、userID、expire time、duration。
      • token写入/更新【追加】存储表(user_setting表,key:详见user_seting.proto),注意用户token可以有多个
      • 记录操作流水activity表。
      • 返回用户信息,并设置cookie
    • /auth/signin/sso:SSO登录支持?
    • /auth/signout:登出。删除存储的token,清理cookie
    • auth/signup:注册
      • 参数:username、password
      • user中第一个用户默认设置Role为HOST(系统管理员)。其他均为USER用户
      • 写user表。注意db中存储为hash过的password(防拖库)
      • 其他登录流程,token生成、写流水等
  • idb IDP相关接口

    • 【GET】/idp
    • 【POST】/idp
    • 【GET】/idp/:idpId
    • 【PATCH】/idp/:idpId
    • 【DELETE】/idp/:idpId
  • user:用户

    • 【GET】/user:Get a list of users
    • 【POST】/user:Create a user
      • 仅当前用户角色为HOST用户时才可以创建用户。且创建用户的角色不允许为HOST。(可以为admin?)
      • 与/auth/signup不同的是不需要产生token、种cookie等操作。
    • 【GET】/user/me:Get current user
      • user basic info from user
      • user setting from user_setting
    • 【GET】/user/name/:username:Get user by username
      • 根据用户名称查找基础信息。部分敏感字段隐藏
      • 这里不需要用户鉴权?
    • 【GET】/user/:id:Get user by id。与上面基本一致
    • 【PATCH】/user/:id:更新用户信息。注意仅HOST用户或者更新用户自己才允许操作
    • 【DELETE】/user/:id:Delete a user from user table.
  • user setting

    • 【POST】/user/setting:Upsert user setting for user_setting
  • tag

    • 【GET】/tag:Get a list of tags。
      • tag 中有用户id字段,仅拉取用户自己的tagid
    • 【POST】/tag:Create a tag into tag
    • 【GET】/tag/suggestion:Get a list of tags suggested from other memos contents。—— 没看懂使用场景
    • 【POST】/tag/delete:Delete a tag from tag
  • storagesystem_setting 中存放的storage-service-id值为当前使用的存储。Local(value: -1),Database(value:0)两个不可修改、删除,可以新增S3类型的存储。

    • 【GET】/storage:Get a list of storages from storage
    • 【POST】/storage:Create storage into storage
      • 目前仅支持 “S3” 类型的存储
    • 【PATCH】/storage/:storageId:Update a storage by storageId
    • 【DELETE】/storage/:storageId:Delete a storage by storageId
      • 需要检查 system_setting 的当前storage-service-id值,如果是正在使用的存储,则不允许直接删除。
  • resource:memo发表前可以先上传资源(得到返回的资源id),然后发表时关联对应的资源(id列表)即可。

    • 【GET】/resource:Get a list of resources
      • 拉取用户上传资源列表。注:需要关联memo_resource 表,如果memo记录已删除是否就无法找回了。
      • 看页面请求,已经走的是v2的接口( /memos.api.v2.ResourceService/ListResources )了。v1接口已废弃?
    • 【POST】/resource:Create resource into resource table
    • 【POST】/resource/blob:Upload resource。
      • settingMaxUploadSizeBytes:文件大小限制,默认32M
      • 根据 system_setting.storage-service-id 的存储类型,选择对应的存储地址
        • database :直接存储在 resource.blob 字段中
        • LocalStorage :存储在本地文件,默认为asserts目录(可修改)。InternalPath字段关联文件存储地址
        • StorageS3 :S3远程存储。ExternalLink字段关联下载地址。
    • 【PATCH】/resource/:resourceId:仅更新Filename。业务场景是什么?
    • 【DELETE】/resource/:resourceId:删除对应的资源。包括文件blob以及 storage
  • memo:发表相关

    • 【GET】/memo:Get a list of memos matching optional filters。
      • PRIVATE 仅用户自身可见。 PROTECTED 登录用户也可以查看。 PUBLIC 未登录也可见。
      • system_setting.memo-display-with-updated-ts 开关用于控制是否按按update time排序,否则按create time排序 【desc】
      • memo 表外,也会将 memo_relationmemo_organizermemo_resource 等关联字段一起拉出。
    • 【POST】/memo:发表。
      • visibility默认继承自 user_setting.memo-visibility or Private。如果 system_setting.disable-public-memos 开启,将USER角色的用户发表改为Private。
      • 插入 mem
      • 插入 memo_resource 表(if ResourceIDList not null)
      • 插入 memo_relation 表(if RelationList not null)
      • 写后立即读?完全没有考虑性能以及db可能的延迟。
      • 如果 user_setting.telegram-user-id 存在,使用telegram bot同步
    • 【GET】/memo/all:跟上面的list接口没啥区别。此处不限用户拉取所有 PUBLICPROTECTED状态的memo。explore场景使用(类似于微博广场的场景)
    • 【GET】/memo/stats:Get memo stats by creator ID or username。
      • 查询某用户或自己的memo记录的发表/更新时间。首页右侧的heat map使用。
    • 【GET】/memo/:memoId:Get memo by ID
    • 【PATCH】/memo/:memoId:更新 memo 以及 memo_relationmemo_resource
    • 【DELETE】/memo/:memoId:删除 memo。注意 memo_relationmemo_resourcememo_organizer 记录并没有清除。删除后资源应该可见,逻辑不严谨。
  • Organize memo (pin/unpin)

    • 【POST】/memo/:memoId/organizer:插入 memo_organizer 表。
      • 注意仅memo的发表人CreatorID才允许执行Pin操作。—— 感觉此处单独一个表有点多余。完全可以在memo中增加一个字段即可。另外也不需要单独接口,完全可以复用update memo接口。
      • register memo & resource relation.
    • 【GET】/memo/:memoId/resource:根据memoID拉取对应的resource列表信息
    • 【POST】/memo/:memoId/resource:根据memoID更新 memo_resource 中对应的资源id。使用场景是什么?如果是更新memo对应的资源,直接调用更新memo即可。貌似没有必要为资源更新单独保留接口。
    • 【DELETE】/memo/:memoId/resource/:resourceId:Delete from memo_resource 。也没找找到使用场景。如果是删除资源,直接更新memo即可。
  • register memo relation。接口与上面resouce类似,GET、POST、DELETE 操作 memo_relation

  • 公共路由(”/o”)

    • 外部资源
      • 【GET】/get/httpmeta:取外部链接的http meta信息
      • 【GET】/get/image:拉取外部链接图片,返回给页面,并设置max-age。为啥页面不直接拉取,而需要走服务端代理?
    • Resouce
      • 【GET】/r/:resourceId:拉取资源
        • 支持thumbnail拉取。如果资源type为“image/png”、”image/jpeg”,会实时生成512的缩略图(Resouce页面首次拉取会展示缩略图,点击是显示完整图片)
      • 【GET】/r/:resourceId/*:与上面接口实现相同。

gRPC路由注册

gRPC使用的端口号为 Profile.Port +1,使用gRPC server框架。包括以下gRPC接口:

  • gRPC拦截器:与v1中的鉴权流程一致,鉴权完毕会在context中写入username供后续逻辑使用。详见 acl.go 实现。
    • 意味着所有v2接口都需要登录?
  • systeminfo:与v1版本的user_setting基本一致。v1版本没有get接口,应该是已经迁移了。
service SystemService {
rpc GetSystemInfo(GetSystemInfoRequest) returns (GetSystemInfoResponse) {
option (google.api.http) = {get: "/api/v2/system/info"};
}
rpc UpdateSystemInfo(UpdateSystemInfoRequest) returns (UpdateSystemInfoResponse) {
option (google.api.http) = {post: "/api/v2/system/info"};
}
}
  • UserService:包括两部分接口
    • user 中的用户基础信息
    • 拉取、创建、删除Token列表( user_setting.UserSettingKey ),注意v1版本API仅在sigin、signout相关流程中有操作token,无直接接口。
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse) {
option (google.api.http) = {get: "/api/v2/users/{username}"};
option (google.api.method_signature) = "username";
}
rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse) {
option (google.api.http) = {
post: "/api/v2/users/{username}"
body: "*"
};
option (google.api.method_signature) = "username";
}
// ListUserAccessTokens returns a list of access tokens for a user.
rpc ListUserAccessTokens(ListUserAccessTokensRequest) returns (ListUserAccessTokensResponse) {
option (google.api.http) = {get: "/api/v2/users/{username}/access_tokens"};
option (google.api.method_signature) = "username";
}
// CreateUserAccessToken creates a new access token for a user.
rpc CreateUserAccessToken(CreateUserAccessTokenRequest) returns (CreateUserAccessTokenResponse) {
option (google.api.http) = {
post: "/api/v2/users/{username}/access_tokens"
body: "*"
};
option (google.api.method_signature) = "username";
}
// DeleteUserAccessToken deletes an access token for a user.
rpc DeleteUserAccessToken(DeleteUserAccessTokenRequest) returns (DeleteUserAccessTokenResponse) {
option (google.api.http) = {delete: "/api/v2/users/{username}/access_tokens/{access_token}"};
option (google.api.method_signature) = "username,access_token";
}
}
  • memos:提供拉取memos的两个接口(目前应该还没有迁移完毕)
service MemoService {
rpc ListMemos(ListMemosRequest) returns (ListMemosResponse) {
option (google.api.http) = {get: "/api/v2/memos"};
}

rpc GetMemo(GetMemoRequest) returns (GetMemoResponse) {
option (google.api.http) = {get: "/api/v2/memos/{id}"};
option (google.api.method_signature) = "id";
}
}
  • tag:与/api/v1/tag基本一致。
service TagService {
rpc ListTags(ListTagsRequest) returns (ListTagsResponse) {
option (google.api.http) = {get: "/api/v2/tags"};
}
}
  • resource:与 /api/v1/resource 基本一致
service ResourceService {
rpc ListResources(ListResourcesRequest) returns (ListResourcesResponse) {
option (google.api.http) = {get: "/api/v2/resources"};
}
}
  • v2接口也支持reflection,可以通过 grpcurl 查看接口说明。
» grpcurl  -plaintext localhost:8082 list
grpc.reflection.v1.ServerReflection
grpc.reflection.v1alpha.ServerReflection
memos.api.v2.MemoService
memos.api.v2.ResourceService
memos.api.v2.SystemService
memos.api.v2.TagService
memos.api.v2.UserService
  • 将所有gRPC接口注册到gRPC gateway,提供v2版本的http API(/api/v2/)。
  • 另外也注册了GRPC web proxy,供前端gRPC web直接使用。
    • 前端使用了nice-grpc-web 来调用grpc接口。避免通过rest API的转换。
    • 另外grpc web proxy是依赖http2的,无法在非h2环境下使用?

路由注册等完成后启动http、gRPC server接口,启动服务。

总结

目前memos作为一款笔记记录工具,从功能上还是比较完善的。但是整体架构比较简单,基本都是DB的CRUD操作,也不支持多机部署,目前无法支撑大量在线的访问场景。在一些数据可靠性、备份机制等方面基本没有考虑。所以建议目前仅个人部署来体验,不适合大规模使用的场景。

  • 优点
    • 功能结构完整:用户、发表、标签、资源以及鉴权、TEL机器人等功能相关接口,接口近50个。
    • docker等一键部署能力。不需要开发能力可以完成host部署。
  • 不足
    • memo等存储还基于嵌入式数据库sqlite,量大时一定会有性能上的瓶颈。看项目roadmap支持mysql已经在计划中了,支持mysql之后性能应该会有所改善。部分接口实现上也没有考虑性能问题的优化(比如去掉多余的db访问)
    • 表过多,造成大量的left join之类的查询。如果记录量达到百万级应该也会产生新的性能问题。感觉一些非核心数据(比如pin、relation、resouce)可以使用redis之类的缓存来解决,避免频繁的多表join等操作。
    • API代码冗长。接口存在一些重复。目前已经在朝v2版本接口迁移中了。后续应该能简化。

优化方向

架构本身并没有最佳,目前的架构在支撑当前的产品定位上是没有任何问题的。这里抛开产品定位,仅从技术角度来谈谈后续可以优化的方向,思路上也会受个人经验的限制。

目标

  • 支撑更大在线的用户并发量
  • 数据安全性

优化思路

  1. 支持分布式部署

当在线量变大时,最直接的解决方案就是横向扩容了。目前的memos记录存储、资源文件存储以及缓存设计等都还是基于本地文件或者嵌入式DB,没有考虑反向代理+分布式多记部署来横向扩展性能的能力。

所以第一步要做的就是将memos记录等依赖本地文件的方案废弃,迁移到DB等中间件。Mysql已经在支持中了,但是在缓存设计、资源管理上也需要进一步考虑。目标上要做到数据与后端服务分离。

  1. 轻重分离

资源管理等重逻辑应该与发表等轻逻辑分析。文件上传、下载以及图片处理等重逻辑不应该与接口逻辑部署在一起而相互影响。

另外资源上传、下载最好直接使用第三方存储、CDN类服务,避免与服务端直接交互,提升终端体验。

  1. 微服务化改造
  • 数据层、逻辑层分离。
  • 接口按功能组拆分。仅50个接口堆在一个单体服务内,性能上也会有影响。

4、核心逻辑重建

将业务核心逻辑拆分为几大系统,各系统针对性优化。比如账号系统、发表系统、资源管理等,各系统按照其能力目标针对性建设。
以发表为例,需要考虑的点:

  • 发表消息存储:主要管理memosID与消息体的映射关系,简单实现的话,可以使用mysql(落地)+redis(在线访问)来存储此类key-value数据。后续进一步考虑成本等优化方案
  • 发表索引:主要目前是为构建首页的memos消息列表而使用,游客态、广场等也有类似的需求。简单实现可以使用redis.zset等实现来存储此类key-list数据类型,或者使用es来构建多种倒排索引并存的需求。后续可以根据实际需要自行实现倒排索引。
  • 读扩散or写扩散:开放式关系链可能更适合读扩散,比如微博等大V场景。封闭式关系链,比如微信,可能更适合写扩散。或者读写并存的方式来构建Timeline类场景服务。

每一块扩展开来也有大量的优化空间,比如缓存的设计、分布式系统热点数据的性能问题、防击穿等,需要逐步来优化。

当然最重要的还是根据系统需求来选择对应的方案,毕竟能有如此在线量的产品本身也不多,大厂内也没几个,没有必要为了过早优化而投入过多的人力。
满足当前需求,以及为未来保留一定的可扩展性方案才是最好的。不必纠结于技术系统的构建上而错过业务发展的最佳时间。