架构与设计契约 / Architecture & Design Contracts
August 17, 2026 · View on GitHub
本文档记录 FrpGUI 的模块布局与关键设计契约,供后续维护者快速建立心智模型,避免因误用全局状态或绕过契约而引入回归。
1. 总体定位
FrpGUI 是 frpc(frp 内网穿透客户端)的图形前端:本身不实现穿透逻辑,只负责配置管理、frpc 子进程生命周期、状态展示与诊断。单二进制跨平台(Windows / macOS / Linux),Go + Shirei(IMGUI)即时模式 GUI。
2. 模块布局
全部业务源码位于 cmd/frpguiapp/,package main,按职责拆分同包多文件(不做子包,便于即时模式 GUI 共享状态与 UI 原语):
| 文件 | 职责 |
|---|---|
main.go | 入口、单次实例锁、帧循环 recover 兜底、进程级 panic 捕获 |
state.go | 全局状态 appState、配置访问器契约(getConfig/putConfig/cfgMu)、缓冲回填 |
panels.go | 顶部标签(服务端 / 代理 / 工具 / 日志)与各页渲染 |
handlers.go | 按钮/IO 触发与导入导出流程 |
widgets.go | 复用 UI 原语(按钮、表单、折叠等) |
config.go | Config / Proxy 模型、MarshalTOML/UnmarshalTOML、存盘往返 |
profiles.go | 多配置预设(档案)的增删改切 |
prefs.go | 偏好(Lang/Font/ActiveProfile)持久化 |
lang.go + lang/*.toml | 动态枚举的多语言包 |
theme*.go + theme/*.toml | 单一「主题」维度(每主题自带明暗意图)与 HSL 语义角色派生 |
process.go | frpc 子进程管理(启动/停止/日志泵),goroutine 经 recoverPanic 包裹 |
netcheck.go | 后台连通性/延迟测试,经 getConfig() 取快照避免竞态 |
integrity.go | frpc 二进制 SHA256 完整性校验 |
clear_creds.go | 清除预设中的敏感字段 |
crash.go | 未捕获 panic 落盘 + recoverPanic 兜底 |
diagnostics.go | 导出诊断 zip(脱敏)+ redactConfig 脱敏白名单 |
confirm.go | 通用二次确认弹窗 |
filepicker.go | 自绘 IMGUI 文件选择器(import/export/diag 三类) |
tools/build.go tools/sbom.go | 跨平台构建脚本 + SBOM 生成(独立 tools 包) |
源码全部 package main,集中在 cmd/frpguiapp/;tools/ 存放跨平台构建与 SBOM 脚本;根目录运行时相关资产为 lang/、theme/、frpc.example.toml;构建产物落在 build/<平台>-amd64/(由 tools/build.go 输出),被 .gitignore 的 build/ 规则排除(frpc 二进制由部署侧随附,不入库)。
3. 并发安全契约(最重要)
appState.cfg(Config)是后台 goroutine(网络测试、frpc 泵/等待)与 GUI 主线程(渲染、按钮回调)都可能触及的共享状态。约定:
- 整份替换走
putConfig(cfg),跨线程快照读取走getConfig();二者均经cfgMu sync.RWMutex守护。 - 单写者不变式:所有原地字段改写(如
appState.cfgName = ...、c.ServerPort = ...)必须发生在 GUI 主线程(渲染帧或按钮回调内),彼此天然串行;后台 goroutine 只允许调用getConfig()取不可变快照,绝不持有或改写appState.cfg。 - 快照是浅拷贝:
getConfig()返回appState.cfg的值拷贝,但Proxies切片与PluginParams/Metadatas等 map 的底层存储仍与主结构共享。因此快照仅保证「顶层字段独立」,调用方必须把快照当作只读使用——既不要在后台 goroutine 里改写嵌套的代理/插件参数(会与主线程原地改写产生竞态),也不要把快照长期持有跨主线程改写。当前所有快照消费方(netcheck 取地址、diagnostics 取配置)均在主线程一次性使用,故无实际竞态;若未来新增「后台读取 + 主线程原地改写嵌套集合」的模式,须改为深拷贝或走putConfig。 - 启动 frpc 前、
SwitchProfile、LoadConfig、导入配置均通过putConfig提交新配置。 - 这套契约由测试(
cfg_test.go)守护:TestConfigAccessor反复并发读写不出现竞态。
历史教训:早期
netcheck.go在后台 goroutine 里直接读appState.cfg.ServerAddr,曾在 race 检测器下报错;改为「主线程拷贝地址 → 传值给 goroutine」或getConfig()快照后消除。
4. 序列化契约(曾踩的真实坑)
config.go 自定义了 Config.MarshalTOML / (*Config).UnmarshalTOML 与 Proxy.MarshalTOML / UnmarshalTOML,目的是把代理的 plugin_<key> 压平、仅输出非空字段、保证 TOML 往返无损。关键点:
- go-toml v2 不会为
[]Proxy切片元素自动调用Proxy.MarshalTOML,也不会为顶层目标自动分发到Config.UnmarshalTOML。 - 因此
SaveConfig/LoadConfig必须显式调用cfg.MarshalTOML()/c.UnmarshalTOML(data),依赖 go-toml 默认派发会导致 frpc 插件参数(localPath、HTTP 鉴权等)被静默丢弃——这是阶段 2 由单测(config_test.go)发现并修复的真实 bug。 - 新增
Config公共字段时,必须同步更新wireConfig镜像结构(含omitempty),否则该字段不会被序列化。
5. i18n 机制
- 语言包 =
lang/<语言码>.toml,放文件即生效,菜单由enumerateLangs()动态生成,无需改代码。 T(key)取当前语言值;缺失键回退到英文(en),再缺失回退到键名本身(永不空显)。- 新增 UI 文案:在全部 16 个语言包补键(非中英文以英文兜底,可后续完善)。键名全小写蛇形。
6. 主题机制
- 单一「主题」维度(不再单独区分模式):每个主题自带明暗意图——
minimal/blue/green/pink/orange/coffee/mint 共 7 套浅色主题(马卡龙淡色背景 + 黑字),dark为唯一中性灰深色主题(低亮度背景 + 白字);彩色主题只提供浅色(深色下观感差,故不提供),独立的dark满足暗色需求。minimal/dark硬编码保底始终可用,theme/*.toml扩展彩色浅色主题(放文件即生效)。 - 角色由
HSL(经 ShireiBackgroundVec)按hue/sat自动派生 25 个语义 UI 角色,无需逐字段手写;目标视觉为马卡龙浅彩风格。
7. 韧性 / 可观测
- 崩溃可观测:未捕获 panic(帧循环、
main启动期、后台 goroutine)落盘到logs/crash-<ts>-<seq>.log(含构建信息与完整堆栈);文件名带进程内序号,同一毫秒内多处同时崩溃互不覆盖。后台 goroutine 经recoverPanic拦下并落盘,不拖垮 GUI。 - 诊断脱敏:
redactConfig掩码Auth.Token/OidcClientSecret/Admin.Pwd以及Proxy的SK/HTTPPwd/GroupKey,并对PluginParams中命中凭据模式的键(由isSecretParamKey判定)一并掩码,不改动原文件;新增密钥字段须同步isSecretParamKey白名单(导出脱敏与清除凭据共用同一判定)。
8. 暂缓的高风险演进项(记录于此,非当前计划)
以下两项属「架构演进」范畴,但因会大面积重写现已稳定的 UI/状态层,当前刻意暂缓,待有充分测试覆盖与明确收益时再推进:
- 收敛全局可变状态:将
appState各字段收敛为更小、不可变、按领域分组的子状态,进一步降低误用风险。当前已通过cfgMu+ 访问器契约把最高危的cfg隔离,收益/风险比已大幅改善。 - schema 驱动表单生成:把代理/服务端表单从手写 IMGUI 改为由结构化 schema 驱动生成,减少重复代码。该重构覆盖
panels.go大部分渲染逻辑,回归面大,须先补齐端到端 UI 测试基地再启动。
更具体的协作细节见 CONTRIBUTING.md、安全披露见 SECURITY.md、发布流程见 RELEASE.md。