Memos
开源自托管轻量笔记服务 Memos 的源码结构笔记:契约先行(protobuf)的分层、多数据库驱动的 store 设计与单仓前后端组织。
[!info] related notes
- 所属 MOC:源码阅读 MOC · Go Web 与后端 MOC
- 理论对照:Go 项目结构与分层 · Handler/Service/Repository · 项目分层架构
- 并列案例:Ardan Labs Service
- 相关概念:internal 包 · gRPC · SQLite · embed · database/sql
Memos
这是什么
开源的自托管轻量笔记 / 备忘录服务,单二进制部署,Go 后端 + React 前端放在同一个仓库。定位是「隐私优先的 Twitter 式速记」。
跟 Ardan Labs Service 的教学样板不同,这是一个真实演进中的产品仓库,因此更能看到取舍的痕迹:契约先行、多数据库支持、前端内嵌、渐进重构。
[!tip] 理解它的关键 不要按目录逐文件扫,而要抓住一条真实数据流,从 React 一直追到 SQL,再原路回来。 对 Memos 最值得先追的是「创建一条 Memo」:
EditorState → Proto → Connect → APIV1Service → Store → Driver → DB → v1pb.Memo → React Query。proto/是这条链的“腰”:它不是业务逻辑,却钉住了前后端共同遵守的数据与服务契约。
版本快照
| 项 | 值 |
|---|---|
| 分支 | main |
| commit | bd636a436522(2026-08-09) |
| 抓取日期 | 2026-08-10 |
| module | github.com/usememos/memos |
| Go 版本 | 1.26.2 |
| 代码生成 | buf(proto/buf.gen.yaml) |
| 前端 | web/,React + Vite |
| 数据库 | SQLite / MySQL / PostgreSQL 三套驱动 |
关键依赖与技术栈
只列直接依赖中决定架构形态的部分(取自 go.mod):
| 领域 | 选型 | 说明 |
|---|---|---|
| HTTP 服务器 | github.com/labstack/echo/v5 | Echo v5,不是 Gin。负责外层 HTTP 服务器、中间件、静态资源、健康检查和把不同 Handler 挂到 URL 空间 |
| RPC / API | connectrpc.com/connect | Connect RPC,React Web 当前主要使用的类型安全 RPC 入口;服务端 handler 还可兼容 gRPC / gRPC-Web |
| REST 网关 | grpc-ecosystem/grpc-gateway/v2 | 根据 proto 中的 HTTP mapping 生成 REST/JSON → Proto RPC 的 Adapter |
| gRPC | google.golang.org/grpc | 与 Connect 生态共存,proto service 仍保持标准 gRPC 语义 |
| 查询表达式 | google/cel-go | internal/filter/ 的过滤语法基于 CEL |
| Markdown | yuin/goldmark | 与自研 internal/markdown/ 并存 |
| CLI / 配置 | spf13/cobra + spf13/viper + joho/godotenv | |
| SQLite 驱动 | modernc.org/sqlite | 纯 Go 实现,无需 CGO,这是能出单二进制的关键 |
| MySQL / PG | go-sql-driver/mysql · lib/pq | |
| 对象存储 | aws-sdk-go-v2/service/s3 | |
| 集成测试 | testcontainers-go(+ mysql / postgres 模块) | |
| AI | openai-go/v3 · google.golang.org/genai | 对应 internal/ai/ |
| MCP | modelcontextprotocol/go-sdk | 对应 server/router/mcp/ |
| RSS | gorilla/feeds | 对应 server/router/rss/ |
[!warning] 容易看错的一点 仓库里同时出现 Echo、Connect、grpc-gateway、gRPC 四个「像是 Web 框架」的东西,但它们不是并列替代关系:
- Echo 是最外层 HTTP Server / 路由挂载外壳;
- Connect handler 承担 RPC transport;
- gRPC-Gateway 承担 REST/JSON transport adapter;
- gRPC 是 proto service 的标准 RPC 语义与兼容协议之一。
所以更准确的图是:
Echo → {Connect handlers, gRPC-Gateway, 其他 HTTP routes} → APIV1Service。业务的 URL 分发、序列化、JSON/Proto 转换大量由生成代码和框架承担,而业务规则仍写在APIV1Service中。
仓库鸟瞰:顶层目录结构
memos/
├── cmd/
│ └── memos/ # 唯一可执行入口:main.go + log.go
│
├── proto/ # 契约先行:对外 API 先写 .proto
│ ├── api/v1/ # 对外 API 契约
│ │ ├── memo_service.proto
│ │ ├── memo_view_service.proto
│ │ ├── user_service.proto
│ │ ├── auth_service.proto
│ │ ├── attachment_service.proto
│ │ ├── instance_service.proto
│ │ ├── idp_service.proto
│ │ ├── ai_service.proto
│ │ └── common.proto
│ ├── store/ # 部分持久化相关 protobuf 结构
│ ├── gen/ # buf 生成产物
│ │ ├── api/
│ │ ├── store/
│ │ └── openapi.yaml
│ ├── openapi_embed.go
│ └── buf.yaml · buf.gen.yaml · buf.lock
│
├── server/ # HTTP/RPC Adapter、鉴权与 API 业务编排
│ ├── server.go # 服务器组装
│ ├── cors.go
│ ├── access/ # 资源级访问控制(memo 可见性)
│ ├── auth/ # authenticator / token / session / context
│ ├── notification/
│ ├── router/
│ │ ├── api/v1/
│ │ │ ├── v1.go # APIV1Service 结构体、构造和网关注册
│ │ │ ├── connect_handler.go
│ │ │ ├── connect_services.go # Connect 薄 Adapter
│ │ │ ├── connect_interceptors.go # metadata / auth 等横切逻辑
│ │ │ ├── gateway_route_resolver.go
│ │ │ ├── authz.go / acl_config.go
│ │ │ └── memo_service.go, user_service.go, auth_service.go, ...
│ │ ├── frontend/
│ │ ├── fileserver/
│ │ ├── rss/
│ │ └── mcp/
│ ├── runner/
│ │ ├── memopayload/
│ │ └── s3presign/
│ └── test/
│
├── store/ # 数据访问层:门面 + Driver + 三套数据库实现
│ ├── store.go # Store 结构体与 New()
│ ├── driver.go # Driver interface
│ ├── migrator.go
│ ├── cache.go · common.go
│ ├── memo.go, user*.go, attachment.go, inbox.go,
│ │ memo_relation.go, memo_share.go, reaction.go,
│ │ idp.go, auth_config.go, instance_setting.go, ...
│ ├── db/
│ │ ├── db.go
│ │ ├── sqlite/
│ │ ├── mysql/
│ │ └── postgres/
│ ├── migration/{sqlite,mysql,postgres}/
│ ├── cache/
│ ├── seed/sqlite/
│ └── test/
│
├── internal/ # 与 API transport 解耦的内部能力包
│ ├── ai/
│ ├── base/
│ ├── markdown/
│ ├── filter/
│ ├── idp/oauth2/
│ ├── storage/s3/
│ ├── email/ · webhook/ · httpgetter/ · motionphoto/
│ ├── profile/ · version/ · scheduler/ · util/ · testutil/
│
├── web/ # React SPA
│ ├── src/
│ │ ├── components/ · pages/ · layouts/ · router/
│ │ ├── contexts/ · hooks/ · lib/ · utils/ · types/
│ │ ├── connect.ts # 前端 transport/client Composition Root
│ │ └── types/proto/ # protoc-gen-es 生成的 TS types/schemas
│ ├── public/ · docs/ · patches/ · scripts/ · tests/
│
├── docs/adr/
├── scripts/
├── .golangci.yaml
└── go.mod
顶层边界职责
| 顶层目录 | 回答什么问题 | 备注 |
|---|---|---|
cmd/memos/ | 进程怎么起来 | 薄 main |
proto/ | 前后端共同的数据和服务契约长什么样 | .proto 是语言无关 IDL,生成 TS / Go / Gateway / OpenAPI |
server/ | 请求如何进入、认证、转成用例执行 | Transport Adapter + API/Application 业务编排混合在一起 |
store/ | 数据怎么读写 | Store 门面 + Driver 接口 + 三套实现 |
internal/ | 可复用内部能力 | Go internal 编译约束 |
web/ | React UI、客户端状态与 typed RPC client | 服务端状态主要交给 React Query |
docs/adr/ | 为什么这么决定 | 架构决策记录 |
分层与依赖方向
flowchart TB
CMD["cmd/memos main"] --> SRV["server/server.go 组装"]
PROTO["proto/*.proto"] -->|buf generate| GENGO["Go generated code"]
PROTO -->|buf generate| GENTS["TS generated code"]
PROTO -->|buf generate| GW["gRPC-Gateway / OpenAPI"]
GENTS --> WEB["React typed client"]
SRV --> ROUTER["server/router/api/v1"]
GENGO --> ROUTER
GW --> ROUTER
ROUTER --> STORE["store/ 门面"]
STORE --> DRIVER["store/driver.go 接口"]
DRIVER --> SQLITE["db/sqlite"]
DRIVER --> MYSQL["db/mysql"]
DRIVER --> PG["db/postgres"]
ROUTER --> INT["internal/*"]
STORE --> INT
后端入站可以压成:
http.Server{Handler: echoServer}
└─ echo.New()
├─ /healthz / frontend / files / MCP / RSS ...
└─ apiV1Service.RegisterGateway(...)
├─ REST /api/v1/*
│ └─ gRPC-Gateway generated handlers
│ └─ APIV1Service
└─ RPC /memos.api.v1.*
└─ Connect handlers
└─ APIV1Service
几个值得注意的结构特征:
0. Echo 是外壳,但不是“完全不碰业务 URL”
更准确地说,Echo 负责把 REST Gateway、Connect mux 以及其他 HTTP Handler 挂到 URL 空间;具体 CreateMemo 的参数解析、Proto request 构造与方法分发主要由 generated handler / Connect handler 完成。
1. 没有独立的 application/usecase package
真正业务规则大量直接写在 server/router/api/v1/*_service.go 的 APIV1Service 方法中。connect_services.go 很薄,但 memo_service.go::CreateMemo 并不薄:认证前置条件、内容限制、payload、附件、relation、webhook、SSE、mention notification 都在这里编排。
2. APIV1Service 是一个 package 级大 Service,方法按文件拆开
结构体定义在 server/router/api/v1/v1.go:
type APIV1Service struct {
... generated UnimplementedXXXServiceServer
Secret string
Profile *profile.Profile
Store *store.Store
MarkdownService markdown.Service
SSEHub *SSEHub
...
}
memo_service.go、user_service.go、auth_service.go 都是 package v1,因此可以在不同文件继续定义:
func (s *APIV1Service) CreateMemo(...) { ... }
func (s *APIV1Service) GetUser(...) { ... }
这体现 Go 常见的「package 聚合、文件按领域拆分」,而不是 TS/Java 式“一文件一个 class”。
3. store 是「门面 + 驱动」而不是「一个领域一个 repository」
store/store.go 定义 Store,内部持有 driver Driver;store/memo.go 定义 Memo persistence API;store/driver.go 定义数据库 contract;store/db/{sqlite,mysql,postgres}/memo.go 实现具体 SQL。
4. 迁移脚本按驱动分目录
store/migration/{sqlite,mysql,postgres}/ 各一套 SQL,store/migrator.go 负责执行与版本守卫。
5. internal/ 是能力库,不是业务层
markdown、filter、AI、S3 等能力在这里;业务流程仍在 APIV1Service。
契约层:.proto 为什么是系统的“腰”
.proto 不是 TS,也不是 Go
proto/api/v1/memo_service.proto 使用 Protocol Buffers IDL。它只描述:
message:数据结构、字段类型和 field number;service/rpc:远程方法名、request、response;google.api.http:RPC 到 REST method/path/body 的映射。
同一份契约经 buf generate 后生成不同消费者需要的代码:
memo_service.proto
│
├─ protoc-gen-es → web/src/types/proto/.../*_pb.ts
├─ protoc-gen-go → proto/gen/api/v1/*.pb.go
├─ protoc-gen-go-grpc → gRPC server/client stubs
├─ connectrpc/go → Connect handlers / descriptors
├─ grpc-gateway → REST/JSON Adapter
└─ OpenAPI generator → openapi
所以 TS 和 Go 不是共享同一段代码,而是共享同一份语言无关契约。
create(MemoSchema) 只造数据,不发请求
前端:
import { create } from "@bufbuild/protobuf";
const memoData = create(MemoSchema, {
content: state.content,
visibility: state.metadata.visibility,
});
这里 create() 的职责只是:普通 JS 数据 + generated MemoSchema → Protobuf Message。
它不负责:
- URL;
- HTTP method;
- fetch;
- 认证;
- 请求路由。
真正的前端 Client 是 createClient + Transport
web/src/connect.ts 是前端 API 基础设施的 Composition Root:
const transport = createConnectTransport({
baseUrl: window.location.origin,
useBinaryFormat: true,
fetch: fetchWithCredentials,
interceptors: [authInterceptor],
});
export const memoServiceClient = createClient(MemoService, transport);
可以把两部分理解为:
MemoService
= 有哪些 RPC、输入输出是什么
Transport
= 请求发哪里、用什么协议、怎么编码、怎么 fetch、有哪些 interceptor
因此业务代码只需要:
memoServiceClient.createMemo({ memo })
而不用手写 URL、JSON.stringify、response.json() 等机械代码。
Connect / gRPC / gRPC-Web / REST 的关系
不要只记成「REST vs gRPC」。更准确:
Remote API
├─ Resource-oriented
│ └─ REST / HTTP+JSON
└─ Procedure-oriented (RPC)
├─ gRPC
├─ gRPC-Web
└─ Connect Protocol
- gRPC:经典 Proto RPC,典型用于服务间通信,依赖 HTTP/2 framing。
- gRPC-Web:浏览器环境可用的 gRPC 适配协议。
- Connect:Buf/Connect RPC 定义的另一套 Web-friendly RPC wire protocol,可由浏览器 fetch 直接调用。
- REST:资源导向,用 HTTP method/path 表达语义。
createClient() 本身不“同时创建三套 Client”;它根据传入的 Transport 决定这一个 client 用哪种协议。Memos React 当前明确用 createConnectTransport(),因此 Web 主路径是 Connect Protocol。若换 createGrpcWebTransport(),则同一个 MemoService descriptor 可以走 gRPC-Web。
gRPC-Gateway 是什么
它不是 Memos 自己写的 JSON 转换器,而是成熟开源的代码生成器 + runtime。
Proto 可以这样定义:
rpc CreateMemo(CreateMemoRequest) returns (Memo) {
option (google.api.http) = {
post: "/api/v1/memos"
body: "memo"
};
}
这里有两层契约:
RPC Contract:
CreateMemo(CreateMemoRequest) -> Memo
HTTP Mapping Contract:
POST /api/v1/memos
body = memo
protoc-gen-grpc-gateway 根据 mapping 生成 Adapter,机械完成:
HTTP JSON / path / query
↓
CreateMemoRequest
↓
APIV1Service.CreateMemo(...)
↓
Proto Memo
↓
HTTP JSON response
Memos 使用 direct-to-implementation 注册方式时,Gateway generated handler 可以直接调用同一个 APIV1Service,不一定要再经一次内部 gRPC 网络 hop。
因此不是「写 RPC 后库自动猜 REST」,而是:写 RPC + 明确 HTTP mapping → 自动生成 REST Adapter。
与 MemoFlow Contract-first 思路的对照
Memos 和 MemoFlow 的共同点不是“用了同一套库”,而是都在追求 Contract / Schema 作为 Source of Truth。
Memos MemoFlow
.proto message ≈ Zod Schema / DTO
service + rpc ≈ RpcMap
protoc-generated TS type ≈ z.infer<typeof Schema>
Proto + codegen ≈ TS package 直接共享
关键区别:
- Memos 前后端是 TypeScript + Go,需要语言无关 IDL,再分别生成两边代码;
- MemoFlow Web/API 都在 TypeScript 生态,可以直接共享
packages/contracts; - Proto 额外规定 field number、跨语言 wire format、RPC service;
- Zod 更直接表达运行时 validation rule。
MemoFlow 中叫 RpcMap 不代表网络层就是 gRPC;RPC 是抽象调用风格,gRPC 只是 RPC 的一种具体协议。
前端状态:Editor Service、API Client 与 QueryClient 不要混
Memos 前端有几个容易混名的层:
MemoEditor/services/memoService.ts
= 编辑器 use-case service
memoServiceClient
= generated typed RPC client
QueryClient
= React Query 的 server-state/cache 管理器
Editor save() 为什么合并 Create / Update
MemoEditor/services/memoService.ts::save() 是 UI 语义的「保存」,不是 Proto MemoService 的完整 CRUD:
用户点 Save
├─ 没有 memoName → createMemo
├─ 有 memoName → updateMemo
└─ 某些场景 → createMemoComment
删除是另一种用户意图,所以走 useDeleteMemo() → memoServiceClient.deleteMemo(),而不是塞进 save()。
QueryClient 是什么
const queryClient = useQueryClient() 拿到 React Query 的缓存总管,负责管理服务器状态在前端的缓存视图。
删除链:
memoServiceClient.deleteMemo(name)
↓
真正修改服务器 / 数据库
↓
onSuccess
├─ removeQueries(detail)
├─ invalidateQueries(lists)
└─ invalidateQueries(user stats)
因此可以记成:
memoServiceClient
= 改服务器事实
queryClient
= 修正前端对服务器事实的认知
Memos 对 server state 的核心策略是:mutation 成功后尽量通过 invalidate/refetch 回到服务器事实,而不是在多个 UI state 里手工维护同一份数据。
SSE 也收敛到同一机制:Go 广播 memo.created / updated / deleted,前端收到后 invalidate 对应 query,最终仍由 React Query 重新同步。
服务器 Composition Root
前端组装点是 web/src/connect.ts;后端对应的重要组装点是:
server/server.go
↓
NewAPIV1Service(secret, profile, store)
↓
APIV1Service{ Store: store, ... }
↓
RegisterGateway(...)
├─ REST Gateway
└─ Connect handlers
Go 这里没有 DI container,主要是朴素的 constructor injection。
认证与 context.Context
Context 不是“请求万能对象”
context.Context 主要承载:
- cancellation;
- deadline;
- request-scoped values。
CreateMemoRequest、JSON body、path 参数等业务输入不会天然放在 ctx 里;generated handler 会把它们解析成 request *v1pb.CreateMemoRequest。
Bearer Token 在业务方法之前已经被处理
Connect 路径大致:
Authorization: Bearer ...
↓
AuthInterceptor
↓
Authenticate
↓
CheckAccess
↓
auth.ApplyToContext
↓
ctx 中带 UserID / Claims / 某些 token 信息
↓
APIV1Service.CreateMemo(ctx, request)
REST Gateway 也复用同一个 Authorizer 思路,把认证结果写入 request context 后再进入 generated gateway handler。
所以 fetchCurrentUser(ctx) 不是重新解析 Bearer / 重新认证。
fetchCurrentUser(ctx) 真正在做什么
逻辑大意:
ctx
↓
auth.GetUserID(ctx)
↓
Store.GetUser(userID)
↓
完整 store.User
因此 Memos 把:
Authentication / Principal
和:
Load current User persistence entity
分成两步。
这是合理的边界意识:Context 更适合放轻量 Principal(UserID/Claims),而不是把完整数据库 Entity 当成万能 request state。
但当前实现也有优化空间:认证路径某些情况下已经查询过 User,随后业务层 fetchCurrentUser() 又可能查一次。更理想的折中可以是:
Auth Middleware
↓
Principal{UserID, Role, ...}
↓
Context
│
├─ 只需要身份 → PrincipalFromContext,无 DB query
└─ 真需要完整 User → request-scoped CurrentUserLoader / cache
这比每个用例都重新 load User 更省,也比直接把 *store.User 长期塞进 Context 更解耦。
APIV1Service、Store 与 Driver 在哪里
APIV1Service
定义:server/router/api/v1/v1.go
构造:NewAPIV1Service(secret, profile, store)
调用方法散落在同 package 的不同文件:
v1.go → struct / constructor / registration
memo_service.go → Memo methods
user_service.go → User methods
auth_service*.go → Auth methods
...
Store
结构体与 New():store/store.go
核心字段:
type Store struct {
profile *profile.Profile
driver Driver
... caches / mutexes
}
Memo persistence API
store/memo.go:
store.Memo
FindMemo / UpdateMemo / DeleteMemo
Store.CreateMemo
Store.GetMemo / ListMemos
Store.UpdateMemo
Store.DeleteMemo
Driver contract
store/driver.go:
type Driver interface {
CreateMemo(...)
ListMemos(...)
UpdateMemo(...)
DeleteMemo(...)
...
}
Concrete DB adapters
store/db/sqlite/memo.go
store/db/mysql/memo.go
store/db/postgres/memo.go
所以完整依赖方向:
APIV1Service
↓
Store
↓
Driver interface
↓
SQLite / MySQL / PostgreSQL
↓
SQL
一条完整写路径:CreateMemo
最值得跟读的一条线:
React EditorState
↓
MemoEditor/services/memoService.save()
↓
create(MemoSchema, {...})
↓
v1pb / TS Proto Memo
↓
memoServiceClient.createMemo()
↓
Connect Transport
↓
Generated Connect Handler
↓
Auth / Metadata Interceptors
↓
ConnectServiceHandler.CreateMemo
↓
APIV1Service.CreateMemo
进入 CreateMemo() 后:
fetchCurrentUser(ctx)
↓
validate request / UID / content length
↓
v1pb.Memo → store.Memo
↓
RebuildMemoPayload
↓
prepare attachments / relations
↓
Store.CreateMemo(ctx, create)
↓
Driver.CreateMemo
↓
INSERT ... RETURNING
↓
得到补齐 DB-generated fields 的 store.Memo
↓
apply attachment / relation mutation
↓
load attachments / relations
↓
convertMemoFromStore(...)
↓
*v1pb.Memo (memoMessage)
↓
Webhook / SSE / Mention notification
↓
return memoMessage
一个重要细节:convertMemoFromStore() 不是“重新 SELECT 一遍 Memo”
SQLite CreateMemo() 使用 INSERT ... RETURNING id, created_ts, updated_ts, row_status,把数据库生成字段直接 Scan 回 create *store.Memo 并返回。
因此:
Store.CreateMemo()
→ 已经拿到持久化后的 store.Memo
convertMemoFromStore() 的职责是持久化模型 → API Resource Representation:转换时间、visibility、resource name、payload,并补 creator / attachments / relations / reactions 等 API 所需信息。它可能为关联信息额外查询,但不是为了“重新恢复刚插入的 Memo 本体”。
memo 与 memoMessage
memo
= *store.Memo
= persistence/internal model
memoMessage
= *v1pb.Memo
= API/Proto response model
这两个变量正好体现 API Model 与 Persistence Model 的边界。
CRUD 返回设计
Memos MemoService 的返回模式很典型:
CreateMemo → Memo
GetMemo → Memo
ListMemos → ListMemosResponse
UpdateMemo → Memo
DeleteMemo → google.protobuf.Empty
Create / Update 为什么返回完整 Resource
客户端提交的数据不是服务器最终状态。创建/修改后,服务端可能补:
- ID / UID / resource name;
- create/update time;
- 默认值;
- payload / tags / snippet;
- relation / attachment;
- 其他 computed fields。
因此返回「服务端最终认定的 Resource」有利于前端同步。
List 为什么套一层 Response
相比裸 []Memo,ListMemosResponse 可以自然扩展:
memos[]
nextPageToken
其他分页 / metadata
Delete 为什么返回 Empty
删除成功通常没有额外业务数据,因此:
return &emptypb.Empty{}, nil
已经足够表达成功;失败通过 RPC error/status 表达,例如 NotFound / PermissionDenied / Unauthenticated / Internal。通常不需要额外 { success: true }。
Delete 也不是永远只能 Empty:软删除、异步删除或需要返回 operation resource 时可以按业务重新设计。
模型转换:API、业务与数据库怎么隔离
这是从 Memos 可以进一步抽象出的通用问题。
Memos 当前的务实模型
Memos 大体是:
API / Proto Model
v1pb.Memo
↕
store.Memo
↕
SQL Row
store.Memo 同时承担「Store 内部模型 + DB Driver 输入输出」的角色,没有再引入一套丰富 Domain Entity。对 CRUD 比重高的项目,这是很务实的选择。
更复杂领域可拆成三层模型
当业务模型和数据库结构差异明显时,更适合:
API DTO / Proto
↓
Application Command
↓
Domain Entity
↓
Repository Interface
════════════════════ architecture boundary
Persistence Adapter
↓
Persistence Mapper
↓
DB Record / Prisma Model / SQL Row
返回时反向:
DB Row
↓
Persistence Mapper
↓
Domain Entity
↓
Response Mapper
↓
API DTO
Mapper 放哪里最合适
优先放在 Repository / Infrastructure Adapter 边界,业务层不要知道数据库 record 长什么样。
Mapper 只做:
- 字段映射;
- 类型转换;
- 序列化 / 反序列化;
- Value Object restore。
不要把业务判断塞进 Mapper,否则它会变成第二个 Service。
什么时候值得拆,什么时候不要过度设计
如果:
Domain Model ≈ DB Row
那像 Memos 一样让 store.Memo 直接服务 Driver 很合理。
如果开始出现:
- Value Object;
- 状态机;
- Aggregate invariant;
- recurrence / schedule 等复杂序列化;
- DB schema 与业务结构长期不一致;
则值得引入:
Domain Entity ↔ Persistence Mapper ↔ DB Record
核心原则:数据库模型是 Infrastructure implementation detail;Repository 是边界。
关键入口与调用链
| 入口 | 路径 | 说明 |
|---|---|---|
| main | cmd/memos/main.go | cobra 命令 |
| 服务器组装 | server/server.go | 创建 Echo、Store、APIV1Service 并注册各入口 |
| API Service 结构体 | server/router/api/v1/v1.go | APIV1Service 定义、构造、Gateway 注册 |
| Connect client 组装 | web/src/connect.ts | createConnectTransport + createClient |
| Proto API Contract | proto/api/v1/memo_service.proto | message / service / rpc / HTTP mapping |
| Buf codegen | proto/buf.gen.yaml | TS / Go / Connect / Gateway / OpenAPI |
| Connect handler 注册 | server/router/api/v1/connect_handler.go | 各 generated service handler |
| Connect Adapter | server/router/api/v1/connect_services.go | req.Msg → APIV1Service → response |
| Connect 拦截器 | server/router/api/v1/connect_interceptors.go | metadata / auth 等横切点 |
| REST Gateway | generated *.pb.gw.go + RegisterGateway | HTTP JSON → Proto RPC Adapter |
| Memo 业务实现 | server/router/api/v1/memo_service.go | Create/Get/List/Update/Delete 业务编排 |
| Store 门面 | store/store.go · store/memo.go | Store 与 Memo persistence API |
| Driver contract | store/driver.go | 多数据库接口 |
| SQLite 实现 | store/db/sqlite/memo.go | 最终 SQL |
| React Query | web/src/hooks/useMemoQueries.ts 等 | server state / mutation / cache invalidation |
阅读路线
推荐按“同一个 Memo 对象怎么跨层换形态”阅读:
web/src/components/MemoEditor/README.md:先理解 EditorState 与 server state 分离。MemoEditor/hooks/useMemoSave.ts:保存 use case 如何编排。MemoEditor/services/memoService.ts:EditorState → Proto Memo,Create / Update 如何选择。web/src/connect.ts:Client / Transport 怎么集中组装。proto/api/v1/memo_service.proto:契约源头。proto/buf.gen.yaml:一份契约如何生成 TS / Go / Gateway。server/router/api/v1/connect_services.go:看薄 Adapter。server/router/api/v1/memo_service.go::CreateMemo:看真正业务编排。server/router/api/v1/v1.go:回看APIV1Service如何组装。store/store.go → store/memo.go → store/driver.go:看 persistence boundary。store/db/sqlite/memo.go:看到INSERT INTO memo/RETURNING。- 原路回来追
convertMemoFromStore():看store.Memo → v1pb.Memo。 - 看 Webhook / SSE,再追前端
useLiveMemoRefresh/ React Query invalidate。 - 第二遍再读
UpdateMemo + FieldMask,第三遍读ListMemos,把 Write Path 和 Read Path 拼起来。
每到一个函数只回答四个问题:
输入是什么?
这一层修改了什么?
下一层调用谁?
输出是什么?
深挖一条线:创建一条 memo 端到端
React MemoEditor
↓
EditorState
↓
useMemoSave()
↓
editor memoService.save()
↓
create(MemoSchema, ...)
↓
memoServiceClient.createMemo()
↓
createClient(MemoService, ConnectTransport)
↓
browser fetch / Connect Protocol
↓
Echo URL space
↓
Connect generated handler
↓
Metadata/Auth Interceptors
↓
ConnectServiceHandler.CreateMemo
↓
APIV1Service.CreateMemo
↓
store.Memo
↓
Store.CreateMemo
↓
Driver.CreateMemo
↓
SQLite/MySQL/PostgreSQL SQL
↓
store.Memo(DB-generated fields 已补齐)
↓
convertMemoFromStore
↓
v1pb.Memo / memoMessage
↓
Connect response
↓
React
↓
QueryClient invalidate/refetch
旁路:
CreateMemo
↓
SSEHub.Broadcast(memo.created)
↓
其他页面 / Tab
↓
useLiveMemoRefresh
↓
React Query invalidate
跟读要点:
- 不要把
create()当网络层;它只创建 Proto Message。 - 不要把
MemoEditor/services/memoService.ts当完整 Memo API;它只是 Editor 保存 use case。 - 不要把
memoServiceClient和QueryClient混在一起:一个访问后端,一个管理前端 server-state cache。 - 不要把
fetchCurrentUser(ctx)当“重新认证”;Token 已经在 interceptor/middleware 层处理,它是在按 UserID load 完整 User。 - 不要把
convertMemoFromStore()理解成“重新 SELECT Memo”;Memo 本体已经从INSERT ... RETURNING回来了。
值得偷师 / 不建议照抄
| 做法 | 评价 | 我的判断 |
|---|---|---|
| protobuf 作为跨语言 API Source of Truth | 推荐 | TS + Go 特别适合,减少 schema drift;需要接受 codegen 成本 |
createClient(Service, Transport) 隔离契约与传输策略 | 推荐 | 业务代码不碰 URL/序列化,Transport 可替换 |
| Proto RPC + gRPC-Gateway 同时暴露 REST | 推荐但有复杂度 | 内部 typed RPC 与外部通用 HTTP 可以共存,前提是确实需要多消费者 |
Connect / REST Adapter 复用同一个 APIV1Service | 推荐 | 避免 transport 层复制业务逻辑 |
| 认证统一写 Principal 到 Context | 推荐 | 横切逻辑集中;完整 DB User 是否放 Context 要谨慎 |
fetchCurrentUser 每个用例显式 load User | 可改进 | 表达前置条件清晰,但可能产生重复查询;可考虑 Principal + request-scoped loader/cache |
| service 实现直接放 router/api/v1,不设独立 application 层 | 务实但耦合偏高 | 中小 CRUD 产品够用;领域复杂后可能需要独立 use case/application boundary |
| Store 门面 + Driver interface 支持三种数据库 | 推荐 | 多数据库差异集中在 persistence layer |
store.Memo 同时承担内部/persistence model | 务实 | 模型简单时减少 mapper;复杂 Domain 不建议照抄 |
| React Query 统一管理 server state | 推荐 | mutation/SSE 最终收敛到 invalidate/refetch,避免多份 UI 状态漂移 |
| Create/Update 返回最终 Resource,Delete 返回 Empty | 推荐 | 调用方得到服务器最终状态,Delete 不制造冗余 success DTO |
| 前后端单仓,前端产物 embed 进二进制 | 适合自托管 | 部署简单,但 build pipeline 更紧耦合 |
docs/adr/ 记录架构决策 | 推荐 | 真实演进项目尤其重要 |
与 Ardan Labs Service 的对照
| 维度 | Ardan Labs Service | Memos |
|---|---|---|
| 性质 | 教学参考实现 | 真实产品 |
| 顶层切分 | api / app / business / foundation | cmd / proto / server / store / internal / web |
| 分层依据 | 手写约定 + 明确 app/business 边界 | Proto 契约 + Transport Adapter + APIV1Service |
| 领域组织 | 纵切,每层一个 xxxapp / xxxbus | 按 proto service / store entity 切分 |
| 用例层 | 独立 app 层 | 无独立 package,主要合并进 APIV1Service |
| 类型策略 | 业务层内部模型更明确 | generated pb type + store model 两层较突出 |
| 存储 | 领域内 stores/*db | 全局 Store 门面 + Driver 多实现 |
| 前端 | api/frontends/admin 极简 | 完整 React SPA,单仓内嵌 |
我的疑问与待验证
- Access Token V2 路径中认证阶段与
fetchCurrentUser()是否在常见请求中稳定产生重复Store.GetUser,以及实际是否已有 cache 抵消成本。 - Connect handler 对 native gRPC / gRPC-Web 的兼容范围在 Memos 当前部署入口中是否全部对外开放,还是主要作为协议兼容能力存在。
APIV1Service随功能增长后是否会继续拆出更明确的 application/usecase service。
沉淀出的笔记
- Contract-first:同一 Source of Truth 可以驱动多语言类型、typed client、REST adapter 与 OpenAPI。
- Transport Adapter 应只解决路由、协议、序列化和认证等横切问题,业务规则收敛到同一 Application Service。
- Server state 与 UI state 应分离;mutation 和实时事件最好最终收敛到统一 cache 同步机制。
- Persistence Mapper 是否值得存在,取决于 Domain Model 与 DB Row 的差异,而不是为了“分层完整”机械增加对象。
相关链接 / 官方入口
| 入口 | 地址 |
|---|---|
| 仓库 | https://github.com/usememos/memos |
| 官网 | https://www.usememos.com/ |
| 文档 | https://www.usememos.com/docs |
| proto 说明 | proto/README.md · proto/api/v1/README.md |
| 架构决策记录 | docs/adr/ |