在 Golang 中使用 Gin + Vue/React 开发单文件应用

在一个 Java 的 web 应用中,借助 Spring Boot 框架,可以很简单地将应用的网页部分和服务端逻辑(一般称为“后端”)部分打包成一个独立的jar文件。通过向用户提供该 jar 包,即可在安装了 JRE 的受支持的终端上运行此应用。

使用 Go 语言时,可借助 go-bindata ( homebrew主分支)或 embed 包( Go 1.16 及之后)等方案,实现将静态文件打包至二进制可执行程序中。

在我的一个应用创建之初, Go 1.16 版本还未发布,当时选择 go-bindata 将网页部分代码(由 Vue 创建的单页应用)作为静态文件,打包并提供给 Gin 框架。随着 1.16 版本的发布,我将这部分代码改为由 embed 实现,迁移的过程非常容易,只需要涉及少量的修改。迁移后发现后者生成的二进制代码明显更大,但考虑到二者的发展前景,选择仍然使用 embed 打包静态文件。

代码结构

简化后的目录结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ tree .
.
├── frontend
│   ├── dist
│   │   ├── favicon.ico
│   │   └── index.html
│   ├── node_modules
│   └── package.json
├── go.mod
├── go.sum
└── main.go

3 directories, 6 files

其中, frontend/ 目录用于保存前端相关的内容, frontend/dist/ 中保存编译后、待发布的静态内容。main.go 作为程序的主要入口,兼具提供 API 与提供静态网页之用途。

Web 页面部分

根据项目的需要选择 React/Vue等框架进行开发,本节使用一个简单 HTML 页面举例。

页面 frontend/dist/index.html 中内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Hello World</title>
</head>
<body>
<div id="hello"></div>
<script>
setInterval(() => {
fetch('/api/hello').then(res => res.json()).then(data => {
document.getElementById('hello').innerHTML = data.message;
})
}, 1000)
</script>
</body>
</html>

其中的 /api/helloGin的后端提供

Gin 部分

程序的入口main.go内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import (
"embed"
"fmt"
"github.com/gin-gonic/gin"
"mime"
"strings"
"time"
)

// 使用 go:embed 注解,将文件内容嵌入到程序中
//go:embed frontend/dist/*
var static embed.FS

func main() {
r := gin.Default()
r.GET("/api/hello", func(c *gin.Context) { // 注册路由
timeFormat := "2006-01-02 15:04:05"
msg := fmt.Sprintf("Hello, current time is %s", time.Now().Format(timeFormat))
c.JSON(200, gin.H{
"message": msg,
})
})
r.NoRoute(func(c *gin.Context) { // 当 API 不存在时,返回静态文件
path := c.Request.URL.Path // 获取请求路径
s := strings.Split(path, ".") // 分割路径,获取文件后缀
prefix := "frontend/dist" // 前缀路径
if data, err := static.ReadFile(prefix + path); err != nil { // 读取文件内容
// 如果文件不存在,返回首页 index.html
if data, err = static.ReadFile(prefix + "/index.html"); err != nil {
c.JSON(404, gin.H{
"err": err,
})
} else {
c.Data(200, mime.TypeByExtension(".html"), data)
}
} else {
// 如果文件存在,根据请求的文件后缀,设置正确的mime type,并返回文件内容
c.Data(200, mime.TypeByExtension(fmt.Sprintf(".%s", s[len(s)-1])), data)
}
})
_ = r.Run(":8080")
}

完整的例子已托管于 Gitlab