背景
在现实生产环境中,我们常常需要在同一网站中提供两种服务:一种是针对客户端的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)
}
}
|
这段代码是项目的入口,它主要实现了以下功能:
- 创建一个Gin引擎实例engine,用于处理HTTP请求。
- 使用gin.Logger()中间件记录请求日志,并使用nice.Recovery()中间件处理请求时的异常情况。
- 调用service.Setup()初始化项目的服务层。
- 调用router.Setup(engine)加载路由配置。
- 启动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)
}
|
这段代码是一个用户登录函数,主要实现以下功能:
- 定义了一个UserLoginReq结构体用于接收客户端发送的登录请求,其中包括用户名和密码两个字段。
- 使用Gin的ShouldBind()方法将客户端发送的表单数据绑定到UserLoginReq结构体中。
- 对于绑定过程中的错误,返回一个带有错误信息的Result。
- 对于用户名和密码的验证,如果不是指定的用户名和密码,返回一个带有错误信息的Result。
- 使用util.GenerateJWT()方法生成JWT(Json Web Token)并将其存储在HTTP Cookie中。
- 返回一个带有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函数动态渲染到页面上。