编程语言

Go教程(八)产品实战 —— 桌面应用(Wails + Vue / Fyne)

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 —

本篇两个实战:

  1. Wails + Vue:日志分析器(选择日志文件 → 并发分析 → 实时进度 → 图表 → 导出报告)。
  2. 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. 练习

  1. 完成日志分析器,用 ECharts 把日志级别画成饼图,并支持拖拽文件到窗口(Wails 的 runtime.OnFileDrop,需在 options 中开启 DragAndDrop)。
  2. 给 AnalyzeFile 增加取消功能:前端点"取消" → Go 侧 context.WithCancel 停止扫描。
  3. 用 Fyne 版 URL 检查工具增加"导出结果到 CSV"按钮(dialog.ShowFileSave)。
  4. 比较:同一个日志分析功能,Python(Tkinter/PyQt)、Electron、Wails 三个版本的启动时间和包体积。

文章评论 (0)