文章目录
P.S. 目前国内还是很缺AI人才的,希望更多人能真正加入到AI行业,共同促进行业进步,增强我国的AI竞争力。想要系统学习AI知识的朋友可以看看我精心打磨的教程 传送门http://blog.csdn.net/jiangjunshow,教程通俗易懂,高中生都能看懂,还有各种段子风趣幽默,从深度学习基础原理到各领域实战应用都有讲解,我22年的AI积累全在里面了。注意,教程仅限真正想入门AI的朋友,否则看看零散的博文就够了。
先交代一句实话:脚手架真正值钱的不是那几千行代码,而是「把工程约定变成会变红的检查」这套手艺。代码会换语言、换框架而作废,手艺不会。就像你会的那口方言,搬家三次都不会丢,但你的沙发是真会散架。
下面开始,全程没废话,除了必要的段子。
1. 为什么是 Wails,不是 Electron
选型真没什么好纠结的,就三条,每条都短到能当朋友圈文案:
- 体积。Electron 打包一个 Chromium 进去,100MB 起步,用户的 C 盘看了都想报警。Wails 用的是系统自带的 WebView,产物就几 MB。几 MB,朋友们,一个安装包还没你家路由器固件大。
- 后端是 Go。我要的是能直接读写本地文件、跑 SQLite、做系统集成的「真桌面程序」。Go 干这些比 Node 舒服,就像用筷子吃面条比用叉子顺。你非要杠说叉子也能吃,行,你开心就好。
- 通信零成本。Wails 自动把 Go 结构体的方法生成为前端能调的 TS 绑定,不用自己写 IPC 协议,也不用起 HTTP 服务。等于你说话不用翻译,对面直接听懂了。
代价也得说清楚:没有 Electron 那套成熟的生态和调试体验,某些系统能力 Wails v2 有坑——坑深后面细说,先把安全带系好。
make dev # wails dev,前端热更新
make build # 编译当前平台产物
make package # 打 dmg / NSIS 安装包
2. 骨架:三层,一层比一层老实
分层很朴素,但每一层的边界我都要求机器能验证,而不是靠人眼盯。人眼是会骗人的,尤其是加班到十一点的时候。
- 绑定方法层(
app.go):前端能直接调的方法都在这。它不许碰数据库,必须走 service 层。这样业务逻辑好测,「前端能调到什么」也一目了然。这一层是前台,只管接客,不管后厨。 - service 层:真正的业务逻辑,可以访问数据库。这是后厨,接单做菜。
- model 层:纯数据结构,是叶子,不许 import 任何人。这是食材,谁都不能乱动。
规矩写出来容易,问题是:怎么让它「不听话就报错」?
3. 核心玩法:把约定编译成会变红的检查
护栏不是写在文档里,是写在 internal/guard/ 包里,以 go test 的形式跑,make test 一起带。用 go/parser + go/ast 解析源码做结构化断言。
这节是重头戏,拆成几小段慢慢聊。
3.1 最重要的一条:护栏必须能感知自己瞎了
这条是整篇最想让你记住的,也是我用事故换来的学费,成本不低。
护栏靠正则或 AST 匹配代码。问题来了:只要代码写法一变,匹配就失效,护栏从「拦违规」退化成「永远绿」——它不报错了,但它也没在干活了,还给你一种「很安全」的错觉。
这种「瞎掉」的护栏比没有护栏更危险。就像你家装了个摄像头,它早就坏了,但红灯一直亮着。你每天看着红灯觉得自己很安全,直到小偷进来才发现那是个装饰品。
所以铁律是:解析到 0 个结果时必须报错,不能当「通过」静默放行。
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()——这是迁移的唯一真相:
// 正向:每个带 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 里登记,附一句为什么用它:
go:
- module: github.com/glebarez/sqlite
version: v1.11.0
reason: 纯 Go SQLite 驱动(无 CGO),跨平台交叉编译零痛苦。
护栏照样双向校验:go.mod 的每个直接依赖和前端 package.json 的 dependencies,都得在清单里;反过来,清单里的每一项也必须真实存在。
这么做的收益不只是「依赖干净」。强制你写理由,等于强制你在引入一个库之前先想一遍「它值不值得」。很多人装依赖的速度比结婚还快,连对方是干嘛的都不知道就领证了。有了这条,好歹得先见个面。
3.4 前端镜像一致性:最容易悄悄漂移的地方
这是我最喜欢的一个护栏,因为它解决的是一个特别隐蔽的问题。
Go 侧的错误码、事件动作名、分页上下界,是「单一真相」。但前端为了编译期能用上一份类型,不得不在 TS 里留一份镜像:
// frontend/src/lib/invoke.ts —— 与 Go 侧 internal/apperr 保持一致(此处为镜像)
export const ErrorCode = {
NotFound: 'not_found',
Validation: 'validation',
Conflict: 'conflict',
Internal: 'internal',
} as const
镜像本身没问题,没人看管的镜像才是问题:Go 那边加了个错误码,前端没跟上,前端就在按一份过期的真相分流,而且不会报任何错。这就像你对象用三年前的微信头像找你——你以为你在跟现在的他聊天,其实对面是 2023 年存档的。
parity_test.go 会解析 Go 常量名(Code*)和 TS 对象(ErrorCode),逐项比对:
错误码:Go 侧 CodeNotFound = "not_found",但前端镜像 invoke.ts 的 ErrorCode 中缺失
——请在 invoke.ts 中补上(镜像已漂移)
还有个设计纪律:镜像只许复制常量与类型,不许复制逻辑。
一条规则如果有两个实现,比一个常量有两份更糟——两个实现会在边界条件上悄悄分叉。所以页码夹取、参数校验这些逻辑,唯一地活在 Go 侧;前端只负责把原始值发过去、把归一化后的值收回来。前端是跑腿的,不是做决定的。
3.5 接线静默失效:最阴的一类问题
有一类 bug 特别讨厌:它不报错、不崩溃,只是某条链路默默断掉了。
典型例子:Wails 的 options.App.Logger 如果没接,前端的日志和 Wails 自身的内部错误就只会写 stdout——打包成 GUI 应用之后,stdout 是没有人能看到的。日志功能「接通了」,但实际什么都没记下来。
删除这一行,编译通过,运行正常,测试全绿。只有等你真出事要看日志的那天,才发现日志是空的。
这类问题没有症状,所以只能靠检查兜住:
// main.go 的 options.App 必须设置 Logger,否则前端日志静默丢失
var requiredWailsOptions = []struct {
field string
reason string
}{
{
field: "Logger",
reason: "不设置它,前端日志与 Wails 内部错误只写 stdout(打包后无人可见),日志会静默丢失",
},
}
同一个文件里还兜了另一件事:构建时用 -ldflags -X 注入版本号的目标符号,必须真实存在。因为链接器对不存在的符号是静默忽略的——你把变量改个名,构建成功、没有警告、版本号悄悄退回 dev,而且从此永远是 dev。dev 版发出去一年,用户看到的全是「开发中版本」,你还不知道。
经验总结一句话:**凡是「坏了也没有症状」的地方,都要有一条会红的检查。**没症状的病最危险,这是全宇宙的共识,从医学到代码。
4. 管道契约:一个真实的坑
跨端通信最容易出问题的不是网络,是「两端对同一种数据的理解不一致」。所以我把错误、分页、事件三样东西定成了管道契约。
4.1 错误协议:为什么我要返回 JSON 字符串
Go 侧的业务错误统一用 apperr 构造,带机器可读的 code 和给人看的 message:
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)。我一开始很自然地返回了对象:
// ❌ 错误示范
func Format(err error) any {
return Wrap(err) // 返回 *Error 对象
}
结果前端拿到的是 new Error(payload),对象被强转成了字符串 "[object Object]",code、message 全部丢失。这是 Wails v2 的实测行为。
[object Object],朋友们,这是前端世界最著名的加密协议——任何对象传过去,都变成这四个词。我那天看到控制台里这行字,感觉自己被加密了。
改成返回 JSON 字符串就好了:
// ✅ 正确做法:返回字符串,前端自行 JSON.parse 还原
func Format(err error) any {
ae := Wrap(err)
if ae == nil {
return ""
}
b, _ := json.Marshal(ae)
return string(b)
}
前端再配一个归一化函数,把可能出现的各种形状统一成 { code, message },解析失败就退化成 internal:
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 代价有多大」。
这个道理跟查案一样:你不能把所有路人的脚步声都录下来,然后指望从里面找到凶手。你得等有异常动静了才抬头。
5. 事件与分页:把「约定」变成可校验的形状
事件统一用 <domain>:<action> 命名,进度类 payload 必须带 done/total,结束类必须带 ok。
前端不许手写字符串字面量 'asset:progress',必须用 eventName(domain, action) 拼——这样拼错单词的代价从「运行时静默无人接收」变成「编译期类型报错」。把犯错成本从「没人理你」提到「编译器骂你」,这买卖划算。
组件里订阅事件必须用 useEvent 组合式函数,随组件卸载自动解绑。原因很实在:Wails 的事件订阅会累积,你漏解绑一次,来回切路由就会触发多次。事件监听漏解绑,就像家里漏水你只关水龙头不关阀门——每次回来都水漫金山,还叠加。
分页同理:列表方法必须返回 page.Result[T],不许返回裸切片;分页参数必须先经 page.Request.Normalized() 归一化再查库。
前端这份职责被明确划走了:前端不得自行实现归一化(页码夹取、页大小上下界),那是 Go 的职责。前端发原始值,收归一化值。规则只有一处实现,就不会有两个地方各夹一次、结果不一致。一人一把尺,量出来的尺寸能对得上才怪。
6. 代码生成器:让新模块长在纪律里
新加一个业务模块,手写「model + service + 绑定方法 + 前端页面」要复制粘贴一堆样板,还容易漏掉注册。所以有个生成器:
make gen name=asset
它从 _example/ 模板生成四段代码,并自动注入到 AllModels()、app.go、路由、菜单里。生成器用了几个我觉得很值得抄的手法:
- 锚点注入。目标文件里放
【gen:routes】这类注释锚点,生成器靠它定位插入位置。护栏也断言锚点存在——锚点被删了两边一起红。这是刻意的耦合,就像插座和插头,天生一对,谁也别想单独活着。 - fail-fast 前置校验。动任何文件之前,先把「锚点在不在」「资源是不是已被占用」全查一遍。否则中途失败会留下「后端生成了、前端没注入」的半拉子状态,重跑又被幂等检查拦住,非常难收拾。半拉子工程比没有工程更糟,就像理发理到一半发现没带推子。
- 幂等 + 拒绝覆盖。目标文件已存在就直接报错,绝不覆盖你写的业务代码。生成器不是拆迁队,它是物业,只补不拆。
// TODO锚点。生成的文件带// TODO: 业务逻辑,人和 AI 都只填锚点处。给 AI 画好格子,它就不会自由发挥到沟里去。
还有一条容易被忽视的规矩:_example/ 是唯一范例,历史模块不是范例。
真实的业务模块会越写越具体、越写越乱,拿它当模板会学到一堆坏味道。所以模板必须单独维护,保持最小、干净。别拿老员工当新人导师——他经验丰富,但他那身坏习惯也是经验丰富。
7. 统一入口:别让人和 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 保证不残留。测过就是测过,别整「我觉得应该没问题」这种自我安慰。
8. 打包发布:版本号只有一个真相
版本号这件事,坑特别多。我的处理原则是四处同源:
wails.json的info.productVersion是唯一真相;- wails 用它渲染 macOS 的
Info.plist、Windows 的 exe 版本资源与 NSIS 注册表; - 构建脚本把同一个值用
-ldflags注入到应用内(日志首行、GetAppInfo)。
所有构建都走同一个 scripts/build.sh,它是唯一的版本注入点。macOS 构建完还会回读产物的 Info.plist 校验版本真的生效了。
版本号这种事情,最怕的是一处改了三处没改,用户看到的版本和你以为的版本对不上,客服被问到怀疑人生。单一真相 + 回读验证,谁也别想偷偷跑偏。
自动发布也很省事,打个 tag 就行:
git tag v0.1.0 && git push origin v0.1.0
CI 会先跑静态检查,再由 macOS / Windows 两条腿分别打包 dmg 和 NSIS 安装器,最后建 Release 挂上产物。
9. 说点难听的:已知限制
一个负责任的脚手架应该把坑写清楚,而不是装看不见。这个基座有几条硬限制:
- macOS 没有系统托盘。Wails v2 的 NSApplication delegate 和所有 systray 库都冲突,所以 macOS 上关闭即退出,托盘只在 Windows/Linux 提供。Mac 用户想最小化到托盘?不存在的,关了就真的关了,再见,走好不送。
- 没有 headless 模式。冒烟测试只定位为本地验证,CI 里只编译不启动 GUI。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 不接受非数字版本。想发v1.0-beta?NSIS 说:不行,你这是想让我吃数字以外的字符,我不吃。
10. 小结:方法论比代码更值钱
回头看,这个基座里真正让我觉得「赚到了」的,不是那几千行 Go 和 Vue,而是这几条可以跨语言、跨框架迁移的原则:
- **把约定编译成会红的检查。**软约束一定会被违反,硬约束不会。文档是提醒,测试是执法。
- **护栏必须能感知自己瞎了。**解析到 0 个结果就报错,别让护栏退化成「永远绿」。绿得可疑,比红得明显更可怕。
- **清单类规则一律双向校验。**正向防漏登记,反向防僵尸条目。进得来,出得去,名单才干净。
- **单一真相 + 受管镜像。**一条规则一个出处;必须复制的只复制常量,不复制逻辑。真相只有一个,这话在架构里是字面意思。
- **凡是「坏了也没症状」的地方,都得有一条检查兜住。**接线、注入、镜像漂移——这三类是重灾区。没症状的病最要命。
- **护栏和治理要先于业务。**早期只有一两条也没关系,越早立,后面每个模块都长在纪律里;等写了几十个模块再回头补,就来不及了。孩子的规矩要从小立,项目也一样。
代码会过时——Go 版本会变,Vue 会变,说不定哪天 Wails v3 就不长这样了。但「把工程约定变成机器可验证的事实」这套思路,换个技术栈照样能用。
如果你的项目还靠 code review 和自觉来守护架构边界,我建议从一条最小的护栏开始——比如「渲染层禁止 import Node 能力」——先跑通,再慢慢加。护栏这东西,是典型的复利投资:今天花十分钟,明天省十小时,后天省十天。
P.S. 目前国内还是很缺AI人才的,希望更多人能真正加入到AI行业,共同促进行业进步,增强我国的AI竞争力。想要系统学习AI知识的朋友可以看看我精心打磨的教程 传送门http://blog.csdn.net/jiangjunshow,教程通俗易懂,高中生都能看懂,还有各种段子风趣幽默,从深度学习基础原理到各领域实战应用都有讲解,我22年的AI积累全在里面了。注意,教程仅限真正想入门AI的朋友,否则看看零散的博文就够了。
966

被折叠的 条评论
为什么被折叠?



