CC Switch 后端四层:Commands → Services → DAO → Database 各自管什么

2026-08-10

看别人项目的后端架构图,最省事的读法是照着箭头往下走一遍。cc-switch 的 README_ZH.md 第 429 行就给了这么一行箭头:Commands → Services → DAO → Database。四个词,四个目录,看上去一目了然。

但真把 src-tauri/src/ 打开逐层核对,会发现这四个词里只有两个对应着真实的 Rust 类型,另外两个是目录约定;而且这四层之间的箭头,有一处是反着走的。这篇就把这四层各自管什么讲清楚,顺带把两处容易读错的地方摊开。

本文依据我们本地 clone 的 cc-switch 仓库快照 c39c903(采集日 2026-08-10,仓库内版本号 3.19.2package.jsonsrc-tauri/Cargo.toml 两处一致)。我们只是静态读源码文本,没有编译、没有运行,也没有安装过这个桌面应用。

先把四层的可核查坐标钉住

写架构解读最怕通篇形容词。先给一张能自己复现的表,后面的每一段都挂在这几个数字上。

目录我们数到的规模复现命令
Commandssrc-tauri/src/commands/14173 行wc -l 求和
Servicessrc-tauri/src/services/40 个 .rs 文件,45838 行wc -l 求和
DAOsrc-tauri/src/database/dao/12 个模块dao/mod.rs:5-16
Databasesrc-tauri/src/database/(含 dao/10730 行wc -l 求和

整个 src-tauri/src 下 Rust 源码合计 169247 行、218 个 .rs 文件(命令是 find src-tauri/src -name '*.rs' | xargs wc -l | tail -1find src-tauri/src -name '*.rs' | wc -l)。四层加起来约七万行,也就是说还有九万多行落在这四个目录之外——比四层的总和还多,散在根级的三十多个 .rs 文件和 proxy/session_manager/ 等子目录里。这一点后面还要再提。

第一个反直觉的地方从这张表就能看出来:层名的顺序不代表体量的顺序。Services 一层就是 Commands 的三倍多。业务逻辑没有均匀摊在四层上,它绝大部分堆在中间那一层。

Commands 层:Tauri 命令的登记处,不完全闭合

commands/ 下按领域切文件,每个文件里挂一串 #[tauri::command]。我们按文件数了几个代表:provider.rs 30 条、proxy.rs 24 条、skill.rs 24 条、settings.rs 19 条、usage.rs 18 条。这些文件大多带模块头注释,比如 commands/proxy.rs 头注写的是「代理服务相关的 Tauri 命令,提供前端调用的 API 接口」——这句注释就是这一层的职责声明:它是前端的入口登记处。

有两处细节说明这一层并不是严格闭合的:

一是 commands/sync_support.rs(97 行)里一条 #[tauri::command] 都没有,它提供 run_post_import_sync 这类内部辅助函数。也就是说 commands/ 目录 ≠ 命令集合,目录里混着不对外的辅助代码。

二是还有 1 个 #[tauri::command] 直接定义在 src-tauri/src/lib.rs 里,压根不在 commands/ 下。你要是想靠「遍历 commands/ 目录」来枚举全部命令,就会漏掉它。

commands/mod.rs 的首行是 #![allow(non_snake_case)],对应着 invoke_handler 里确实注册了两个 camelCase 命名的命令 commands::queryProviderUsagecommands::testUsageScript。这是一处能自己去 lib.rs 的 handler 块里对着看的细节。

(命令总数怎么数才不漏,涉及一个 grep 写法上的坑,我们另有一篇专门讲,这里不展开。)

Services 层:业务策略住在这里

services/mod.rs 声明了 34 个 pub mod,加上 provider/webdav_sync/ 两个子目录,实际是 40 个 .rs 文件。这一层最大的单文件是 services/proxy.rs,7342 行,模块头注写「代理服务业务逻辑层,提供代理服务器的启动、停止和配置管理」。

判断一段逻辑该不该算「业务策略」,一个可操作的判据是看常量落在哪。举个例子:services/skill.rs 第 267 行有 SKILL_BACKUP_RETAIN_COUNT: usize = 20,备份目录是 ~/.cc-switch/skill-backups/services/skill.rs:521-523)。「保留几份」这种策略性数字既不在命令层,也不在表结构里,就在 Services。你要改这类行为,第一站就该来这一层 grep 常量名。

这一层还有一组文件值得单独点出来:session_usage.rs(读 ~/.claude/projects/ 下的 JSONL)、session_usage_codex.rs(3086 行,读 ~/.codex/sessions/)、session_usage_gemini.rs(读 ~/.gemini/tmp/<hash>/chats/session-*.json)、session_usage_grokbuild.rs(读 ~/.grok/ 下的 updates.jsonl)、session_usage_opencode.rs(读 ~/.local/share/opencode/opencode.db 这个 SQLite 库)。

这意味着 Services 层会直接读你本机上各个 CLI 工具的真实会话文件。这些目录里是你自己的对话记录与配置,属于本机敏感数据,读这一层代码时心里要有数——这是事实陈述,不是说它安全或不安全。

★ 反直觉之一:DAO 不是一层对象

这是全篇最容易读错的地方。

看到「DAO 层」,很自然会以为存在一堆 ProviderDaoSettingsDao 之类的结构体,由上层持有。实际不是。src-tauri/src/database/dao/mod.rs 第 18-21 行的注释写得很直白:所有 DAO 方法都通过 Database impl 提供,无需单独导出;整个 dao/mod.rs 对外只 re-export 了两个类型,FailoverQueueItemProfile

也就是说,dao/ 下那 12 个模块是 12 组挂在同一个 Database 类型上的 impl 块,按领域拆成 12 个文件而已:providers.rs(828 行)、proxy.rs(982 行)、usage_rollup.rs(573 行)、skills.rs(404 行)、settings.rs(327 行)、mcp.rs(268 行)、profiles.rs(207 行)、failover.rs(149 行)、providers_seed.rs(120 行)、prompts.rs(88 行)、stream_check.rs(74 行)、universal_providers.rs(74 行)。把这几个数加起来大约 4100 行——占 database/ 整体 10730 行的不到一半,database/ 里更大的两块反倒是 schema.rs(3239 行)和 backup.rs(1915 行),它们不在 dao/ 下。

再看全局状态:src-tauri/src/store.rs 全文只有 23 行,AppState 结构体只有三个字段——db: Arc<Database>proxy_service: ProxyServiceusage_cache: Arc<UsageCache>store.rs:6-11)。AppState::new(db) 内部用同一个 Arc<Database> 去构造 ProxyService,再新建一个 UsageCachestore.rs:14-22)。

把这两处放在一起看,结论就清楚了:所谓「DAO 层」在类型系统里没有独立的边界,它是靠目录归属维持的一个约定。这不是缺陷,Rust 里把 impl 拆到多个文件是很常见的组织方式;但读架构图时不能按「四个对象逐级持有」去想,否则你会在代码里找一个根本不存在的 DAO 类型。

自己核对的动作:打开 src-tauri/src/database/dao/mod.rs,看它有没有 pub struct;再打开 src-tauri/src/store.rs,数 AppState 有几个字段。两分钟就能验完。

Database 层:一把锁,一个连接

最底下这层的核心结构体同样朴素。src-tauri/src/database/mod.rs 第 80-82 行,Database 只有一个字段:conn: Mutex<Connection>,注释说明「rusqlite::Connection 本身不是 Sync 的,因此需要这层包装」。

加锁走的是宏 lock_conn!database/mod.rs:65-74),锁毒化时转成 AppError::Database("Mutex lock failed: ..."),而不是 unwrap 直接 panic。这是一个能落到具体行号的实现选择:并发安全靠一把进程内 Mutex,而不是连接池。

其它几处坐标:数据库文件路径是 get_app_config_dir().join("cc-switch.db"),默认目录 ~/.cc-switchdatabase/mod.rs:101config.rs:207);初始化时开启 PRAGMA foreign_keys = ON,全新库还会先设 PRAGMA auto_vacuum = INCREMENTALdatabase/mod.rs:112-119);另外提供 Database::memory() 内存库供测试用(database/mod.rs:186)。rusqlite 版本是 0.31,启用了 bundledbackuphooks 三个特性(src-tauri/Cargo.toml:76)。

hooks 这个特性正好引出下一个话题。

★ 反直觉之二:有一处箭头是反向的

四层箭头给人的印象是数据自上而下流:命令调服务,服务调 DAO,DAO 落库。但 database/mod.rs 第 84-94 行注册了一个 SQLite update_hook:对 INSERT / UPDATE / DELETE 三类动作,直接调用 crate::services::webdav_auto_sync::notify_db_changed(table)crate::services::s3_auto_sync::notify_db_changed(table)

最底层的 Database 直接点名调用了 Services 层的两个自动同步 worker。方向和箭头相反。

这处代码就在那儿,行号可查;至于为什么这么写、是不是有意为之,我们不推断,也不据此评价这套架构。你要读同步链路的时候记住这条回调路径就够了——它意味着任何一次落库都会顺带触发同步侧的通知,无论调用方在哪一层发起。

一处文档与代码对不上:注释里 5 个 DAO,实际 12 个

src-tauri/src/database/mod.rs 第 18-23 行有一段 ASCII 目录树注释,画的 dao/ 下是五个文件:providers.rsmcp.rsprompts.rsskills.rssettings.rs。而 dao/mod.rs 第 5-16 行实际声明了 12 个模块,比注释多出 failoverprofilesproviders_seedproxystream_checkuniversal_providersusage_rollup 这七个。

两处不一致,以我们实读的 dao/mod.rs 为准。核对动作sed -n '18,23p' src-tauri/src/database/mod.rssed -n '5,16p' src-tauri/src/database/dao/mod.rs 两段对着看。

类似的还有一处在 README 侧:README_ZH.md 第 561-568 行的项目结构树只写了 commands/ services/ database/ proxy/ session_manager/ deeplink/ mcp/,而实际仓库里还有 src-tauri/src/proxy/usage/src-tauri/src/session_manager/terminal/src-tauri/src/services/provider/src-tauri/src/services/webdav_sync/ 这些子目录,以及三十多个根级 .rs 文件(app_config.rstray.rssettings.rs 等)。

说完差异就停。这两处只影响一件事:别拿注释里的目录树当索引去找文件,直接 ls 目录更可靠。

四层共用的一条错误通道

分层之外还有一根贯穿的线:src-tauri/src/error.rs(146 行)用 thiserror 定义了单一枚举 AppError,16 个变体,从 ConfigInvalidInputIo 一直到 DatabaseAllProvidersCircuitOpenNoProvidersConfigurederror.rs:7-62)。

它实现了 From<PoisonError<T>>From<rusqlite::Error>From<AppError> for String,还自定义了 serde::Serialize——序列化成 to_string() 的字符串(error.rs:92-117)。其中 Localized 变体带 key/zh/en 三个字段,Display 输出格式是 "{zh} ({en})"error.rs:47-52)。

这决定了一件很实际的事:四层任意一层冒出来的错误,最终传到前端时都是一个字符串,不是结构化对象。想传结构化信息就得自己打包,format_skill_error 就是这么干的:把 code/context/suggestion 序列化成 JSON 字符串交给前端解析,序列化失败时兜底成 ERROR:{code}error.rs:120-146)。

想改点东西,该落到哪一层

按目录归属倒推,判据其实很简单:

  • 要给前端加一个新的调用入口——落在 commands/ 下对应领域的文件,别忘了它还要在 lib.rsinvoke_handler 里注册(我们数到 invoke_handler 的注册条目是 294 行,lib.rs:1318 起)。
  • 要改一条业务策略(保留几份、走哪条兜底路径)——去 services/ grep 常量名,前面那个 SKILL_BACKUP_RETAIN_COUNT 就是范例。
  • 要动数据形状——database/schema.rs(3239 行)负责建表与迁移,DAO 只负责取数存数。表结构与迁移机制各自都有专门的一篇讲,这里不展开。

至于「某个具体改动会牵动哪些调用方」,项目没有给出一份跨层依赖清单,我们也没有静态分析过调用图,所以不给通用答案——按上面三条定位到层,再在层内 grep 是更实在的做法。

本文没覆盖什么

诚实交代边界:我们没有读 src-tauri/src/proxy/ 整个目录(约 60 个文件、数万行,含 providers 转换层与 usage 子模块),也没读 session_manager/deeplink/mcp/ 这几个根级子目录。所以「本地代理链路在这四层里怎么摆」不在本文覆盖范围内。

另外,仓库里 Rust 单元测试分布在 145 个文件、合计 2401 个测试函数(#[test] 2207 + #[tokio::test] 194),src-tauri/tests/ 下另有 12 个文件、8112 行、120 个测试函数(其中 support.rs 是共享辅助,测试数为 0)。这些是 grep 计数结果,不代表它们全部通过——我们没有编译过这个仓库,也没跑过任何一条测试。计数口径本身也有不确定性:属性行计数会把 #[cfg(target_os = "macos")] 之类平台门控下的测试也算进去,实际在单一平台上编译执行的数量会低于 2401。


本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册与发布说明、src/config/ 的预设定义与 src-tauri/src/ 的后端源码整理,核对日 2026-08-10,对应仓库快照 c39c903。本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用,因此不涉及界面外观、操作手感与切换速度的任何描述。文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。