目录

一拳搞定 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 后端代码

dist目录

  • 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}

效果

json

pure

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}

效果

pure

三、参考

https://github.com/gin-gonic/gin#serving-static-files

https://github.com/gin-gonic/gin#html-rendering