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