⬆︎
×
TOC
CHAT

Go语言Web开发(1):Gin框架

Gin是一个轻量级的Go语言Web框架,它具有高性能和简洁的设计。由于其快速的路由匹配和处理性能,Gin成为Go语言中最受欢迎的Web框架之一。

官网:gin-gonic.com/zh-cn

GitHub:github.com/gin-gonic/gin

部分函数、结构体的详细说明请通过IDE查看。

1 环境搭建与程序热加载

下载并安装Gin:

go get -u github.com/gin-gonic/gin

gin引入,若使用诸如http.StatusOK等常量,则还需引入net/http包:

import (
    "github.com/gin-gonic/gin"
    "net/http"
)

新建main.go配置路由,详情如下所示(若想改变默认启动端口,只需传入参数r.Run(":9000")):

package main

import "github.com/gin-gonic/gin"

func main() {
    // 创建一个默认路由引擎
    r := gin.Default()
    // 配置路由
    r.GET("/", func(c *gin.Context) {
        // c.JSON:返回JSON格式的数据
        c.JSON(200, gin.H{
            "message": "Hello, Hyplus!",
        })
    })
    // 启动HTTP服务,默认为0.0.0.0:8080
    r.Run()
}

热加载是指当对代码进行修改时,程序能够自动重新加载并执行,使得开发更加便利,可以快速进行代码测试,省去了每次手动重新编译。可以借助第三方包进行实现:

  1. fresh:github.com/gravityblast/fresh(推荐)
go get github.com/pilu/fresh

# 开启fresh
fresh
go run github.com/pilu/fresh    # 若fresh命令不存在,则运行该条
  1. github.com/codegangsta/gin

2 路由与模板基础

路由(Routing)由一个URL(路径)和一个特定的HTTP方法(GETPOST等)组成,涉及到应用如何响应客户端对某个网站节点的访问。

2.1 RESTful API

RESTful API是目前较为成熟的一套互联网应用程序API设计理论,设计路由时建议参考RESTful API指南。在RESTful架构中,每个网址代表一种资源,不同的请求方式表示执行不同的操作:

  • GET(SELECT):从服务器取出资源(一项或多项),获取Get传值可使用c.Query()方法。
  • POST(CREATE):在服务器新建一个资源
  • PUT(UPDATE):在服务器更新资源(客户端提供改变后的完整资源)
  • DELETE(DELETE):从服务器删除资源
package main

import (
    "github.com/gin-gonic/gin"
    "net/http"
)

func main() {
    r := gin.Default()

    r.GET("/news", func(c *gin.Context) {
        aid := c.Query("aid")
        c.String(http.StatusOK, "aid=%s", aid)
    })

    r.POST("/add", func(c *gin.Context) {
        c.String(http.StatusOK, "POST请求-主要用于增加数据")
    })

    r.PUT("/edit", func(c *gin.Context) {
        c.String(http.StatusOK, "PUT请求-主要用于编辑数据")
    })

    r.DELETE("/delete", func(c *gin.Context) {
        c.String(http.StatusOK, "DELETE请求-主要用于删除数据")
    })

    r.Run()
}

动态路由(域名/user/20):

r.GET("/user/:uid", func(c *gin.Context) {
    uid := c.Param("uid")
    c.String(http.StatusOK, "userID=%s", uid)
})

2.2 响应数据函数

通过c.String()c.JSON()c.JSONP()c.XML()c.HTML()函数返回数据:

  1. c.String()返回一个字符串,需传入状态码、格式字符串和参数(类似于Printf()):
r.GET("/", func(c *gin.Context) {
    c.String(http.StatusOK, "Hello, %v!", "首页")
})
  1. c.JSON()返回一个JSON数据,需传入状态码、gin.H(实为map[string]interface{})或自定义结构体:
type Article struct {
    Title   string `json:"title"`
    Desc    string `json:"desc"`
    Content string `json:"content"`
}

func main() {
    r := gin.Default()

    // 使用gin.H
    r.GET("/json1", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "success": true,
            "msg":     "Hyplus GO",
        })
    })

    // 使用自定义结构体
    r.GET("/json2", func(c *gin.Context) {
        a := &Article{
            Title:   "Gin框架基础教程",
            Desc:    "Hyplus GO",
            Content: "test",
        }
        c.JSON(http.StatusOK, a)
    })

    r.Run()
}
  1. c.JSONP()用于响应JSONP数据格式(主要用于跨域请求):
type Article struct {
    Title   string `json:"title"`
    Desc    string `json:"desc"`
    Content string `json:"content"`
}

func main() {
    r := gin.Default()

    // 响应JSONP请求,可传入回调函数(callback)
    //  【例】请求:http://localhost:8080/jsonp?callback=xxxx
    //       返回数据:xxxx({"title":"Gin框架基础教程","desc":"Hyplus GO","content":"Gin框架基础教程"});
    r.GET("/jsonp", func(c *gin.Context) {
        a := &Article{
            Title:   "Gin框架基础教程",
            Desc:    "Hyplus GO",
            Content: "Gin框架基础教程",
        }
        c.JSONP(http.StatusOK, a)
    })

    r.Run()
}
  1. c.XML()返回XML数据,类似于c.JSON()
type Article struct {
    Title   string `json:"title"`
    Desc    string `json:"desc"`
    Content string `json:"content"`
}

func main() {
    r := gin.Default()

    // 使用gin.H
    r.GET("/xml1", func(c *gin.Context) {
        c.XML(http.StatusOK, gin.H{
            "success": true,
            "msg":     "Hello XML",
        })
    })

    // 使用自定义结构体
    r.GET("/xml2", func(c *gin.Context) {
        a := &Article{
            Title:   "Gin框架基础教程",
            Desc:    "Hello XML",
            Content: "test",
        }
        c.XML(http.StatusOK, a)
    })

    r.Run()
}

2.3 渲染模板

c.HTML()用于渲染模板,需传入状态码、模板地址、gin.H

2.3.1 加载模板

使用LoadHTMLGlob()方法加载模板(或LoadHTMLFiles()直接指定文件),传入参数形如templates/*,表示加载该目录下所有模板:

type Article struct {
    Title   string `json:"title"`
    Desc    string `json:"desc"`
    Content string `json:"content"`
}

func main() {
    r := gin.Default()

    // 配置模板文件
    r.LoadHTMLGlob("templates/*")

    r.GET("/", func(c *gin.Context) {
        c.HTML(http.StatusOK, "index.html", gin.H{
            "title": "首页",
        })
    })

    r.GET("/news", func(c *gin.Context) {
        news := &Article{
            Title:   "Gin框架基础教程",
            Desc:    "Hello Templates",
            Content: "test",
        }
        c.HTML(http.StatusOK, "news.html", gin.H{
            "title": "I'm data from server",
            "news":  news,
        })
    })

    r.GET("/goods", func(c *gin.Context) {
        c.HTML(http.StatusOK, "goods.html", gin.H{
            "title": "This is Goods Page, data from server!",
            "price": 20,
        })
    })

    r.Run()
}

在前台可通过形如{{.xxx}}的插值表达式来访问后台数据,例如/news对应的页面如下所示(对结构体成员的访问同样使用.):

<!-- templates/news.html -->
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Document</title>
</head>
<body>
    <h2>{{.title}}</h2>
    <p>
        {{.news.Title}}
    </p>
    <p>
        {{.news.Desc}}
    </p>
    <p>
        {{.news.Content}}
    </p>
</body>
</html>

若模板位于多级目录,则LoadHTMLGlob()方法所传参数应形如templates/**/*(若目录层数更深则为templates/**/**/*)。

// 配置模板文件
r.LoadHTMLGlob("templates/**/*")

// 后台首页
r.GET("/admin", func(c *gin.Context) {
    c.HTML(http.StatusOK, "admin/index.html", gin.H{
        "title": "后台首页",
    })
})

同时,在定义模板时需通过define定义名称,defineend成对出现,如下为templates/admin/index.html的定义:

{{ define "admin/index.html" }}
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Document</title>
</head>
<body>
    <h2>{{.title}}</h2>
</body>
</html>
{{ end }}

2.3.2 模板基本语法

模板语法都包含在{{}}之间,其中{{.}}中的点表示当前对象。

当传入一个结构体对象时,可用.访问结构体的对应字段;模板中应使用defineend定义。(见前述案例)

注释使用/**/,执行时会忽略;可以多行;不能嵌套,并且必须紧贴分界符始止:

{{/* comment */}}

可以在模板中声明变量,用于保存传入模板的数据或其他语句生成的结果。变量名前需加$,与Go语言一样使用:=初始化值,如下例所示:

{{$obj := .title}}  <!-- 定义变量 -->

<h4>{{$obj}}</h4>

有时在使用模板语法时会不可避免地引入空格或换行符,导致模板最终渲染结果不符预期,此时可用{{-/-}}分别去除模板内容左/右侧的所有空白符号(-紧挨花括号,并且与模板值之间使用空格分隔):

{{- .Name -}}

布尔函数(比较函数)会将任何类型的零值视为假,其余视为真:eqneltlegtge(例如gt arg1 arg2表示arg1 > arg2)。常结合条件判断(if...else if...else...end)使用,如下例所示:

{{if pipeline}} T1 {{end}}

{{if pipeline}} T1 {{else}} T0 {{end}}

{{if pipeline}} T1 {{else if pipeline}} T0 {{end}}

{{if gt .score 60}}
及格
{{else}}
不及格
{{end}}

{{if gt .score 90}}
优秀
{{else if gt .score 60}}
及格
{{else}}
不及格
{{end}}

使用range关键字进行遍历,有以下两种写法,其中pipeline的值必须是数组、切片、字典或通道:

{{range $key, $value := .obj}}
    {{$value}}
{{else}}
    {{???}}
{{end}}

如下例所示:

r.GET("/admin", func(c *gin.Context) {
    c.HTML(http.StatusOK, "admin/index.html", gin.H{
        "title": "后台首页",
        "langs": []string{"Go", "Java", "C", "Python"},
        "newsList": []interface{}{
            &Article{Title: "Go语言基础教程", Desc: "Hello Go!", Content: "content1"},
            &Article{Title: "Java语言基础教程", Desc: "Hello Java!", Content: "content2"},
            &Article{Title: "C语言基础教程", Desc: "Hello C!", Content: "content2"},
        },
        "emptySlice": []int{},
    })
})
{{ define "admin/index.html" }}
<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Document</title>
</head>
<body>
    <h2>{{.title}}</h2>

    <!--循环遍历数据-->
    <ul>
        {{range $key, $value := .langs}}
            <li>{{$key}} - {{$value}}</li>
        {{else}}
            Nope.
        {{end}}
    </ul>
    <br>
    <ul>
        {{range $key, $value := .newsList}}
            <li>{{$key}} - {{$value.Title}} - {{$value.Content}}</li>
        {{else}}
            今日无新闻
        {{end}}
    </ul>
    <br>
    <ul>
        {{range $key, $value := .emptySlice}}
            <li>{{$key}} - {{$value}}</li>
        {{else}}
            <li>数组中无数据!</li>
        {{end}}
    </ul>
</body>
</html>
{{ end }}

with用于解构结构体来输出数据:

user := UserInfo{
    Name: "Teresa",
    Gender: "F",
    Age: 17,
}

router.GET("/", func(c *gin.Context) {
    c.HTML(http.StatusOK, "default/index.html", gin.H{
        "user": user,
    })
})
{{with .user}}
    <h4>姓名:{{.Name}}</h4>
    <h4>性别:{{.Gender}}</h4>
    <h4>年龄:{{.Age}}</h4>
{{end}}

<!-- 相当于
    <h4>{{.user.Name}}</h4>
    <h4>{{.user.Gender}}</h4>
    <h4>{{.user.Age}}</h4>
-->

2.3.3 预定义函数与自定义函数

执行模板时,函数从两个函数字典中查找:首先是模板函数字典,然后是全局函数字典。

预定义的全局函数如下:

  • and:返回其第一个empty参数或最后一个参数,and x y等价于if x then y else x;所有参数均可执行。
  • or:返回第一个非empty参数或最后一个参数,or x y等价于if x then x else y;所有参数均可执行。
  • not:返回其单个参数的布尔值的否定。
  • len:返回其参数的整数类型长度。
  • index:执行结果为第一个参数以剩下的参数为索引/键指向的值,例如index x 1 2 3返回x[1][2][3]的值;每个被索引的主体必须是数组、切片或者字典。
  • print:即fmt.Sprint
  • printf:即fmt.Sprintf
  • println:即fmt.SprintIn
  • html:返回与其参数的文本表示形式等效的转义HTML;该函数在html/template中不可用。
  • urlquery:以适合嵌入到网址查询中的形式返回其参数的文本表示的转义值;该函数在html/template中不可用。
  • js:返回与其参数的文本表示形式等效的转义JavaScript。
  • call:执行结果为调用第一个参数的返回值,该参数必须是函数类型,其余参数作为调用该函数的参数;例如call .X.Y 1 2等价于Go语言中的dot.X.Y(1, 2),其中Y为函数类型的字段或字典的值,或其他类似情况
    • call的第一个参数的执行结果必须是函数类型的值(与print等预定义函数明显不同)
    • 该函数类型值必须有1到2个返回值,若有2个则后一个必须为error接口类型
    • 若有2个返回值的方法返回的errornil,模板执行会中断并返回给调用模板执行者该错误
{{len .title}}
{{index .hobby 2}}

一般通过router.SetFuncMap()方法自定义模板函数,参数需传入template.FuncMap,如下所示:

router.SetFuncMap(template.FuncMap{
    "formatDate": formatAsDate,
})

【例】注册全局模板函数并加载模板(注意顺序,注册模板函数应在加载模板之前)

package main

import (
    "fmt"
    "html/template"
    "net/http"
    "time"
    "github.com/gin-gonic/gin"
)

func formatAsDate(t time.Time) string {
    year, month, day := t.Date()
    return fmt.Sprintf("%d/%02d/%02d", year, month, day)
}

func main() {
    router := gin.Default()

    // 注册全局模板函数
    // 注意顺序,注册模板函数应在加载模板之前
    router.SetFuncMap(template.FuncMap{
        "formatDate": formatAsDate,
    })

    // 加载模板
    router.LoadHTMLGlob("templates/**/*")
    router.GET("/", func(c *gin.Context) {
        c.HTML(http.StatusOK, "default/index.html", map[string]interface{}{
            "title": "前台首页",
            "now": time.Now(),
        })
    })

    router.Run(":8080")
}

2.3.4 嵌套template

新建templates/deafult/page_header.html

{{ define "default/page_header.html" }}
<h1>这是一个头部</h1>
{{end}}

在其他模板中外部引入,语法如下,注意以下两点:

  1. 引入名称为page_header.html中定义的名称
  2. 引入时注意最后的点(.)!
{{template "default/page_header.html" .}}

如下例所示:

{{ define "default/index.html" }}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    {{template "default/page_header.html" .}}
</body>
</html>
{{end}}

2.4 静态文件服务

当渲染的HTML文件中引用了静态文件时,需要配置静态web服务。方式形如r.Static("/static", "./static"),其中前面的/static表示路由,后面的./static表示路径:

func main() {
    r := gin.Default()
    r.Static("/static", "./static")
    r.LoadHTMLGlob("templates/**/*")
    // ...
    r.Run(":8080")
}
<!-- 模板文件 ... -->
<link rel="stylesheet" href="/static/css/base.css" />

3 路由详解

本节详细介绍路由传值、返回值。

3.1 Get、Post请求响应

RESTful API基础详见2.1节。

3.1.1 Get请求传值、动态路由传值

可以使用Query()方法获取指定参数的值,还可使用DefaultQuery()方法在第2参数设置默认值。以GET /user?uid=20&page=1为例:

router.GET("/user", func(c *gin.Context) {
    uid := c.Query("uid")
    page := c.DefaultQuery("page", "0")
    c.String(http.StatusOK, "uid=%v page=%v", uid, page)
})

可以动态路由传值,只需使用Param()方法。以hyperplasma.top/user/20为例:

r.GET("/user/:uid", func(c *gin.Context) {
    uid := c.Param("uid")
    c.String(200, "userID=%s", uid)
})

3.1.2 Post请求传值、获取表单数据

定义add_user.html页面:

{{ define "default/add_user.html" }}
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
    </head>
    <body>
        <form action="/doAddUser" method="post">
            用户名:<input type="text" name="username" />
            密码: <input type="password" name="password" />
            <input type="submit" value="提交">
        </form>
    </body>
</html>
{{end}}

通过PostForm()方法接收表单(Form)数据,还可使用DefaultPostForm()方法在第2参数设置默认值。对于上述模板中的表单,可如下设置请求响应:

router.GET("/addUser", func(c *gin.Context) {
    c.HTML(http.StatusOK, "default/add_user.html", gin.H{})
})

router.POST("/doAddUser", func(c *gin.Context) {
    username := c.PostForm("username")
    password := c.PostForm("password")
    age := c.DefaultPostForm("age", "20")

    c.JSON(http.StatusOK, gin.H{
        "username": username,
        "password": password,
        "age": age,
    })
})

3.1.3 请求数据绑定到结构体

为了能够更方便的获取请求相关参数,提高开发效率,可以基于请求的Content-Type识别请求数据类型并利用反射机制自动提取请求中QueryString、form表单、JSON、XML等参数到结构体中。

ShouldBind()方法非常强大能够基于请求自动提取JSON、form表单和QueryString类型的数据,并将值绑定到指定的结构体对象,用法如下所示——

事先定义结构体类型:

// 注意首字母大写
type Userinfo struct {
    Username string `form:"username" json:"user"`
    Password string `form:"password" json:"password"`
}

Get传值绑定到结构体——/?username=akira37&password=123456

router.GET("/", func(c *gin.Context) {
    var userinfo Userinfo
    if err := c.ShouldBind(&userinfo); err == nil {
        c.JSON(http.StatusOK, userinfo) // 返回数据:{"user":"akira37","password":"123456"}
    } else {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": err.Error()
        })
    }
})

Post传值绑定到结构体(表单定义于请求体):

router.POST("/doLogin", func(c *gin.Context) {
    var userinfo Userinfo
    if err := c.ShouldBind(&userinfo); err == nil {
        c.JSON(http.StatusOK, userinfo) // 返回数据:{"user":"akira37","password":"123456"}
    } else {
        c.JSON(http.StatusBadRequest, gin.H{
            "error": err.Error()
        })
    }
})

3.1.4 Post获取XML数据

在开发API时经常会用到JSON或XML来作为数据交互的格式,可以用c.GetRawData()获取数据。

【例】接收如下所示的XML数据:

<?xml version="1.0" encoding="UTF-8"?>
<article>
    <content type="string">我是Akira37</content>
    <title type="string">Hyperplasma!!!</title>
</article>

定义如下结构体:

type Article struct {
    Title string `xml:"title"`
    Content string `xml:"content"`
}

路由如下:

router.POST("/xml", func(c *gin.Context) {
    b, _ := c.GetRawData()  // 从c.Request.Body读取请求数据
    article := &Article{}
    if err := xml.Unmarshal(b, &article); err == nil {
        c.JSON(http.StatusOK, article)
    } else {
        c.JSON(http.StatusBadRequest, err.Error())
    }
})

3.2 简单的路由组

使用router.Group()方法定义一个路由组

func main() {
    router := gin.Default()

    v1 := router.Group("/v1")
    {
        v1.POST("/login", loginEndpoint)
        v1.POST("/submit", submitEndpoint)
        v1.POST("/read", readEndpoint)
    }

    v2 := router.Group("/v2")
    {
        v2.POST("/login", loginEndpoint)
        v2.POST("/submit", submitEndpoint)
        v2.POST("/read", readEndpoint)
    }

    router.Run(":8080")
}

3.3 路由文件、分组

设该项目为gin_demo,新建routes文件夹,在其中新建adminRoutes.goapiRoutes.godefaultRoutes.go

/* adminRoutes.go */
package routes

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

func AdminRoutesInit(router *gin.Engine) {
    adminRouter := router.Group("/admin")
    {
        adminRouter.GET("/user", func(c *gin.Context) {
            c.String(http.StatusOK, "用户")
        })
        adminRouter.GET("/news", func(c *gin.Context) {
            c.String(http.StatusOK, "news")
        })
    }
}
/* apiRoutes.go */
package routes

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

func ApiRoutesInit(router *gin.Engine) {
    apiRoute := router.Group("/api")
    {
        apiRoute.GET("/user", func(c *gin.Context) {
            c.JSON(http.StatusOK, gin.H{
                "username": "Akira",
                "age": 108,
            })
        })
        apiRoute.GET("/news", func(c *gin.Context) {
            c.JSON(http.StatusOK, gin.H{
                "title": "这是新闻",
            })
        })
    }
}
/* defaultRoutes.go */
package routes

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

func DefaultRoutesInit(router *gin.Engine) {
    defaultRoute := router.Group("/")
    {
        defaultRoute.GET("/", func(c *gin.Context) {
            c.String(http.StatusOK, "首页")
        })
    }
}

配置main.go

package main

import (
    "gin_demo/routes"
    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()
    routes.AdminRoutesInit(r)
    routes.ApiRoutesInit(r)
    routes.DefaultRoutesInit(r)
    r.Run(":8080")
}

4 自定义控制器

4.1 控制器分组

当项目较大时需要对控制器进行分组:

新建controller/admin目录,在其中新建NewsController.goUserController.go等文件:

/* NewsController.go */
package admin

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

type NewsController struct {
}

func (c NewsController) Index(ctx *gin.Context) {
    ctx.String(http.StatusOK, "新闻首页")
}
/* UserController.go */
package admin

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

type UserController struct {
}

func (c UserController) Index(ctx *gin.Context) {
    ctx.String(http.StatusOK, "这是用户首页")
}

func (c UserController) Add(ctx *gin.Context) {
    ctx.String(http.StatusOK, "增加用户")
}

在前述routes文件夹中各路由文件中进行配置即可。此处以adminRoutes.go为例,其他路由的配置方法类似:

/* adminRoutes.go */
package routes

import (
    "gin_demo/controller/admin"
    "net/http"
    "github.com/gin-gonic/gin"
)

func AdminRoutesInit(router *gin.Engine) {
    adminRouter := router.Group("/admin")
    {
        adminRouter.GET("/user", admin.UserController{}.Index)
        adminRouter.GET("/user/add", admin.UserController{}.Add)
        adminRouter.GET("/news", admin.NewsController{}.Add)
    }
}

4.2 控制器的继承

新建controller/admin/BaseController.go

/* BaseController.go */
package admin

import (
    "net/http"
    "github.com/gin-gonic/gin"
)

type BaseController struct {
}

func (c BaseController) Success(ctx *gin.Context) {
    ctx.String(http.StatusOK, "成功")
}

func (c BaseController) Error(ctx *gin.Context) {
    ctx.String(http.StatusOK, "失败")
}

新建NewsController,让其继承BaseController,继承后即可调用控制器中的公共方法:

package admin

import (
    "github.com/gin-gonic/gin"
)

type NewsController struct {
    BaseController
}

func (c NewsController) Index(ctx *gin.Context) {
    c.Success(ctx)
}

5 中间件

Gin框架允许开发者在处理请求的过程中,加入用户自己的钩子(Hook)函数,即中间件。中间件适合处理公共的业务逻辑,例如登录认证、权限校验、数据分页、记录日志、耗时统计等。

通俗地讲,中间件就是匹配路由前和匹配路由完成后执行的一系列操作。

5.1 路由中间件

Gin中的中间件必须为gin.HandlerFunc类型。配置路由时可以传递多个回调函数,最后一个回调函数前触发的方法都可称为中间件,如下例所示:

package main

import (
    "fmt"
    "github.com/gin-gonic/gin"
)

func initMiddleware(ctx *gin.Context) {
    fmt.Println("这是中间件")
}

func main() {
    r := gin.Default()

    r.GET("/", initMiddleware, func(ctx *gin.Context) {
        ctx.String(200, "首页——中间件演示")
    })
    r.GET("/news", initMiddleware, func(ctx *gin.Context) {
        ctx.String(200, "新闻页面——中间件演示")
    })

    r.Run(":8080")
}

中间件中加入ctx.Next()可以在路由匹配完成后执行一些操作。例如统计一个请求的执行时间:

package main

import (
    "fmt"
    "time"
    "github.com/gin-gonic/gin"
)

func initMiddleware(ctx *gin.Context) {
    fmt.Println("Step1: 中间件执行中")
    start := time.Now().UnixNano()
    // 调用该请求的剩余处理程序
    ctx.Next()
    fmt.Println("Step3: 程序执行完成,计算时间…" )
    // 计算耗时
    end := time.Now().UnixNano()
    fmt.Println(end - start)
}

func main() {
    r := gin.Default()

    r.GET("/", initMiddleware, func(ctx *gin.Context) {
        fmt.Println("Step2: 执行首页返回数据")
        ctx.String(200, "首页——中间件演示")
    })
    r.GET("/news", initMiddleware, func(ctx *gin.Context) {
        ctx.String(200, "新闻页面——中间件演示")
    })

    r.Run(":8080")
}

一个路由配置多个中间件的执行顺序:

func initMiddlewareOne(ctx *gin.Context) {
    fmt.Println("initMiddlewareOne - 1 - 中间件执行中")
    ctx.Next()  // 调用该请求的剩余处理程序
    fmt.Println("initMiddlewareOne - 2 - 中间件执行中")
}

func initMiddlewareTwo(ctx *gin.Context) {
    fmt.Println("initMiddlewareTwo - 1 - 中间件执行中")
    ctx.Next()  // 调用该请求的剩余处理程序
    fmt.Println("initMiddlewareTwo - 2 - 中间件执行中")
}

func main() {
    r := gin.Default()
    r.GET("/", initMiddlewareOne, initMiddlewareTwo, func(ctx *gin.Context) {
        fmt.Println("执行路由中的语句…")
        ctx.String(200, "首页——中间件演示")
    })
    r.Run(":8080")
}
initMiddlewareOne - 1 - 中间件执行中
initMiddlewareTwo - 1 - 中间件执行中
执行路由中的语句…
initMiddlewareTwo - 2 - 中间件执行中
initMiddlewareOne - 2 - 中间件执行中

ctx.Abort()方法用于终止调用该请求的剩余处理程序:

func initMiddlewareOne(ctx *gin.Context) {
    fmt.Println("initMiddlewareOne - 1 - 中间件执行中")
    ctx.Next()  // 调用该请求的剩余处理程序
    fmt.Println("initMiddlewareOne - 2 - 中间件执行中")
}

func initMiddlewareTwo(ctx *gin.Context) {
    fmt.Println("initMiddlewareTwo - 1 - 中间件执行中")
    ctx.Abort() // 终止调用该请求的剩余处理程序
    fmt.Println("initMiddlewareTwo - 2 - 中间件执行中")
}

func main() {
    r := gin.Default()
    r.GET("/", initMiddlewareOne, initMiddlewareTwo, func(ctx *gin.Context) {
        fmt.Println("执行路由中的语句…")
        ctx.String(200, "首页——中间件演示")
    })
    r.Run(":8080")
}
initMiddlewareOne - 1 - 中间件执行中
initMiddlewareTwo - 1 - 中间件执行中
initMiddlewareTwo - 2 - 中间件执行中
initMiddlewareOne - 2 - 中间件执行中

5.2 全局中间件

通过r.Use()方法配置全局中间件

package main

import (
    "fmt"
    "github.com/gin-gonic/gin"
)

func initMiddleware(ctx *gin.Context) {
    fmt.Println("全局中间件通过r.Use()方法配置")
    ctx.Next()
}

func main() {
    r := gin.Default()

    r.Use(initMiddleware)

    r.GET("/", initMiddleware, func(ctx *gin.Context) {
        ctx.String(200, "首页——中间件演示")
    })
    r.GET("/news", initMiddleware, func(ctx *gin.Context) {
        ctx.String(200, "新闻页面——中间件演示")
    })

    r.Run(":8080")
}

5.3 在路由分组中配置中间件

为路由组注册中间件有以下两种写法(以下以中间件StatCost()为例):

  1. 作为r.Group()路由组方法的可变参数:
shopGroup := r.Group("/shop", StatCost())
{
    shopGroup.GET("/index", func(c *gin.Context) {
        // ...
    })
    // ...
}
  1. 使用路由组对象的Use()方法(与前述全局中间件类似)
shopGroup := r.Group("/shop")
shopGroup.Use(StatCost())
{
    shopGroup.GET("/index", func(c *gin.Context) {
        // ...
    })
    // ...
}

分组路由AdminRoutes.go中配置中间件:

package routes

import (
    "fmt"
    "gin_demo/controller/admin"
    "net/http"
    "github.com/gin-gonic/gin"
)

func initMiddleware(ctx *gin.Context) {
    fmt.Println("路由分组中间件")
    ctx.Next()
}

func AdminRoutesInit(router *gin.Engine) {
    adminRouter := router.Group("/admin", initMiddleware)
    {
        adminRouter.GET("/user", admin.UserController{}.Index)
        adminRouter.GET("/user/add", admin.UserController{}.Add)
        adminRouter.GET("/news", func(c *gin.Context) {
            c.String(http.StatusOK, "news")
        })
    }
}

5.4 中间件和对应控制器之间共享数据

分别通过ctx.Set()ctx.Get()方法设置、获取值。

中间件设置值:

func InitAdminMiddleware(ctx *gin.Context) {
    fmt.Println("路由分组中间件")
    ctx.Set("username", "张三")   // 通过ctx.Set()在请求上下文中设置值,后续的处理函数能够获取该值
    ctx.Next()
}

控制器获取值:

func (c UserController) Index(ctx *gin.Context) {
    username, _ := ctx.Get("username")
    fmt.Println(username)
    ctx.String(http.StatusOK, "这是用户首页")
}

5.5 注意事项

gin.Default()默认使用了LoggerRecovery中间件,其中:

  • Logger中间件将日志写入gin.DefaultWriter(即使配置了GIN_MODE=release
  • Recovery中间件会recover任何panic:若存在panic,会写入500响应码

若不想使用上述两个默认中间件,可以使用gin.New()新建一个没有任何默认中间件的路由。

当在中间件或handler中启动新的goroutine时,不能使用原始的上下文c *gin.Context), 必须使用其只读副本c.Copy()):

r.GET("/", func(c *gin.Context) {
    cCp := c.Copy()
    go func() {
        time.Sleep(5 * time.Second)
        fmt.Println("Done in path " + cCp.Request.URL.Path)   // 使用创建的副本
    }()
    c.String(http.StatusOK, "首页")
})

6 自定义Model

对于简单应用,可以直接在Controller中完成常见的业务逻辑。但若有个功能可在多个控制器或多个模板中复用,则可将公共的功能单独抽取出来作为一个模块(Model)。

Model是逐步抽象的过程,一般会在Model中封装一些公共方法让各Controller使用;亦可在Model中实现数据库相关操作。

6.1 封装公共函数

新建models目录,在其中新建tools.go

package models

import (
    "crypto/md5"
    "fmt"
    "time"
    "github.com/astaxie/beego"
)

// 时间戳间戳转换成日期
func UnixToDate(timestamp int) string {
    t := time.Unix(int64(timestamp), 0)
    return t.Format("2006-01-02 15:04:05")
}

//日期转换成时间戳
func DateToUnix(str string) int64 {
    template := "2006-01-02 15:04:05"
    t, err := time.ParseInLocation(template, str, time.Local)
    if err != nil {
        return 0
    }
    return t.Unix()
}

func GetUnix() int64 {
    return time.Now().Unix()
}

func GetDate() string {
    template := "2006-01-02 15:04:05"
    return time.Now().Format(template)
}

func GetDay() string {
    template := "20060102"
    return time.Now().Format(template)
}

func Md5(str string) string {
    data := []byte(str)
    return fmt.Sprintf("%x\n", md5.Sum(data))
}

6.2 调用Model、注册全局模板函数

控制器中调用Model中的函数:

package controllers

import (
    "gin_demo/models"
)

func main() {
    day := models.GetDay()
}

如2.3.3所述,在main.go中通过router.SetFuncMap()方法自定义模板函数,(注意顺序,注册模板函数应在加载模板之前):

r := gin.Default()
r.SetFuncMap(template.FuncMap{
    "unixToDate": models.UnixToDate,
})

在控制器中应用:

func (c UserController) Add(ctx *gin.Context) {
    ctx.HTML(http.StatusOK, "admin/user/add.html", gin.H{
        "now": models.GetUnix(),
    })
}

对应的模板:

<h2>{{.now | unixToDate}}</h2>

6.3 补充内容:Md5加密

可在go语言包官网(pkg.go.dev)获取md5包。

方式1:

data := []byte("123456")
h := md5.Sum(data)
md5str := fmt.Sprintf("%x", h)
fmt.Println(md5str)

方式2:

h := md5.New()
io.WriteString(h, "123456")
fmt.Printf("%x\n", h.Sum(nil))

7 文件上传

注:上传文件的表单应设置enctype="multipart/form-data"

7.1 单文件上传

简单示例:

func main() {
    router := gin.Default()
    // 为multipart forms设置较低的内存限制(默认为32 MiB)
    router.MaxMultipartMemory = 8 << 20  // 8 MiB
    router.POST("/upload", func(c *gin.Context) {
        // 单文件
        file, _ := c.FormFile("file")
        log.Println(file.Filename)

        dst := "./" + file.Filename
        // 上传文件至指定的完整文件路径
        c.SaveUploadedFile(file, "./files/" + file.Filename)

        c.String(http.StatusOK, fmt.Sprintf("'%s' uploaded!", file.Filename))
    })
    router.Run(":8080")
}

文件上传基本步骤:

  1. 定义模板:上传文件的表单应设置enctype="multipart/form-data"
{{ define "admin/user/add.html" }}
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
    </head>
    <body>
        <form action="/admin/user/doAdd" method="post" enctype="multipart/form-data">
            用户名:<input type="text" name="username" placeholder="用户名"><br><br>
            头像:<input type="file" name="face"><br><br>
            <input type="submit" value="提交">
        </form>
    </body>
</html>
{{ end }}
  1. 定义业务逻辑:使用ctx.FormFile()方法从获取表单中的文件,使用path.Join()函数指定文件路径(类似于Python中的os.path.join()),使用ctx.SaveUploadedFile(file, dst)方法将文件以指定路径保存
func (c UserController) DoAdd(ctx *gin.Context) {
    username := ctx.PostForm("username")
    file, err := ctx.FormFile("face")
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, gin.H{
            "message": err.Error(),
        })
        return
    }
    // 上传文件到指定的目录
    dst := path.Join("./static/upload", file.Filename)
    fmt.Println(dst)
    ctx.SaveUploadedFile(file, dst)
    ctx.JSON(http.StatusOK, gin.H{
        "message": fmt.Sprintf("'%s' uploaded!", file.Filename),
        "username": username,
    })
}

7.2 多文件上传

上传不同名称的多个文件(相当于多次单文件上传):

  1. 定义模板:同前述
{{ define "admin/user/add.html" }}
<!-- ... -->
<form action="/admin/user/doAdd" method="post" enctype="multipart/form-data">
    用户名:<input type="text" name="username" placeholder="用户名"><br><br>
    头像1:<input type="file" name="face1"><br><br>
    头像2:<input type="file" name="face2"><br><br>
    <input type="submit" value="提交">
</form>
<!-- ... -->
{{ end }}
  1. 定义业务逻辑:相当于多次单文件上传
func (c UserController) DoAdd(ctx *gin.Context) {
    username := ctx.PostForm("username")
    face1, err1 := ctx.FormFile("face1")
    face2, err2 := ctx.FormFile("face2")
    if err1 != nil && err2 != nil {
        ctx.JSON(http.StatusInternalServerError, gin.H{
            "message": err.Error(),
        })
        return
    }
    // 上传文件到指定的目录
    if err1 == nil {
        dst1 := path.Join("./static/upload", face1.Filename)
        ctx.SaveUploadedFile(face1, dst1)
    }
    if err2 == nil {
        dst2 := path.Join("./static/upload", face2.Filename)
        ctx.SaveUploadedFile(face2, dst2)
    }
    ctx.JSON(http.StatusOK, gin.H{
        "message": "文件上传成功",
        "username": username,
    })
}

上传相同名称的多个文件:

  1. 定义模板:同前述
{{ define "admin/user/add.html" }}
<!-- ... -->
<form action="/admin/user/doAdd" method="post" enctype="multipart/form-data">
    用户名:<input type="text" name="username" placeholder="用户名"><br><br>
    头像1:<input type="file" name="face[]"><br><br>
    头像2:<input type="file" name="face[]"><br><br>
    <input type="submit" value="提交">
</form>
<!-- ... -->
{{ end }}
  1. 定义业务逻辑:先使用ctx.MultipartForm()方法获取Multipart form,再直接对form.File使用name索引得到全部该名称的文件,最后使用for range遍历、上传每个文件
func (c UserController) DoAdd(ctx *gin.Context) {
    username := ctx.PostForm("username")

    // Multipart form
    form, _ := ctx.MultipartForm()
    files := form.File["face[]"]

    for _, file := range files {
        // 上传文件至指定目录
        dst := path.Join("./static/upload", file.Filename)
        ctx.SaveUploadedFile(file, dst)
    }

    ctx.JSON(http.StatusOK, gin.H{
        "message": "文件上传成功",
        "username": username,
    })
}

7.3 按日期存储上传的文件

  1. 定义模板:同前述
{{ define "admin/user/add.html" }}
<!-- ... -->
<form action="/admin/user/doAdd" method="post" enctype="multipart/form-data">
    用户名:<input type="text" name="username" placeholder="用户名"><br><br>
    头像:<input type="file" name="face"><br><br>
    <input type="submit" value="提交">
</form>
<!-- ... -->
{{ end }}
  1. 定义业务逻辑
func (c UserController) DoAdd(ctx *gin.Context) {
    username := ctx.PostForm("username")

    // 1. 获取上传的文件
    file, err1 := ctx.FormFile("face")

    if err1 == nil {
        //2. 获取后缀名(.jpg .png .gif .jpeg),判断类型是否正确
        extName := path.Ext(file.Filename)
        allowExtMap := map[string]bool{
            ".jpg": true,
            ".png": true,
            ".gif": true,
            ".jpeg": true,
        }
        if _, ok := allowExtMap[extName]; !ok {
            ctx.String(http.StatusInternalServerError, "文件类型不合法")
            return
        }

        //3. 创建图片保存目录
        day := models.GetDay()  // 调用此前在Model中定义的工具函数,生成日期
        dir := "./static/upload/" + day
        if err := os.MkdirAll(dir, 0666); err != nil {
            log.Error(err)
        }

        //4. 生成文件名称
        fileUnixName := strconv.FormatInt(models.GetUnix(), 10) // 时间戳作为文件名
        saveDir := path.Join(dir, fileUnixName + extName)
        ctx.SaveUploadedFile(file, saveDir)
    }

    ctx.JSON(http.StatusOK, gin.H{
        "message": "文件上传成功",
        "username": username,
    })
}

8 Cookie

HTTP是无状态协议,若要实现多个页面之间共享数据,可以使用Cookie或Session。

Cookie存储于访问者计算机的浏览器中,使得可以用同一浏览器访问同一域名时共享数据。

Cookie可实现的功能:

  1. 保持用户登录状态
  2. 保存用户历史记录
  3. 智能推荐
  4. 购物车

8.1 设置和获取Cookie

设置cookie使用c.SetCookie()方法,函数签名如下所示,其中各参数意义如下:

  1. name:cookie的键。
  2. value:cookie的值。
  3. maxAge:过期时间,若不设置则传入nil
  4. path:cookie的路径。
  5. domain:作用域,本地调试为localhost,正式上线配置成域名
  6. secure:为true时,cookie在HTTP中无效,在HTTPS中 才有效
  7. httpOnly:微软对COOKIE做的扩展,若在COOKIE中设置了httpOnly属性, 则其他程序(JS脚本、applet等)将无法读取COOKIE信息,防止XSS攻击。
SetCookie(name, value string, maxAge int, path, domain string, secure, httpOnly bool)

获取cookie使用c.Cookie()方法,传入cookie的name即可,使用方式:

cookie, err := c.Cookie("name")

综合使用例:

package main

import (
    "gin_demo/models"
    "html/template"
    "github.com/gin-gonic/gin"
    "net/http"
)

func main() {
    r := gin.Default()
    r.SetFuncMap(template.FuncMap{
        "unixToDate": models.UnixToDate,
    })

    r.GET("/", func(c *gin.Context) {
        c.SetCookie("usrename", "Akira37" , 3600, "/", "localhost", false, true)
        c.String(http.StatusOK, "首页")
    })

    r.GET("/user", func(c *gin.Context) {
        username, _ := c.Cookie("usrename")
        c.String(http.StatusOK, "用户-" + username)
    })

    r.Run(":8080")
}

8.2 多个二级域名共享Cookie

若想让多个域名(例如kina.hyperplasma.topio.hyperplasma.top)共享cookie,只需将c.SetCookie()方法的domain作用域参数配置为“.+根域名”即可,例如:

c.SetCookie("usrename", "Akira37" , 3600, "/", ".hyperplasma.top", false, true)

9 Session

Session是另一种记录客户状态的机制,保存在服务器上。当客户端浏览器第一次访问服务器并发送请求时,服务器端会创建一个session对象,生成一个key-value键值对,然后将value保存到服务器,将key(cookie)返回到浏览器客户端。浏览器下次访问时会携带key(cookie),找到对应的 value(session)。

Gin官方未提供Session相关的文档,可以使用第三方的Session中间件来实现,例如gin-contrib/sessionsgithub.com/gin-contrib/sessions),使用以下命令安装:

go get github.com/gin-contrib/sessions

gin-contrib/sessions中间件支持的存储引擎

  • cookie
  • memstore
  • redis
  • memcached
  • mongodb

9.1 基于Cookie存储Session

额外引入github.com/gin-contrib/sessions/cookie,通过cookie.NewStore()函数创建基于Cookie的存储引擎(需传入用于加密的[]byte);通过sessions.Sessions()生成Session中间件(需传入Session名称与存储引擎)并配置。

控制器中使用sessions.Default()函数初始化Session对象,可调用其Options()方法设置过期时间等属性(需传入sessions.Options结构体),调用其Set(name, value)方法设置键-值,最后调用其Save()方法保存;接收方则通过其Get(name)方法读取Session值。

如下例所示:

package main

import (
    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/cookie"
    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()

    // 创建基于Cookie的存储引擎,secret11111参数为用于加密的密钥
    store := cookie.NewStore([]byte("secret11111"))
    // 设置Session中间件,参数mysession为Session的名称(此处亦为Cookie的名称)
    r.Use(sessions.Sessions("mysession", store))

    r.GET("/", func(c *gin.Context) {
        // 初始化Session对象
        session := sessions.Default(c)
        // 设置过期时间
        session.Options(sessions.Options{
            MaxAge: 3600 * 6,   // 6hrs
        })
        //设置Session
        session.Set("username", "Akira37")
        session.Save()

        c.JSON(200, gin.H{
            "msg": session.Get("username")
        })
    })

    r.GET("/user", func(c *gin.Context) {
        // 初始化Session对象
        session := sessions.Default(c)
        // 通过session.Get()读取Session值
        username := session.Get("username")

        c.JSON(200, gin.H{"username": username})
    })

    r.Run(":8000")
}

9.2 基于Redis存储Session

若想将Session数据保存到Redis中,只需将Session的存储引擎改为Redis即可。

额外引入github.com/gin-contrib/sessions/redis,通过redis.NewStore()函数创建基于Redis的存储引擎,参数说明如下:

  1. size:Redis最大空闲连接数
  2. network:通信协议,tcpudp
  3. address:Redis地址,格式为host:port
  4. password:Redis密码
  5. keyPairs:Session加密密钥,类型为[]bytes(同前述Cookie存储引擎)

其他函数使用方式与Cookie存储引擎基本一致,综合使用例如下:

package main

import (
    "github.com/gin-contrib/sessions"
    "github.com/gin-contrib/sessions/redis"
    "github.com/gin-gonic/gin"
)

func main() {
    r := gin.Default()

    // 初始化基于Redis的存储引擎
    store, _ := redis.NewStore(10, "tcp", "localhost:6379", "", []byte("secret1122"))
    r.Use(sessions.Sessions("mysession", store))

    r.GET("/", func(c *gin.Context) {
        session := sessions.Default(c)
        session.Set("username", "Teresa")
        session.Save()
        c.JSON(200, gin.H{
            "username": session.Get("username")
        })
    })

    r.GET("/user", func(c *gin.Context) {
        session := sessions.Default(c)
        username := session.Get("username")
        c.JSON(200, gin.H{
            "username": username})
    })

    r.Run(":8000")
}

《Go语言Web开发(1):Gin框架》有1条评论

发表评论