Featured image of post Go Gin:一种同时支持 REST API 和 Go Template 服务端模板渲染的解决方案

Go Gin:一种同时支持 REST API 和 Go Template 服务端模板渲染的解决方案

在现实生产环境中,我们常常需要在同一网站中提供两种服务:一种是针对客户端的 REST API,另一种是基于服务器端的 Go Template 服务端模板渲染。这两种服务有不同的使用场景,因此需要同时支持。本文尝试基于 Gin 架构提供一种这样的解决方案。

背景

在现实生产环境中,我们常常需要在同一网站中提供两种服务:一种是针对客户端的 REST API,另一种是基于服务器端的 Go Template 服务端模板渲染。这两种服务有不同的使用场景,因此需要同时支持。但是,由于 SEO 等方面的考虑,我们也不能完全使用单页面应用(SPA)的架构,而必须采用服务端渲染。

这个问题在实际生产中很普遍。比如,在一个电商网站中,我们需要提供给客户端一个接口用于获取商品列表,同时也需要在服务端渲染出页面,让搜索引擎能够索引网站内容并提高 SEO 效果。但是如果 REST API 和 Go Template 服务端模板渲染调用的是不同的后端服务,就会存在数据不一致、接口定义不统一等问题,同时也造成了重复开发。

为了解决这个问题,我们需要一种同时支持 REST API 和 Go Template 服务端模板渲染,并调用同一后端服务的解决方案。这样就能够保证数据的一致性和接口的统一性,同时也能够保证 SEO 效果。本文尝试基于 Gin 架构提供一种这样的解决方案,可以帮助我们实现同时支持 REST API 和 Go Template 服务端模板渲染,从而更好地满足我们的业务需求。

项目结构

GitHub 地址:https://github.com/juzeon/gin-hybrid/

项目结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
.
├── data # 数据结构定义
│   └── dto
├── go.mod
├── go.sum
├── LICENSE
├── main.go
├── middleware # 中间件
│   └── auth.go
├── pkg # 常用包
│   ├── app
│   └── util
├── README.md
├── router # 路由
│   ├── router.go
│   ├── user.go
│   └── web_router.go
├── service # 服务
│   ├── service.go
│   └── user.go
└── web # Go Template模板
    ├── static
    └── template

分解介绍

入口

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func main() {
	engine := gin.New()
	engine.Use(gin.Logger(), nice.Recovery(router.RecoveryFunc))
	service.Setup()
	router.Setup(engine)
	err := engine.Run(fmt.Sprintf(":%v", 7070))
	if err != nil {
		panic(err)
	}
}

这段代码是项目的入口,它主要实现了以下功能:

  1. 创建一个 Gin 引擎实例 engine,用于处理 HTTP 请求。
  2. 使用 gin.Logger() 中间件记录请求日志,并使用 nice.Recovery() 中间件处理请求时的异常情况。
  3. 调用 service.Setup() 初始化项目的服务层。
  4. 调用 router.Setup(engine) 加载路由配置。
  5. 启动 HTTP 服务,监听 7070 端口,处理客户端的请求。如果启动服务失败,会通过 panic(err) 来抛出异常。

服务

无论是 REST API 还是 Go Template 均调用统一的服务。对于每一个服务(例如 User 服务),其声明都是类似的:

1
2
3
4
5
6
type UserService struct {
}

func NewUserService() *UserService {
	return &UserService{}
}

使用结构体来定义服务的目的是,如果该服务存在依赖项(例如某个 DAO 层的示例),可以通过 New 函数传入并作为结构体内的变量接收,类似于 Spring Boot 中的 Bean。这样的好处是能很明确地知道某个服务依赖了哪些外部 Bean。

以 Login 函数为例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func (u UserService) Login(aw *app.Wrapper) app.Result {
	type UserLoginReq struct {
		Username string `form:"username" binding:"required"`
		Password string `form:"password" binding:"required"`
	}
	var req UserLoginReq
	if err := aw.Ctx.ShouldBind(&req); err != nil {
		return aw.Error(err.Error())
	}
	if req.Username != "admin" || req.Password != "123456" {
		return aw.Error("Wrong username or password (tips: admin, 123456)")
	}
	jwt := util.GenerateJWT(1, 5, "administrator")
	aw.Ctx.SetCookie("hybrid_authorization", jwt, 60*60*24*365, "/", "", false, true)
	return aw.Success(jwt)
}

这段代码是一个用户登录函数,主要实现以下功能:

  1. 定义了一个 UserLoginReq 结构体用于接收客户端发送的登录请求,其中包括用户名和密码两个字段。
  2. 使用 Gin 的 ShouldBind() 方法将客户端发送的表单数据绑定到 UserLoginReq 结构体中。
  3. 对于绑定过程中的错误,返回一个带有错误信息的 Result。
  4. 对于用户名和密码的验证,如果不是指定的用户名和密码,返回一个带有错误信息的 Result。
  5. 使用 util.GenerateJWT() 方法生成 JWT(Json Web Token)并将其存储在 HTTP Cookie 中。
  6. 返回一个带有 JWT 信息的 Result。

在登录部分既通过 REST API 方式返回了生成的 JWT,以便客户端等获取到并保存到本地缓存中;又调用 SetCookie 函数设置 JWT Cookie,这是因为以模板为基础的前端可能通过 axios.post 这样的方式请求登录接口,因此可以让浏览器自己的逻辑将 Cookie 设置好。注意 SetCookie 的 httponly 为 true,可以一定程度上保证安全性,但也使 JavaScript 不可操作该 Cookie,因此 SetCookie 函数是必须的。

服务层的 Setup 函数负责初始化服务 Bean,以供路由调用:

1
2
3
4
5
var ExUser *UserService

func Setup() {
	ExUser = NewUserService()
}

app 包

上述服务函数传入一个 aw *app.Wrapper,返回一个 app.Result,这与传统 Gin 项目使用 gin.Context 有所不同。实际上 app.Wrapper 是对 gin.Context 的封装,app.Result 是对 REST API 的 JSON 返回格式的统一定义,并提供了一些辅助函数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ...
type Result struct {
	Code    int         `json:"code"`
	Msg     string      `json:"msg,omitempty"`
	Data    interface{} `json:"data,omitempty"`
	wrapper *Wrapper
}
// ...
type Wrapper struct {
	Ctx *gin.Context
}
// ...

其中 Wrapper 提供一个 ExtractUserClaims 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
func (w Wrapper) ExtractUserClaims() *dto.UserClaims {
	raw, exist := w.Ctx.Get("userClaims")
	if !exist {
		panic("userClaims not exists")
	}
	uc, ok := raw.(*dto.UserClaims)
	if !ok {
		panic("userClaims failed to convert")
	}
	return uc
}

// dto.UserClaims
type UserClaims struct {
	jwt.StandardClaims
	UserID    int       `json:"user_id"`
	RoleID    int       `json:"role_id"`
	RoleName  string    `json:"role_name"`
	LoginTime time.Time `json:"login_time"`
}

该方法从 gin.Context 中获取 userClaims 变量,作为 JWT 解析结果,即用户的 JWT 信息。userClaims 变量是在路由层被解析和设置的,我们稍后会介绍到相关的代码。

路由

路由层是整个项目架构的核心,担当使 REST API 和模板共存的职能。

在 Setup 函数中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func Setup(e *gin.Engine) {
	e.Use(func(ctx *gin.Context) {
		if ctx.GetHeader("Authorization") != "" {
			return
		}
		if token, err := ctx.Cookie("hybrid_authorization"); err == nil {
			ctx.Request.Header.Set("Authorization", token)
		}
	})

	api := e.Group("/api")
	RegisterAPIRouters(GetUserAPIRouters(), api.Group("/user"))

	e.HTMLRender = loadTemplates()
	e.Static("/static", "web/static")
	RegisterWebRouters(GetWebRouters(), e)
}

该函数是路由层的初始化函数,由 main 函数调用。e 是从 main 函数传入的 gin.Engine。

代码首先为 gin.Engine 加入了一个中间件。由于客户端等直接调用 REST API 的应用使用 Authorization 的 HTTP Header,以Bearer ...的形式传递 JWT Token,而网页则是使用hybrid_authorization的 Cookie。该中间件将这两种传递方式相统一。

接下来是 API 接口的注册部分。将所有 REST API 注册在/api目录下。其有关的两个函数我们稍后会介绍到。

最后是模板页面的注册部分。我们将web/static作为静态资源注册在/static目录下,并注册 Go Template 模板页面。其中 loadTemplates 函数返回了一个基于github.com/gin-contrib/multitemplate的多模板渲染引擎,因为我们将模板进行了拆分。以下是web目录的具体结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
.
├── static # 存放静态资源,直接挂载于 /static 目录下。例如一些css和js等。
│   ├── js
│   │   ├── app_axios.js
│   │   └── helper.js
│   └── style
│       └── layout.css
└── template # 存放go template
    ├── base # 布局页面,包括html、head、meta等标签,其中body的内容部分将由具体选择的页面决定。
    │   └── layout.gohtml
    ├── head # 可按需引入的头文件,例如在用户设置页面为了更换头像需要引入头像裁剪的类库,而在其他页面则不需要。这样可以缩减页面引入文件的数量和体积。
    │   ├── image_processor.gohtml
    │   └── marked.gohtml
    ├── page # 每个具体的页面
    │   ├── index.gohtml # 主页
    │   └── user
    │       ├── login.gohtml # 用户登录页
    │       └── me.gohtml # 用户信息页
    └── standalone # 额外的独立页面
        └── error.gohtml # 错误页面

API 路由

在传统的 Gin 开发中,我们常常在路由中为调用一个服务插入一系列前置的处理函数或中间件。例如一个展示用户信息的服务需要确认用户已经登录,可能还需要判断用户权限。因此它将在调用具体的服务函数之前插入鉴权中间件,该中间件可能检查 JWT Token,检查权限,并将 JWT 解析后的实体设置到 gin.Context 中等。

因此在我们的封装中,对于每一个 API 调用(我们称之为一个 APIRouter),也包含了一系列中间件和服务,我们称之为 Handler:

1
2
3
4
5
type APIRouter struct {
	Method   string // HTTP 类型,如 get 或 post
	Path     string // 接口路径,如 /login
	Handlers []func(aw *app.Wrapper) app.Result // 一系列的处理函数,其函数签名即为中间件、服务函数的签名
}

我们将每一个 APIRouter 看作是一个整体,对应一个独立的 API 服务。GetUserAPIRouters 函数便返回了一组 APIRouter,包括登录、查看用户信息和一个测试的获取信息服务:

 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
func GetUserAPIRouters() []APIRouter {
	srv := service.ExUser
	routers := []APIRouter{
		{
			Method:   "post",
			Path:     "/login",
			Handlers: AssembleHandlers(srv.Login),
		},
		{
			Method:   "get",
			Path:     "/me",
			Handlers: AssembleHandlers(middleware.Auth, srv.Me),
		},
		{
			Method:   "get",
			Path:     "/get_data",
			Handlers: AssembleHandlers(srv.GetData),
		},
	}
	return routers
}

// middleware.Auth
func Auth(aw *app.Wrapper) app.Result {
	authHeader := aw.Ctx.GetHeader("Authorization")
	if strings.HasPrefix(authHeader, "Bearer ") {
		authHeader = authHeader[7:]
	}
	claims, err := util.ParseJWT(authHeader)
	if err != nil {
		return aw.Error("Login Required")
	}
	aw.Ctx.Set("userClaims", claims)
	return aw.OK()
}
// ...
func AssembleHandlers(handlers ...func(aw *app.Wrapper) app.Result) []func(aw *app.Wrapper) app.Result {
	var result []func(aw *app.Wrapper) app.Result
	for _, handler := range handlers {
		result = append(result, handler)
	}
	return result
}

AssembleHandlers 仅仅是一个辅助函数,作为一个variadic function,将传入的可变数量的 handlers 组装为切片并返回。

/me接口获取了用户信息,因此在之前需要使用 Auth 中间件鉴权。Auth 中间件检查 HTTP Header 中的 Authorization 头,如果解析成功,则将其设置在 gin.Context 的 userClaims 中。如果解析失败,则返回一个错误类型的 app.Result,我们自定义的路由处理函数捕获到这个错误,就会停止于此,不会继续调用后面的 srv.Me 函数。RegisterAPIRouters 函数即实现了这个功能:

 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
func RegisterAPIRouters(apiRouters []APIRouter, g *gin.RouterGroup) {
	if !strings.HasPrefix(g.BasePath(), "/") {
		panic("BasePath must start with /: " + g.BasePath())
	}
	for _, apiRouter := range apiRouters {
		apiRouter := apiRouter
		if !strings.HasPrefix(apiRouter.Path, "/") {
			panic("Path must start with /: " + apiRouter.Path)
		}
		apiRouter.Method = strings.ToLower(apiRouter.Method)
		commonHandler := func(ctx *gin.Context) {
			aw := app.NewWrapper(ctx)
			var result app.Result
			for _, handler := range apiRouter.Handlers {
				result = handler(aw)
				if !result.IsSuccessful() {
					break
				}
			}
			ctx.JSON(result.GetResponseCode(), result)
		}
		switch apiRouter.Method {
		case "get":
			g.GET(apiRouter.Path, commonHandler)
		case "post":
			g.POST(apiRouter.Path, commonHandler)
		default:
			panic("method " + apiRouter.Method + " not found")
		}
		PathAPIRouterMap[g.BasePath()+apiRouter.Path] = apiRouter
	}
}

函数接受一个 APIRouter 的切片(对应一组完成的 API,如用户登录、获取用户信息等),使用 for 遍历对于每一个 APIRouter 都进行处理。而对于每一个 APIRouter,链式调用其中的 handlers。一旦某一个 handler 返回的 app.Result 结果包含错误码,则认为本 APIRouter 的调用出错,停止调用后续的 handler,返回 result 的结果。我们使用 gin.Context 的 JSON 方法将 app.Result 实例转为 JSON 并写入 HTTP Response。

PathAPIRouterMap 是一个全局 map 变量,将路径(如/user/login)映射到对应的 APIRouter,以便后续模板路由使用。

因此我们刚刚提到的注册 user 路由的部分调用了方才阐释的两个函数:

1
RegisterAPIRouters(GetUserAPIRouters(), api.Group("/user")) // 将 user 的路由挂载到 /user 目录下

模板路由

定义模板路由的结构体,与 APIRouter 对应:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type WebRouter struct {
	Name           string               // name of router
	OverwritePath  string               // use this to rewrite relativePath if it's not null
	UseAPIs        []APIRouter          // APIRouters to call
	Process        func(map[string]any) // additionally process renderMap
	Title          string
	GetTitle       func(map[string]any) string // use GetTitle instead of Title if this function exists
	GetKeywords    func(map[string]any) string
	GetDescription func(map[string]any) string
}

每个 WebRouter 对应一个在服务端渲染的 HTML 页面,其中包含一个以 UseAPIs 命名的 APIRouter 切片,用于存放该页面调用的 APIRouter,因为有可能一个页面需要调用多个 API。

与 APIRouter 的声明类似,GetWebRouters 返回一个 WebRouter 的切片。AssemblePaths 与 AssembleHandlers 功能类似,将variadic function的 path 映射为该页面需要调用的 APIRouters(通过先前填充的 PathAPIRouterMap 完成路径到 APIRouter 实例的映射)。

 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
func GetWebRouters() []WebRouter {
	routers := []WebRouter{
		{
			Name:  "index",
			Title: "Index",
		},
		{
			Name:  "user/login",
			Title: "Login",
		},
		{
			Name:    "user/me",
			Title:   "User Information",
			UseAPIs: AssemblePaths("/user/me"),
		},
	}
	return routers
}
// ...
func AssemblePaths(paths ...string) []APIRouter {
	var routers []APIRouter
	for _, path := range paths {
		if !strings.HasPrefix(path, "/") {
			panic("path must start with /: " + path)
		}
		if !strings.HasPrefix(path, "/api") {
			path = "/api" + path
		}
		router, ok := PathAPIRouterMap[path]
		if !ok {
			panic("router path " + path + " not exist")
		}
		routers = append(routers, router)
	}
	return routers
}

函数 GetWebRoutersCommonAPIs 定义了通用调用 API,在所有页面渲染前均需要调用,如获取用户信息的接口。每个页面均可以使用此信息获取用户的登录状态,如果用户已登录,那么其中将包含更多的附加信息。map[string]APIRouter的键部分即该实体在模板中被使用的变量名,按照 Go Template 的写法,如.user.UserID在用户已登录状态下即为用户 ID。

GetWebRoutersFuncs 定义了 Go Template 中可以使用的辅助函数。包含sprig的函数库和自定义的一些函数。

两个函数代码如下:

 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
func GetWebRoutersCommonAPIs() map[string]APIRouter {
	return map[string]APIRouter{
		"user": AssemblePaths("/user/me")[0],
	}
}

func GetWebRoutersFuncs() map[string]any {
	merged := map[string]any{}
	for key, item := range map[string]any(sprig.FuncMap()) {
		merged[key] = item
	}
	custom := map[string]any{
		"raw": func(str string) template.HTML {
			return template.HTML(str)
		},
		"concat": func(values ...any) string {
			v := ""
			for range values {
				v += "%v"
			}
			return fmt.Sprintf(v, values...)
		},
		"ago": func(value time.Time) string {
			return timeago.NoMax(timeago.Chinese).Format(value)
		},
	}
	for key, item := range custom {
		merged[key] = item
	}
	return merged
}

GetWebRouters 函数返回的结果被传入到 RegisterWebRouters 函数中,在这个函数中注册模板路由。其实现与 RegisterAPIRouters 大同小异,区别在于根据 UseAPIs 的列表调用了多个 API,并使用 gin.Context 的 HTML 函数渲染模板。

 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
// ...

// call APIs specified by templates
for ix, apiRouter := range webRouter.UseAPIs {
	for _, apiHandler := range apiRouter.Handlers {
		result = apiHandler(aw)
		if !result.IsSuccessful() {
			break
		}
	}
	if !result.IsSuccessful() {
		ctx.HTML(result.GetResponseCode(), "error.gohtml", result)
		return
	}
	if ix == 0 {
		renderMap["d"] = result.Data
		renderMap["code"] = result.Code
		renderMap["msg"] = result.Msg
	} else {
		renderMap["d"+strconv.Itoa(ix)] = result.Data
		renderMap["code"+strconv.Itoa(ix)] = result.Code
		renderMap["msg"+strconv.Itoa(ix)] = result.Msg
	}
}

// call common APIs
for name, apiRouter := range GetWebRoutersCommonAPIs() {
	for _, apiHandler := range apiRouter.Handlers {
		result = apiHandler(aw)
		if !result.IsSuccessful() {
			break
		}
	}
	renderMap[name] = result.Data
}

// ...

ctx.HTML(200, templateName+".gohtml", renderMap)

对于 UseAPIs 中的 API 调用返回的结果,以.d作为访问的实体。如调用的 API 对应的 APIRouter 在 REST API 中最终将返回一个Data中包含Name的 JSON,那么在模板中使用.d.Name即可访问这项数据。UseAPIs 第二项的实体名为.d1,第三项为.d2,以此类推。可以通过检查.code.msg判断错误的发生情况。

模板和 Vue

本模板项目提供了可选的 Vue 3 和 Vuetify 支持(使用 CDN Mode),在web/template/base/layout.gohtml包含对其的初始化。

为了使分页面写法的 Vue 页面在 IDE 中得到更好的代码提示,layout 页面使用了一个 dummy function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<script>
    let pageVueOptions

    // dummy function for pages
    function createApp(options) {
        pageVueOptions = options
        return {
            mount(str) {
            }
        }
    }
</script>

之后在每个具体业务逻辑的页面中可以参考使用如下模板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{{define "head"}}
{{end}}

<div id="page">
    {{define "page"}}
    
    {{end}}
</div>

{{define "script"}}
<script>
    createApp({
        data() {
            return {
            }
        },
    }).mount("#page")
</script>
{{end}}

这样可以让每个页面的写法更类似于 Vue 的单页面组件。在head块中引入所需要的外部资源文件(为了保证每个页面引入的资源版本统一,可以在web/template/head中定义);在page块中定义页面的 HTML 代码部分;在script块中定义 JavaScript 逻辑。

需要特别提醒的是,为了更好的 SEO,请仔细考虑使用 Go Template 的 for 函数和 Vue 的 v-for 函数的不同场景。对于博客主页的文章列表,为了让搜索引擎更好地索引,我们需要在服务端渲染博文列表和链接,因此我们在 UseAPIs 中声明 API 调用,并在模板中使用 Go Template 的 for 函数;而对于一些 SEO 不太重要的页面,例如用户的关注列表,可以在前端使用 axios 异步调用 REST API,然后使用 v-for 函数动态渲染到页面上。

Licensed under CC BY-NC-SA 4.0