Memos

开源自托管轻量笔记服务 Memos 的源码结构笔记:契约先行(protobuf)的分层、多数据库驱动的 store 设计与单仓前后端组织。

#type / resource #status / growing #tech / dev / backend #tech / dev / frontend #resource / go

[!info] related notes

Memos

这是什么

开源的自托管轻量笔记 / 备忘录服务,单二进制部署,Go 后端 + React 前端放在同一个仓库。定位是「隐私优先的 Twitter 式速记」。

Ardan Labs Service 的教学样板不同,这是一个真实演进中的产品仓库,因此更能看到取舍的痕迹:契约先行、多数据库支持、前端内嵌、渐进重构。

[!tip] 理解它的关键 不要按目录逐文件扫,而要抓住一条真实数据流,从 React 一直追到 SQL,再原路回来。 对 Memos 最值得先追的是「创建一条 Memo」:EditorState → Proto → Connect → APIV1Service → Store → Driver → DB → v1pb.Memo → React Queryproto/ 是这条链的“腰”:它不是业务逻辑,却钉住了前后端共同遵守的数据与服务契约。

版本快照

分支main
commitbd636a436522(2026-08-09)
抓取日期2026-08-10
modulegithub.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/v5Echo v5,不是 Gin。负责外层 HTTP 服务器、中间件、静态资源、健康检查和把不同 Handler 挂到 URL 空间
RPC / APIconnectrpc.com/connectConnect RPC,React Web 当前主要使用的类型安全 RPC 入口;服务端 handler 还可兼容 gRPC / gRPC-Web
REST 网关grpc-ecosystem/grpc-gateway/v2根据 proto 中的 HTTP mapping 生成 REST/JSON → Proto RPC 的 Adapter
gRPCgoogle.golang.org/grpc与 Connect 生态共存,proto service 仍保持标准 gRPC 语义
查询表达式google/cel-gointernal/filter/ 的过滤语法基于 CEL
Markdownyuin/goldmark与自研 internal/markdown/ 并存
CLI / 配置spf13/cobra + spf13/viper + joho/godotenv
SQLite 驱动modernc.org/sqlite纯 Go 实现,无需 CGO,这是能出单二进制的关键
MySQL / PGgo-sql-driver/mysql · lib/pq
对象存储aws-sdk-go-v2/service/s3
集成测试testcontainers-go(+ mysql / postgres 模块)
AIopenai-go/v3 · google.golang.org/genai对应 internal/ai/
MCPmodelcontextprotocol/go-sdk对应 server/router/mcp/
RSSgorilla/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.goAPIV1Service 方法中。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.gouser_service.goauth_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 Driverstore/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.stringifyresponse.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 本体”。

memomemoMessage

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

相比裸 []MemoListMemosResponse 可以自然扩展:

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 是边界。

关键入口与调用链

入口路径说明
maincmd/memos/main.gocobra 命令
服务器组装server/server.go创建 Echo、Store、APIV1Service 并注册各入口
API Service 结构体server/router/api/v1/v1.goAPIV1Service 定义、构造、Gateway 注册
Connect client 组装web/src/connect.tscreateConnectTransport + createClient
Proto API Contractproto/api/v1/memo_service.protomessage / service / rpc / HTTP mapping
Buf codegenproto/buf.gen.yamlTS / Go / Connect / Gateway / OpenAPI
Connect handler 注册server/router/api/v1/connect_handler.go各 generated service handler
Connect Adapterserver/router/api/v1/connect_services.goreq.Msg → APIV1Service → response
Connect 拦截器server/router/api/v1/connect_interceptors.gometadata / auth 等横切点
REST Gatewaygenerated *.pb.gw.go + RegisterGatewayHTTP JSON → Proto RPC Adapter
Memo 业务实现server/router/api/v1/memo_service.goCreate/Get/List/Update/Delete 业务编排
Store 门面store/store.go · store/memo.goStore 与 Memo persistence API
Driver contractstore/driver.go多数据库接口
SQLite 实现store/db/sqlite/memo.go最终 SQL
React Queryweb/src/hooks/useMemoQueries.tsserver state / mutation / cache invalidation

阅读路线

推荐按“同一个 Memo 对象怎么跨层换形态”阅读:

  1. web/src/components/MemoEditor/README.md:先理解 EditorState 与 server state 分离。
  2. MemoEditor/hooks/useMemoSave.ts:保存 use case 如何编排。
  3. MemoEditor/services/memoService.ts:EditorState → Proto Memo,Create / Update 如何选择。
  4. web/src/connect.ts:Client / Transport 怎么集中组装。
  5. proto/api/v1/memo_service.proto:契约源头。
  6. proto/buf.gen.yaml:一份契约如何生成 TS / Go / Gateway。
  7. server/router/api/v1/connect_services.go:看薄 Adapter。
  8. server/router/api/v1/memo_service.go::CreateMemo:看真正业务编排。
  9. server/router/api/v1/v1.go:回看 APIV1Service 如何组装。
  10. store/store.go → store/memo.go → store/driver.go:看 persistence boundary。
  11. store/db/sqlite/memo.go:看到 INSERT INTO memo / RETURNING
  12. 原路回来追 convertMemoFromStore():看 store.Memo → v1pb.Memo
  13. 看 Webhook / SSE,再追前端 useLiveMemoRefresh / React Query invalidate。
  14. 第二遍再读 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。
  • 不要把 memoServiceClientQueryClient 混在一起:一个访问后端,一个管理前端 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 ServiceMemos
性质教学参考实现真实产品
顶层切分api / app / business / foundationcmd / 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/
创建于 2026/8/10 更新于 2026/8/11