我把架构约定编译成了会变红的测试:Wails v2 + Go + Vue3 桌面脚手架实战

一句话结论:脚手架真正值钱的不是那几千行代码,而是「把工程约定变成 CI 里会失败的检查」这套手艺。代码会因为换语言、换框架而作废,手艺不会。

写在前面:脚手架是怎么烂掉的

先说个大家都见过的场景。

你兴冲冲搭了个项目基座,写了三份文档:READMECONTRIBUTINGdocs/规范.md。里面写得清清楚楚------"控制器不许直接操作数据库"、"新增依赖要去清单里登记"、"前端不能硬编码颜色"。

三个月后你回来看:

  • 有人图省事在 app.go 里直接 gorm.Open 了,反正能跑。
  • deps.yaml 里躺着三个早就删掉的依赖,没人知道。
  • 前端一个组件里写了个 #3b82f6,主题换肤那天它没跟着变。
  • 日志功能"接好了",但打包出来前端日志一条都看不到------因为启动时少接了一行 Logger,不报错、不崩溃,就是默默丢了。

问题不在于规矩写得不清,而在于规矩是"软"的。 软约束一定会被违反,尤其是在 AI 参与生成代码的今天------AI 不会读你的 CONTRIBUTING.md,它只会照着最省事的方式写。

所以我做这个基座时,给自己定了一条最高的原则:

凡是能被机器验证的约定,就不要留在文档里当口号,把它编译成一条会变红的检查。

这篇就聊我是怎么用 Wails v2 把这条原则落地的。技术栈是 Go + Vue3,但方法论跟语言无关,你做 Electron、做 Tauri 一样能搬。


一、为什么是 Wails,不是 Electron

选型没什么好纠结的,就三条:

  1. 包体。Electron 打包一个 Chromium 进去,100MB 起步。Wails 用系统自带的 WebView(macOS 是 WKWebView,Windows 是 WebView2),产物就几 MB。
  2. 后端是 Go。我要的是能直接读写本地文件、跑 SQLite、做系统集成的"真桌面程序",Go 干这些比 Node 舒服。
  3. 通信零成本。Wails 会自动把 Go 结构体的方法生成为前端可调用的 TS 绑定,不用自己写 IPC 协议,也不用起 HTTP 服务。

代价也要说清楚:没有 Electron 那种成熟的生态和调试体验,某些系统能力(后面会讲)Wails v2 有坑。

bash 复制代码
# 一条命令就能跑起来
make dev      # wails dev,前端热更新
make build    # 编译当前平台产物
make package  # 打 dmg / NSIS 安装包

二、基座的骨架

分层很朴素,但每一层的边界我都要能用机器验证:

对应到 Wails 的形态:

  • 绑定方法层app.go):前端能直接调用的方法都在这里。它不许碰数据库,必须经 service 层------这样业务逻辑才好测,也让"前端能调到什么"这件事一目了然。
  • service 层:真正的业务逻辑,可以访问数据库。
  • model 层:纯数据结构,是叶子,不许 import 任何人。

规矩写出来容易,问题是怎么让它"不听话就报错"。这就是下一节。


三、核心方法:把约定编译成会红的检查

护栏不是写在文档里,而是写在 internal/guard/ 包里,以 go test 的形式运行,make test 的时候一起跑。用 go/parser + go/ast 解析源码做结构化断言。

3.1 最重要的铁律:护栏必须"感知自己瞎了"

这条是整篇文章里最想让你记住的,也是我从真实事故里长出来的教训。

护栏靠正则或 AST 匹配代码。只要代码写法一变,匹配就会失效,护栏从"拦违规"退化成"永远绿"------它不报错了,但它也没在干活了,还给你一种"很安全"的错觉。这种"瞎掉"的护栏比没有护栏更危险。

所以铁律是:

解析到 0 个结果时必须报错,不能当作"通过"静默放行。

代码长这样(internal/guard/layer_test.go):

go 复制代码
func TestBindingsDoNotTouchDB(t *testing.T) {
	files, err := parseDir(projectRoot())
	if err != nil {
		t.Fatalf("解析项目根目录失败: %v", err)
	}
	f, ok := files[bindingsFile]
	if !ok {
		// 关键:找不到目标文件 → 报错,而不是"跳过这项检查"
		t.Fatalf("护栏找不到 %s------写法可能已变更,请同步更新护栏解析规则", bindingsFile)
	}
	for _, imp := range collectImports(f) {
		if imp == "gorm.io/gorm" || strings.HasPrefix(imp, "gorm.io/") ||
			strings.Contains(imp, "internal/database") {
			t.Errorf("%s 越界:绑定方法层不得直接 import %s,应经 service 层", bindingsFile, imp)
		}
	}
}

看到那句报错文案了吗------"写法可能已变更,请同步更新护栏解析规则"。每个护栏里都带这句话。它的潜台词是:护栏失效是一件必须有人处理的事,不是可以忽略的噪音。

3.2 双向校验:防止"僵尸条目"

单向校验只能防"漏登记",防不住反向的漂移。所以凡是清单类的护栏,我都做双向

  • 正向:真实存在的东西,必须登记在册。
  • 反向:登记在册的东西,必须真实存在。

模型注册就是这么干的。每个内嵌 BaseModel 的结构体都必须登记进 AllModels()(这是迁移的唯一真相):

go 复制代码
// 正向:每个带 BaseModel 的结构体都必须登记
for s := range structsWithBase {
	if !registered[s] {
		t.Errorf("模型 %s 内嵌 BaseModel 但未登记进 AllModels()", s)
	}
}
// 反向:每个登记项都必须真实存在
for s := range registered {
	if !structsWithBase[s] {
		t.Errorf("AllModels() 登记了 %s,但 model 包中无对应结构体(僵尸条目/拼写错误)", s)
	}
}

反向校验这一条,专治那种"删了模型忘了删注册"的烂账。

3.3 依赖登记制

新增依赖不能只 go get / npm install,必须在 deps.yaml 里登记,附一句为什么用它

yaml 复制代码
go:
  - module: github.com/glebarez/sqlite
    version: v1.11.0
    reason: 纯 Go SQLite 驱动(无 CGO),跨平台交叉编译零痛苦。

护栏照样双向校验:go.mod 的每个直接依赖(非 indirect)和前端 package.jsondependencies,都得在清单里;反过来,清单里的每一项也必须真实存在。

这么做的收益不只是"依赖干净"。强制你写理由,等于强制你在引入一个库之前先想一遍"它值不值得"

3.4 前端镜像一致性------最容易悄悄漂移的地方

这是我最喜欢的一个护栏,因为它解决的是一个特别隐蔽的问题。

Go 侧的错误码、事件动作名、分页上下界,是"单一真相"。但前端为了编译期能用上一份类型,不得不在 TS 里留一份镜像

ts 复制代码
// frontend/src/lib/invoke.ts ------ 与 Go 侧 internal/apperr 保持一致(此处为镜像)
export const ErrorCode = {
  NotFound: 'not_found',
  Validation: 'validation',
  Conflict: 'conflict',
  Internal: 'internal',
} as const

镜像本身没问题,没人看管的镜像才是问题:Go 那边加了个错误码,前端没跟上,前端就在按一份过期的真相分流,而且不会报任何错。

所以 parity_test.go 会去解析 Go 常量名(Code*)和 TS 对象(ErrorCode),逐项比对:

ini 复制代码
错误码:Go 侧 CodeNotFound = "not_found",但前端镜像 invoke.ts 的 ErrorCode 中缺失
        ------请在 invoke.ts 中补上(镜像已漂移)

而且这里有个我想强调的设计纪律:

镜像只许复制常量与类型,不许复制逻辑。

一件规则如果有了两个实现,比一个常量有两份更糟------两个实现会在边界条件上悄悄分叉。所以页码夹取、参数校验这些逻辑,唯一地活在 Go 侧;前端只负责把原始值发过去、把归一化后的值收回来。

3.5 接线静默失效------最阴的一类问题

有一类 bug 特别讨厌:它不报错、不崩溃,只是某条链路默默断掉了。

典型例子:Wails 的 options.App.Logger 如果没接,前端的日志和 Wails 自身的内部错误就只会写 stdout------打包成 GUI 应用之后,stdout 是没有人能看到的。日志功能"接通了",但实际什么都没记下来。

删除这一行,编译通过,运行正常,测试全绿。只有等你真出事要看日志的那天,才发现日志是空的。

这类问题没有症状,所以只能靠检查兜住:

go 复制代码
// main.go 的 options.App 必须设置 Logger,否则前端日志静默丢失
var requiredWailsOptions = []struct {
	field  string
	reason string
}{
	{
		field:  "Logger",
		reason: "不设置它,前端日志与 Wails 内部错误只写 stdout(打包后无人可见),日志会静默丢失",
	},
}

同一个文件里还兜了另一件事:构建时用 -ldflags -X 注入版本号的目标符号,必须真实存在。因为链接器对不存在的符号是静默忽略的 ------你把变量改个名,构建成功、没有警告、版本号悄悄退回 dev,而且从此永远是 dev

经验总结一句话:凡是"坏了也没有症状"的地方,都要有一条会红的检查。


四、管道契约:一个真实的坑

跨端通信最容易出问题的不是网络,是"两端对同一种数据的理解不一致"。所以我把错误、分页、事件三样东西定成了管道契约

4.1 错误协议:为什么我要返回 JSON 字符串

Go 侧的业务错误统一用 apperr 构造,带机器可读的 code 和给人看的 message

go 复制代码
type Error struct {
	Code    string `json:"code"`
	Message string `json:"message"`
	Detail  string `json:"detail,omitempty"`
}

func NotFound(message string) *Error { return &Error{Code: CodeNotFound, Message: message} }
func Validation(message string) *Error { return &Error{Code: CodeValidation, Message: message} }

看起来平平无奇,但这里踩过一个真实的坑,值得单独讲:

Wails 允许你自定义错误格式化器(ErrorFormatter)。我一开始很自然地返回了对象

go 复制代码
// ❌ 错误示范
func Format(err error) any {
    return Wrap(err)   // 返回 *Error 对象
}

结果前端拿到的是 new Error(payload)对象被强转成了字符串 "[object Object]"codemessage 全部丢失。这是 Wails v2 的实测行为。

改成返回 JSON 字符串就好了:

go 复制代码
// ✅ 正确做法:返回字符串,前端自行 JSON.parse 还原
func Format(err error) any {
	ae := Wrap(err)
	if ae == nil {
		return ""
	}
	b, _ := json.Marshal(ae)
	return string(b)
}

前端再配一个归一化函数,把可能出现的各种形状统一成 { code, message },解析失败就退化成 internal

ts 复制代码
export function normalizeError(raw: unknown): AppError {
  const text = raw instanceof Error ? raw.message : String(raw)
  try {
    const parsed = JSON.parse(text)
    if (parsed && typeof parsed.code === 'string' && typeof parsed.message === 'string') {
      return parsed as AppError
    }
  } catch { /* 落到兜底 */ }
  return { code: ErrorCode.Internal, message: text || '未知错误' }
}

下游永远拿不到 [object Object],这就是契约的价值------它不保证一切顺利,但保证出问题时形状是稳定的。

4.2 顺带做的一件事:慢调用埋点

同一条 invoke() 里,我还埋了耗时统计:超过 50ms 的绑定调用记一条告警,启动时汇总一行 IPC 概况

刻意不逐条记录------绝大多数调用就几毫秒,逐条记会让日志量随调用次数线性增长,把真正要看的告警淹掉。一行汇总足以回答"这次启动的 IPC 代价有多大"。


五、事件与分页:把"约定"变成可校验的形状

事件 统一用 <domain>:<action> 命名,进度类 payload 必须带 done/total,结束类必须带 ok

前端不许手写字符串字面量 'asset:progress',必须用 eventName(domain, action) 拼------这样拼错单词的代价从"运行时静默无人接收"变成"编译期类型报错"。

组件里订阅事件必须用 useEvent 组合式函数,随组件卸载自动解绑。原因很实在:Wails 的事件订阅会累积,你漏解绑一次,来回切路由就会触发多次。

分页 同理:列表方法必须返回 page.Result[T],不许返回裸切片;分页参数必须先经 page.Request.Normalized() 归一化再查库。

前端这份职责被明确划走了:前端不得自行实现归一化(页码夹取、页大小上下界),那是 Go 的职责。前端发原始值,收归一化值。规则只有一处实现,就不会有两个地方各夹一次、结果不一致。


六、黄金范例 + 代码生成器:让新模块长在纪律里

新加一个业务模块,手写"model + service + 绑定方法 + 前端页面"要复制粘贴一堆样板,还容易漏掉注册。所以有个生成器:

bash 复制代码
make gen name=asset

它从 _example/ 模板生成四段代码,并自动注入到 AllModels()app.go、路由、菜单里。生成器用了几个我觉得很值得抄的手法:

  1. 锚点注入 。目标文件里放 【gen:routes】 这类注释锚点,生成器靠它定位插入位置。护栏也断言锚点存在------锚点被删了两边一起红。这是刻意的耦合
  2. fail-fast 前置校验。动任何文件之前,先把"锚点在不在""资源是不是已被占用"全查一遍。否则中途失败会留下"后端生成了、前端没注入"的半拉子状态,重跑又被幂等检查拦住,非常难收拾。
  3. 幂等 + 拒绝覆盖。目标文件已存在就直接报错,绝不覆盖你写的业务代码。
  4. // TODO 锚点 。生成的文件带 // TODO: 业务逻辑,人和 AI 都只填锚点处。

还有一条容易被忽视的规矩:_example/ 是唯一范例,历史模块不是范例。

真实的业务模块会越写越具体、越写越乱,拿它当模板会学到一堆坏味道。所以模板必须单独维护,保持最小、干净。


七、统一入口:别让人和 AI 去记脚本路径

所有操作都收敛到 make <target>

命令 作用
make dev 启动开发态(前端热更新)
make build 编译当前平台产物
make package 打包真安装包(dmg / NSIS)
make test 跑 Go 测试(含架构护栏)
make lint gofmt + 护栏 + go vet + ESLint + vue-tsc
make smoke 冒烟测试
make gen 生成新模块

两个细节值得一提:

make lint 里的 vue-tsc 类型检查不能省。 开发态的 vite 只剥离类型、不做检查,缺了这一步,类型错误会一路漂到打包才炸。

Makefile 在干净检出时不能乱喷错误。 根包用 //go:embed 嵌了 frontend/dist,而这个目录不入库。如果哪个变量用了立即展开,会导致任何 make 目标(包括 make help)都先报一句难懂的编译错误。所以要有前置检查,明确告诉你"先跑 npm run build",而不是抛一句 Go 的原始报错。

冒烟测试的目标也很朴素:把"能跑"变成可以观察到的事实 ,而不是嘴上说"我测过了"。构建 → 启动 → 断言 → 清理,trap cleanup EXIT 保证不残留。


八、打包发布:版本号只有一个真相

版本号这件事,坑特别多。我的处理原则是四处同源

  • wails.jsoninfo.productVersion 是唯一真相;
  • wails 用它渲染 macOS 的 Info.plist、Windows 的 exe 版本资源与 NSIS 注册表;
  • 构建脚本把同一个值用 -ldflags 注入到应用内(日志首行、GetAppInfo)。

所有构建都走同一个 scripts/build.sh,它是唯一的版本注入点。macOS 构建完还会回读产物的 Info.plist 校验版本真的生效了

自动发布也很省事,打个 tag 就行:

bash 复制代码
git tag v0.1.0 && git push origin v0.1.0

CI 会先跑静态检查,再由 macOS / Windows 两条腿分别打包 dmg 和 NSIS 安装器,最后建 Release 挂上产物。


九、说点难听的:已知限制

一个负责任的脚手架应该把坑写清楚,而不是装看不见。这个基座有几条硬限制:

  • macOS 没有系统托盘。Wails v2 的 NSApplication delegate 和所有 systray 库都冲突,所以 macOS 上关闭即退出,托盘只在 Windows/Linux 提供。
  • 没有 headless 模式。冒烟测试只定位为本地验证,CI 里只编译不启动 GUI。
  • Linux 产物编译不在 CI 矩阵里 。Wails 在 Linux 上按 webkit2gtk 版本做 cgo 链接,默认找 4.0,而 Ubuntu 24.04+ 只提供 4.1,必须带 -tags webkit2_41。这属于"取决于 runner 装了什么"的环境耦合,维护成本高于收益,所以矩阵只保留 macOS / Windows(Linux 的 Go 层行为仍由静态检查腿覆盖)。
  • 窗口位置不持久化,只记住尺寸和最大化。Wails v2 没有创建期位置选项,运行期设置会和窗口显示产生竞态(能看到跳动),还得自己夹取屏幕边界,否则窗口会还原到已经拔掉的显示器上。
  • 版本号只认数字点分格式 (如 0.1.0),因为 Windows 的 NSIS 不接受非数字版本。

十、小结:方法论比代码更值钱

回头看,这个基座里真正让我觉得"赚到了"的,不是那几千行 Go 和 Vue,而是这几条可以跨语言、跨框架迁移的原则:

  1. 把约定编译成会红的检查。 软约束一定会被违反,硬约束不会。
  2. 护栏必须能感知自己瞎了。 解析到 0 个结果就报错,别让护栏退化成"永远绿"。
  3. 清单类规则一律双向校验。 正向防漏登记,反向防僵尸条目。
  4. 单一真相 + 受管镜像。 一条规则一个出处;必须复制的只复制常量,不复制逻辑。
  5. 凡是"坏了也没症状"的地方,都得有一条检查兜住。 接线、注入、镜像漂移------这三类是重灾区。
  6. 护栏和治理要先于业务。 早期只有一两条也没关系,越早立,后面每个模块都长在纪律里;等写了几十个模块再回头补,就来不及了。

代码会过时------Go 版本会变,Vue 会变,说不定哪天 Wails v3 就不长这样了。但"把工程约定变成机器可验证的事实"这套思路,换个技术栈照样能用。

如果你的项目还靠 code review 和自觉来守护架构边界,我建议从一条最小的护栏开始------比如"渲染层禁止 import Node 能力"------先跑通,再慢慢加。护栏这东西,是典型的复利投资。


github仓库地址

相关推荐
量化分析码农2 小时前
【Python量化数据工程实战 #01】拉下来的行情全是"脏数据"?停牌、跳空、异常值一站式清洗
后端
cpolar技术支持2 小时前
本地 Playwright 测试报告怎么远程复盘?Trace Viewer 跑起来后,用 cpolar 分享失败现场
前端·自动化测试·测试工具·cpolar·playwright
wordbaby2 小时前
企业级后台管理系统路由设计与最佳实践指南
前端
专业程序开发源2 小时前
springboot篮球联赛管理系统13635-计算机课程设计、毕业设计
vue.js·spring boot·后端·python·django·php·课程设计
胡志辉的博客2 小时前
【完全开源】IP 纯净度检测 可一键部署到自己的CF
前端·javascript·chrome·ip·chromium
Hilaku3 小时前
作为面试官,我最怕遇到什么样的候选人?
前端·javascript·程序员
行百里er3 小时前
轻量级 Spring 监测工具——Spring Insight 发布了
spring boot·后端·监控
落魄大学生之流水线上谋生计3 小时前
幻境相机 Mirage Camera
github
TiDi3 小时前
Pinia优化重复请求
前端