一拳搞定 Gin | 配置前后端分离后的静态资源访问
目录
本文介绍通过 Gin 框架访问后端的静态资源
一、前言
最近给公司做运维平台,前端是基于 Ant Design Pro 构建的 React 项目,后端是基于 Gin 搭建的 Go Web 项目。在部署的时候考虑到方便性,选择了把构建好的前端资源放到后端服务器,通过启动后端服务器直接运行前后端程序。
二、实战分析
2.1 静态资源
代码:
1func main() {
2 r := gin.Default()
3
4 dir, _ := os.Getwd()
5 // static
6 r.Static("/assets", dir+"/src/gin/assets")
7 r.StaticFS("/more_static", http.Dir(dir+"/src/gin/assets"))
8 r.StaticFile("/favicon.ico", dir+"/src/gin/resources/favicon.ico")
9
10 // Listen and serve on 0.0.0.0:8080
11 r.Run(":8080")
12}
访问如下地址:
1http://127.0.0.1:8080/more_static/favicon.ico
2http://127.0.0.1:8080/assets/favicon.ico
3http://127.0.0.1:8080/favicon.ico
均可以请求到头像图片。
2.1.1 静态文件
访问某个路径,直接找到对应的资源返回。
使用:
1 r.StaticFile("/favicon.ico", dir+"/src/gin/resources/favicon.ico")
源码分析:
1// File writes the specified file into the body stream in a efficient way.
2func (c *Context) File(filepath string) {
3 http.ServeFile(c.Writer, c.Request, filepath)
4}
- Gin 接收到的 HTTP 请求会到这里,然后交给 http 文件服务。
/net/http/fs.go:674
1func ServeFile(w ResponseWriter, r *Request, name string) {
2 if containsDotDot(r.URL.Path) {
3 // Too many programs use r.URL.Path to construct the argument to
4 // serveFile. Reject the request under the assumption that happened
5 // here and ".." may not be wanted.
6 // Note that name might not contain "..", for example if code (still
7 // incorrectly) used filepath.Join(myDir, r.URL.Path).
8 Error(w, "invalid URL path", StatusBadRequest)
9 return
10 }
11 dir, file := filepath.Split(name)
12 serveFile(w, r, Dir(dir), file, false)
13}
- 分隔名称,会分离出资源的路径和名称
- 然后调用寻找文件的逻辑
/net/http/fs.go:546
1// name is '/'-separated, not filepath.Separator.
2func serveFile(w ResponseWriter, r *Request, fs FileSystem, name string, redirect bool) {
3 const indexPage = "/index.html"
4
5 // redirect .../index.html to .../
6 // can't use Redirect() because that would make the path absolute,
7 // which would be a problem running under StripPrefix
8 if strings.HasSuffix(r.URL.Path, indexPage) {
9 localRedirect(w, r, "./")
10 return
11 }
12
13 f, err := fs.Open(name)
14 if err != nil {
15 msg, code := toHTTPError(err)
16 Error(w, msg, code)
17 return
18 }
19 defer f.Close()
20
21 d, err := f.Stat()
22 if err != nil {
23 msg, code := toHTTPError(err)
24 Error(w, msg, code)
25 return
26 }
27
28 ...
29}
- 用的 net 包的 filesystem 打开文件,如果不存在则包 HTTP 的 404 错误
- 如果是文件夹会找 index.html 文件,本文不扩展
1func (d Dir) Open(name string) (File, error) {
2 if filepath.Separator != '/' && strings.ContainsRune(name, filepath.Separator) {
3 return nil, errors.New("http: invalid character in file path")
4 }
5 dir := string(d)
6 if dir == "" {
7 dir = "."
8 }
9 fullName := filepath.Join(dir, filepath.FromSlash(path.Clean("/"+name)))
10 f, err := os.Open(fullName)
11 if err != nil {
12 return nil, mapDirOpenError(err, fullName)
13 }
14 return f, nil
15}
- 拼接完整的路径
- 使用标准的
os.Open()
打开
2.1.2 静态目录
路径加上某个前缀对应访问某个目录下的资源。
使用:
1 r.Static("/assets", dir+"/src/gin/assets")
源码分析:
1func (group *RouterGroup) Static(relativePath, root string) IRoutes {
2 return group.StaticFS(relativePath, Dir(root, false))
3}
- 实际调用也是
StaticFS
方法,加了个是否展示目录下的内容的控制
/github.com/gin-gonic/gin/fs.go:24
1func Dir(root string, listDirectory bool) http.FileSystem {
2 fs := http.Dir(root)
3 if listDirectory {
4 return fs
5 }
6 return &onlyfilesFS{fs}
7}
- 这里主要是能够支持是否列出目录下的内容(HTTP 默认文件的实现)
- 静态目录是关闭的,文件服务器也可以配置一样的效果
1func (f neuteredReaddirFile) Readdir(count int) ([]os.FileInfo, error) {
2 // this disables directory listing
3 return nil, nil
4}
- 实现返回 nil,和
Dir(root, false)
配置的 false 关联
2.1.3 文件服务器
能够通过 HTTP 请求访问文件,类似于 FTP 服务器。
使用:
1 r.StaticFS("/more_static", http.Dir(dir+"/src/gin/assets"))
效果如下:
源码分析:
1// StaticFS works just like `Static()` but a custom `http.FileSystem` can be used instead.
2// Gin by default user: gin.Dir()
3func (group *RouterGroup) StaticFS(relativePath string, fs http.FileSystem) IRoutes {
4 if strings.Contains(relativePath, ":") || strings.Contains(relativePath, "*") {
5 panic("URL parameters can not be used when serving a static folder")
6 }
7 handler := group.createStaticHandler(relativePath, fs)
8 urlPattern := path.Join(relativePath, "/*filepath")
9
10 // Register GET and HEAD handlers
11 group.GET(urlPattern, handler)
12 group.HEAD(urlPattern, handler)
13 return group.returnObj()
14}
- GET 和 HEAD 请求都会到 StaticHandler,所以看下面代码如何创建这个 Handler 即可
/github.com/gin-gonic/gin/routergroup.go:185
1func (group *RouterGroup) createStaticHandler(relativePath string, fs http.FileSystem) HandlerFunc {
2 absolutePath := group.calculateAbsolutePath(relativePath)
3 fileServer := http.StripPrefix(absolutePath, http.FileServer(fs))
4
5 return func(c *Context) {
6 if _, nolisting := fs.(*onlyfilesFS); nolisting {
7 c.Writer.WriteHeader(http.StatusNotFound)
8 }
9
10 file := c.Param("filepath")
11 // Check if file exists and/or if we have permission to access it
12 f, err := fs.Open(file)
13 if err != nil {
14 c.Writer.WriteHeader(http.StatusNotFound)
15 c.handlers = group.engine.noRoute
16 // Reset index
17 c.index = -1
18 return
19 }
20 f.Close()
21
22 fileServer.ServeHTTP(c.Writer, c.Request)
23 }
24}
- 正常执行会走
fileServer.ServeHTTP(c.Writer, c.Request)
,HTTP 包的逻辑
/net/http/server.go:2040
1func StripPrefix(prefix string, h Handler) Handler {
2 if prefix == "" {
3 return h
4 }
5 return HandlerFunc(func(w ResponseWriter, r *Request) {
6 if p := strings.TrimPrefix(r.URL.Path, prefix); len(p) < len(r.URL.Path) {
7 r2 := new(Request)
8 *r2 = *r
9 r2.URL = new(url.URL)
10 *r2.URL = *r.URL
11 r2.URL.Path = p
12 h.ServeHTTP(w, r2)
13 } else {
14 NotFound(w, r)
15 }
16 })
17}
- 去除下前缀的空格
- copy 了 request 去执行接下去的任务
1func FileServer(root FileSystem) Handler {
2 return &fileHandler{root}
3}
4
5func (f *fileHandler) ServeHTTP(w ResponseWriter, r *Request) {
6 upath := r.URL.Path
7 if !strings.HasPrefix(upath, "/") {
8 upath = "/" + upath
9 r.URL.Path = upath
10 }
11 serveFile(w, r, f.root, path.Clean(upath), true)
12}
- 前缀 URL 加上 /
- 文件服务逻辑,和静态文件部分调用是一样的方法,这里是 true
2.2 单页应用实战
上面讲的都是 gin 本身基础内容,下面说下项目中的实战例子。
2.2.1 静态文件位置
项目文件夹如下:
- dist 前端
npm intall
打出来的包 - pkg 后端代码
- index.html 单页应用,只有一个 HTML,其它都是 .js 渲染出来的
2.2.2 项目配置
通过下面代码加到 gin 里面。
1root := path.Join(dir, "dist")
2
3r.Use(static.NewFileSystem(root, "/", constant.ApiVersion, false).HandlerFunc())
参数说明:
- root 文件服务的根目录
"/"
规则,既匹配全部- 不包含的路径,指后端接口路径,比如
/api/v1
- false 上面提到默认的实现有体现,ture 会罗列文件夹下的文件列表,用作 FTP 服务器
2.2.3 原理说明
自定义的 fileSystem
1type FileSystem struct {
2 root string
3 prefix string
4 exclude string
5 indexes bool
6 http.FileSystem
7 http.Handler
8}
- root 根路径,文件系统用
- prefix 请求前缀,按我的场景是
"/"
,如果你有类似/health
的这种接口,那么请扩展 exclude 的逻辑 - exclude 忽略的路径,我的场景是
/api/v1
,所以的后端接口默认都带上这个路径 - indexes 是否遍历子目录查找,我的场景是不需要的,所以配置
false
gin 对请求的处理逻辑
1func (fs *FileSystem) HandlerFunc() gin.HandlerFunc {
2 return func(c *gin.Context) {
3 if fs.exclude != "" && strings.HasPrefix(c.Request.URL.Path, fs.exclude) {
4 c.Next()
5 } else {
6 if fs.Exist(c) {
7 fs.ServeHTTP(c.Writer, c.Request)
8 c.Abort()
9 }
10 }
11 }
12}
- 如果是
/api/v1
后端接口,则直接进入下一步 - 否则判断是否存在,存在会执行标准的 ServeHTTP
判断文件是否存在
1func (fs *FileSystem) Exist(c *gin.Context) bool {
2 filepath := c.Request.URL.Path
3 if isFrontPageRouter(filepath, fs.exclude) {
4 return true
5 }
6
7 if p := strings.TrimPrefix(filepath, fs.prefix); len(p) < len(filepath) {
8 name := path.Join(fs.root, p)
9 stats, err := os.Stat(name)
10 if err != nil {
11 return false
12 }
13 if stats.IsDir() {
14 if !fs.indexes {
15 index := path.Join(name, INDEX)
16 _, err := os.Stat(index)
17 if err != nil {
18 return false
19 }
20 }
21 }
22 return true
23 }
24 return false
25}
- 如果是前端页面,直接返回存在,因为始终是
index.html
- 其它用
os
去判断文件是否真实存在
http.HandlerFunc 改进
1// StripPrefix like http.StripPrefix
2func StripPrefix(prefix, exclude string, h http.Handler) http.Handler {
3 if prefix == "" {
4 return h
5 }
6 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
7 if p := strings.TrimPrefix(r.URL.Path, prefix); len(p) < len(r.URL.Path) {
8 r2 := new(http.Request)
9 *r2 = *r
10 r2.URL = new(url.URL)
11 *r2.URL = *r.URL
12 r2.URL.Path = p
13 b := isFrontPageRouter(r.URL.Path, exclude)
14 if b {
15 r2.URL.Path = "/"
16 }
17 h.ServeHTTP(w, r2)
18 } else {
19 http.NotFound(w, r)
20 }
21 })
22}
- 新加参数 exclude,表示是否不包含的路径
- isFrontPageRouter 是否是前端页面的路径,如果是前端页面会改写
URL.Path = "/"
,保证始终访问index.html
文件
2.3 扩展阅读
下面内容只是简单跑个 demo 看看效果,在前后端分离后几乎用不到
2.3.1 直接返回前端HTML
启动程序
1func main() {
2 r := gin.Default()
3
4 // Serves unicode entities
5 r.GET("/json", func(c *gin.Context) {
6 c.JSON(200, gin.H{
7 "html": "<b>Hello, world!</b>",
8 })
9 })
10
11 // Serves literal characters
12 r.GET("/purejson", func(c *gin.Context) {
13 c.PureJSON(200, gin.H{
14 "html": "<b>Hello, world!</b>",
15 })
16 })
17
18 // listen and serve on 0.0.0.0:8080
19 r.Run(":8080")
20}
效果
2.3.2 通过模板返回HTML
创建模板
1$ mkdir templates && touch index.tmpl
编辑模板
1<html>
2<body>
3<h1>
4 {{ .title }}
5</h1>
6</body>
7</html>
启动程序
1func main() {
2 r := gin.Default()
3
4 r.LoadHTMLGlob(dir + "/src/gin/templates/*")
5 r.GET("/index", func(c *gin.Context) {
6 c.HTML(http.StatusOK, "index.tmpl", gin.H{
7 "title": "Main website",
8 })
9 })
10
11 // listen and serve on 0.0.0.0:8080
12 r.Run(":8080")
13}
效果