现在的很多程序都会提供一个 Dashboard 类似的页面用于查看程序状态并进行一些管理的功能,通常都不会很复杂,但是其中用到的图片和网页的一些静态资源,如果需要用户额外存放在一个目录,也不是很方便,如果能打包进程序发布的二进制文件中,用户下载以后可以直接使用,就方便很多。
最近在阅读 InfluxDB 的源码,发现里面提供了一个 admin 管理的页面,可以通过浏览器来执行一些命令以及查看程序运行的信息。但是我运行的时候只运行了一个 influxd 的二进制文件,并没有看到 css, html 等文件。
原来 InfluxDB 中使用了 statik 这个工具将静态资源都编译进了二进制文件中,这样用户只需要运行这个程序即可,而不需要管静态资源文件存放的位置。
一、statik
statik 命令参数
> statik -h
statik [options]
Options:
-src The source directory of the assets, "public" by default.
-dest The destination directory of the generated package, "." by default.
-ns The namespace where assets will exist, "default" by default.
-f Override destination if it already exists, false by default.
-include Wildcard to filter files to include, "*.*" by default.
-m Ignore modification times on files, false by default.
-Z Do not use compression, false by default.
-p Name of the generated package, "statik" by default.
-tags Build tags for the generated package.
-c Godoc for the generated package.
-help Prints this text.
Examples:
Generates a statik package from ./assets directory. Overrides
if there is already an existing package.
$ statik -src=assets -f
Generates a statik package only with the ".js" files
from the ./public directory.
$ statik -include=*.js
-p 参数指定生成的包名
-f 强制覆盖目标文件
-ns 生成带命名空间的fs,配合fs.RegisterWithNamespace 使用
statik 中的 fs.go
主要代码
const defaultNamespace = "default"
// Register registers zip contents data, later used to initialize
// the statik file system.
func Register(data string) {
RegisterWithNamespace(defaultNamespace, data)
}
// RegisterWithNamespace registers zip contents data and set asset namespace,
// later used to initialize the statik file system.
func RegisterWithNamespace(assetNamespace string, data string) {
zipData[assetNamespace] = data
}
// New creates a new file system with the default registered zip contents data.
// It unzips all files and stores them in an in-memory map.
func New() (http.FileSystem, error) {
return NewWithNamespace(defaultNamespace)
}
// NewWithNamespace creates a new file system with the registered zip contents data.
// It unzips all files and stores them in an in-memory map.
func NewWithNamespace(assetNamespace string) (http.FileSystem, error) {
......
}
dist 目录中的是打包好的 vue 项目,如下。
statik 可以将每个 vue 项目打包进一个go文件,如果你的项目中有多个 vue 项目需要打包,这里就需要有一个命名空间来区分访问的是哪个目录下的哪个文件,默认的命名空间是default
。
假设项目中有前台页面和后台页面,分别存放在 frontend/dit
和backend/dit
,现在需要将他们都使用 statik 打包进来。所以在浏览器中访问的时候也要显示的指明访问的是哪一块的资源文件,所以在 vue 打包的时候需要设置assetsPublicPath
,
build: {
// Template for index.html
index: path.resolve(__dirname, '../dist/index.html'),
// Paths
assetsRoot: path.resolve(__dirname, '../dist'),
assetsSubDirectory: 'static',
assetsPublicPath: '/backend/dist',
这里, index.html
中的 js, css 等的地址要带上assetsPublicPath
前缀。
<!DOCTYPE html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1">
<title>gocron - 定时任务系统</title><link href=/backend/static/css/app.ebfe035f57a51b3dbb6508c126d9d61a.css rel=
stylesheet></head><body><div id=app></div><script type=text/javascript src=/backend/static/js/manifest.047c13
3009965c5daaca.js></script><script type=text/javascript src=/backend/static/js/vendor.8798af468674508e4d9a.js>
</script><script type=text/javascript src=/backend/static/js/app.22dca5056b8b03f70aff.js></script></body></html>
安装 statik 包
go get -d github.com/rakyll/statik
go install github.com/rakyll/statik
首先要声明,一般在 main.go 文件中,这样直观一些,src 和 dest 都是相对于当前文件的路径:
package main
//go:generate statik -src=./backend/dist -dest=./include -p backend -ns backend -f
//go:generate statik -src=./frontend/dist -dest=./include -p frontend -ns frontend -f
func main() {
......
}
go:generate
指令需要手动执行,来到根目录下
go generate ./...
会默认生成
include/backend/statik.go
include/frontend/statik.go
statik.go
文件内容为
// Code generated by statik. DO NOT EDIT.
package statik
import (
"github.com/rakyll/statik/fs"
)
const Backend = "backend" // static asset namespace
func init() {
data := ".............."
fs.RegisterWithNamespace("backend", data)
}
将文件信息注册到 fs 之后,就可以实例化出来一个 http.FileSystem 对象。
注意,如果你没有使用命名空间,就需要以下划线_
的形式手动引入 statik.go
所在的包。
import (
"resmirror/include/backend"
"resmirror/include/frontend"
"github.com/rakyll/statik/fs"
...
)
var statikBackendFS http.FileSystem
var statikFrontendFS http.FileSystem
func init() {
var err error
statikBackendFS, err = fs.NewWithNamespace(backend.Backend)
statikFrontendFS, err = fs.NewWithNamespace(frontend.Frontend)
if err != nil {
log.Fatal(err)
}
}
最后就是使用 statikBackendFS
和statikFrontendFS
来读取文件内容了,注意,根目录是在dist
内部的,比如
file, err := statikBackendFS.Open("/index.html")
这里返回的 file 类型是http.File
,它服务于文件系统 FileSystem ,因此它是只读的,没有提供写的接口。
// A File is returned by a FileSystem's Open method and can be
// served by the FileServer implementation.
//
// The methods should behave the same as those on an *os.File.
type File interface {
io.Closer
io.Reader
io.Seeker
Readdir(count int) ([]fs.FileInfo, error)
Stat() (fs.FileInfo, error)
}
还没完,你可能还记得,前面我们区分开了 backend 和 frontend,也就是说访问的时候是这样的
http://localhost/backend/dist/static/js/app.510882c2.js
http://localhost/fantend/dist/static/js/app.c26e62d2.js
而我们编译进来的 statik.go 文件系统却是如下形式 /static/js/app.510882c2.js
,所以需要在路由上做一层处理。
不同的框架有不同的写法,比如 macaron
框架可以将其注册到中间件中,而下面的Prefix
选项就是设置路由前缀的,比如此处设置为backend/dist
,这样,访问/backend/dist/static/js/app.510882c2.js
就是在访问 statikBackendFS
下的/static/js/app.510882c2.js
// 中间件注册
func RegisterMiddleware(m *macaron.Macaron) {
......
m.Use(
macaron.Static(
"",
macaron.StaticOptions{
Prefix: staticDir,
FileSystem: statikBackendFS,
},
),
)
......
}
有的框架并没有支持statik FS
,所以需要自己来实现,思路就是在路由那里做隐射。
以下是 beego 的写法
beego.InsertFilter("/admin/*", beego.BeforeStatic, func(ctx *context.Context) {
if tools.InArray(ctx.Request.URL.Path, []string{"/admin", "/admin/", "/admin/index.html"}) {
file, err := statikBackendFS.Open("/index.html")
if err != nil {
logs.Error("backend读取首页文件失败")
http.NotFound(ctx.ResponseWriter, ctx.Request)
return
}
io.Copy(ctx.ResponseWriter, file)
}
})
beego.InsertFilter("/backend/dist/*", beego.BeforeStatic, func(ctx *context.Context) {
realFile := strings.Replace(ctx.Request.URL.Path, "/backend/dist", "", 1)
file, err := statikBackendFS.Open(realFile)
if err != nil {
logs.Error("backend目录下文件不存在:", realFile)
http.NotFound(ctx.ResponseWriter, ctx.Request)
return
}
// io.Copy(ctx.ResponseWriter, file)
http.ServeContent(ctx.ResponseWriter, ctx.Request, realFile, time.Time{}, file)
})
这里将 io.Copy()
换成了http.ServeContent()
是有原因的,因为io.Copy()
并没有设置Content-Type
,因此默认值为text/plain
,着导致浏览器并不会去解析 js, css 等文件,换成http.ServeContent()
之后就你解决这个问题,它的说明如下
// ServeContent replies to the request using the content in the
// provided ReadSeeker. The main benefit of ServeContent over io.Copy
// is that it handles Range requests properly, sets the MIME type, and
// handles If-Match, If-Unmodified-Since, If-None-Match, If-Modified-Since,
// and If-Range requests.
//
// If the response's Content-Type header is not set, ServeContent
// first tries to deduce the type from name's file extension and,
// if that fails, falls back to reading the first block of the content
// and passing it to DetectContentType.
// The name is otherwise unused; in particular it can be empty and is
// never sent in the response.
//
// If modtime is not the zero time or Unix epoch, ServeContent
// includes it in a Last-Modified header in the response. If the
// request includes an If-Modified-Since header, ServeContent uses
// modtime to decide whether the content needs to be sent at all.
//
// The content's Seek method must work: ServeContent uses
// a seek to the end of the content to determine its size.
//
// If the caller has set w's ETag header formatted per RFC 7232, section 2.3,
// ServeContent uses it to handle requests using If-Match, If-None-Match, or If-Range.
//
// Note that *os.File implements the io.ReadSeeker interface.
func ServeContent(w ResponseWriter, req *Request, name string, modtime time.Time, content io.ReadSeeker)
二、go embed (推荐)
除了使用 statik 之外,还有一种方式特别好用,golang 从 1.16 就开始支持 embed
特性,你可以将文件或者目录编译到 go 的代码中。可以将目标资源编程成string, []byte, embed.FS
三种类型。
//go:embed hello.txt
var s string
//go:embed hello.txt
var b []byte
//go:embed hello.txt
var f embed.FS
func main() {
print(s)
print(string(b))
data, _ := f.ReadFile("hello.txt")
print(string(data))
}
注意1,如果没有显示的调用embed
这个包的话,则需要手动引入import _ "embed"
注意2,引入的资源要跟当前go文件在同一个目录中,也就是说它并不支持../
的方式来指向外面的目录。
//go:embed hello1.txt hello2.txt
var f embed.FS
func main() {
data1, _ := f.ReadFile("hello1.txt")
fmt.Println(string(data1))
data2, _ := f.ReadFile("hello2.txt")
fmt.Println(string(data2))
}
指定目录
//go:embed helloworld
var f embed.FS
func main() {
data1, _ := f.ReadFile("helloworld/hello1.txt")
fmt.Println(string(data1))
data2, _ := f.ReadFile("helloworld/hello2.txt")
fmt.Println(string(data2))
}
另外,embed.FS
调用Open()
打开的文件并不支持http.ServeContent()
,我们需要把embed.FS
转换成http.FileSystem
。使用http.FS(embedDist)
方法。
还是以上面的项目为例。
在main.go
中
//go:embed backend/dist frontend/dist
var embedDist embed.FS
routers.EmbedDistFS = http.FS(embedDist)
这里与 statik 的不同在于,编译后文件的访问路径要带上目录前缀,比如/backend/dist/index.html
,/frontend/dist/index.html
。
在router.go
中
var EmbedDistFS http.FileSystem
beego.InsertFilter("/backend/dist/*", beego.BeforeStatic, func(ctx *context.Context) {
realFile := ctx.Request.URL.Path
file, err := EmbedDistFS.Open(realFile)
if err != nil {
logs.Error("backend目录下文件不存在:", realFile)
http.NotFound(ctx.ResponseWriter, ctx.Request)
return
}
http.ServeContent(ctx.ResponseWriter, ctx.Request, realFile, time.Time{}, file)
})
并不需要单独执行什么命令,go编译器自动检测 go:embed 指令并执行,方便很多。