mdi-chevron-left
Last:跨平台终端神器Tabby
Next:ubuntu20.04安装MySQL纪实
mdi-chevron-right
# gin简介
Golang中的gin是一个非常强大而常用的web引擎,我们很多项目都是使用gin来构建的。
前台系统一般需要考虑SEO,我不太习惯基于nodejs的vue-ssr,通常我会使用gin的template功能来实现,类似于JAVA中的freemarker,当然,它的模板引擎是Golang自带的,而不需要自己手撸,这一点Golang真的太爽了,内置SDK简直就是军火库。
后台系统一般使用vue,这时候可以使用gin的RouteGroup,Get,Post,Parse等功能来舒服地设计restful API。
# gin template常规用法
一个非常常见的场景,使用`c.HTML(200, "a.tmpl", obj)`来返回一个被obj对象渲染了的`a.tmpl`模板的html给客户。obj可以有很多属性,甚至是一个map,来将内部的数据渲染到html中,比如“文章”的加载。
在这样使用之前,我们需要为gin配置template,按照官方文档的demo,我们可以这样配置:
```go
func main() {
router := gin.Default()
router.LoadHTMLGlob("templates/*")
//router.LoadHTMLFiles("templates/template1.html", "templates/template2.html")
router.GET("/index", func(c *gin.Context) {
c.HTML(http.StatusOK, "index.tmpl", gin.H{
"title": "Main website",
})
})
router.Run(":8080")
}
```
其中第一种用法`router.LoadHTMLGlob("templates/*")`是加载相对路径`./templates`下所有的一级文件,比如`/templates/index.tmpl`等。第二种用法`router.LoadHTMLFiles("templates/template1.html", "templates/template2.html")`,是分别加载指定的文件。
# 场景和痛点
考虑一个简单的CMS系统场景,系统分为前后台,前台使用gin的template渲染数据,后台使用vue编写。
要求:前台系统的渲染,可以通过后台系统动态调整模板代码,并且热加载。
在实现这个简单的功能时,我遇到了一些关于gin使用template的痛点,记录如下:
## 痛点1:模板文件路径
### 描述
如上面所说,`router.LoadHTMLGlob("templates/*")`可以加载相对路径`./templates`下所有的一级文件,但是它无法加载诸如`./templates/components/banner.tmpl`这种子目录下的文件。如果我们的项目模板文件夹下就这么干巴巴的一层路径,那显然不太具有层次感,显得文件组织相当杂乱。
或许你会发现无法加载子路径下的模板文件,是因为`router.LoadHTMLGlob("templates/*")`参数里的正则引起的。我们可以换成`router.LoadHTMLGlob("templates/**/*")`试试,结果会发现,这种写法,可以加载`templates/`下所有二级文件,比如`./templates/components/banner.tmpl`,或者`./templates/products/p1.tmpl`,但是却无法加载根目录下的`/templates/index.tmpl`文件了。
### 解决
这个问题我们可以使用golang自带的filepath包来解决,使用它来遍历所有的`.templates/`文件夹下的模板文件,并使用`router.LoadHTMLFiles()`函数分别加载。
```go
// load templates in any deep director
templateFiles := make([]string, 0, 32)
filepath.Walk("./templates", func(path string, info os.FileInfo, err error) error {
if strings.HasSuffix(path, ".html") {
templateFiles = append(templateFiles, path)
}
return nil
})
e.LoadHTMLFiles(templateFiles...)
```
## 痛点2:模板一定要是文件吗?
### 描述
有时候,我们希望在前台系统上线后,后台系统有能力让前台系统动态更新模板,以快速调整前台页面渲染。
于是我们考虑一个问题:模板一定要是文件吗?可不可以存在于数据库中,甚至上线后还可以动态改变,即时生效。
答案是可以,参考gin的官方文件,有这样一段代码:

借鉴这个demo,编写自己的模板渲染器:
```go
var frontendTemplate *template.Template
func InitFrontend(e *gin.Engine) {
// init template
frontendTemplate = nil
InitFrontendTemplate()
e.SetHTMLTemplate(frontendTemplate)
}
func InitFrontendTemplate() {
// 从数据库加载模板map
fmap, err := model.GetSetting("tmpl")
if err != nil {
panic(err)
}
for k, v := range fmap {
err = SetFrontendTemplate(k, v)
if err != nil {
panic(err)
}
}
}
func SetFrontendTemplate(key string, value string) (err error) {
if frontendTemplate == nil {
frontendTemplate, err = template.New(key).Parse(value)
} else {
_, err = frontendTemplate.New(key).Parse(value)
}
return
}
```
以上代码使用`e.SetHTMLTemplate(frontendTemplate)`函数给gin传入一套模板`frontendTemplate`,这套模板来自于数据库,使用`SetFrontednTemplate`进行每一项数据的添加。
好了,现在gin启动时,模板的配置来自于数据库了,不再将模板数据暴露在文件系统中了。下面,问题来了。
## 痛点3:模板文件可以热加载吗?
### 描述
在开发过程中,我们可能认为模板理所应当地可以热加载,因为在我们使用模板文件的时候,每一次修改模板文件,前端均可以做出即时的响应。那么,这是天经地义的吗?
通过Github上的提问可以看出,模板并不是每种场景下都能热加载的,gin在Debug模式下,允许模板热加载,实时生效,这是牺牲性能的模式。而在gin的Release模式下,是不允许模板热加载的,相关说明如下:

模板文件的热加载尚且受制于Gin的运行模式,我们项目上线一般都会选在在Release模式下部署,如果我们的模板数据来自于数据库,想必同样存在无法热更新的问题,那么,将模板代码存入数据库的意义就丧失了,因为我们的最终目的是在后台系统中可以动态修改模板代码,实时影响到前台。
### 解决
既然gin在Release模式下,运行时是不可以热加载模板的,那我们只有思考另一种解决办法:让前台服务能够受控于后台系统,优雅地重启!
网络上关于gin优雅地关闭,有很多文章可以借鉴,但是关于gin的热重启,很少有人提到,尤其是我这种场景:golang程序中,同时运行两个gin服务,一个backendServer,一个frontendServer,两个服务运行于不同的端口上,希望能通过backendServer的相关API,控制frontendServer的相关行为,比如重启。
#### 多个gin服务
首先介绍一下go程序中运行多个gin服务的方法,gin的官方文档有相关介绍:https://github.com/gin-gonic/gin#run-multiple-service-using-gin

官方示例使用了`golang.org/x/sync/errgroup`包,来并发运行多个gin服务,我这里借鉴其思想,稍作调整:
```go
func main() {
frontendEngine := gin.Default()
service.InitFrontend(frontendEngine)
backendEngine := gin.Default()
service.InitBackend(backendEngine)
// 无阻塞的方式启动前台服务
frontServer := &http.Server{
Addr: utils.Config.Server.FrontPort,
Handler: frontendEngine,
}
go func() {
if err := frontServer.ListenAndServe(); err != nil {
seelog.Warn(err)
}
}()
// 无阻塞的方式启动后台服务
backServer := &http.Server{
Addr: utils.Config.Server.BackPort,
Handler: backendEngine,
}
go func() {
if err := backServer.ListenAndServe(); err != nil && errors.Is(err, http.ErrServerClosed) {
seelog.Warn(err)
}
}()
// 等待空信号,保持golang程序的运行
blank := make(chan int64)
<-blank
}
```
上述代码,初始化了一个前台一个后台两个gin服务,然后通过`go`关键字并发运行这两个服务,最后通过阻塞一个空的chan让程序一直等待,不至于一运行就结束。
#### 优雅地重启前端服务
基于上述代码,我们可以很容易想象,给后端服务设置一个restful API,在这个API中,产生一个重启前端的信号,将主程序尾部的空等代码,改成循环监听这个重启信号,一旦监听到这个信号,则让前端服务器重启,从而达到重新加载所有模板数据的目的。代码如下:
```go
restartChan := make(chan int64)
frontendEngine := gin.Default()
service.InitFrontend(frontendEngine)
backendEngine := gin.Default()
service.InitBackend(backendEngine)
backendEngine.POST("api/restart", service.MidwarePower(service.POWERMASK_RESTART), func(c *gin.Context) {
restartChan <- 1
c.Status(200)
})
// ...
// 跟之前的代码一致
// 等待重启信号
for range restartChan {
seelog.Info("restart...")
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
err := frontServer.Shutdown(ctx)
if err != nil {
seelog.Error(err)
}
// 无阻塞重新启动前端服务
go func() {
time.Sleep(time.Second * 3)
cancel()
frontendEngine = gin.Default()
service.InitFrontend(frontendEngine)
frontServer = &http.Server{
Addr: utils.Config.Server.FrontPort,
Handler: frontendEngine,
}
if err := frontServer.ListenAndServe(); err != nil {
seelog.Warn(err)
} else {
seelog.Info("restart success.")
}
}()
}
```
# 总结
至此,完美地解决了场景给出的要求,后台可以实时从数据库调取模板数据供管理员编辑,管理员在后台中可以编辑保存模板文件,然后通过重启前台的API,让前台服务优雅地重启,实现模板数据的实时更新。
mdi-chevron-left
Last:跨平台终端神器Tabby
Next:ubuntu20.04安装MySQL纪实
mdi-chevron-right