0%

Golang通过Embed嵌入静态资源

前言

通过Golang开发前后端项目时,普遍采用 Gin 结合 Vue 或者 React 项目来实现。

但在部署时,前端的项目则需要通过nginx来实现转发。对于小型的前后端项目来说,这种方式就有点复杂了。

小型的前后端项目,一般会考虑将静态文件打包到go二进制文件中,这样在发布时只需要部署单个二进制文件即可。相对于 PythonJava 等项目来说,并不需要前期配置依赖的运行环境,省时、省力还非常方便。

在之前的方法中,普遍采用第三方的类库实现。比较知名的有 go-bindatapackr 等。

这里来聊聊使用golang最新支持的 embed 特性如何实现。


embed了解

Go1.16 引入了 //go:embed 功能,可以将静态资源文件内容直接打包到二进制文件,方便部署。

数据类型

在embed中,可以将静态资源文件嵌入到三种类型的变量:string , byte sliceembed.FS 这三种类型。

数据类型 说明
string 表示数据被编码成 utf8 编码的字符串
[]byte 表示数据存储为二进制格式
embed.FS 表示存储多个文件和目录的结构,而 string[]byte 只能存储单个文件

其中,string[]byte 需要通过 import (_ "embed") 的形式引入 embed

示例:

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
package main  

import (
"embed"
_ "embed"
"fmt"
)


// 以字符串的形式嵌入 hello.txt
//go:embed hello.txt
var s string

// 以字节数组的形式嵌入
//go:embed hello.txt
var b []byte

// 以FS文件系统的形式嵌入
//go:embed hello.txt
var f embed.FS

func main() {
fmt.Println("embed string: ", s)

fmt.Println("embed byte: ", string(b))

data, _ := f.ReadFile("hello.txt")
fmt.Println("embed fs: ", string(data))
}

执行后输出为:

1
2
3
embed string:  你好鸭
embed byte: 你好鸭
embed fs: 你好鸭

通配符

当通过 embed.FS 嵌入多个文件时,支持 通配符 的使用。

通配符 释义
? 代表任意一个字符(不包括半角中括号)
* 代表0至多个任意字符组成的字符串(不包括半角中括号)
[…] 和 [!…] 代表任意一个匹配方括号里字符的字符,!表示任意不匹配方括号中字符的字符
[a-z]、[0-9] 代表匹配a-z任意一个字符的字符或是0-9中的任意一个数字
** 部分系统支持,*不能跨目录匹配,** 可以,不过目前在golang中和*是同义词

例如,我们可以通过如下的方式来嵌入多个目录下的文件:

1
2
3
4
5
6
7
8
package server

import "embed"

// content holds our static web server content.
//go:embed image/* template/*
//go:embed html/index.html
var content embed.FS

注意:支持同时设置多个 //go:embed 来嵌入多个文件或目录,或者在同一行的 //go:embed 中通过 空格分隔 的形式设置多个文件或目录

所以,我们也可以通过如下的方式来实现同样的效果:

1
2
//go:embed image template html/index.html
var content embed.FS

不过这两种方式是有区别的:

纯目录名的方式 //go:embed image template 会排除掉该目录下包含的以 ._ 开头的文件,例如 image/.tempfile 文件不会被包含在内;
而通配符的方式 //go:embed image/* template/* 则会包含该目录下的所有文件,即 image/.tempfile 会被包含在内


前端实现

通过 vite 来创建 vuereact 前端项目。

创建react项目

1
yarn create vite react-app

或者:

1
2
3
yarn create vite react-app --template react

yarn create vite react-app --template react-ts

编译:

1
2
3
cd react-app
yarn
yarn dev

构建:

1
yarn build

创建vue项目

1
2
3
4
yarn create vite vue-app

# 或者:
yarn create vite vue-app --template vue

编译:

1
2
3
cd vue-app
yarn
yarn dev

构建:

1
yarn build

Gin实现打包

创建gin项目

创建一个基本的Gin项目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// main.go

package main

import (
"github.com/gin-gonic/gin"
)

func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(200, gin.H{
"msg": "pong",
})
})

r.Run()
}

作为前端首页

一般情况下,我们想将前端页面作为首页来访问,即访问 / 根路径直接打开的是 index.html 页面。

参照Gin官方文档中的介绍,我们可以通过如下的方式导入静态文件:

1
2
3
4
5
6
7
8
9
10
func main() {
router := gin.Default()
router.Static("/assets", "./assets")
router.StaticFS("/more_static", http.Dir("my_file_system"))
router.StaticFile("/favicon.ico", "./resources/favicon.ico")
router.StaticFileFS("/more_favicon.ico", "more_favicon.ico", http.Dir("my_file_system"))

// Listen and serve on 0.0.0.0:8080
router.Run(":8080")
}

这里,我们选择 StaticFS 来添加 FS 资源。

将上面打包好的 vue 静态项目 dist 目录下的文件拷贝到 Golang 项目的 web 目录下,作为静态资源目录。

相应的目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.
├── README.md
├── go.mod
├── go.sum
├── main.go
└── web
   ├── assets
   │   ├── index.16c4fe9c.css
   │   ├── index.e9286135.js
   │   ├── logo.03d6d6da.png
   │   └── vendor.65715d52.js
   ├── favicon.ico
   ├── index.html
   └── web.go

web 目录下创建 web.go 文件,作为静态资源导入的 embed 文件:

1
2
3
4
5
6
7
8
// web/web.go

package web

import "embed"

//go:embed index.html favicon.ico assets
var Static embed.FS

然后在 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
// main.go

package main

import (
"demo/web"
"github.com/gin-gonic/gin"
"net/http"
)

func main() {
r := gin.Default()

//r.GET("/ping", func(c *gin.Context) {
// c.JSON(200, gin.H{
// "msg": "pong",
// })
//})

r.StaticFS("/", http.FS(web.Static))

r.Run()
}

通过浏览器访问 http://localhost:8080/ 后发现能够正常访问:

20220820192105


多路由实现

不过一般情况下,在 Gin 的路由中我们除了添加静态资源外,还会设置前端的api请求路由。这样我们既可以直接访问默认路径 / 打开web页面,又可以通过请求 /api/* 的路径实现 api接口 调用。

如下面的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// main.go

package main

import (
"demo/web"
"github.com/gin-gonic/gin"
"net/http"
)

func main() {
r := gin.Default()

r.GET("/api/hello", func(c *gin.Context) {
c.JSON(200, gin.H{
"msg": "hello world",
})
})

r.StaticFS("/", http.FS(web.Static))

r.Run()
}

但执行 go run main.go 命令后会报错:

1
2
3
[GIN-debug] GET    /api/hello                --> main.main.func1 (3 handlers)
[GIN-debug] GET /*filepath --> github.com/gin-gonic/gin.(*RouterGroup).createStaticHandler.func1 (3 handlers)
panic: catch-all wildcard '*filepath' in new path '/*filepath' conflicts with existing path segment 'api' in existing prefix '/api'

参考网上相关的讨论,我整理并总结了如下三种解决方法。

重定向法

查看上面的错误信息,/*filepath 会匹配到所有的根路径下的路由导致出现问题,那么我们把静态页放到一个二级目录下,例如 /ui,这样匹配到的就是 /ui/*filepath 下的所有路径了,跟 /api 就不会出现冲突的问题。

我们将 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
package main  

import (
"demo/web"
"github.com/gin-gonic/gin"
"net/http"
)

func main() {
r := gin.Default()

r.GET("/api/hello", func(c *gin.Context) {
c.JSON(200, gin.H{
"msg": "hello world",
})
})

r.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "ui/")
})
r.StaticFS("/ui", http.FS(web5.Web5Static))

r.Run()
}

不过,这种情况下我们还需要对前端代码做一些修改,将原来的默认根路径 / 修改为 /ui

打开文件 vite.config.js ,添加 base 配置项:

1
2
3
4
5
6
7
8
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
base: '/ui/',
})

此时,访问浏览器会发现,默认根路径 / 会执行 301 跳转到 /ui 路径下实现静态资源的正常访问:

20220820192159

相关讨论:(建议看一下这个


中间件法

我找到了一个 Gin 项目的第三方扩展中间件,可以实现对静态资源文件的调用:gin-contrib/static: Static middleware (github.com)

但是该三方库目前对于 Embed 方式嵌入的静态资源文件并不支持。好在我在该三方库的 Issues 中找到了相关的讨论:

以及某位大神提交的 Pull requests

不过开发者一直没有对该提交进行合并。那我们只能自行实现了。

在项目根目录下创建 static 文件夹,包含两个文件 serve.goembed.go 。结合上面的 Issues 讨论,最终实现的文件代码如下:

文件 static/serve.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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
package static  

import (
"github.com/gin-gonic/gin"
"net/http"
"os"
"path"
"strings"
)

const INDEX = "index.html"

type ServeFileSystem interface {
http.FileSystem
Exists(prefix string, path string) bool
}

type localFileSystem struct {
http.FileSystem
root string
indexes bool
}

func LocalFile(root string, indexes bool) *localFileSystem {
return &localFileSystem{
FileSystem: gin.Dir(root, indexes),
root: root,
indexes: indexes,
}
}

func (l *localFileSystem) Exists(prefix string, filepath string) bool {
if p := strings.TrimPrefix(filepath, prefix); len(p) < len(filepath) {
name := path.Join(l.root, p)
stats, err := os.Stat(name)
if err != nil {
return false
}
if stats.IsDir() {
if !l.indexes {
index := path.Join(name, INDEX)
_, err := os.Stat(index)
if err != nil {
return false
}
}
}
return true
}
return false
}

func ServeRoot(urlPrefix, root string) gin.HandlerFunc {
return Serve(urlPrefix, LocalFile(root, false))
}

// Static returns a middleware handler that serves static files in the given directory.
func Serve(urlPrefix string, fs ServeFileSystem) gin.HandlerFunc {
fileserver := http.FileServer(fs)
if urlPrefix != "" {
fileserver = http.StripPrefix(urlPrefix, fileserver)
}
return func(c *gin.Context) {
if fs.Exists(urlPrefix, c.Request.URL.Path) {
fileserver.ServeHTTP(c.Writer, c.Request)
c.Abort()
}
}
}

文件 static/embed.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
  
package static

import (
"embed"
"io/fs"
"net/http"
)

type embedFileSystem struct {
http.FileSystem
}

func (e embedFileSystem) Exists(prefix string, path string) bool {
_, err := e.Open(path)
if err != nil {
return false
}
return true
}

func EmbedFolder(fsEmbed embed.FS, targetPath string) ServeFileSystem {
fsys, err := fs.Sub(fsEmbed, targetPath)
if err != nil {
panic(err)
}
return embedFileSystem{
FileSystem: http.FS(fsys),
}
}

文件 main.go 中通过 中间件 的方式引入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main  

import (
"demo/static"
"demo/web"
"github.com/gin-gonic/gin"
"net/http"
)

func main() {
r := gin.Default()

r.GET("/api/hello", func(c *gin.Context) {
c.JSON(200, gin.H{
"msg": "hello world",
})
})

r.Use(static.Serve("/", static.EmbedFolder(web.Static, ".")))

r.Run()
}

访问地址 http://localhost:8080/ 发现能够正常返回,访问 http://localhost:8080/api/hello 也能正常响应。

20220820192226

总结:

这种方法采用中间件的方式实现,也就不会遇到官方 StaticFS 方法的路径通配符冲突问题。


黑科技法

2022-12-30 更新

这种方法是我后来翻看 issues Wildcard route conflicts with static files · Issue #360 · gin-gonic/gin 时看到了的,尝试了一下发现居然可以。

直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {
r := gin.Default()

r.GET("/api", func(c *gin.Context) {
c.JSON(200, gin.H{
"msg": "hello world",
})
})

// 直接这样设置会报错:
// panic: catch-all wildcard '*filepath' in new path '/*filepath' conflicts with existing path segment 'api' in existing prefix '/api'
//r.StaticFS("/", http.FS(web.Static))

// 黑科技
r.NoRoute(gin.WrapH(http.FileServer(http.FS(web.Static))))

r.Run()
}

通过 NoRoute 来实现,但这种方法不是很推荐使用。如果上面的 /api/* 中没有匹配的路由,那么也会进入到 NoRoute 中,可能会出现问题。

详见:


作为后端首页

特殊情况下,我们可能还会将静态页面作为后端的入口文件,例如各种管理后台。此时访问的路径一般会定义为 /admin 的形式。

一级目录结构

其实这个需求实现起来和上面的 重定向法 有些类似。

我们将前端项目打包后的 dist 目录内容拷贝到Golang项目的 web 目录下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
.
├── README.md
├── go.mod
├── go.sum
├── main.go
└── web
   ├── assets
   │   ├── index.16c4fe9c.css
   │   ├── index.e9286135.js
   │   ├── logo.03d6d6da.png
   │   └── vendor.65715d52.js
   ├── favicon.ico
   ├── index.html
   └── web.go

需要注意在 yarn build 打包前要将 vite.config.js 中的 base 配置项修改为 /admin/ :

1
2
3
4
5
6
7
8
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
base: '/admin/',
})

文件 web/web.go 中的内容依旧如下:

1
2
3
4
5
6
package web  

import "embed"

//go:embed index.html favicon.ico assets
var Static embed.FS

只需要注意将 main.go 中的静态文件路径修改为 /admin 即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main  

import (
"github.com/gin-gonic/gin"
)

func main() {
r := gin.Default()

r.GET("/api/hello", func(c *gin.Context) {
c.JSON(200, gin.H{
"msg": "hello world",
})
})

r.StaticFS("/admin", http.FS(web.Static))

r.Run()

注意:因为静态文件路径 /admin 是非根路径,所以生成的通配符路径为 /admin/*filepath ,也就不需要像 重定向法 中介绍的那样,需要手动添加301 跳转来避开 通配符路径冲突 问题了。

多级目录结构

某些特殊情况下,我们可能会将后台的前端项目放在Golang项目的二级目录下;或者设置多个单独的二级前端项目来进行分别访问。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.
├── README.md
├── go.mod
├── go.sum
├── main.go
└── web
   ├── admin
   │   ├── assets
   │   │   ├── index.16c4fe9c.css
   │   │   ├── index.e9286135.js
   │   │   ├── logo.03d6d6da.png
   │   │   └── vendor.65715d52.js
   │   ├── favicon.ico
   │   └── index.html
   ├── ui
   │   ├── assets
   │   │   ├── index.16c4fe9c.css
   │   │   ├── index.e9286135.js
   │   │   ├── logo.03d6d6da.png
   │   │   └── vendor.65715d52.js
   │   ├── favicon.ico
   │   └── index.html
   └── web.go

其中,web/admin 表示管理后台的前端项目,需要通过路径 /admin 来进行访问;web/ui 表示首页的前端项目,需要通过路径 / 进行访问。

这种情况下,有以下两点需要注意:

其一,也是比较容易忽略的,要将 vite.config.js 中的 base 配置项修改为相应路径;

其二,由于现在管理后台的前端项目是处于二级目录 web/admin 下,那么我们直接通过如下的方式会发现找不到首页:

文件 main.go :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main  

import (
"github.com/gin-gonic/gin"
)

func main() {
r := gin.Default()

r.GET("/api/hello", func(c *gin.Context) {
c.JSON(200, gin.H{
"msg": "hello world",
})
})

r.StaticFS("/admin", http.FS(web.Static))

r.Run()

访问 http://localhost:8080/admin/ 会显示为如下内容:

20220820192301

这是因为首页文件 index.html 并不是在 web.go 文件的同级目录下,这样 embed 也就找不到该文件了。

所以,一方面除了定义 //go:embed 时要设置 web.go 同级下的目录文件:

文件 web/web.go :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package web  

import "embed"

//go:embed admin/*
var AdminStatic embed.FS

//go:embed ui/*
var UiStatic embed.FS

// 或者不用通配符:

//go:embed admin
var AdminStatic embed.FS

//go:embed ui
var UiStatic embed.FS

另一方面还要在调用时指定二级目录,可以通过 fs.Sub 来实现:

文件 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
package main  

import (
"github.com/gin-gonic/gin"
)

func main() {
r := gin.Default()

r.GET("/api/hello", func(c *gin.Context) {
c.JSON(200, gin.H{
"msg": "hello world",
})
})

fAdmin, _ := fs.Sub(web.AdminStatic, "admin")
r.StaticFS("/admin", http.FS(fAdmin))

r.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "ui/")
})
fUi, _ := fs.Sub(web.UiStatic, "ui")
r.StaticFS("/ui", http.FS(fUi))

r.Run()

这样,就实现了多级目录场景下的正常访问。


iris实现打包

同样作为Golang中受欢迎的web开发框架,iris 相比 gin 实现静态文件的嵌入就相对比较简单了。经过测试,iris 中并不会遇到 gin 中的 通配符路径冲突 的问题。

需要注意,安装 iris 时要使用 master 分支最新代码:

1
go get github.com/kataras/iris/v12@master

参照上面 Gin实现打包 步骤中的各种场景,这里仅对 iris 中的 多路由多目录场景 具体说明。


多级场景实现

目录结构如下,这里前端页面 web/ui 采用 react框架开发,后端页面 web/admin 采用vue框架开发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.
├── README.md
├── go.mod
├── go.sum
├── main.go
└── web
├── admin
│   ├── assets
│   │   ├── index.16c4fe9c.css
│   │   ├── index.e9286135.js
│   │   ├── logo.03d6d6da.png
│   │   └── vendor.65715d52.js
│   ├── favicon.ico
│   └── index.html
├── ui
│   ├── assets
│   │   ├── index.3fce1f81.css
│   │   ├── index.edeff469.js
│   │   └── react.35ef61ed.svg
│   ├── index.html
│   └── vite.svg
└── web.go

此外,管理平台通过路径 /admin 访问,需要在vue项目的 vite.config.js 中添加 base: '/admin/', 配置项。

web/web.go 代码如下:

1
2
3
4
5
6
7
8
9
package web  

import "embed"

//go:embed admin
var AdminStatic embed.FS

//go:embed ui
var UiStatic embed.FS

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
package main  

import (
"demo/web"
"github.com/kataras/iris/v12"
"io/fs"
"net/http"
)

func main() {
i := iris.New()
i.Get("/api", func(c iris.Context) {
c.JSON(iris.Map{
"msg": "hello",
})
})

// 前端首页
i.HandleDir("/", http.FS(web.UiStatic))

// 管理后台
fsys, _ := fs.Sub(web.AdminStatic, "admin")
i.HandleDir("/admin", http.FS(fsys))

i.Listen(":8081")

访问浏览器,却发现:

  • http://localhost:8081/ 404 Not Found
  • http://localhost:8081/admin 请求正常
  • http://localhost:8081/api 请求正常

此时让人很诧异的是为什么首页会访问不到?

再回看上面的文件目录结构,web.go 文件嵌入同目录下的 uiadmin 目录,虽然 ui 目录是和 web.go 文件处于同一目录下,但我们要访问的却是 ui 目录下的 index.html 文件。至此,问题也就找到了。

虽然在前台页面中我们访问的是根路由,但要访问的文件却是在二级目录下。换句话说:

//go:embed 处理的文件要在同一目录下,否则就需要手动处理目录层级关系

把上面的 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
package main  

import (
"demo/web"
"github.com/kataras/iris/v12"
"io/fs"
"net/http"
)

func main() {
i := iris.New()
i.Get("/api", func(c iris.Context) {
c.JSON(iris.Map{
"msg": "hello",
})
})

// 前端首页
//i.HandleDir("/", http.FS(web.UiStatic))
fsy, _ := fs.Sub(web6.UiStatic, "ui")
i.HandleDir("/", http.FS(fsy))

// 管理后台
fsys, _ := fs.Sub(web.AdminStatic, "admin")
i.HandleDir("/admin", http.FS(fsys))

i.Listen(":8081")

再次访问,会发现前后端及接口都正常响应了。

20220820192320


总结

其实 embed 特性的使用并不复杂,只是在处理目录层级上需要注意。理解了这一点,使用起来也就很简单了。


如有疑问或需要技术讨论,请留言或发邮件到 service@itfanr.cc