gin框架初了解

gin的路由

gin框架中采用的路由库是基于httprouter做的,httprouter会将所有路由规则构造成一颗前缀树

例如有root and as at cn com,这样子查询效率会比传统的KV模式要快

image.png

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
	// 1. 创建路由,默认用了Logger(), Recovery()两个中间件
	r:=gin.Default()
	// 2. 绑定路由规则GET
	r.GET("/user/:name/*action", func(c *gin.Context) {
		//取API参数
		name:=c.Param("name")
		ac:=c.Param("action")
		c.String(http.StatusOK,name+" is "+ac)
	})
	// 2. 取URL参数
	r.GET("/test", func(c *gin.Context) {
		//URL参数可以通过DefaultQuery()或Query()方法获取
		//DefaultQuery()若参数不存在,返回一个设定的默认值,Query()若不存在是返回空字符串
		name:=c.DefaultQuery("name","没有此变量")
		c.String(http.StatusOK,name)
	})
	// 3. 监听相应ip和端口,ip为空则为本地
	r.Run(":8000")

上传文件

 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
		//限制表单上传大小为8MBgin框架默认限制为32MB
	r.MaxMultipartMemory = 8 << 20
	// 可以获取客户端传来的表单参数
	r.POST("/form", func(c *gin.Context) {
		//表单参数可以设置当参数不存在时的默认值
		c.DefaultPostForm("type","alert")
		//接受有此变量的参数,若变量不存在则返回空字符串
		name:=c.PostForm("username")
		password:=c.PostForm("password")
		//多选框用PostFormArray()可以以切片的格式接受数据
		hobbys:=c.PostFormArray("hobby")
		c.String(http.StatusOK,fmt.Sprintf("name is %s,\nusername is %s,\nhobby is %v",name,password,hobbys))

		//在前端表单中,enctype为multipart/form-data则可以发送文件,同时也能发送文字
		//存单个图片
		//file, _ := c.FormFile("file")
		//// 存到项目根目录下,名字不变
		//c.SaveUploadedFile(file,file.Filename)
		//c.String(http.StatusOK,file.Filename+"upload success")

		//存多个图片,上传多个文件的话前端的input标签要加 multiple
		form, err := c.MultipartForm()
		if err != nil {
			c.String(http.StatusBadRequest,"upload file faild,err:"+err.Error())
		}
		files:=form.File["files"]
		for _,file:=range files{
			err := c.SaveUploadedFile(file, file.Filename)
			if err != nil {
				c.String(http.StatusBadRequest,"upload file faild,err:"+err.Error())
				return
			}
		}
		c.String(200,fmt.Sprintf("upload file success,%v file",len(files)))
	})

routes group(路由组)

路由组是为了管理一些相同的URL,进行规范

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	// 2. 集中处理请求,{}花括号去掉也不会报错,这是一个代码书写规范
	// 使用组的时候访问地址就是  address:port/v1/login
	v1:=r.Group("/v1")
	{
		v1.GET("/login",login)
		v1.GET("/submit",submit)
	}
	v2:=r.Group("/v2")
	{
		v2.GET("/add",add)
		v2.GET("/deltel",deltel)
	}

image.png

gin数据解析和绑定

json数据解析和绑定

 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
type Login struct {
	//binding:"required"为前端的必选字段,如果接受到的值是空值则会报错
	User string	`json:"user" binding:"required"`
	Password string	`json:"password" binding:"required"`
}
func main() {
	// 1. 创建路由
	r:=gin.Default()
	// 2. JSON绑定
	r.POST("/loginJson", func(c *gin.Context) {
		var login Login
		//request的body中的数据自动按照json格式解析并绑定到结构体,
		//当客户端传输的数据为json格式的内容时,结构体的tag要定义json格式,且绑定的方法为ShouldBindJSON(&login)
		err := c.ShouldBindJSON(&login)
		if err != nil {
			//返回错误信息,且返回的内容是一个json格式
			//gin.H封装了生成json数据的工具
			c.JSON(http.StatusBadRequest,gin.H{"error":err.Error()})
			return
		}
		c.JSON(http.StatusOK,login.User)
	})
	// 3. 监听相应ip和端口ip为空则为本地
	r.Run(":8000")
}

表单数据解析和绑定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type Login struct {
	User string	`form:"username"`
	Password string	`form:"password"`
}
func main() {
	// 1. 创建路由
	r:=gin.Default()
	// 2. JSON绑定
	r.POST("/loginForm", func(c *gin.Context) {
		var login Login
		//Bind()默认解析并绑定form格式
		//根据请求头中的content-type自动推断
		err := c.Bind(&login)
		if err != nil {
			//返回错误信息,且返回的内容是一个json格式
			//gin.H封装了生成json数据的工具
			c.JSON(http.StatusBadRequest,gin.H{"error":err.Error(),"status":"400"})
			return
		}
		c.JSON(http.StatusOK,login.User)
	})
	// 3. 监听相应ip和端口ip为空则为本地
	r.Run(":8000")
}
1
2
3
4
5
    <form action="http://127.0.0.1:8000/loginForm" method="post" enctype="multipart/form-data">
        用户名:<input type="text" name="username" ><br>
        密&nbsp&nbsp码:<input type="password" name="password">
        <input type="submit" value="登录">
    </form>

URI数据解析和绑定

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type Login struct {
	User string	`uri:"username"`
	Password string	`uri:"password"`
}

func main() {
	// 1. 创建路由
	r:=gin.Default()
	// 2. JSON绑定
	r.GET("/loginUri/:username/:password", func(c *gin.Context) {
		var login Login
		err := c.ShouldBindUri(&login)
		if err != nil {
			//返回错误信息,且返回的内容是一个json格式
			//gin.H封装了生成json数据的工具
			c.JSON(http.StatusBadRequest,gin.H{"error":err.Error(),"status":"400"})
			return
		}
		c.JSON(http.StatusOK,login.User+"----"+login.Password)
	})
	// 3. 监听相应ip和端口ip为空则为本地
	r.Run(":8000")
}

使用 curl http://127.0.0.1:8000/loginUri/root/admin 可以测试是否成功,root就是用户名,admin是密码

gin渲染

各种数据格式的响应

 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
        //1. json
	r.GET("/someJSON", func(c *gin.Context) {
		c.JSON(http.StatusOK,gin.H{"message":"someJSON","status":200})
	})
	//2. 结构体
	r.GET("/someStruct", func(c *gin.Context) {
		var msg =struct{
			Name string
			Message string
			Number int
		}{"root","message",123}
		c.JSON(http.StatusOK,msg)
	})
	//3. XML
	r.GET("/someXML", func(c *gin.Context) {
		c.XML(200,gin.H{"message":"abc"})
	})
	//4. YAML响应
	r.GET("/someYAML", func(c *gin.Context) {
		c.YAML(http.StatusOK,gin.H{"name":"zhangsan"})
	})
	//5. protobuf格式,谷歌开发的高效存储读取的工具
	r.GET("/someProtoBuf", func(c *gin.Context) {
		resp:=[]int64{int64(1),int64(9)}
		//定义数据
		label:="lable"
		//构建protobuf格式数据
		data:=&protoexample.Test{
			Label: &label,
			Reps: resp,
		}
		c.ProtoBuf(http.StatusOK,data)
	})

HTML模板渲染

  • gin支持加载HTML模板,然后根据模板参数进行配置并返回响应的数据,本质上就是字符串替换
  • LoadHTMLGlob()方法可以加载模板文件(加载路径下的所有或部分文件)
1
2
3
4
5
6
7
        //加载模板文件,r.LoadHTMLFiles()是匹配文件,r.LoadHTMLGlob()匹配路径
	r.LoadHTMLGlob("templates/*")
	r.GET("/index", func(c *gin.Context) {
		//根据文件名渲染
		//加载模板是加载的路径,替换的是文件中的某个变量
		c.HTML(200,"index.tmpl",gin.H{"title":"标题"})
	})

index.tmpl:

1
2
3
4
5
<html>
    <h1>
        {{.title}}
    </h1>
</html>

重定向

1
2
3
4
r.GET("/redirect", func(c *gin.Context) {
		//支持内部和外部重定向
		c.Redirect(http.StatusMovedPermanently,"http://www.baidu.com/")
	})

同步异步

  • goroutine机制可以方便的实现异步处理
  • 在启动新的goroutine时,不能使用原始上下文,必须使用它的只读副本(gin框架要求的)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
        //异步,瞬间结束,goroutine在后台继续执行
	r.GET("/async", func(c *gin.Context) {
		//必须先复制一个副本
		copyContext:=c.Copy()
		//需要注意的是这里开了一个goroutine在后台执行后,即使GET结束了依然在执行,因为GET不是主函数,即使结束了也不会影响到goroutine
		go func() {
			time.Sleep(3*time.Second)
			log.Println("异步执行:"+copyContext.Request.URL.Path)
		}()
		//可以开多个goroutine且共用一个context副本
		go func() {
			time.Sleep(3*time.Second)
			log.Println("异步执行:"+copyContext.Request.URL.Path)
		}()
	})
	//同步,需等待3S才会执行结束
	r.GET("/sync", func(c *gin.Context) {
			time.Sleep(3 * time.Second)
			log.Println("同步执行:" + c.Request.URL.Path)
	})

gin中间件

  • gin可以构建中间件,但它只对注册过的路由函数起作用
  • 对于分组路由,嵌套使用中间件可以限定中间件的作用范围
  • 中间件分为全局中间件,单个路由中间件和群组中间件
  • gin中间件必须是一个gin.HandlerFunc类型,中间件就类似于拦截器,例如作用于某些页面需要用户登录,先在中间件进行判断,判断成功后再进行真正的业务处理
  • 中间件在处理用户请求之前会执行,之后也会执行

全局中间件

所有请求都经过此中间件,可以定义多个中间件,执行顺序是依次顺序执行。

image.png

 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
func MiddleWare() gin.HandlerFunc{
	return func(c *gin.Context) {
		t:=time.Now()
		//GET之前执行
		fmt.Println("中间件执行了")
		//设置变量到context(本质上是把一个值设置到request里面去),通过get()
		c.Set("name","zhangsan")
		//执行函数(发送数据到request),Next()就是遍历一遍c.handlers,然后把所有的c.handlersGET/POST的第二个函数参数)都执行
		c.Next()

		//下面是在response响应之前GET执行之后进行的操作
		//当执行完c.Next()后,request之后GET之前的中间件代码就已经结束了,下面的就都是response之前要执行的代码
		status:=c.Writer.Status()
		fmt.Println(status)
		//统计从请求过来到发送response之间的耗时
		t1:=time.Since(t)
		fmt.Println("耗时:",t1)
	}
}
func main() {
	// 1. 创建路由,默认用了Logger(), Recovery()两个中间件
	r:=gin.Default()
	//注册中间件
	r.Use(MiddleWare())
	//{}是为了代码规范
	{
		r.GET("/middleware", func(c *gin.Context) {
			//取值,GET返回值有两个,一个获取到的值和一个是否值存在的布尔值
			value,_:=c.Get("name")
			c.JSON(200,gin.H{"name":value})
		})
	}

	// 3. 监听相应ip和端口ip为空则为本地
	r.Run(":8000")

当执行完c.Next()后,request之后GET之前的中间件代码就已经结束了,下面的就都是response之前要执行的代码

在中间件中可以设定三种代码: Next()、return、Abort()

  • Next(): 表示跳过当前中间件的剩余内容,去执行下一个中间件。当所有操作执行完之后,再以出栈的顺序执行剩余代码。类似于一个划分,Next()之前的代码是在客户端发起请求后服务器顺序执行的,之后的代码是在服务器response之后 客户端收到数据之前倒叙执行的。就像函数调用栈的形式,中间件1的Next()指向了中间件2,中间件2的Next()部分指向了具体的handlerfunc,当具体func执行完后才会执行中间件2的剩余代码,以此类推。

image.png

  • return:终止执行当前中间件的剩余内容,转而执行下一个中间件。且所有函数执行完后不会执行return之后的代码。如上图中把中间件2的next换成return后的打印顺序是1-2-3-5。 4在return之后,不会发生执行。如函数调用栈中某个子函数在执行一半时return,这个子函数会立即返回结束。
  • Abort(): 不再执行Abort()之后的中间件,在Abort()之前的代码执行完后开始向客户端响应,先执行Abort之下的代码,然后倒叙向客户端响应。依然如函数调用栈,Abort()的调用表示不会再调用任何子函数,执行完有Abort的当前函数后层层返回,执行上级中间件next()后的剩余代码。

image.png

局部中间件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
        //注册中间件
	r.Use(MiddleWare())
	//{}是为了代码规范
	{
		//根路由后面是定义的局部中间件
		r.GET("/middleware",MiddleWare(), func(c *gin.Context) {
			//取值,GET返回值有两个,一个获取到的值和一个是否值存在的布尔值
			value,_:=c.Get("name")
			c.JSON(200,gin.H{"name":value})
		})
	}

会话控制

Cookie是什么

  • HTTP是无状态协议,服务器不能记录浏览器的访问状态,也就是说服务器不能区分两次请求是否由同一个客户端发出
  • Cookie就是解决HTTP协议无状态的方案之一,它实际上就是服务器保存在浏览器上的一段信息,浏览器有了Cookie之后,每次向服务器发送请求都会同时将该信息发送给服务器,服务器收到请求后就可以根据该信息处理请求
  • Cookie由服务器创建,并发送给浏览器,最终由浏览器保存

Cookie的用途

  • 保持用户登录状态
  • 电商网站的购物车(京东),商品添加到cookie,无需登录,关闭浏览器重新打开依然存在

image.png

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
        // 服务端给客户端cookie
	r.GET("cookie", func(c *gin.Context) {
		//获取客户端是否携带了cookie
		cookie, err := c.Cookie("key_cookie")
		if err != nil {
			cookie ="Not Set"
			//给客户端设置cookie,随着response一起返回给浏览器
			//maxAge int,cookie的存在时间,单位为秒,超过时间cookie会过期,为0时会跟着会话结束而结束
			//path,cookie在浏览器中的所在目录,默认为“”,让浏览器自己分配
			//domain ,域名,通常设置为“”即可
			//secure,是否只能通过https访问
			//httpOnly,是否允许别人通过js获取自己的cookie
			c.SetCookie("key_cookie","value_cookie",60,"","",false,true)
		}
		//第一次是NotSet,刷新页面重新发送请求后则是value_cookie
		fmt.Println(cookie)
	})

示例:用中间件判断用户登录

 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
func cookieGin() gin.HandlerFunc{
	return func(c *gin.Context) {
		cookie, err := c.Cookie("name")
		if err == nil {
			if cookie == "zhangsan"{
				c.Next()
				return
			}
		}
		//返回错误
		c.JSON(http.StatusBadRequest,gin.H{"error":"StatusUnauthorized"})
		//c.Abort()的作用是不再调用后续的函数处理,直接response
		c.Abort()
		return
	}
}
func main() {
	r:=gin.Default()

	r.GET("/login", func(c *gin.Context) {
		c.SetCookie("name","zhangsan",60,"/","localhost",false,true)
	})
	//不进行login直接home就会被中间件拦截
	r.GET("/home", cookieGin(), func(c *gin.Context) {
		cookie, _ := c.Cookie("name")
		c.JSON(200,gin.H{"name":cookie})
	})

	r.Run(":8000")
}

Cookie的缺点

  • 不安全,明文的(加密了也不太安全)
  • 增加了带宽消耗
  • 可以被禁用(禁用之后功能就不再完善)
  • cookie有上限

session

session它可以弥补cookie的不足。session必须依赖于cookie才能使用。它在服务端生成一个SessionId,然后放在cookie里传给客户端,cookie本身改存到了服务端。

session常用的是临时session,用来记录用户的状态,当浏览器关闭时session也会消失。下次打开浏览器就需要重新存入。

在三次握手阶段,客户端第一次握手时请求服务器,服务器产生cookie。

然后在第二次握手之前服务器将这个cookie加密作为key,生成session为value,然后存在服务器设置的容器中。第二次握手服务器将cookie明文发给客户端,客户端决定是否存储。

第三次握手客户端发送cookie过去,服务器加密cookie并以这个cookie去查找是否有对应的session。

image.png

gin-contrib : gin框架的各种插件中间件,如session在gin中是默认不支持的,可以通过这个包中的session来给gin安装,使用中间件的方式。

https://github.com/gin-contrib/sessions session在gin中的使用

 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
package main

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

func main() {
  r := gin.Default()
  store, _ := redis.NewStore(10, "tcp", "localhost:6379", "", []byte("secret"))
  // "mysession"cookie的名称,即key,setCookie中的name等于的内容
  r.Use(sessions.Sessions("mysession", store))

  r.GET("/incr", func(c *gin.Context) {
    session := sessions.Default(c)
    var count int
    v := session.Get("count")
    if v == nil {
      count = 0
    } else {
      count = v.(int)
      count++
    }
    session.Set("count", count)
    session.Save()
    c.JSON(200, gin.H{"count": count})
  })
  r.Run(":8000")
}

image.png

比如存储到redis中,session会在redis中以session_开头加cookie的value的加密值来拼接一个redis的key: session_UHNOMHSNWKEJWSJIOPY2AQI7VCE3P2SMDV4LGSFNHJWPCJFM72ZQ,且该用户的所有的session.set操作都会放到这一个key中[无论有多少个session,都不会创建新的redis-key,仅在这一个key中进行拼接存储],获取时会先获取这里面的所有值,因为里面存的是key-value的格式,它获取所有值后再通过key来获取具体的session-value

总结下来session的流程是客户端请求,然后服务器返回cookie,同时将这个cookie的value值加密组装存到redis或其他容器中(不取name的原因是name在不同主机中值是相同的),然后返回cookie的name和value等给客户端,下次客户端访问就可以将这个cookie的name和value传给服务器,服务器通过将value加密然后查询是否有对应的session值

一定要注意的是每个浏览器(开无痕也会出现新的cookie)都会有一个唯一的cookie来存储它自己的session,因此在这个cookie使用session.Del(“key”) 只会删除当前cookie中存的session值,不会影响到其他的key-session。所以在实际应用中同一主机不同浏览器存储的session是不同的,因为两者是不同的cookie,redis中指向的是不同的key。如在Chrome中登录了淘宝,Edge中并不会直接显示你已经登录了,你需要重新登录,他们属于两个不同的session。

一定要记得,并不是每一个会话都有一个单独的session,session取决于这个cookie是否更改过,关闭此浏览器再重新打开这个网页后,虽然会话是新的,但是其cookie还是原本的值,在redis中依然存在。只有换一个浏览器(或开启无痕模式)或者 删除cookie让其重新生成新的cookie 才能在redis中生成新的session记录。

image.png

set和delete操作后一定要进行save操作。

options可以设置这个session存在的时间,当其为0时表示在会话结束时就会消失。重新打开浏览器session就会失效。

1
2
3
	store.Options(sessions.Options{
		MaxAge: 10,
	})

Session中间件开发

设计一个通用的session服务,支持内存存储和redis存储

session模块设计

  • 本质上k-v系统,通过key进行增删改查
  • session可以存储在内存或者redis中

https://github.com/XiaoNuoZ/session_cookie_Middleware

模板引擎的使用

在一些前后端不分离的Web架构中,我们通常需要在后端将一些数据渲染到HTML文档中,从而实现动态的网页(网页的布局和样式大致一样,但展示的内容并不一样)效果。

我们这里说的模板可以理解为事先定义好的HTML文档文件,模板渲染的作用机制可以简单理解为文本替换操作–使用相应的数据去替换HTML文档中事先准备好的标记。

Go语言内置了文本模板引擎 text/template和用于HTML文档的 html/template。它们的作用机制可以简单归纳如下:

  1. 模板文件通常定义为 .tmpl.tpl为后缀(也可以使用其他的后缀),必须使用 UTF8编码。
  2. 模板文件中使用 {{}}包裹和标识需要传入的数据。
  3. 传给模板这样的数据就可以通过点号(.)来访问,如果数据是复杂类型的数据,可以通过{ { .FieldName }}来访问它的字段。
  4. {{}}包裹的内容外,其他内容均不做修改原样输出。

Go语言模板引擎的使用可以分为三部分:定义模板文件、解析模板文件和模板渲染.

其中,定义模板文件时需要我们按照相关语法规则去编写,就是在html内容中加入{{ . }}来进行接收数据

上面定义好了模板文件之后,可以使用下面的常用方法去解析模板文件,得到模板对象:

1
2
3
func (t *Template) Parse(src string) (*Template, error)
func ParseFiles(filenames ...string) (*Template, error)
func ParseGlob(pattern string) (*Template, error)

渲染模板简单来说就是使用数据去填充模板。

1
2
func (t *Template) Execute(wr io.Writer, data interface{}) error
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data interface{}) error
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func sayHello(w http.ResponseWriter,r *http.Request){
	//解析模板
	t,_:=template.ParseFiles("./hello.html")
	//也可以使用t,_:=template.New("hello.html").ParseFiles("./hello.html")
	//一定要注意的就是New中的参数一定要和模板文件名字对应上,不然解析会失败
	//如果ParseFiles传了很多个文件,那么至少要和其中一个对应上

	//需要注意的是,结构体首字母必须大写才能在html中接收到,但是map不在乎大小写
	u1:=User{
		Name: "笑傩",
		Age: 18,
		Address: "四川",
	}
	m1:=map[string]interface{}{
		"Name": "笑傩",
		"Age": 18,
		"Address": "四川",
	}
	//渲染模板,通过嵌套的方法可以传过去多个数据
	t.Execute(w,map[string]interface{}{
		"u1":u1,
		"m1":m1,
	})
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<body>
    <p>Hello {{.u1.Name}}</p>
    <p>Age {{.u1.Age}}</p>
  
    <p>{{.m1.Name}}</p>
    <p>Age {{.m1.Age}}</p>

    //with可以设定一个局部作用域,在此作用域中.代表.m1
    {{with .m1}}
    <p>{{.Name}}</p>
    <p>Age {{.Age}}</p>
    {{end}}

     {{index .slice 2}} //取切片中索引为2的值
</body>

变量

还可以在模板中声明变量,用来保存传入模板的数据或其他语句生成的结果。具体语法如下:

1
2
{{ $v1 := 100}}
{{ $age := .m1.age }}

其中 $age是变量的名字,在后续的代码中就可以使用该变量了。

有时候我们在使用模板语法的时候会不可避免的引入一下空格或者换行符,这样模板最终渲染出来的内容可能就和我们想的不一样,这个时候可以使用 {{-语法去除模板内容左侧的所有空白符号, 使用 -}}去除模板内容右侧的所有空白符号。{{- .Name -}}, -要紧挨 {{}},同时与模板值之间需要使用空格分隔。

自定义函数

Go的模板支持自定义函数。

1
2
3
4
5
6
7
	//定义一个自定义的函数,返回值要么有一个,要么有两个且第二个返回值必须是error类型
	kua := func(name string,address string)(string,error) {
		return name+"测试"+address,nil
	}
	//解析模板,采用链式操作在Parse之前调用Funcs添加自定义的kua函数,把kua存入FuncMap中
	//添加自定义函数一定要在解析模板之前
	t,_:=template.New("hello.html").Funcs(template.FuncMap{"kua":kua}).ParseFiles("./hello.html")

然后在前端文件中则是通过 {{kua .Name .Address}} 来调用,模板也有管道符 {{ .Name | kua }},意思是将Name的值作为后面函数的参数

嵌套template

可以在template中嵌套其他的template。这个template可以是单独的文件,也可以是通过 define定义的template。

t.tmpl文件内容如下:

 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
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>tmpl test</title>
</head>
<body>
  
    <h1>测试嵌套template语法</h1>
    <hr>
    {{template "ul.tmpl"}}
    <hr>
    {{template "ol.tmpl"}}
</body>
</html>

{{ define "ol.tmpl"}}
<ol>
    <li>吃饭</li>
    <li>睡觉</li>
    <li>打豆豆</li>
</ol>
{{end}}

ul.tmpl文件内容如下:

1
2
3
4
5
<ul>
    <li>注释</li>
    <li>日志</li>
    <li>测试</li>
</ul>

路由处理函数如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func tmplDemo(w http.ResponseWriter, r *http.Request) {
	tmpl, err := template.ParseFiles("./t.tmpl", "./ul.tmpl")
	if err != nil {
		fmt.Println("create template failed, err:", err)
		return
	}
	user := UserInfo{
		Name:   "笑傩",
		Gender: "男",
		Age:    18,
	}
	tmpl.Execute(w, user)
}

注意:在解析模板时,被嵌套的模板一定要在后面解析,例如上面的示例中 t.tmpl模板中嵌套了 ul.tmpl,所以 ul.tmpl要在 t.tmpl后进行解析。

block定义块模板

1
{{block "name" pipeline}} T1 {{end}}

block是定义模板 {{define "name"}} T1 {{end}}和执行 {{template "name" pipeline}}缩写,典型的用法是定义一组根模板,然后通过在其中重新定义块模板进行自定义。

定义一个根模板 templates/base.tmpl,内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <title>Go Templates</title>
</head>
<body>
<div class="container-fluid">
    {{block "content" . }}{{end}}
</div>
</body>
</html>

然后定义一个 templates/index.tmpl,”继承”base.tmpl,然后重新定义区块 content,在区块中写入不同于其他网页的内容即可,这样就可以实现多个页面用同一个根模板,仅部分数据不同:

1
2
3
4
5
6
7
//一定要在最后加上在根模板定义的pipeline,表示从根模板把数据拿过来
{{template "base.tmpl" .}}

{{define "content"}}
    <div>Hello world!</div>
    <p>Hello {{ . }}</p>
{{end}}

然后使用 template.ParseGlob按照正则匹配规则解析模板文件,然后通过 ExecuteTemplate渲染指定的模板:

1
2
3
4
5
6
7
8
func index(w http.ResponseWriter, r *http.Request){
	//也可以使用template.ParseFiles("./templates/根模板","./templates/继承模板")
	//根模板一定要写在前面
	tmpl, _ := template.ParseGlob("templates/*.tmpl")
	//使用ExecuteTemplate的原因是前面解析了两个模板,以前使用Execute是因为只渲染一个模板,不需要指定也知道渲染哪个模板,现在有两个则必须要进行指定
	name := "笑傩"
	tmpl.ExecuteTemplate(w, "index.tmpl", name)
}

如果我们的模板名称冲突了,例如不同业务线下都定义了一个 index.tmpl模板,我们可以通过下面两种方法来解决。

  1. 在模板文件开头使用 {{define 模板名}}语句显式的为模板命名(不写默认将文件名作为模板名)。
  2. 可以把模板文件存放在 templates文件夹下面的不同目录中,然后使用 template.ParseGlob("templates/**/*.tmpl")解析模板。

修改默认的标识符

Go标准库的模板引擎使用的花括号 {{}}作为标识,而许多前端框架(如 VueAngularJS)也使用 {{}}作为标识符,所以当我们同时使用Go语言模板引擎和以上前端框架时就会出现冲突,这个时候我们需要修改标识符,修改前端的或者修改Go语言的。这里演示如何修改Go语言模板引擎默认的标识符:

1
2
template.New("test").Delims("{[", "]}").ParseFiles("./t.tmpl")
然后在模板中就可以通过   {[ .name ]} 来引用数据

text/template与html/template的区别

html/template针对的是需要返回HTML内容的场景,在模板渲染过程中会对一些有风险的内容进行转义,以此来防范跨站脚本攻击。比如在输入框中输入sql语句造成拖库或者死循环,html/template可以对内容进行转移,保证输出的html内容都是安全的。但是在某些场景下,我们如果相信用户输入的内容,不想转义的话,可以自行编写一个safe函数,手动返回一个 template.HTML类型的内容,template.HTML可以将用户输入的内容转成html

gin框架渲染tmpl模板

Gin框架中使用 LoadHTMLGlob() 加载文件夹或者 LoadHTMLFiles() 加载单一文件夹 来进行HTML模板渲染。Glob通过正则表达式进行匹配,templates/**/*表示templates下的任意文件夹中的任意文件,不能省略,它代表任意文件夹。 templates/*是表示templates下的任意文件,它加载不到该文件夹下的某个文件夹中的文件,必须加上**。

tmpl文件中最好在内容前加上define定义,避免在未来不同文件夹中有同名文件

1
2
3
{{define "posts/index.html"}}
	......
{{end}}
1
2
3
4
5
	r.LoadHTMLGlob("templates/**/*")
	r.GET("index", func(c *gin.Context){
		//第二个参数是defin定义的名字,如果没有则默认为文件名(即使是在二级目录templates/posts/下也只是仅有文件名,因此需要define定义)
		c.HTML(http.StatusOK,"index.html","zhangsan")
	})

自定义模板函数

定义一个不转义相应内容的 safe模板函数如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func main() {
	router := gin.Default()
	router.SetFuncMap(template.FuncMap{
		"safe": func(str string) template.HTML{
			return template.HTML(str)
		},
	})
	router.LoadHTMLFiles("./index.tmpl")

	router.GET("/index", func(c *gin.Context) {
		c.HTML(http.StatusOK, "index.tmpl", "<a href='https://baidu.com'>baidu</a>")
	})

	router.Run(":8080")
}

index.tmpl中使用定义好的 safe模板函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <title>修改模板引擎的标识符</title>
</head>
<body>
//此处通过管道将取到的数据作为safe函数的参数
<div>{{ . | safe }}</div>
</body>
</html>

静态文件处理

当我们渲染的HTML文件中引用了静态文件时,我们只需要按照以下方式在渲染页面前调用 gin.Static方法即可。Static方法要在加载模板文件之前调用。第一个参数为在html文件中引用的目录 href="/static/index.css",第二个为本地静态文件存放路径

1
2
3
4
5
6
func main() {
	r := gin.Default()
	r.Static("/static", "./statics")
	r.LoadHTMLGlob("templates/**/*")
	r.Run(":8080")
}

关于模板文件和静态文件的路径,需要根据公司/项目的要求进行设置。可以使用下面的函数获取当前执行程序的路径。

1
2
3
4
5
6
func getCurrentPath() string {
	if ex, err := os.Executable(); err == nil {
		return filepath.Dir(ex)
	}
	return "./"
}

JSON渲染、XML渲染和YAML渲染

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
	r.GET("/someJSON", func(c *gin.Context) {
		// 方式一:自己拼接JSON
		c.JSON(http.StatusOK, gin.H{"message": "Hello world!"})
		c.XML(http.StatusOK, gin.H{"message": "Hello world!"})
		c.YAML(http.StatusOK, gin.H{"message": "ok", "status": http.StatusOK})
	})
	r.GET("/moreJSON", func(c *gin.Context) {
		// 方法二:使用结构体
		type msg struct {
			Name    string `json:"user"`
			Message string
			Age     int
		}
		msg.Name = "小王子"
		msg.Message = "Hello world!"
		msg.Age = 18
		//返回的数据中Name在前端显示为user
		c.JSON(http.StatusOK, msg)
		c.XML(http.StatusOK, msg)
	})

获取参数

获取querystring参数

querystring指的是URL中 ?后面携带的参数,例如:/user/search?username=笑傩。 获取请求的querystring参数的方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
	r.GET("/user/search", func(c *gin.Context) {
		//没取到值则为第二个参数
		username := c.DefaultQuery("username", "小王子")
		//没取到username为零值
		username := c.Query("username")
		//没取到ok的值为false
		name,ok:=c.GetQuery("name")
		if !ok {
			name="no name"
		}
		//输出json结果给调用方
		c.JSON(http.StatusOK, gin.H{
			"username": username,
		})
	})

获取form参数

当前端请求的数据通过form表单提交时,例如向 /user/search发送一个POST请求,获取请求数据的方式如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
	r.POST("/user/search", func(c *gin.Context) {
		// DefaultPostForm取不到值时会返回指定的默认值,但是如果前端是输入框,则用户不填时默认为空字符串,表示永远无法返回默认值
		username:=c.DefaultPostForm("xxx","something")
		//getPostform取不到则返回false
		username,ok:=c.GetPostForm("xxx")
		if !ok{
			username="somebody"
		}
		username := c.PostForm("username")
		address := c.PostForm("address")
		//输出json结果给调用方
		c.JSON(http.StatusOK, gin.H{
			"message":  "ok",
			"username": username,
			"address":  address,
		})
	})

获取path参数

请求的参数通过URL路径传递,例如:/user/search/xiaoNuo/abc。 获取请求URL路径中的参数的方式如下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
	r.GET("/user/search/:username/:address", func(c *gin.Context) {
		//获取URI参数就只有一个Param方法,通常用在博客日期统计,如blog/19/03
		username := c.Param("username")
		address := c.Param("address")
		//输出json结果给调用方
		c.JSON(http.StatusOK, gin.H{
			"message":  "ok",
			"username": username,
			"address":  address,
		})
	})

参数绑定

为了能够更方便的获取请求相关参数,提高开发效率,可以基于请求的 Content-Type识别请求数据类型并利用反射机制自动提取请求中 QueryStringform表单JSONXML等参数到结构体中。 下面的示例代码演示了 .ShouldBind()强大的功能,它能够基于请求自动提取 JSONform表单QueryString类型的数据(一个方法提取多种类型),并把值绑定到指定的结构体对象。

 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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// Binding from JSON
type Login struct {
	//binding:"required"为前端的必选字段,如果接受到的值是空值则会报错,不加则表示该字段前端可以为空
	//通常只需要写一个form就可以同时获取到GET POST以及json格式的数据,但如果在form的基础上加了json的tag,则发往后端的json名必须和tag定义的相同,不加则以form为准
	//这个方法是通过反射实现,因此结构体名和字段名皆必须为大写
	User     string `form:"user" json:"user" binding:"required"`
	Password string `form:"password" json:"password" binding:"required"`
}

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

	// 绑定JSON的示例 
	router.POST("/loginJSON", func(c *gin.Context) {
		var login Login

		if err := c.ShouldBind(&login); err == nil {
			fmt.Printf("login info:%#v\n", login)
			c.JSON(http.StatusOK, gin.H{
				"user":     login.User,
				"password": login.Password,
			})
		} else {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		}
	})

	// 绑定form表单示例
	router.POST("/loginForm", func(c *gin.Context) {
		var login Login
		// ShouldBind()会根据请求的Content-Type自行选择绑定器
		if err := c.ShouldBind(&login); err == nil {
			c.JSON(http.StatusOK, gin.H{
				"user":     login.User,
				"password": login.Password,
			})
		} else {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		}
	})

	// 绑定QueryString示例 (/loginQuery?user=xiaonuo&password=123456)
	router.GET("/loginForm", func(c *gin.Context) {
		var login Login
		// ShouldBind()会根据请求的Content-Type自行选择绑定器
		if err := c.ShouldBind(&login); err == nil {
			c.JSON(http.StatusOK, gin.H{
				"user":     login.User,
				"password": login.Password,
			})
		} else {
			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		}
	})

	// Listen and serve on 0.0.0.0:8080
	router.Run(":8080")
}

ShouldBind会按照下面的顺序解析请求中的数据完成绑定:

  1. 如果是 GET 请求,只使用 Form 绑定引擎(query)。
  2. 如果是 POST 请求,首先检查 content-type 是否为 JSONXML,然后再使用 Formform-data)。

文件上传

单个文件上传

文件上传前端页面代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <title>上传文件示例</title>
</head>
<body>
<!--enctype="multipart/form-data":以二进制形式传输数据,传输文件必须要使用此参数 -->
<form action="/upload" method="post" enctype="multipart/form-data">
    <input type="file" name="f1">
    <input type="submit" value="上传">
</form>
</body>
</html>

后端gin框架部分代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
	// 处理multipart forms提交文件时默认的内存限制是32 MiB
	// 可以通过下面的方式修改
	// router.MaxMultipartMemory = 8 << 20  // 8 MiB
	router.POST("/upload", func(c *gin.Context) {
		//从请求中读取文件
		f,_:=c.FormFile("f1")
		//Join将任意数量的路径元素拼接成一条路径,用斜线将它们拼接。空元素将被忽略。如果都是空的则返回空字符串
		dst:=path.Join("./",f.Filename)
		//将读取到的文件保存到服务端
		c.SaveUploadedFile(f,dst)
		c.JSON(http.StatusOK, gin.H{
			"message": fmt.Sprintf("'%s' uploaded!", file.Filename),
		})
	})

多个文件上传

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
r.POST("/index", func(c *gin.Context) {
		//从请求中读取文件
		form,_:=c.MultipartForm()
		//根据input标签的name属性获取内容,一定要注意的是上传多个文件,其name属性要一致
		files:=form.File["f1"]
		for _,file:=range files{
			dst:=path.Join("./",file.Filename)
			c.SaveUploadedFile(file,dst)
		}
	})

重定向

HTTP重定向

HTTP 重定向很容易。 内部、外部重定向均支持。

1
2
3
4
	r.GET("/login", func(c *gin.Context) {
		//跳转到站外,URL也会改变。属于请求重定向
		c.Redirect(http.StatusMovedPermanently,"https://www.baidu.com")
	})

路由重定向

路由重定向,使用 HandleContext

1
2
3
4
5
6
	r.GET("/a", func(c *gin.Context) {
		//跳转到/b对应的路由处理函数
		//这种属于内部跳转,属于请求转发,网页上的URL始终不会改变的,依然是/a
		c.Request.URL.Path="/b"	//把请求的URL修改成/b
		r.HandleContext(c)	//继续进行后续的操作
	})

Gin路由

普通路由

1
2
3
r.GET("/index", func(c *gin.Context) {...})
r.GET("/login", func(c *gin.Context) {...})
r.POST("/login", func(c *gin.Context) {...})

此外,还有一个可以匹配所有请求方法的 Any方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
	//请求方法的大杂烩
	r.Any("/login", func(c *gin.Context) {
		switch c.Request.Method {
		//case可以是字符串也可以使用http包的常量
		case "GET":
			c.JSON(http.StatusOK,gin.H{"method":"GET"})
		case http.MethodPost:
			c.JSON(http.StatusOK,gin.H{"method":"POST"})
		}
	})

为没有配置处理函数的路由添加处理程序,默认情况下它返回404代码,下面的代码为没有匹配到路由的请求都返回 views/404.html页面。

1
2
3
r.NoRoute(func(c *gin.Context) {
		c.HTML(http.StatusNotFound, "views/404.html", nil)
	})

路由组

我们可以将拥有共同URL前缀的路由划分为一个路由组。习惯性一对 {}包裹同组的路由,这只是为了看着清晰,你用不用 {}包裹功能上没什么区别。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
func main() {
	r := gin.Default()
	//把公用的前缀提取出来,创建一个路由组
	userGroup := r.Group("/user")
	{
		userGroup.GET("/index", func(c *gin.Context) {...})
		userGroup.GET("/login", func(c *gin.Context) {...})
		userGroup.POST("/login", func(c *gin.Context) {...})

	}
	shopGroup := r.Group("/shop")
	{
		shopGroup.GET("/index", func(c *gin.Context) {...})
		shopGroup.GET("/cart", func(c *gin.Context) {...})
		shopGroup.POST("/checkout", func(c *gin.Context) {...})
	}
	r.Run()
}

路由组也是支持嵌套的,例如:

1
2
3
4
5
6
7
8
9
shopGroup := r.Group("/shop")
	{
		shopGroup.GET("/index", func(c *gin.Context) {...})
		shopGroup.GET("/cart", func(c *gin.Context) {...})
		shopGroup.POST("/checkout", func(c *gin.Context) {...})
		// 嵌套路由组,访问URL为/shop/xx/oo
		xx := shopGroup.Group("xx")
		xx.GET("/oo", func(c *gin.Context) {...})
	}

通常我们将路由分组用在划分业务逻辑或划分API版本时。

Gin框架中的路由使用的是httprouter这个库。

其基本原理就是构造一个路由地址的前缀树。

Gin中间件

Gin框架允许开发者在处理请求的过程中,加入用户自己的钩子(Hook)函数。这个钩子函数就叫中间件,中间件适合处理一些公共的业务逻辑,比如登录认证、权限校验、数据分页、记录日志、耗时统计等。它的作用范围是作用于中间件之后的路由,中间件之前的不会被波及。

定义中间件

Gin中的中间件必须是一个 gin.HandlerFunc类型。例如像下面的代码一样定义一个统计请求耗时的中间件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// StatCost 是一个统计耗时请求耗时的中间件
func StatCost() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		c.Set("name", "小王子") // 可以通过c.Set在请求上下文中设置值,后续的处理函数能够取到该值c.Get()
		//中间件执行完后默认会继续执行对应的处理函数,但当需要对响应做修改时就需要c.Next()临时调用执行函数,然后回来继续执行中间件
		//如果有多个中间件存在,Next的作用是执行下一个中间件函数
		//(若下一个中间件中也有next,则继续向后执行),当下一个是处理函数,则是执行处理函数。
		//遵循先执行后退出的运行原则

		c.Next()	//调用后续的处理函数

		//c.Abort() 	//阻止调用后续的处理函数,自身执行完后直接返回上一级,没有上一级则直接返回response

		//下面是response之前需要执行的代码
		cost:=time.Since(start)
		log.Println(cost)
	}
}

如果用中间件来进行登录判断,则可以通过if判断是否登录,是则next(),不是则abort()。千万不能在不是时使用return。return只是退出当前函数,但是下一个函数依然会继续执行

注册中间件

在gin框架中,我们可以为每个路由添加任意数量的中间件。

为全局路由注册

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func main() {
	// 新建一个没有任何默认中间件的路由
	r := gin.New()
	//注册全局中间件
	r.Use(StatCost())

	r.GET("/test", func(c *gin.Context) {
		name := c.MustGet("name").(string) // 从上下文取值
		log.Println(name)
		c.JSON(http.StatusOK, gin.H{
			"message": "Hello world!",
		})
	})
	r.Run()
}

为某个路由单独注册

1
2
3
4
5
6
7
8
// 给/test2路由单独注册中间件StatCost()(可注册多个)
	r.GET("/test2", StatCost(), func(c *gin.Context) {
		name := c.MustGet("name").(string) // 从上下文取值
		log.Println(name)
		c.JSON(http.StatusOK, gin.H{
			"message": "Hello world!",
		})
	})

为路由组注册中间件

为路由组注册中间件有以下两种写法。

写法1:

1
2
3
4
5
shopGroup := r.Group("/shop", StatCost())
{
    shopGroup.GET("/index", func(c *gin.Context) {...})
    ...
}

写法2:

1
2
3
4
5
6
shopGroup := r.Group("/shop")
shopGroup.Use(StatCost())
{
    shopGroup.GET("/index", func(c *gin.Context) {...})
    ...
}

中间件注意事项

gin默认中间件

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

  • Logger中间件将日志写入 gin.DefaultWriter,即使配置了 GIN_MODE=release
  • Recovery中间件会recover任何 panic。如果有panic的话,会写入500响应码。

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

gin中间件中使用goroutine

当在中间件或 handler中启动新的 goroutine时,不能使用原始的上下文(c *gin.Context),必须使用其只读副本 c.Copy()。避免并发不安全的问题

运行多个服务

我们可以在多个端口启动服务,例如:

 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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package main

import (
	"log"
	"net/http"
	"time"

	"github.com/gin-gonic/gin"
	"golang.org/x/sync/errgroup"
)

var (
	g errgroup.Group
)

func router01() http.Handler {
	e := gin.New()
	e.Use(gin.Recovery())
	e.GET("/", func(c *gin.Context) {
		c.JSON(
			http.StatusOK,
			gin.H{
				"code":  http.StatusOK,
				"error": "Welcome server 01",
			},
		)
	})

	return e
}

func router02() http.Handler {
	e := gin.New()
	e.Use(gin.Recovery())
	e.GET("/", func(c *gin.Context) {
		c.JSON(
			http.StatusOK,
			gin.H{
				"code":  http.StatusOK,
				"error": "Welcome server 02",
			},
		)
	})

	return e
}

func main() {
	server01 := &http.Server{
		Addr:         ":8080",
		Handler:      router01(),
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	server02 := &http.Server{
		Addr:         ":8081",
		Handler:      router02(),
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
	}
   // 借助errgroup.Group或者自行开启两个goroutine分别启动两个服务
	g.Go(func() error {
		return server01.ListenAndServe()
	})

	g.Go(func() error {
		return server02.ListenAndServe()
	})

	if err := g.Wait(); err != nil {
		log.Fatal(err)
	}
}
Licensed under CC BY-NC-SA 4.0