Compare commits
2 Commits
ca2309dda0
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e6549f2cb | |||
| 60f3412a55 |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1067" height="600" viewBox="0 0 1067 600"><rect width="1067" height="600" fill="#ffffff"/><g font-family="'Segoe UI', system-ui, sans-serif"><g transform="translate(453.5,130) scale(3.2)"><path d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z" fill="#4D6BFE"/></g><text x="533.5" y="410" font-size="86" font-weight="800" fill="#141b2e" text-anchor="middle">DSH Launcher</text><text x="533.5" y="460" font-size="28" font-weight="500" fill="#6b7280" text-anchor="middle">DeepSeek Harness 桌面启动器 · Windows / macOS / Linux</text><rect x="383.5" y="500" width="300" height="54" rx="27" fill="#4D6BFE"/><text x="533.5" y="535" font-size="24" font-weight="700" fill="#ffffff" text-anchor="middle">按目录 + 版本启动</text></g></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 209 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 342 KiB |
@@ -0,0 +1,110 @@
|
||||
---
|
||||
title: DSH Launcher — 一个把 DeepSeek Harness 装进桌面 GUI 的启动器
|
||||
published: 2026-08-28
|
||||
updated: 2026-08-28
|
||||
description: 介绍 DSH Launcher,一个用 Wails v2 + Go + React 编写的跨平台桌面启动器(Windows / macOS / Linux),按「目录 + 版本」一键启动 DeepSeek Harness:实例管理、npm 版本查询、实时日志、插件市场,推 tag 自动构建发布三端安装包。
|
||||
image: /posts/dsh-launcher-intro/img/cover.svg
|
||||
tags: ['DSH', 'DeepSeek', 'Wails', 'Go', 'React', '桌面应用', 'CI/CD']
|
||||
difficulty: 入门
|
||||
---
|
||||
|
||||
## DSH Launcher 是什么
|
||||
|
||||
[DSH Launcher](https://github.com/wishesl/dsh-launcher) 是一个**跨平台桌面 GUI 启动器**(Windows / macOS / Linux),用来以「指定目录 + 指定版本」的方式启动 [DeepSeek Harness(DSH)](https://github.com/deepseek-ai/DeepSeek-Harness),并可视化地查询版本、管理实例、查看实时日志、安装插件。
|
||||
|
||||
DSH 的启动方式本质是一条 `npx -y @deepseek-ai/dsh@<版本> web` 命令(在某个工作目录里运行)。启动器把「选目录 + 选版本 + 启动/停止 + 看版本」封装成了开箱即用的图形界面。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 为什么做这个
|
||||
|
||||
写这个启动器主要为了解决两个真实的痛点:
|
||||
|
||||
1. **npx 会优先命中启动目录里的本地 `node_modules` 副本**——哪怕 npm 上已经发了新版,只要目录里有旧版,`npx @deepseek-ai/dsh web` 就永远跑旧版。想升级得先搞清楚"我到底在跑哪一份",非常容易记混。
|
||||
2. **版本查询 / 升级要敲一堆命令行**:`npm view @deepseek-ai/dsh versions`、`npx -y @deepseek-ai/dsh@latest web`……记不住,还容易敲错。
|
||||
|
||||
启动器的价值就一句话:**一个实例 = 一个目录 + 一个版本**,多个实例互不干扰,版本对照一目了然,再也不用手敲 npx 命令。
|
||||
|
||||
## 核心功能
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| **实例管理** | 每个实例绑定一个目录和一个版本;卡片式列表,状态指示灯区分 启动中 / 运行中 / 已停止 / 异常退出,支持一键 启动 / 停止 / 删除 / 打开网页 |
|
||||
| **版本查询** | 展示 npm `latest` / `next` dist-tag、全部版本历史与发布时间;本地实际版本通过读取目录 `node_modules/@deepseek-ai/dsh/package.json` 探测;官方 registry 优先,npmmirror 兜底 |
|
||||
| **实时日志** | 启动日志流式回显到右侧常驻面板,自动识别 Web 地址、区分崩溃与正常退出、清理孤儿进程 |
|
||||
| **插件市场** | 内置插件市场:发现 / 收藏 / 安装 / 卸载 DSH 插件,进度实时展示 |
|
||||
| **系统托盘** | 点 ✕ 默认最小化到托盘而非退出,DSH 继续后台运行;托盘菜单可启动/停止各实例 |
|
||||
| **单实例** | 重复启动 exe 只会把已运行实例的窗口唤回前台,不会开第二个窗口 |
|
||||
| **自动发布** | 推 `v*` 标签即触发 GitHub Actions 构建三端产物并发布 Release |
|
||||
|
||||
## 两个有意思的设计
|
||||
|
||||
### 1. 服务态与进程态分离
|
||||
|
||||
最初版本里,「顶部显示 DSH 已就绪 / 打开按钮」完全依赖启动器自己管理的进程状态:进程输出里正则抓 URL → TCP 探测 → 标记 ready。这套逻辑有个隐患——一旦进程管理状态和真实情况脱节(比如快速停止/重启时的竞态、冷启动时持久化状态泄漏),顶部就永远显示"运行中"却没有打开按钮。
|
||||
|
||||
后来把它拆成了**两套互不影响的独立状态**:
|
||||
|
||||
| 状态 | 判定依据 | 驱动什么 |
|
||||
|------|----------|----------|
|
||||
| **服务态**(dsh 已启动) | 配置端口能正常访问 DSH 服务(后端 HTTP 探测,2s 兜底轮询 + 启停/保存/删除即时重查) | 顶部「已就绪」+ 打开按钮 |
|
||||
| **进程态**(启动器在管理) | 启动器自己 spawn / kill 的进程生命周期 | 实例卡片的 启动 / 停止 按钮 |
|
||||
|
||||
好处:只要端口真的在服务,顶部就能打开——哪怕进程是被外部方式拉起来的;进程管理状态怎么折腾都不影响"能不能打开"。
|
||||
|
||||
### 2. 跨平台的进程管理抽象层
|
||||
|
||||
Wails v2 框架本身支持三端,但项目真正跨平台需要处理**进程树管理**这个平台差异最大的部分。抽象成了 `procattr_windows.go` / `procattr_unix.go` 两个文件:
|
||||
|
||||
| 能力 | Windows | macOS / Linux |
|
||||
|------|---------|----------------|
|
||||
| 启动 shell | `cmd /c`(解析 `.cmd` shim) | `sh -c` |
|
||||
| 子进程属性 | `HideWindow`(不闪控制台) | `Setsid`(独立会话/进程组) |
|
||||
| 进程树终止 | Job Object + `taskkill /T /F` | `kill(-pgid)` 组杀树 |
|
||||
|
||||
`dsh_process.go`、`install.go`、`market_ops.go`、`env.go` 全部改走统一的 `shellCommand()` / `killProcessTree()`,不再有散落的平台代码。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层 | 技术 |
|
||||
|----|------|
|
||||
| 桌面壳 / 后端 | **Wails v2.15.0** + **Go 1.25**(Windows / macOS / Linux 三端) |
|
||||
| 前端 | **React 18** + **TypeScript** + **Vite 3** |
|
||||
| 系统托盘 | `fyne.io/systray`(独立 goroutine 跑消息循环,三端通用) |
|
||||
| 单实例 | Wails `options.SingleInstanceLock` |
|
||||
| CI / 发布 | GitHub Actions:三平台矩阵自动构建 + Releases |
|
||||
|
||||
## 多平台发布踩过的坑
|
||||
|
||||
自动化发布([workflow](https://github.com/wishesl/dsh-launcher/blob/master/.github/workflows/release.yml))流程是:**推 `v*` 标签 → 三平台并行构建 → 统一发 Release**。过程中踩了几个典型坑:
|
||||
|
||||
1. **Ubuntu 24.04 移除了 webkit2gtk-4.0**:Wails v2.10.2 默认仍要 4.0,24.04 只有 4.1。解法是升级 Wails 到 **v2.15.0**,构建时加 `-tags webkit2_41`,并补装 `libsoup-3.0-dev`。
|
||||
2. **`macos-13`(Intel runner)已被 GitHub 下线**:macOS x64 产物改为在 arm64 runner 上用 `GOARCH=amd64` 交叉编译(Xcode 通用工具链原生支持)。
|
||||
3. **Go 版本水涨船高**:Wails v2.15 要求 Go 1.25,CI 的 `setup-go` 也要跟着升。
|
||||
|
||||
## 快速上手
|
||||
|
||||
```bash
|
||||
# 下载对应平台安装包(Releases 页)或自行构建
|
||||
git clone https://github.com/wishesl/dsh-launcher.git
|
||||
cd dsh-launcher
|
||||
wails build # Windows / macOS
|
||||
wails build -tags webkit2_41 # Linux(需 webkit2gtk-4.1 系统依赖)
|
||||
```
|
||||
|
||||
打开应用后:
|
||||
|
||||
1. 左侧「实例」→「+ 添加实例」→ 选择 DSH 启动目录 → 选版本 → 启动方式选**本地副本**(官方推荐)→ 保存;
|
||||
2. 卡片点「启动」,右侧日志面板自动弹出并实时滚动;
|
||||
3. 顶部出现「DSH 已就绪 · 名称 · 地址」后点它即可在浏览器打开 DSH web;
|
||||
4. 提示「本地副本未安装」时先点「安装到目录」,把该版本真实装进目录的 `node_modules`。
|
||||
|
||||
## 开源信息
|
||||
|
||||
- GitHub:[wishesl/dsh-launcher](https://github.com/wishesl/dsh-launcher)
|
||||
- Releases(三平台安装包):<https://github.com/wishesl/dsh-launcher/releases>
|
||||
- 技术选型参考:DSH 本质是 `npx @deepseek-ai/dsh@<版本> web` 跑在选定目录里,多实例互不干扰;一个目录对应一个版本,别混着用。
|
||||
|
||||
欢迎 Star、提 Issue,或者直接来提功能需求 🙂
|
||||
@@ -0,0 +1,42 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1067" height="600" viewBox="0 0 1067 600">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#f6f4fb"/>
|
||||
<stop offset="1" stop-color="#e7f0f7"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="go" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#00add8"/>
|
||||
<stop offset="1" stop-color="#0087a8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="1067" height="600" fill="url(#bg)"/>
|
||||
<text x="533" y="84" text-anchor="middle" font-family="sans-serif" font-size="40" font-weight="700" fill="#2b3340">Go 单文件打包进阶</text>
|
||||
<text x="533" y="126" text-anchor="middle" font-family="sans-serif" font-size="24" fill="#8090a0">React 前端 + go:embed + 交叉编译</text>
|
||||
<rect x="96" y="196" width="230" height="96" rx="16" fill="#ffffff" stroke="#d8d2ec" stroke-width="3"/>
|
||||
<text x="211" y="238" text-anchor="middle" font-family="sans-serif" font-size="28" font-weight="700" fill="#5b5568">dist/</text>
|
||||
<text x="211" y="266" text-anchor="middle" font-family="sans-serif" font-size="15" fill="#8a84a0">Vite 构建产物</text>
|
||||
<path d="M346 244 h64" stroke="#7c5cff" stroke-width="4" fill="none"/>
|
||||
<path d="M402 238 l12 6 l-12 6 z" fill="#7c5cff"/>
|
||||
<rect x="418" y="196" width="230" height="96" rx="16" fill="url(#go)"/>
|
||||
<text x="533" y="236" text-anchor="middle" font-family="monospace" font-size="24" font-weight="700" fill="#ffffff">go:embed all:dist</text>
|
||||
<text x="533" y="266" text-anchor="middle" font-family="sans-serif" font-size="15" fill="#d8f4fb">编译期塞进二进制</text>
|
||||
<path d="M668 244 h64" stroke="#7c5cff" stroke-width="4" fill="none"/>
|
||||
<path d="M724 238 l12 6 l-12 6 z" fill="#7c5cff"/>
|
||||
<rect x="740" y="176" width="240" height="136" rx="18" fill="#ffffff" stroke="#7c5cff" stroke-width="4"/>
|
||||
<text x="860" y="224" text-anchor="middle" font-family="monospace" font-size="24" font-weight="700" fill="#5b3fd4">magic-img</text>
|
||||
<text x="860" y="256" text-anchor="middle" font-family="sans-serif" font-size="15" fill="#5b5568">单文件 · 约 26MB</text>
|
||||
<text x="860" y="284" text-anchor="middle" font-family="monospace" font-size="14" fill="#8a84a0">windows/amd64 · linux/amd64</text>
|
||||
<g font-family="sans-serif" font-size="18" fill="#5b5568">
|
||||
<rect x="96" y="404" width="275" height="74" rx="12" fill="#ffffff" stroke="#e3ddf3" stroke-width="2"/>
|
||||
<text x="233" y="434" text-anchor="middle" font-weight="700">HashRouter + base /dist</text>
|
||||
<text x="233" y="460" text-anchor="middle" font-size="14" fill="#8a84a0">无需 SPA fallback</text>
|
||||
<rect x="396" y="404" width="275" height="74" rx="12" fill="#ffffff" stroke="#e3ddf3" stroke-width="2"/>
|
||||
<text x="533" y="434" text-anchor="middle" font-weight="700">index.html no-store</text>
|
||||
<text x="533" y="460" text-anchor="middle" font-size="14" fill="#8a84a0">升级立即生效</text>
|
||||
<rect x="696" y="404" width="275" height="74" rx="12" fill="#ffffff" stroke="#e3ddf3" stroke-width="2"/>
|
||||
<text x="833" y="434" text-anchor="middle" font-weight="700">GOOS=linux GOARCH=amd64</text>
|
||||
<text x="833" y="460" text-anchor="middle" font-size="14" fill="#8a84a0">无 cgo,任意平台交叉编译</text>
|
||||
</g>
|
||||
<text x="533" y="548" text-anchor="middle" font-family="sans-serif" font-size="18" fill="#a49cc0">纯 Go 依赖(modernc SQLite)是跨平台交叉编译的前提</text>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,225 @@
|
||||
---
|
||||
title: Go 单文件打包进阶:React 前端 + 交叉编译,一次踩完所有坑
|
||||
published: 2026-08-16T00:00:00
|
||||
updated: 2026-08-16
|
||||
description: 以 Magic Img(React 19 + Vite + Gin + SQLite)为实例,记录把前端塞进 Go 二进制、并在 Windows 上交叉编译 Linux 产物的完整过程:embed 的 all 前缀、hash 路由、缓存策略、GOOS 交叉编译等实操坑。
|
||||
image: /posts/go-embed-react-single-binary/img/cover.svg
|
||||
tags: ['Go', 'React', 'embed', '交叉编译', '打包']
|
||||
draft: false
|
||||
series: Go 工程实践
|
||||
seriesOrder: 3
|
||||
difficulty: 进阶
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
这是「Go 单文件打包」系列第三篇。上一篇([把前端塞进二进制](/posts/go-embed-frontend))记录了一个 Gin + Vue 3 项目的 embed 方案。这次换了个项目继续踩坑:**Magic Img**——一个自托管、本地优先的 AI 生图提示词管理工具,技术栈是:
|
||||
|
||||
- 前端:React 19 + TypeScript + Vite + Tailwind v4(UI 库 Magic UI + shadcn/ui)
|
||||
- 后端:Go + Gin + GORM + SQLite(**modernc 纯 Go 驱动,无 cgo**)
|
||||
|
||||
目标一样:**一个二进制拖到任何机器上就能跑**——前端、API、数据库全部内嵌。
|
||||
|
||||
和上篇不同的地方有三个,正好是这篇要讲的重点:
|
||||
|
||||
1. 前端用了 **HashRouter + Vite base `/dist`**,服务端**不再需要 SPA fallback**(上篇坑一的新解法);
|
||||
2. embed 用 **`all:` 前缀**一劳永逸解决 `_` 开头文件被排除的问题(上篇坑二的升级解法);
|
||||
3. 需要**在 Windows 上交叉编译 Linux 产物**,踩了「生成的文件无法在 Linux 执行」的坑。
|
||||
|
||||
## 核心思路
|
||||
|
||||
和上篇一致:`//go:embed` 在编译期把前端 `dist/` 塞进二进制,运行时由 Go 直接提供静态服务。差异在于路由策略和缓存策略,后面细说。
|
||||
|
||||
## 逐步实现
|
||||
|
||||
### 1. 前端:hash 路由 + base /dist
|
||||
|
||||
vite.config.ts 里固定 `base: "/dist/"`,路由用 `HashRouter`:
|
||||
|
||||
```ts
|
||||
// vite.config.ts
|
||||
export default defineConfig({
|
||||
base: "/dist/", // 所有资源路径以 /dist/ 开头
|
||||
...
|
||||
})
|
||||
```
|
||||
|
||||
```tsx
|
||||
// App.tsx —— HashRouter:路径信息全在 location.hash 里
|
||||
<HashRouter>
|
||||
<Routes>...</Routes>
|
||||
</HashRouter>
|
||||
```
|
||||
|
||||
**为什么这样选**:history 路由需要服务端把未知路径全部 fallback 回 `index.html`(上篇坑一)。hash 路由下浏览器请求的永远是 `/dist/` 这一个入口,**静态服务直接返回 index.html 就完事,一个 fallback 都不用写**。内嵌场景里这是最省心的组合。
|
||||
|
||||
### 2. 构建产物复制进后端目录
|
||||
|
||||
`npm run build` 除了编译,还会把 `dist/` 复制到 `backend/web/dist`(一个小脚本 `scripts/copy-dist.mjs` 干这个活),供 `go:embed` 使用:
|
||||
|
||||
```json
|
||||
// frontend/package.json
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build && node scripts/copy-dist.mjs"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 后端:embed + http.FileSystem 包装
|
||||
|
||||
```go
|
||||
// backend/web/embed.go
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// Dist 内嵌前端静态文件系统
|
||||
var Dist http.FileSystem = &fileSystem{prefix: "dist", fs: &distFS}
|
||||
|
||||
type fileSystem struct {
|
||||
prefix string
|
||||
fs *embed.FS
|
||||
}
|
||||
|
||||
func (f *fileSystem) Open(name string) (http.File, error) {
|
||||
if name == "/" {
|
||||
name = f.prefix
|
||||
} else {
|
||||
name = f.prefix + "/" + strings.TrimPrefix(name, "/")
|
||||
}
|
||||
return http.FS(f.fs).Open(name)
|
||||
}
|
||||
```
|
||||
|
||||
两个细节:
|
||||
|
||||
- **`all:dist` 里的 `all:` 前缀**:embed 目录时默认会排除 `_` 和 `.` 开头的文件(上篇坑二就是被这个坑了,当时靠改 Vite 输出名绕开)。`all:` 前缀表示「全部包含」,前端构建产物里那些 `_xxx.js` 助手文件直接安全落地,不用再改前端配置;
|
||||
- `http.FileSystem` 包装:加了一层 `dist/` 前缀映射,`http.FileServer` 和 Gin 都能直接用。
|
||||
|
||||
### 4. 路由:静态服务 + 缓存策略
|
||||
|
||||
```go
|
||||
// backend/internal/handler/router.go
|
||||
func NewRouter(db *gorm.DB, uploadDir string) *gin.Engine {
|
||||
r := gin.Default()
|
||||
...
|
||||
dist := http.FileServer(web.Dist)
|
||||
r.GET("/dist", func(c *gin.Context) { c.Redirect(302, "/dist/") })
|
||||
r.GET("/dist/*filepath", func(c *gin.Context) {
|
||||
p := c.Param("filepath")
|
||||
if p == "/" || p == "/index.html" {
|
||||
// index.html 禁缓存:升级后浏览器立刻拿到新入口
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
} else if strings.HasPrefix(p, "/assets/") {
|
||||
// 带内容 hash 的资源可放心长缓存
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
}
|
||||
http.StripPrefix("/dist", dist).ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
r.GET("/", func(c *gin.Context) { c.Redirect(302, "/dist/") })
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
缓存策略是内嵌发布的必修课(坑二细说):入口禁缓存、hash 资源长缓存。
|
||||
|
||||
### 5. 打包脚本:Windows 版 + Linux 交叉编译版
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# build-linux.sh —— 在 Windows/macOS/Linux 任意平台交叉编译 linux/amd64 产物
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
BINARY="${BINARY:-magic-img}"
|
||||
|
||||
echo "[1/3] 构建前端并复制产物到 backend/web/dist ..."
|
||||
(cd frontend && npm run build)
|
||||
|
||||
echo "[2/3] 交叉编译后端并内嵌前端 (CGO_ENABLED=0 GOOS=linux GOARCH=amd64) ..."
|
||||
(cd backend && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "$BINARY" -ldflags="-s -w" ./cmd/server)
|
||||
chmod +x "backend/$BINARY"
|
||||
|
||||
echo "[3/3] 打包完成: backend/$BINARY"
|
||||
```
|
||||
|
||||
Windows 版(`build-win.bat`)同理,只是产物叫 `magic-img.exe`。
|
||||
|
||||
## 踩坑记录
|
||||
|
||||
### 坑一:`//go:embed dist` 静默漏掉 `_` 开头文件(升级解法)
|
||||
|
||||
上篇说过:embed 目录时**硬编码排除 `_` 和 `.` 开头文件**。上篇的解法是改 Vite 输出文件名去掉 `_` 前缀;这次直接用:
|
||||
|
||||
```go
|
||||
//go:embed all:dist // all: 前缀 = 包含全部文件,不再静默跳过
|
||||
```
|
||||
|
||||
**结论:能用 `all:` 就用 `all:`,从根上消灭这类"文件莫名 404"的问题。**
|
||||
|
||||
### 坑二:开发版是新的、打包版却是旧的
|
||||
|
||||
现象:Vite 开发服务一切正常,`:8080/dist/` 打包版却是旧界面,强刷也不一定好。
|
||||
|
||||
排查过程值得记录:先是怀疑 `go build` 缓存了旧 embed(上篇坑三的思路),重编重启无效;然后直接抓取 8080 返回的 HTML 和 JS,发现**服务端内容已经是新的**——问题出在**浏览器缓存了 `index.html`**:它引用旧 hash 的资源,一直不更新。
|
||||
|
||||
**解决**:入口文件响应 `Cache-Control: no-store`(见上面路由代码),带 hash 的资源给 `immutable` 长缓存。升级后浏览器每次拉新入口,资源文件名一变就自动换新,两全其美。
|
||||
|
||||
> 教训:排查"新旧不一致"先分清是哪一层——服务端产物、浏览器入口缓存、还是资源缓存。抓包看响应内容再动手。
|
||||
|
||||
### 坑三:Windows 上跑的 build-linux.sh,产出的文件 Linux 却执行不了
|
||||
|
||||
最初脚本只写了 `CGO_ENABLED=0 go build`,**没指定 `GOOS/GOARCH`**。在 Windows 上跑,产出的其实是个 Windows PE 文件(只是没叫 `.exe`),传到 Linux 自然「无法执行」。
|
||||
|
||||
**解决**:脚本里显式交叉编译:
|
||||
|
||||
```bash
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "$BINARY" -ldflags="-s -w" ./cmd/server
|
||||
```
|
||||
|
||||
能这么干的前提是**全依赖树无 cgo**——SQLite 用的是 modernc 纯 Go 驱动,bcrypt 用 `x/crypto`。这也是选型时就要留意的点:**想跨平台交叉编译,先把 cgo 依赖清干净。**
|
||||
|
||||
另外两个小提醒:Windows 上编出的文件没有 Unix 执行位,上传服务器后记得 `chmod +x`;`-ldflags="-s -w"` 剥离符号,体积从 30MB+ 降到 26MB。
|
||||
|
||||
### 坑四:dist 被 gitignore 后,embed 直接编译失败
|
||||
|
||||
`backend/web/dist` 是构建产物、入了 .gitignore,但 `//go:embed` 在**编译期**就要读它——刚 clone 下来直接 `go build` 会报 embed 找不到文件。
|
||||
|
||||
**解决**:gitignore 里保留 `dist/.gitkeep`(一个空文件),让目录存在于版本库中:
|
||||
|
||||
```gitignore
|
||||
backend/web/dist/*
|
||||
!backend/web/dist/
|
||||
!backend/web/dist/.gitkeep
|
||||
```
|
||||
|
||||
这样 clone 后可以先 `go build` 通过,再 `npm run build` 灌入真实前端。
|
||||
|
||||
### 坑五:>500KB chunk 警告,别慌
|
||||
|
||||
Vite 构建会警告单 chunk 超过 500KB。这个项目里 Base UI 组件库全量引入,单 chunk 约 640KB——**属正常,不要为了消警告去拆配置**。真要优化就上路由级 `React.lazy`。
|
||||
|
||||
## 最终效果
|
||||
|
||||
```bash
|
||||
$ build-win.bat # backendmagic-img.exe
|
||||
$ bash build-linux.sh # backendmagic-img(linux/amd64 ELF)
|
||||
```
|
||||
|
||||
一个约 26MB 的二进制,内嵌 React 前端、Gin API、SQLite 数据初始化,拷到 Windows/Linux 机器直接跑,浏览器访问 `http://localhost:8080` 自动重定向到 `/dist/`。
|
||||
|
||||
## 总结
|
||||
|
||||
这轮相比上篇的三点升级:
|
||||
|
||||
1. **hash 路由 + base /dist**:省掉 SPA fallback,静态服务即开即用;
|
||||
2. **`all:` embed 前缀**:`_` 开头文件问题一劳永逸;
|
||||
3. **显式 `GOOS/GOARCH` 交叉编译**:让「一个脚本、任意平台、产出 Linux 二进制」成为现实。
|
||||
|
||||
加上**入口 no-store / 资源 immutable** 的缓存策略,单文件分发的体验才算闭环——不然每次升级都要和浏览器缓存打架。
|
||||
Reference in New Issue
Block a user