前言
通过Golang开发前后端项目时,普遍采用 Gin
结合 Vue
或者 React
项目来实现。
但在部署时,前端的项目则需要通过nginx来实现转发。对于小型的前后端项目来说,这种方式就有点复杂了。
小型的前后端项目,一般会考虑将静态文件打包到go二进制文件中,这样在发布时只需要部署单个二进制文件即可。相对于 Python
或 Java
等项目来说,并不需要前期配置依赖的运行环境,省时、省力还非常方便。
在之前的方法中,普遍采用第三方的类库实现。比较知名的有 go-bindata
、packr
等。
这里来聊聊使用golang最新支持的 embed
特性如何实现。
embed了解
Go1.16 引入了 //go:embed
功能,可以将静态资源文件内容直接打包到二进制文件,方便部署。
数据类型
在embed中,可以将静态资源文件嵌入到三种类型的变量:string
, byte slice
和 embed.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" )
var s string
var b []byte
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"
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
来创建 vue
或 react
前端项目。
创建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
|
构建:
创建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
|
构建:
Gin实现打包
创建gin项目
创建一个基本的Gin项目:
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("/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")) 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
|
package web import "embed"
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
|
package main import ( "demo/web" "github.com/gin-gonic/gin" "net/http" ) func main() { r := gin.Default()
r.StaticFS("/", http.FS(web.Static))
r.Run() }
|
通过浏览器访问 http://localhost:8080/
后发现能够正常访问:
多路由实现
不过一般情况下,在 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
|
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'
export default defineConfig({ plugins: [vue()], base: '/ui/', })
|
此时,访问浏览器会发现,默认根路径 /
会执行 301
跳转到 /ui
路径下实现静态资源的正常访问:
相关讨论:(建议看一下这个)
中间件法
我找到了一个 Gin 项目的第三方扩展中间件,可以实现对静态资源文件的调用:gin-contrib/static: Static middleware (github.com)
但是该三方库目前对于 Embed
方式嵌入的静态资源文件并不支持。好在我在该三方库的 Issues
中找到了相关的讨论:
以及某位大神提交的 Pull requests
:
不过开发者一直没有对该提交进行合并。那我们只能自行实现了。
在项目根目录下创建 static
文件夹,包含两个文件 serve.go
和 embed.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)) }
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
也能正常响应。
总结:
这种方法采用中间件的方式实现,也就不会遇到官方 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", }) })
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'
export default defineConfig({ plugins: [vue()], base: '/admin/', })
|
文件 web/web.go
中的内容依旧如下:
1 2 3 4 5 6
| package web import "embed"
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/
会显示为如下内容:
这是因为首页文件 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"
var AdminStatic embed.FS
var UiStatic embed.FS
var AdminStatic embed.FS
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"
var AdminStatic embed.FS
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
文件嵌入同目录下的 ui
和 admin
目录,虽然 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", }) })
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")
|
再次访问,会发现前后端及接口都正常响应了。
总结
其实 embed
特性的使用并不复杂,只是在处理目录层级上需要注意。理解了这一点,使用起来也就很简单了。