Vibe Kanban 云端服务端架构实战:基于 Axum 与 ElectricSQL 的实时同步引擎解析
【免费下载链接】vibe-kanbanGet 10X more out of Claude Code, Codex or any coding agent项目地址: https://gitcode.com/GitHub_Trending/vi/vibe-kanban
Vibe Kanban 的remotecrate 是其托管云服务端(Vibe Kanban Cloud):一个由 Axum 构建的 REST API、一个由 Vite 打包的 React SPA 前端,以及通过 ElectricSQL 实现的实时同步通道。本篇技术指南以 crates/remote/AGENTS.md 为骨架,结合仓库源码,系统讲解该服务端的架构分层、ElectricSQL 实时同步机制(Shape 订阅 + txid 握手)、类型安全的 CRUD 路由模式(MutationBuilder)、认证授权、数据库迁移与类型生成管线,读完你既能照葫芦画瓢搭建本地开发环境,也能掌握"如何为这套实时同步系统新增一张同步表"的完整实操路径。
一、整体架构:三层服务如何协同
remotecrate 不是单一进程,而是三组组件协作的"读路径实时化"架构,其拓扑如下:
remote-server (Axum, port 8081) ├── /v1/* REST API (CRUD + auth + webhooks) ├── /shape/* ElectricSQL proxy (auth-gated shape subscriptions) └── /srv/static React SPA (built by Vite, served as fallback) PostgreSQL (port 5432) └── wal_level=logical, electric_sync role with REPLICATION ElectricSQL (port 3000, internal) └── Subscribes to Postgres via logical replication, streams shapes over HTTP这套架构的核心设计原则在 crates/remote/AGENTS.md 中被反复强调:写入走 REST API(权威路径),读取走 ElectricSQL 实时流(读路径)。PostgreSQL 开启wal_level=logical逻辑复制,ElectricSQL 作为订阅方捕获变更,再以 HTTP Shape 流的形式推送给客户端;Axum 服务端则扮演"网关"角色,既对外提供 CRUD 接口,又对所有 Shape 订阅做鉴权代理。
从源码看,服务端启动的完整顺序在 crates/remote/src/app.rs 中清晰可见:
- 创建数据库连接池(
db::create_pool); - 执行 SQLx 迁移(
db::migrate); - 创建/更新
electric_sync角色密码(db::ensure_electric_role_password),这一步必须在 ElectricSQL 启动前完成; - 同步 Electric 发布(publication)列表(
ensure_electric_publications); - 初始化 JWT 服务、OAuth 提供方注册表(GitHub/Google)、OAuth 握手与令牌校验服务;
- 按需初始化邮件(Loops)、R2、Azure Blob、GitHub App、billing、analytics 等可选服务;
- 组装
AppState与路由树,监听SERVER_LISTEN_ADDR(默认0.0.0.0:8081)。
值得注意:服务端要求必须配置至少一个认证提供方(GitHub OAuth / Google OAuth / 本地账号任选其一),否则app.rs会直接bail!("no OAuth providers configured")拒绝启动。
二、构建与运行:开发环境与 Docker 部署
2.1 本地一键启动
仓库根目录的 package.json 提供了完整的 remote 开发脚本:
# (在仓库根目录执行) pnpm run remote:dev # 让桌面客户端连接本地云服务端 export VK_SHARED_API_BASE=http://localhost:3000 pnpm run dev其中remote:dev实际执行的是:
cd crates/remote && docker compose --env-file .env.remote up --build ; docker compose --env-file .env.remote down -v即通过 crates/remote/docker-compose.yml 一键拉起remote-db(PostgreSQL 16,显式以-c wal_level=logical启动)、electric(electricsql/electric:1.4.13)与remote-server三个服务。electric服务的配置中有一组关键环境变量:
DATABASE_URL:使用electric_sync角色连接 Postgres(密码来自ELECTRIC_ROLE_PASSWORD,默认remote);ELECTRIC_MANUAL_TABLE_PUBLISHING: true:启用手动表发布,只有显式调用electric_sync_table的表才会被同步;ELECTRIC_FEATURE_FLAGS: allow_subqueries,tagged_subqueries:允许 Shape 的 WHERE 子句中使用子查询(这正是 issue 关联表按项目订阅的实现基础)。
2.2 完整模式与清理
需要附带附件存储(Azurite)或 relay 隧道时,可运行pnpm run remote:dev:full(额外启用relay、attachmentsprofile);彻底清理并删除数据库则用:
pnpm run remote:dev:clean该命令执行docker compose ... down -v --remove-orphans,会连同数据卷一并删除。
2.3 生产镜像
生产构建采用多阶段 Docker:Node(构建前端)→ Rust(编译服务端)→ Debian slim(运行态)。前端在镜像构建期间完成打包,运行期由 Axum 从硬编码路径/srv/static提供静态资源。自托管时通过PUBLIC_BASE_URL与REMOTE_SERVER_PORTS=0.0.0.0:3000:8081对外暴露服务。
2.4 关于 billing 私有依赖
billingcrate 通过vk-billingfeature 引入,在自托管 Docker 构建(FEATURES为空)时会被从 Cargo.toml 中剥离。因此所有涉及 billing 的代码必须用#[cfg(feature = "vk-billing")]门控,crates/remote/src/routes/mod.rs 中甚至为未启用该 feature 的情况提供了返回空路由的mod billing兜底实现。任何新代码都不得在未加 feature gate 的情况下 import billing crate。
三、核心模块地图
AGENTS.md 用一张表勾勒了 remote crate 的关键模块,结合源码补充如下:
| 模块 | 职责 |
|---|---|
| app.rs | 服务端引导:连接池 → 迁移 → electric 角色 → JWT → OAuth → 服务 → 监听 |
| config.rs | RemoteServerConfig,全部由环境变量解析;空字符串视为未设置 |
| state.rs | AppState,跨路由共享(连接池、JWT、OAuth、billing、R2 等) |
| shapes.rs | 16 个ShapeDefinition<T>常量,供 ElectricSQL 同步 |
| shape_definition.rs | ShapeDefinition结构体、ShapeExporttrait、define_shape!宏(含编译期 SQL 校验) |
| mutation_definition.rs | MutationBuilder:类型安全 CRUD 路由 + TS 类型元数据 |
| routes/electric_proxy.rs | 鉴权代理:把 Shape 请求转发给内部 ElectricSQL |
| routes/mod.rs | 路由树、SPA 兜底服务、all_mutation_definitions()聚合 |
| db/mod.rs | 连接池、迁移、ensure_electric_role_password() |
auth/ | JWT、OAuth 提供方(GitHub/Google)、会话中间件 |
AppState之所以能支撑所有路由,是因为它在app.rs中被一次性组装后注入 AxumRouter,所有 handler 通过State(state): State<AppState>提取——这也是routes/tags.rs中每个 handler 的统一取用方式。
四、ElectricSQL 实时同步:从 Shape 到 txid 握手
4.1 工作原理四步走
AGENTS.md 将同步机制归纳为四步,源码完全印证:
- Shape 定义:Shape 是"单表订阅 + 可选
WHERE/columns过滤条件",在 shapes.rs 中以常量形式定义,属于服务端受控内容; - 鉴权代理:electric_proxy.rs 先校验组织/项目成员身份,再转发 Shape 请求到内部 ElectricSQL 服务;
- 写操作:create/update/delete 全部走 REST 端点,返回包裹着 Postgres 事务 ID(
txid)的MutationResponse<T>; - 乐观更新收敛:前端在 Electric 流上等待该
txid出现,一旦出现即丢弃乐观 UI 状态。
4.2 Shape 定义的结构
每个 Shape 由define_shape!宏生成,例如组织级与项目级的典型定义(shapes.rs):
pub const PROJECTS_SHAPE: ShapeDefinition<Project> = crate::define_shape!( name: "PROJECTS_SHAPE", table: "projects", where_clause: r#""organization_id" = $1"#, url: "/shape/projects", params: ["organization_id"], ); pub const PROJECT_ISSUES_SHAPE: ShapeDefinition<Issue> = crate::define_shape!( name: "PROJECT_ISSUES_SHAPE", table: "issues", where_clause: r#""project_id" = $1"#, url: "/shape/project/{project_id}/issues", params: ["project_id"], );ShapeDefinition<T>的字段(shape_definition.rs)包括name、table、where_clause、params、url,并通过PhantomData<T>绑定行类型,T必须实现ts_rs::TS,从而让 Shape 同时具备运行时元数据与编译期类型。
define_shape!宏最巧妙之处在于编译期 SQL 校验:宏体内生成一个_validate()函数,用sqlx::query!把table + where_clause拼成真实查询进行编译期检查,并把params中的每个参数绑定为uuid::Uuid::nil()占位——这意味着表名拼写错误、WHERE 引用不存在的列,都会在编译阶段直接报错,从源头杜绝"写错 SQL 上线后才炸"的经典事故。
16 个 Shape 按订阅粒度分为三档:
- 组织级(
organization_id参数):projects、notifications(按user_id)、organization_member_metadata、users(WHERE 用子查询id IN (SELECT user_id FROM organization_member_metadata WHERE organization_id = $1)限制为组织成员); - 项目级(
project_id参数):tags、project_statuses、issues、workspaces(owner_user_id/project_id两种)、pull_requests、pull_request_issues,以及 issue 关联表issue_assignees、issue_followers、issue_tags、issue_relationships(均通过issue_id IN (SELECT id FROM issues WHERE project_id = $1)子查询下钻到项目粒度); - Issue 级(
issue_id参数):issue_comments、issue_comment_reactions(comment_id IN (SELECT id FROM issue_comments WHERE issue_id = $1))。
这种"项目级流式下发 issue 关联数据、issue 级单独流式评论"的粒度划分,既保证了看板页所需的完整数据快照,又避免了大评论流随看板高频刷新。
4.3 代理转发:客户端无法篡改的查询
proxy_table(electric_proxy.rs)的转发逻辑严格遵循"服务端定表、客户端只传参数值":
- 以
ELECTRIC_URL为基础 URL,路径固定为/v1/shape; - 服务端写入
table与where参数(where即 Shape 的where_clause,来自服务端常量,客户端不可覆盖); - 按顺序把
params绑定为params[1]、params[2]……(对应 WHERE 中的$1、$2占位符); - 仅放行 Electric 协议白名单参数
offset、handle、live、cursor、columns中的客户端传值; - 若配置了
ELECTRIC_SECRET则附带 secret; - 携带
x-vk-electric-sticky头(值为会话 UUID)发起请求,并将响应体以流式方式透传(不缓冲),同时剔除Content-Encoding/Content-Length头并追加Vary: Authorization,保证浏览器侧的缓存语义正确。
4.4 txid 握手:消除 UI 闪烁的关键
所有变更类 handler 必须返回 Postgres 事务 ID,这是 AGENTS.md 反复强调的硬性约定,否则会导致前端"乐观更新提前丢弃、随后又被 Electric 流回滚"的 UI 闪烁:
// 路由 handler 内 let result = db::issues::create_issue(&pool, &payload).await?; // MutationResponse 包含 pg_current_xact_id() 得到的 txid Ok(Json(MutationResponse { data: result.data, txid: result.txid }))以 routes/tags.rs 的create_tag为例:先做成员权限校验(ensure_project_access),再做业务校验(HSL 颜色格式),最终TagRepository::create返回的正是MutationResponse<Tag>。前端在 Electric 流上等到该txid之后,才认为本地写操作已被服务端确认并进入稳定状态。
4.5 安全边界
- ElectricSQL 仅限内网:客户端绝不直接连 ElectricSQL,所有 Shape 请求必须经过 electric_proxy.rs 的鉴权代理;代理层任何授权失败返回
403 Forbidden,连接失败返回502 Bad Gateway; - Shape 是服务端常量:表名、WHERE、columns 全部由服务端控制,客户端无法请求任意表或任意数据范围——"订阅什么"由 shapes.rs 的 16 个常量说了算,而不是由客户端说了算。
五、新增一张同步表的完整实操
AGENTS.md 给出了四条操作步骤,这里结合迁移与代理源码逐条展开:
第 1 步:创建迁移。新表需要REPLICA IDENTITY FULL并纳入 Electric 发布。仓库的做法是封装electric_sync_table(p_schema, p_table)函数(定义于 20251127000000_electric_support.sql),内部执行ALTER TABLE %s REPLICA IDENTITY FULL并注册同步;20260114000000_electric_sync_tables.sql 就是批量调用该函数的范例:
SELECT electric_sync_table('public', 'users'); SELECT electric_sync_table('public', 'projects'); -- ... 其余表 -- 子查询过滤的表额外建立索引以保障性能 CREATE INDEX IF NOT EXISTS idx_issue_assignees_issue_id ON issue_assignees(issue_id);注意:ELECTRIC_MANUAL_TABLE_PUBLISHING: true意味着未调用该函数的表不会进入同步通道。
第 2 步:定义 Shape。在 shapes.rs 中用define_shape!宏新增常量,并按订阅粒度选择organization_id/project_id/issue_id作为params。若 WHERE 涉及子查询(如 issue 关联表),需确保迁移中为子查询过滤列建好索引。
第 3 步:注册代理路由。若新 Shape 属于既有 scope 模式(org/project/issue),直接在 shape_routes.rs 中登记即可;若需要全新 scope 模式,则在 electric_proxy.rs 中新增代理路由并复用proxy_table通用转发逻辑。
第 4 步:返回 txid。该表的所有 mutation 路由必须返回MutationResponse<T>包裹的事务 ID(见 4.4 节)。
六、Mutation 模式:一套 Builder 生成路由与类型元数据
所有 CRUD 路由遵循一致的MutationBuilder模式(AGENTS.md 中的范式):
MutationBuilder::<Entity, CreatePayload, UpdatePayload>::new("entities") .list(list_handler) .get(get_handler) .create(create_handler) .update(update_handler) .delete(delete_handler) .build()这套 Builder 的工程价值在 mutation_definition.rs 中体现得淋漓尽致:
- 一个定义,两份产物:
MutationBuilder既通过router()生成 Axum 子路由(GET/POST /{table}与GET/PATCH/DELETE /{table}/{id}),又通过definition()产出MutationDefinition元数据供 TS 生成器消费; - handler 签名与声明类型强绑定:
create/update方法要求 handler 的提取器元组实现HasJsonPayload<C>/HasJsonPayload<U>trait——该 trait 只对"末尾含Json<T>"的提取器元组实现,从而保证"声明的创建/更新类型"与"handler 实际接收的 JSON 载荷类型"永远一致,杜绝元数据漂移; - 无端点用标记类型:不提供 create/update 的实体分别用
NoCreate/NoUpdate标记,definition()根据组合生成对应的create_type: None元数据。
具体落地可对照 routes/tags.rs:
pub fn mutation() -> MutationBuilder<Tag, CreateTagRequest, UpdateTagRequest> { MutationBuilder::new("tags") .list(list_tags) .get(get_tag) .create(create_tag) .update(update_tag) .delete(delete_tag) } pub fn router() -> axum::Router<AppState> { mutation().router() }tags的完整 CRUD 在同一个文件内实现:list_tags/get_tag先ensure_project_access校验项目成员资格;create_tag/update_tag校验 HSL 颜色(is_valid_hsl_color);所有写操作返回MutationResponse<Tag>。新实体照此模式实现后,TS 类型由pnpm run remote:generate-types自动生成。
七、认证与授权
- JWT(
auth/jwt.rs):使用VIBEKANBAN_REMOTE_JWT_SECRET签名,所有受保护路由统一挂载require_session中间件。注意 config.rs 中的validate_jwt_secret有硬性约束:该 secret 必须是 Base64 编码,解码后长度不得少于 32 字节,否则启动直接报InvalidVar错误; - OAuth(
auth/provider.rs):GitHub 与 Google 双提供方,通过ProviderRegistry注册;至少配置一个提供方(含本地账号SELF_HOST_LOCAL_AUTH_EMAIL/SELF_HOST_LOCAL_AUTH_PASSWORD),否则AuthConfig::from_env返回NoOAuthProviders错误。空环境变量一律视为"未配置"而跳过; - 成员校验:所有资源路由在访问数据库之前先做组织/项目成员资格校验。以
tags为例,每个 handler 都通过RequestContext(来自中间件)拿到当前用户,再调用ensure_project_access(state.pool(), ctx.user.id, project_id);若组织/项目校验不通过则返回 403/404。
路由树的组织在 routes/mod.rs 中:公开路由(/health、OAuth、review、billing public 等)与受保护路由分别组装,受保护部分整体套上require_session中间件,再统一nest("/v1", ...);最后.fallback_service(spa)把所有未命中 API 的请求交给/srv/static的index.html(SPA 路由兜底),并叠加压缩、CORS、请求 ID 与追踪层。
八、前端与共享类型
8.1 前端(packages/remote-web/)
前端技术栈为React 18 + React Router 7 + Vite + Tailwind:
- 在 Docker 镜像构建期间完成打包,产物从
/srv/static提供; VITE_APP_BASE_URL与VITE_API_BASE_URL为构建期变量,直接烘焙进 JS bundle,修改后必须重新构建;- OAuth 采用 PKCE 流程(
pkce.ts),避免授权码拦截风险; - ElectricSQL Shape 全部经
/shape/*代理消费,前端不直接感知内部 ElectricSQL 地址。
8.2 api-types:跨端共享类型
远程服务端与本地桌面应用共享的类型全部放在 crates/api-types/ crate 中,remote与server两个 crate 都依赖它。它包含三类内容:
- 行类型(Row types):数据库实体的 API 表示(
Issue、Project、User、Workspace等); - 请求类型(Request types):create/update 载荷(
CreateIssueRequest、UpdateProjectRequest等); - 共享枚举:
IssuePriority、MemberRole、PullRequestStatus、NotificationType等。
所有类型派生ts-rs::TS以便自动导出 TypeScript。凡是两个后端都会用到的新实体,类型必须定义在 api-types,而不是 remote crate 内部。
8.3 类型生成管线
src/bin/generate_types.rs(源码见 generate_types.rs)生成 shared/remote-types.ts——远程前端消费的唯一TypeScript 类型文件。运行方式:
pnpm run remote:generate-types # 写入 shared/remote-types.ts pnpm run remote:generate-types --check # CI 模式,文件过期则非零退出生成文件包含四类内容:
- 接口声明:api-types 中每个行/请求类型的
::decl()输出(type_decls向量逐个声明,生成时统一补export前缀); ShapeDefinition<T>常量:每个 ElectricSQL Shape 一条,来源于shapes::all_shapes(),形如export const PROJECT_ISSUES_SHAPE = defineShape<Issue>('issues', ['project_id'] as const, '/v1/shape/project/{project_id}/issues', ...);MutationDefinition<TRow, TCreate, TUpdate>常量:每个 CRUD 实体一条,来源于routes::all_mutation_definitions()(见 routes/mod.rs,当前聚合了 projects、notifications、tags、project_statuses、issues、issue_assignees、issue_followers、issue_tags、issue_relationships、issue_comments、issue_comment_reactions、pull_request_issues 共 12 个实体),类型名经to_screaming_snake_case转换为TAG_MUTATION这类常量名;- 类型辅助工具:
MutationRowType、MutationCreateType、MutationUpdateType,用于从 mutation 定义中提取对应类型。
当 api-types 新增远程前端需要的类型时,把它的::decl()加进type_decls并重跑生成器即可。本地桌面应用有独立生成器(crates/server/src/bin/generate_types.rs),输出 shared/types.ts。
九、数据库、迁移与测试
- 迁移:SQLx 管理,位于 crates/remote/migrations/,启动时自动执行;新迁移必须以时间戳前缀命名(如
20260317000000_xxx.sql); - 离线模式:SQLx 的编译期查询校验需要连接真实 Postgres 或离线查询数据(
.sqlx/目录),CI 构建用pnpm run remote:prepare-db(即cd crates/remote && bash scripts/prepare-db.sh)生成离线数据; - 连接池:最大 10 个连接;
- 测试:
cargo test --manifest-path crates/remote/Cargo.tomldefine_shape!的编译期校验、HasJsonPayload的结构化约束,加上 SQLx 的编译期 SQL 检查,共同构成了"类型错误在编译期暴露"的三重防线。
十、常见陷阱速查
AGENTS.md 总结了五个高频坑,全部有源码依据:
- 空字符串 vs 未设置:Docker Compose 的
${VAR:-}会产生"",而std::env::var()对空串返回Ok("")。可选配置必须用!v.is_empty()判断(config.rs 中 R2/Azure/GitHub App 的from_env全部遵循此模式),否则会把"未配置"误判为"已配置但值为空"; - ElectricSQL 启动顺序:
remote-server必须先启动(先跑迁移并创建electric_sync角色),ElectricSQL 才能连接成功——因此 docker-compose 中electric的depends_on同时包含remote-db与remote-server,且都要求condition: service_healthy; - Billing feature gate:所有 billing 代码必须置于
#[cfg(feature = "vk-billing")]之后(见 routes/mod.rs 的兜底模块写法); - 前端 URL 变量是构建期的:
VITE_*在构建时烘焙进 JS bundle,改环境变量必须重新构建镜像,不能靠运行时注入; - SPA 兜底路径硬编码:前端从
/srv/static提供(routes/mod.rs),该路径只存在于 Docker 容器内,本地裸跑服务端时需自行保证该目录存在。
结语
Vibe Kanban 的remotecrate 示范了一套值得借鉴的"托管型实时协作后端"落地形态:Axum 承担权威写入与鉴权网关,ElectricSQL 承担只读实时分发,服务端常量化的 Shape 定义 + 编译期 SQL 校验 + txid 握手共同保证了实时性与一致性,而MutationBuilder与generate_types把"新增一张同步表"压缩为"一个迁移 + 一个常量 + 一条注册 + 一行 txid"的最小改动面。对想要在自有产品中复刻类似实时看板/协作体验的开发者而言,这份 AGENTS.md 与其源码实现就是一份可逐步对照的工程蓝图。
【免费下载链接】vibe-kanbanGet 10X more out of Claude Code, Codex or any coding agent项目地址: https://gitcode.com/GitHub_Trending/vi/vibe-kanban
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考