编程语言

Go教程(九)产品交付 —— Go + Vue 打包成"单文件"可分发产品

Go 09:产品交付 —— Go + Vue 打包成"单文件"可分发产品

很多知名的自托管产品都是 Go 后端 + Web 前端 → 编译成一个二进制文件:
Grafana、Gitea、PocketBase、Memos、Syncthing、Caddy……
用户下载一个文件、双击/一条命令就能运行,不需要安装 Python、Node、数据库。这是 Go 做"产品"最大的优势。

本篇把 06 篇的 Gin 后端 + TS 05 篇的 Vue 前端,做成一个可发布的产品 acct:

$ acct version
acct 1.2.0 (commit a1b2c3d, built 2026-11-02)

$ acct serve --open          # 启动服务并自动打开浏览器
$ acct backup ./bak.db       # 在线备份数据

1. 项目结构

acct/
├── cmd/acct/main.go          # 入口:子命令 serve / backup / version
├── internal/...              # 06 篇的 config / model / service / handler / router
├── web/                      # Vue 3 + TS 项目(TS 05 篇)
│   ├── src/
│   ├── dist/                 # npm run build 产物(被 embed 进二进制)
│   └── embed.go              # Go 包:嵌入 dist
├── Makefile
├── .goreleaser.yaml
└── Dockerfile

2. 开发模式:前后端分别运行

// web/vite.config.ts —— 开发时把 /api 代理到 Go
export default defineConfig({
  plugins: [vue()],
  server: { proxy: { "/api": "http://localhost:8080" } },
});
# 终端 1
go run ./cmd/acct serve
# 终端 2
cd web && npm run dev       # 访问 http://localhost:5173,享受 Vue 热更新

3. 生产模式:把前端嵌入二进制(embed)

// web/embed.go
package web

import (
	"embed"
	"io/fs"
)

//go:embed all:dist
var dist embed.FS

// FS 返回去掉 "dist/" 前缀的文件系统
func FS() fs.FS {
	sub, err := fs.Sub(dist, "dist")
	if err != nil {
		panic(err)
	}
	return sub
}

SPA 需要一个"回退"处理:/records/2026-11 这种前端路由在服务端不存在对应文件,应返回 index.html:

// internal/router/spa.go
package router

import (
	"io/fs"
	"net/http"
	"path"
	"strings"
)

func SPAHandler(fsys fs.FS) http.Handler {
	fileServer := http.FileServer(http.FS(fsys))
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		p := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
		if p == "" {
			p = "index.html"
		}
		if _, err := fs.Stat(fsys, p); err != nil {
			// 文件不存在 → 前端路由,返回 index.html(不缓存,保证发版后能拿到新版本)
			w.Header().Set("Cache-Control", "no-cache")
			r2 := r.Clone(r.Context())
			r2.URL.Path = "/"
			fileServer.ServeHTTP(w, r2)
			return
		}
		if strings.HasPrefix(p, "assets/") {
			// Vite 产物文件名带 hash,可以永久缓存
			w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
		}
		fileServer.ServeHTTP(w, r)
	})
}
// internal/router/router.go 中追加(在所有 /api 路由注册之后)
spa := SPAHandler(web.FS())
r.NoRoute(func(c *gin.Context) {
	if strings.HasPrefix(c.Request.URL.Path, "/api/") {
		c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) // API 404 仍返回 JSON
		return
	}
	spa.ServeHTTP(c.Writer, c.Request)
})

注意:go build 之前必须先 npm run build 生成 web/dist,否则 embed 会编译失败。用 Makefile 统一流程。

4. 入口:子命令、配置、数据目录、版本信息

// cmd/acct/main.go
package main

import (
	"context"
	"errors"
	"flag"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/exec"
	"os/signal"
	"path/filepath"
	"runtime"
	"syscall"
	"time"

	"example.com/acct/internal/config"
	"example.com/acct/internal/model"
	"example.com/acct/internal/router"
	"github.com/glebarez/sqlite"
	"gorm.io/gorm"
)

// 构建时通过 -ldflags "-X main.version=..." 注入
var (
	version = "dev"
	commit  = "none"
	date    = "unknown"
)

func main() {
	cmd := "serve"
	if len(os.Args) > 1 {
		cmd = os.Args[1]
	}
	args := []string{}
	if len(os.Args) > 2 {
		args = os.Args[2:]
	}

	var err error
	switch cmd {
	case "serve":
		err = serve(args)
	case "backup":
		err = backup(args)
	case "version", "--version", "-v":
		fmt.Printf("acct %s (commit %s, built %s)\n", version, commit, date)
	default:
		fmt.Fprintln(os.Stderr, "usage: acct [serve|backup|version]")
		os.Exit(2)
	}
	if err != nil {
		slog.Error(cmd+" failed", "err", err)
		os.Exit(1)
	}
}

// defaultDataDir:macOS ~/Library/Application Support/acct,Windows %AppData%\acct,Linux ~/.config/acct
func defaultDataDir() string {
	dir, err := os.UserConfigDir()
	if err != nil {
		return "./data"
	}
	return filepath.Join(dir, "acct")
}

func envOr(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

func openDB(dataDir string) (*gorm.DB, error) {
	if err := os.MkdirAll(dataDir, 0o755); err != nil {
		return nil, err
	}
	dsn := filepath.Join(dataDir, "acct.db") + "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)"
	db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
	if err != nil {
		return nil, err
	}
	return db, db.AutoMigrate(&model.User{}, &model.Record{})
}

// 优先级:命令行参数 > 环境变量 > 默认值(≈ Python 的 argparse + os.environ)
func serve(args []string) error {
	fs := flag.NewFlagSet("serve", flag.ExitOnError)
	addr := fs.String("addr", envOr("ACCT_ADDR", "127.0.0.1:8080"), "监听地址")
	dataDir := fs.String("data", envOr("ACCT_DATA", defaultDataDir()), "数据目录")
	openBrowser := fs.Bool("open", false, "启动后打开浏览器")
	_ = fs.Parse(args)

	db, err := openDB(*dataDir)
	if err != nil {
		return err
	}
	cfg := config.Load()
	srv := &http.Server{Addr: *addr, Handler: router.New(cfg, db)}

	errCh := make(chan error, 1)
	go func() { errCh <- srv.ListenAndServe() }()
	slog.Info("acct started", "version", version, "addr", *addr, "data", *dataDir)

	if *openBrowser {
		go func() {
			time.Sleep(300 * time.Millisecond)
			_ = openURL("http://" + *addr)
		}()
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	select {
	case err := <-errCh:
		if !errors.Is(err, http.ErrServerClosed) {
			return err // 例如端口被占用
		}
	case <-ctx.Done():
	}
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	return srv.Shutdown(shutdownCtx)
}

// backup 使用 SQLite 的 VACUUM INTO 在线生成一致性备份,服务运行中也可执行
func backup(args []string) error {
	fs := flag.NewFlagSet("backup", flag.ExitOnError)
	dataDir := fs.String("data", envOr("ACCT_DATA", defaultDataDir()), "数据目录")
	_ = fs.Parse(args)
	if fs.NArg() != 1 {
		return errors.New("usage: acct backup [--data DIR] <output.db>")
	}
	db, err := openDB(*dataDir)
	if err != nil {
		return err
	}
	out, _ := filepath.Abs(fs.Arg(0))
	if err := db.Exec("VACUUM INTO ?", out).Error; err != nil {
		return err
	}
	fmt.Println("backup saved to", out)
	return nil
}

func openURL(url string) error {
	switch runtime.GOOS {
	case "darwin":
		return exec.Command("open", url).Start()
	case "windows":
		return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
	default:
		return exec.Command("xdg-open", url).Start()
	}
}

子命令多了以后可以换成 spf13/cobra(kubectl、docker、gh 都用它),还能自动生成 shell 补全。

5. 一键构建:Makefile

VERSION ?= $(shell git describe --tags --always --dirty)
COMMIT  := $(shell git rev-parse --short HEAD)
DATE    := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)

.PHONY: web build test all

web:
	cd web && npm ci && npm run build

build: web
	CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/acct ./cmd/acct

test:
	go test ./internal/... -race -cover
	cd web && npm run test

all: test build
make build && ls -lh bin/acct     # 约 20~30 MB,包含后端 + 前端 + SQLite 驱动
./bin/acct serve --open

6. 多平台发布:GoReleaser

一条命令为所有平台编译、打包、生成校验和、发布到 GitHub Releases:

# .goreleaser.yaml
version: 2

before:
  hooks:
    - sh -c "cd web && npm ci && npm run build"

builds:
  - main: ./cmd/acct
    binary: acct
    env: [CGO_ENABLED=0]
    goos: [linux, windows, darwin]
    goarch: [amd64, arm64]
    ldflags:
      - -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X main.date={{.Date}}

archives:
  - formats: [tar.gz]
    format_overrides:
      - goos: windows
        formats: [zip]

checksum:
  name_template: checksums.txt
brew install goreleaser
goreleaser release --snapshot --clean   # 本地试打包,产物在 dist/
git tag v1.2.0 && git push --tags
goreleaser release --clean              # 正式发布(需 GITHUB_TOKEN)

GoReleaser 还能生成 Homebrew tap、Scoop、deb/rpm、Docker 镜像,让用户 brew install acct。

7. 部署方式

7.1 Linux 服务器:systemd

# /etc/systemd/system/acct.service
[Unit]
Description=acct accounting service
After=network.target

[Service]
User=acct
Environment=ACCT_ADDR=127.0.0.1:8080
Environment=ACCT_DATA=/var/lib/acct
Environment=JWT_SECRET=please-change-me
ExecStart=/usr/local/bin/acct serve
Restart=on-failure

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now acct
journalctl -u acct -f          # 查看日志

前面再放 nginx 做 HTTPS 反向代理(与本博客 conf/ 中的 nginx 配置方式相同)。

7.2 Docker(三阶段构建:Node 构建前端 → Go 编译 → 极小运行镜像)

FROM node:22-slim AS web
WORKDIR /web
COPY web/package*.json ./
RUN npm ci
COPY web/ .
RUN npm run build

FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=web /web/dist ./web/dist
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION}" -o /acct ./cmd/acct

FROM gcr.io/distroless/static
COPY --from=build /acct /acct
ENV ACCT_ADDR=0.0.0.0:8080 ACCT_DATA=/data
VOLUME /data
EXPOSE 8080
ENTRYPOINT ["/acct", "serve"]

7.3 个人用户:直接下载运行

Windows 用户双击 acct.exe(可加 --open 自动打开浏览器);配合 08 篇的 Wails,还能把同样的前端做成桌面版。

8. 产品化清单

能力 做法
版本信息 -ldflags -X 注入;/api/version 接口返回,方便 QA 确认测试版本
检查更新 启动时请求 GitHub Releases API,与当前版本比较(golang.org/x/mod/semver)
数据库升级 每个版本的迁移脚本用 golang-migrate 管理,启动时自动执行;升级前自动备份
备份/恢复 acct backup;恢复即替换数据文件
首次启动 无用户时引导创建管理员(前端/setup 页面)
日志 slog 输出 JSON;文件轮转用 lumberjack
健康检查 /healthz(存活)、/readyz(数据库可用)
安全默认值 默认只监听127.0.0.1;JWT_SECRET 未设置时首次启动随机生成并保存到数据目录
发布 GoReleaser + CHANGELOG + 校验和

9. QA 视角的发布验证

  • 三个平台的二进制都能启动,acct version 输出正确版本号和 commit。
  • 旧版本数据目录 → 新版本启动,数据完整、迁移成功。
  • 端口被占用时给出清晰错误,进程退出码非 0。
  • 刷新 /records/2026-11 等前端路由不出现 404;/api/xxx 不存在时返回 JSON 404。
  • 发布新版本后,浏览器不需要强制刷新就能拿到新前端(index.html no-cache 生效)。

10. 练习

  1. 按本篇把 06 篇后端 + Vue 前端合并为 acct,用 make build 生成单文件。
  2. 增加 acct restore <file> 子命令,以及 /api/version 接口。
  3. 用 GoReleaser 生成 macOS / Windows / Linux 三个平台的包,在 Windows 虚拟机上验证。
  4. 进阶:参考 08 篇,用 Wails 把同一套前端 + 后端做成桌面版(Wails 中直接调用 service 层,无需 HTTP)。

文章评论 (0)