Go 08:产品实战 —— 桌面应用(Wails + Vue / Fyne)
Go 不只是写服务端。借助 Wails(Go 后端 + Web 前端,思路同 Electron/Tauri)或 Fyne(纯 Go 原生 GUI),
可以做出体积小、启动快、无需安装运行时的跨平台桌面程序。
1. 选型
| 方案 | 界面写法 | 优点 | 缺点 | 适合 |
|---|---|---|---|---|
| Wails v2 | Vue / React + TS(系统 WebView) | 界面能力强、可复用 Web 技能;Go 方法自动生成 TS 绑定 | 依赖系统 WebView(Win10+ 自带 WebView2) | 你的情况:会 Vue + 学 Go |
| Fyne | 纯 Go 代码 | 一种语言搞定;同一份代码还能编译到 Android/iOS | 界面风格固定(Material 风),复杂 UI 吃力 | 内部工具、小工具 |
| Gio | 纯 Go(即时模式) | 性能好 | 学习曲线陡 | 定制化绘制 |
| (对照)Electron / Tauri | TS 07 篇 | — | — | — |
| (对照)C# WPF / WinForms | XAML / 设计器 | Windows 体验最好 | 基本只限 Windows | — |
本篇两个实战:
- Wails + Vue:日志分析器(选择日志文件 → 并发分析 → 实时进度 → 图表 → 导出报告)。
- Fyne:图形化 URL 健康检查工具(复用 05 篇的
checker包)。
2. Wails + Vue:日志分析器
2.1 安装与创建
go install github.com/wailsapp/wails/v2/cmd/wails@latest
wails doctor # 检查环境(macOS 需 Xcode 命令行工具)
wails init -n log-analyzer -t vue-ts # 官方 Vue + TS 模板
cd log-analyzer
wails dev # 开发模式:Go 改动自动重编译,Vue 热更新
log-analyzer/
├── main.go # 入口:窗口配置、绑定 App
├── app.go # 暴露给前端的方法(自动生成 TS 绑定)
├── analyzer/ # 自己新建:纯逻辑包,可单测
│ └── analyzer.go
├── frontend/ # 标准 Vite + Vue 3 + TS 项目
│ ├── src/App.vue
│ └── wailsjs/ # wails 自动生成:Go 方法的 TS 封装 + 类型
└── wails.json
Wails 的核心魔法:Go 结构体的公开方法 → 自动生成同名 TS 函数,返回 Promise,参数/返回值类型也自动生成。
不需要写 HTTP 接口,也不需要像 Electron 那样手写 IPC 与 preload。
2.2 纯逻辑包(和 GUI 无关,可单独测试)
// analyzer/analyzer.go
package analyzer
import (
"bufio"
"io"
"regexp"
"sort"
"strings"
)
var (
levelRe = regexp.MustCompile(`\b(DEBUG|INFO|WARN|WARNING|ERROR|FATAL)\b`)
numRe = regexp.MustCompile(`\d+`)
)
type ErrorCount struct {
Message string `json:"message"`
Count int `json:"count"`
}
type Report struct {
Lines int `json:"lines"`
Levels map[string]int `json:"levels"`
TopErrors []ErrorCount `json:"topErrors"`
}
// Analyze 逐行读取(大文件也不会占满内存),progress 回调报告已读取字节数
func Analyze(r io.Reader, progress func(readBytes int64)) (*Report, error) {
rep := &Report{Levels: map[string]int{}}
errs := map[string]int{}
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 1024*1024), 1024*1024) // 支持超长行
var read int64
for sc.Scan() {
line := sc.Text()
read += int64(len(line)) + 1
rep.Lines++
level := "OTHER"
if m := levelRe.FindString(line); m != "" {
level = strings.Replace(m, "WARNING", "WARN", 1)
}
rep.Levels[level]++
if level == "ERROR" || level == "FATAL" {
// 把数字替换成 N,相同类型的错误归为一类:"timeout after 30s id=123" → "timeout after Ns id=N"
msg := line[levelRe.FindStringIndex(line)[1]:]
errs[numRe.ReplaceAllString(strings.TrimSpace(msg), "N")]++
}
if progress != nil && rep.Lines%20000 == 0 {
progress(read)
}
}
if err := sc.Err(); err != nil {
return nil, err
}
for msg, n := range errs {
rep.TopErrors = append(rep.TopErrors, ErrorCount{Message: msg, Count: n})
}
sort.Slice(rep.TopErrors, func(i, j int) bool { return rep.TopErrors[i].Count > rep.TopErrors[j].Count })
if len(rep.TopErrors) > 10 {
rep.TopErrors = rep.TopErrors[:10]
}
return rep, nil
}
// analyzer/analyzer_test.go
package analyzer
import (
"strings"
"testing"
)
func TestAnalyze(t *testing.T) {
log := `2026-11-01 10:00:00 INFO start
2026-11-01 10:00:01 ERROR timeout after 30s id=1
2026-11-01 10:00:02 ERROR timeout after 30s id=2
2026-11-01 10:00:03 WARNING disk 91%`
rep, err := Analyze(strings.NewReader(log), nil)
if err != nil {
t.Fatal(err)
}
if rep.Levels["ERROR"] != 2 || rep.Levels["WARN"] != 1 {
t.Errorf("levels = %v", rep.Levels)
}
if rep.TopErrors[0].Count != 2 {
t.Errorf("同类错误应被归并: %+v", rep.TopErrors)
}
}
2.3 暴露给前端的 App
// app.go
package main
import (
"context"
"encoding/csv"
"fmt"
"os"
"time"
"log-analyzer/analyzer"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
type App struct {
ctx context.Context
}
func NewApp() *App { return &App{} }
func (a *App) startup(ctx context.Context) { a.ctx = ctx }
// FileReport 是返回给前端的结构,wails 会生成对应的 TS class
type FileReport struct {
File string `json:"file"`
SizeMB float64 `json:"sizeMB"`
DurationMs int64 `json:"durationMs"`
Report *analyzer.Report `json:"report"`
}
// ChooseFile 打开系统文件对话框;用户取消返回空字符串
func (a *App) ChooseFile() (string, error) {
return runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: "选择日志文件",
Filters: []runtime.FileFilter{{DisplayName: "日志 (*.log;*.txt)", Pattern: "*.log;*.txt"}},
})
}
// AnalyzeFile 分析文件,过程中通过事件推送进度
func (a *App) AnalyzeFile(path string) (*FileReport, error) {
f, err := os.Open(path)
if err != nil {
return nil, err // Go 返回 error → 前端 Promise reject
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return nil, err
}
start := time.Now()
total := float64(stat.Size())
rep, err := analyzer.Analyze(f, func(read int64) {
runtime.EventsEmit(a.ctx, "analyze:progress", float64(read)/total*100) // Go → 前端推送
})
if err != nil {
return nil, err
}
runtime.EventsEmit(a.ctx, "analyze:progress", 100.0)
return &FileReport{
File: path,
SizeMB: total / 1024 / 1024,
DurationMs: time.Since(start).Milliseconds(),
Report: rep,
}, nil
}
// ExportCSV 把 Top 错误导出为 CSV
func (a *App) ExportCSV(errs []analyzer.ErrorCount) (string, error) {
path, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
DefaultFilename: fmt.Sprintf("errors-%s.csv", time.Now().Format("20060102")),
})
if err != nil || path == "" {
return "", err
}
f, err := os.Create(path)
if err != nil {
return "", err
}
defer f.Close()
w := csv.NewWriter(f)
_ = w.Write([]string{"count", "message"})
for _, e := range errs {
_ = w.Write([]string{fmt.Sprint(e.Count), e.Message})
}
w.Flush()
return path, w.Error()
}
// main.go
package main
import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
//go:embed all:frontend/dist
var assets embed.FS // 前端构建产物打包进二进制
func main() {
app := NewApp()
err := wails.Run(&options.App{
Title: "日志分析器",
Width: 1100,
Height: 760,
AssetServer: &assetserver.Options{Assets: assets},
OnStartup: app.startup,
Bind: []interface{}{app}, // App 的公开方法都会生成 TS 绑定
})
if err != nil {
println("Error:", err.Error())
}
}
log-analyzer/analyzer这个导入路径取决于go.mod中的 module 名(模板默认为项目名)。
2.4 Vue 前端:像调用本地函数一样调用 Go
运行 wails dev 后会自动生成 frontend/wailsjs/go/main/App.d.ts:
// 自动生成,无需手写
export function ChooseFile(): Promise<string>;
export function AnalyzeFile(arg1: string): Promise<main.FileReport>;
export function ExportCSV(arg1: Array<analyzer.ErrorCount>): Promise<string>;
<!-- frontend/src/App.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { ChooseFile, AnalyzeFile, ExportCSV } from "../wailsjs/go/main/App";
import { EventsOn } from "../wailsjs/runtime/runtime";
import type { main } from "../wailsjs/go/models";
const report = ref<main.FileReport | null>(null);
const progress = ref(0);
const busy = ref(false);
const error = ref("");
let off: (() => void) | undefined;
onMounted(() => { off = EventsOn("analyze:progress", (p: number) => (progress.value = Math.round(p))); });
onUnmounted(() => off?.());
async function pickAndAnalyze() {
const path = await ChooseFile();
if (!path) return;
busy.value = true;
error.value = "";
progress.value = 0;
try {
report.value = await AnalyzeFile(path); // 调用 Go 方法
} catch (e) {
error.value = String(e); // Go 返回的 error 在这里捕获
} finally {
busy.value = false;
}
}
async function exportErrors() {
if (!report.value) return;
const saved = await ExportCSV(report.value.report.topErrors);
if (saved) alert(`已导出:${saved}`);
}
</script>
<template>
<main>
<button :disabled="busy" @click="pickAndAnalyze">选择日志文件</button>
<progress v-if="busy" :value="progress" max="100" />
<p v-if="error" class="error">{{ error }}</p>
<section v-if="report">
<h3>{{ report.file }}</h3>
<p>{{ report.sizeMB.toFixed(1) }} MB,{{ report.report.lines }} 行,耗时 {{ report.durationMs }} ms</p>
<h4>日志级别</h4>
<div v-for="(n, level) in report.report.levels" :key="level" class="bar-row">
<span class="label">{{ level }}</span>
<span class="bar" :style="{ width: (n / report.report.lines) * 100 + '%' }" />
<span>{{ n }}</span>
</div>
<h4>Top 10 错误 <button @click="exportErrors">导出 CSV</button></h4>
<table>
<tr v-for="e in report.report.topErrors" :key="e.message">
<td>{{ e.count }}</td><td>{{ e.message }}</td>
</tr>
</table>
</section>
</main>
</template>
<style scoped>
.bar-row { display: flex; align-items: center; gap: 8px; }
.label { width: 60px; }
.bar { height: 12px; background: #4f7cff; min-width: 2px; }
.error { color: #d33; }
</style>
想要更漂亮的界面:直接
npm install element-plus echarts,和普通 Vue 项目一样用(参考 TS 05 篇)。
2.5 打包发布
wails build # 当前平台,产物在 build/bin/
wails build -platform windows/amd64 -nsis # 在 Mac 上也能交叉编译 Windows 版 + NSIS 安装包
wails build -platform darwin/universal # macOS 通用包(Intel + Apple 芯片),需在 Mac 上构建
体积参考:一个包含 Vue 界面的 Wails 应用通常 8~15 MB,同样功能的 Electron 版本约 100 MB+。
3. Fyne:纯 Go 的图形化 URL 检查工具
不想写前端?Fyne 让你只用 Go 写界面。这里直接复用 05 篇的 checker 包,为 CLI 工具加一个 GUI。
go get fyne.io/fyne/v2@latest
go install fyne.io/tools/cmd/fyne@latest # 打包工具
Fyne 依赖 CGO 和 OpenGL:macOS 需 Xcode 命令行工具,Windows 需安装 gcc(如 MSYS2)。
// cmd/urlcheck-gui/main.go
package main
import (
"context"
"fmt"
"net/http"
"strings"
"time"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"example.com/urlcheck/checker" // 05 篇的包
)
func main() {
a := app.NewWithID("com.example.urlcheck")
w := a.NewWindow("URL 健康检查")
input := widget.NewMultiLineEntry()
input.SetPlaceHolder("每行一个 URL,例如\nhttps://www.google.com\nhttps://httpbin.org/status/500")
input.SetMinRowsVisible(8)
concurrency := widget.NewSelect([]string{"1", "5", "10", "20"}, nil)
concurrency.SetSelected("10")
var results []checker.Result
table := widget.NewTable(
func() (int, int) { return len(results), 3 },
func() fyne.CanvasObject { return widget.NewLabel("placeholder text") },
func(id widget.TableCellID, o fyne.CanvasObject) {
r := results[id.Row]
label := o.(*widget.Label)
switch id.Col {
case 0:
label.SetText(map[bool]string{true: "✅", false: "❌"}[r.OK()])
case 1:
label.SetText(r.URL)
case 2:
if r.Error != "" {
label.SetText(r.Error)
} else {
label.SetText(fmt.Sprintf("%d %v", r.Status, r.Duration.Round(time.Millisecond)))
}
}
},
)
table.SetColumnWidth(0, 40)
table.SetColumnWidth(1, 320)
table.SetColumnWidth(2, 220)
status := widget.NewLabel("就绪")
progress := widget.NewProgressBarInfinite()
progress.Hide()
var runBtn *widget.Button
runBtn = widget.NewButton("开始检查", func() {
urls, _ := checker.ParseURLs(strings.NewReader(input.Text))
if len(urls) == 0 {
status.SetText("请先输入 URL")
return
}
var n int
fmt.Sscan(concurrency.Selected, &n)
runBtn.Disable()
progress.Show()
status.SetText(fmt.Sprintf("正在检查 %d 个 URL...", len(urls)))
go func() { // 耗时操作放到 goroutine,界面不卡顿
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
c := &checker.Checker{Client: &http.Client{Timeout: 10 * time.Second}, Concurrency: n}
res := c.CheckAll(ctx, urls)
fyne.Do(func() { // 回到 UI 线程更新界面(Fyne 2.6+)
results = res
table.Refresh()
ok := 0
for _, r := range res {
if r.OK() {
ok++
}
}
status.SetText(fmt.Sprintf("完成:%d 成功,%d 失败", ok, len(res)-ok))
progress.Hide()
runBtn.Enable()
})
}()
})
top := container.NewVBox(
input,
container.NewHBox(widget.NewLabel("并发数"), concurrency, runBtn),
progress,
status,
)
w.SetContent(container.NewBorder(top, nil, nil, nil, table))
w.Resize(fyne.NewSize(640, 600))
w.ShowAndRun()
}
go run ./cmd/urlcheck-gui
fyne package -os darwin -icon icon.png # 生成 .app
fyne package -os windows -icon icon.png # 生成 .exe(需在 Windows 或配置交叉编译)
fyne package -os android -app-id com.example.urlcheck -icon icon.png # 同一份代码打包 Android
关于 Go 做移动端:Fyne 和
gomobile能出 Android/iOS 包,适合内部工具;
面向大众用户的 App 仍建议用 TS 08 篇的方案或原生开发,Go 负责后端 API。
4. 桌面应用 QA 关注点
| 关注点 | Wails / Fyne 特有的检查 |
|---|---|
| WebView 依赖 | Windows 7/8 或精简版系统无 WebView2 时的提示与安装引导(Wails) |
| 大文件 | 1GB 日志:内存是否稳定、界面是否卡顿、进度是否准确 |
| 中断 | 分析中关闭窗口是否正常退出(goroutine 是否用 ctx 取消) |
| 编码 | GBK 编码日志、Windows 换行\r\n |
| 高分屏 | Retina / Windows 缩放 150% 下的界面 |
| 签名 | macOS 未签名应用被 Gatekeeper 拦截;Windows SmartScreen 提示 |
5. 练习
- 完成日志分析器,用 ECharts 把日志级别画成饼图,并支持拖拽文件到窗口(Wails 的
runtime.OnFileDrop,需在 options 中开启DragAndDrop)。 - 给
AnalyzeFile增加取消功能:前端点"取消" → Go 侧context.WithCancel停止扫描。 - 用 Fyne 版 URL 检查工具增加"导出结果到 CSV"按钮(
dialog.ShowFileSave)。 - 比较:同一个日志分析功能,Python(Tkinter/PyQt)、Electron、Wails 三个版本的启动时间和包体积。
文章评论 (0)