架构与设计契约 / 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.goConfig / Proxy 模型、MarshalTOML/UnmarshalTOML、存盘往返
profiles.go多配置预设(档案)的增删改切
prefs.go偏好(Lang/Font/ActiveProfile)持久化
lang.go + lang/*.toml动态枚举的多语言包
theme*.go + theme/*.toml单一「主题」维度(每主题自带明暗意图)与 HSL 语义角色派生
process.gofrpc 子进程管理(启动/停止/日志泵),goroutine 经 recoverPanic 包裹
netcheck.go后台连通性/延迟测试,经 getConfig() 取快照避免竞态
integrity.gofrpc 二进制 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 输出),被 .gitignorebuild/ 规则排除(frpc 二进制由部署侧随附,不入库)。

3. 并发安全契约(最重要)

appState.cfgConfig)是后台 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 前、SwitchProfileLoadConfig、导入配置均通过 putConfig 提交新配置。
  • 这套契约由测试(cfg_test.go)守护:TestConfigAccessor 反复并发读写不出现竞态。

历史教训:早期 netcheck.go 在后台 goroutine 里直接读 appState.cfg.ServerAddr,曾在 race 检测器下报错;改为「主线程拷贝地址 → 传值给 goroutine」或 getConfig() 快照后消除。

4. 序列化契约(曾踩的真实坑)

config.go 自定义了 Config.MarshalTOML / (*Config).UnmarshalTOMLProxy.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(经 Shirei BackgroundVec)按 hue/sat 自动派生 25 个语义 UI 角色,无需逐字段手写;目标视觉为马卡龙浅彩风格。

7. 韧性 / 可观测

  • 崩溃可观测:未捕获 panic(帧循环、main 启动期、后台 goroutine)落盘到 logs/crash-<ts>-<seq>.log(含构建信息与完整堆栈);文件名带进程内序号,同一毫秒内多处同时崩溃互不覆盖。后台 goroutine 经 recoverPanic 拦下并落盘,不拖垮 GUI。
  • 诊断脱敏redactConfig 掩码 Auth.Token/OidcClientSecret/Admin.Pwd 以及 ProxySK/HTTPPwd/GroupKey,并对 PluginParams 中命中凭据模式的键(由 isSecretParamKey 判定)一并掩码,不改动原文件;新增密钥字段须同步 isSecretParamKey 白名单(导出脱敏与清除凭据共用同一判定)。

8. 暂缓的高风险演进项(记录于此,非当前计划)

以下两项属「架构演进」范畴,但因会大面积重写现已稳定的 UI/状态层,当前刻意暂缓,待有充分测试覆盖与明确收益时再推进:

  1. 收敛全局可变状态:将 appState 各字段收敛为更小、不可变、按领域分组的子状态,进一步降低误用风险。当前已通过 cfgMu + 访问器契约把最高危的 cfg 隔离,收益/风险比已大幅改善。
  2. schema 驱动表单生成:把代理/服务端表单从手写 IMGUI 改为由结构化 schema 驱动生成,减少重复代码。该重构覆盖 panels.go 大部分渲染逻辑,回归面大,须先补齐端到端 UI 测试基地再启动。

更具体的协作细节见 CONTRIBUTING.md、安全披露见 SECURITY.md、发布流程见 RELEASE.md