You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

763 lines
31 KiB
Markdown

2 years ago
# 24 | Web 服务Web 服务核心功能有哪些,如何实现?
你好,我是孔令飞。从今天开始,我们进入实战第三站:服务开发。在这个部分,我会讲解 IAM项目各个服务的构建方式帮助你掌握Go 开发阶段的各个技能点。
在Go项目开发中绝大部分情况下我们是在写能提供某种功能的后端服务这些功能以RPC API 接口或者RESTful API接口的形式对外提供能提供这两种API接口的服务也统称为Web服务。今天这一讲我就通过介绍RESTful API风格的Web服务来给你介绍下如何实现Web服务的核心功能。
那今天我们就来看下Web服务的核心功能有哪些以及如何开发这些功能。
## Web服务的核心功能
Web服务有很多功能为了便于你理解我将这些功能分成了基础功能和高级功能两大类并总结在了下面这张图中
![](https://static001.geekbang.org/resource/image/1a/2e/1a6d38450cdd0e115e505ab30113602e.jpg?wh=2248x1835)
下面,我就按图中的顺序,来串讲下这些功能。
要实现一个Web服务首先我们要选择通信协议和通信格式。在Go项目开发中有HTTP+JSON 和 gRPC+Protobuf两种组合可选。因为iam-apiserver主要提供的是REST风格的API接口所以选择的是HTTP+JSON组合。
**Web服务最核心的功能是路由匹配。**路由匹配其实就是根据`(HTTP方法, 请求路径)`匹配到处理这个请求的函数,最终由该函数处理这次请求,并返回结果,过程如下图所示:
![](https://static001.geekbang.org/resource/image/1f/9d/1f5yydeffb32732e7d0e23a0a9cd369d.jpg?wh=2248x975)
一次HTTP请求经过路由匹配最终将请求交由`Delete(c *gin.Context)`函数来处理。变量`c`中存放了这次请求的参数在Delete函数中我们可以进行参数解析、参数校验、逻辑处理最终返回结果。
对于大型系统可能会有很多个API接口API接口随着需求的更新迭代可能会有多个版本为了便于管理我们需要**对路由进行分组**。
有时候我们需要在一个服务进程中同时开启HTTP服务的80端口和HTTPS的443端口这样我们就可以做到对内的服务访问80端口简化服务访问复杂度对外的服务访问更为安全的HTTPS服务。显然我们没必要为相同功能启动多个服务进程所以这时候就需要Web服务能够支持**一进程多服务**的功能。
我们开发Web服务最核心的诉求是输入一些参数校验通过后进行业务逻辑处理然后返回结果。所以Web服务还应该能够进行**参数解析**、**参数校验**、**逻辑处理**、**返回结果**。这些都是Web服务的业务处理功能。
上面这些是Web服务的基本功能此外我们还需要支持一些高级功能。
在进行HTTP请求时经常需要针对每一次请求都设置一些通用的操作比如添加Header、添加RequestID、统计请求次数等这就要求我们的Web服务能够支持**中间件**特性。
为了保证系统安全,对于每一个请求,我们都需要进行**认证**。Web服务中通常有两种认证方式一种是基于用户名和密码一种是基于Token。认证通过之后就可以继续处理请求了。
为了方便定位和跟踪某一次请求,需要支持**RequestID**定位和跟踪RequestID主要是为了排障。
最后当前的软件架构中很多采用了前后端分离的架构。在前后端分离的架构中前端访问地址和后端访问地址往往是不同的浏览器为了安全会针对这种情况设置跨域请求所以Web服务需要能够处理浏览器的**跨域**请求。
到这里我就把Web服务的基础功能和高级功能串讲了一遍。当然上面只介绍了Web服务的核心功能还有很多其他的功能你可以通过学习[Gin的官方文档](https://github.com/gin-gonic/gin)来了解。
你可以看到Web服务有很多核心功能这些功能我们可以基于net/http包自己封装。但在实际的项目开发中 我们更多会选择使用基于net/http包进行封装的优秀开源Web框架。本实战项目选择了Gin框架。
接下来我们主要看下Gin框架是如何实现以上核心功能的这些功能我们在实际的开发中可以直接拿来使用。
## 为什么选择Gin框架
优秀的Web框架有很多我们为什么要选择Gin呢在回答这个问题之前我们先来看下选择Web框架时的关注点。
在选择Web框架时我们可以关注如下几点
* 路由功能;
* 是否具备middleware/filter能力
* HTTP 参数path、query、form、header、body解析和返回
* 性能和稳定性;
* 使用复杂度;
* 社区活跃度。
按 GitHub Star 数来排名,当前比较火的 Go Web 框架有 Gin、Beego、Echo、Revel 、Martini。经过调研我从中选择了Gin框架原因是Gin具有如下特性
* 轻量级,代码质量高,性能比较高;
* 项目目前很活跃,并有很多可用的 Middleware
* 作为一个 Web 框架,功能齐全,使用起来简单。
那接下来我就先详细介绍下Gin框架。
[Gin](https://github.com/gin-gonic/gin)是用Go语言编写的Web框架功能完善使用简单性能很高。Gin核心的路由功能是通过一个定制版的[HttpRouter](https://github.com/julienschmidt/httprouter)来实现的,具有很高的路由性能。
Gin有很多功能这里我给你列出了它的一些核心功能
* 支持HTTP方法GET、POST、PUT、PATCH、DELETE、OPTIONS。
* 支持不同位置的HTTP参数路径参数path、查询字符串参数query、表单参数form、HTTP头参数header、消息体参数body
* 支持HTTP路由和路由分组。
* 支持middleware和自定义middleware。
* 支持自定义Log。
* 支持binding和validation支持自定义validator。可以bind如下参数query、path、body、header、form。
* 支持重定向。
* 支持basic auth middleware。
* 支持自定义HTTP配置。
* 支持优雅关闭。
* 支持HTTP2。
* 支持设置和获取cookie。
## Gin是如何支持Web服务基础功能的
接下来我们先通过一个具体的例子看下Gin是如何支持Web服务基础功能的后面再详细介绍这些功能的用法。
我们创建一个webfeature目录用来存放示例代码。因为要演示HTTPS的用法所以需要创建证书文件。具体可以分为两步。
第一步,执行以下命令创建证书:
```
cat << 'EOF' > ca.pem
-----BEGIN CERTIFICATE-----
MIICSjCCAbOgAwIBAgIJAJHGGR4dGioHMA0GCSqGSIb3DQEBCwUAMFYxCzAJBgNV
BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX
aWRnaXRzIFB0eSBMdGQxDzANBgNVBAMTBnRlc3RjYTAeFw0xNDExMTEyMjMxMjla
Fw0yNDExMDgyMjMxMjlaMFYxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0
YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQxDzANBgNVBAMT
BnRlc3RjYTCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAwEDfBV5MYdlHVHJ7
+L4nxrZy7mBfAVXpOc5vMYztssUI7mL2/iYujiIXM+weZYNTEpLdjyJdu7R5gGUu
g1jSVK/EPHfc74O7AyZU34PNIP4Sh33N+/A5YexrNgJlPY+E3GdVYi4ldWJjgkAd
Qah2PH5ACLrIIC6tRka9hcaBlIECAwEAAaMgMB4wDAYDVR0TBAUwAwEB/zAOBgNV
HQ8BAf8EBAMCAgQwDQYJKoZIhvcNAQELBQADgYEAHzC7jdYlzAVmddi/gdAeKPau
sPBG/C2HCWqHzpCUHcKuvMzDVkY/MP2o6JIW2DBbY64bO/FceExhjcykgaYtCH/m
oIU63+CFOTtR7otyQAWHqXa7q4SbCDlG7DyRFxqG0txPtGvy12lgldA2+RgcigQG
Dfcog5wrJytaQ6UA0wE=
-----END CERTIFICATE-----
EOF
cat << 'EOF' > server.key
-----BEGIN PRIVATE KEY-----
MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAOHDFScoLCVJpYDD
M4HYtIdV6Ake/sMNaaKdODjDMsux/4tDydlumN+fm+AjPEK5GHhGn1BgzkWF+slf
3BxhrA/8dNsnunstVA7ZBgA/5qQxMfGAq4wHNVX77fBZOgp9VlSMVfyd9N8YwbBY
AckOeUQadTi2X1S6OgJXgQ0m3MWhAgMBAAECgYAn7qGnM2vbjJNBm0VZCkOkTIWm
V10okw7EPJrdL2mkre9NasghNXbE1y5zDshx5Nt3KsazKOxTT8d0Jwh/3KbaN+YY
tTCbKGW0pXDRBhwUHRcuRzScjli8Rih5UOCiZkhefUTcRb6xIhZJuQy71tjaSy0p
dHZRmYyBYO2YEQ8xoQJBAPrJPhMBkzmEYFtyIEqAxQ/o/A6E+E4w8i+KM7nQCK7q
K4JXzyXVAjLfyBZWHGM2uro/fjqPggGD6QH1qXCkI4MCQQDmdKeb2TrKRh5BY1LR
81aJGKcJ2XbcDu6wMZK4oqWbTX2KiYn9GB0woM6nSr/Y6iy1u145YzYxEV/iMwff
DJULAkB8B2MnyzOg0pNFJqBJuH29bKCcHa8gHJzqXhNO5lAlEbMK95p/P2Wi+4Hd
aiEIAF1BF326QJcvYKmwSmrORp85AkAlSNxRJ50OWrfMZnBgzVjDx3xG6KsFQVk2
ol6VhqL6dFgKUORFUWBvnKSyhjJxurlPEahV6oo6+A+mPhFY8eUvAkAZQyTdupP3
XEFQKctGz+9+gKkemDp7LBBMEMBXrGTLPhpEfcjv/7KPdnFHYmhYeBTBnuVmTVWe
F98XJ7tIFfJq
-----END PRIVATE KEY-----
EOF
cat << 'EOF' > server.pem
-----BEGIN CERTIFICATE-----
MIICnDCCAgWgAwIBAgIBBzANBgkqhkiG9w0BAQsFADBWMQswCQYDVQQGEwJBVTET
MBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50ZXJuZXQgV2lkZ2l0cyBQ
dHkgTHRkMQ8wDQYDVQQDEwZ0ZXN0Y2EwHhcNMTUxMTA0MDIyMDI0WhcNMjUxMTAx
MDIyMDI0WjBlMQswCQYDVQQGEwJVUzERMA8GA1UECBMISWxsaW5vaXMxEDAOBgNV
BAcTB0NoaWNhZ28xFTATBgNVBAoTDEV4YW1wbGUsIENvLjEaMBgGA1UEAxQRKi50
ZXN0Lmdvb2dsZS5jb20wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAOHDFSco
LCVJpYDDM4HYtIdV6Ake/sMNaaKdODjDMsux/4tDydlumN+fm+AjPEK5GHhGn1Bg
zkWF+slf3BxhrA/8dNsnunstVA7ZBgA/5qQxMfGAq4wHNVX77fBZOgp9VlSMVfyd
9N8YwbBYAckOeUQadTi2X1S6OgJXgQ0m3MWhAgMBAAGjazBpMAkGA1UdEwQCMAAw
CwYDVR0PBAQDAgXgME8GA1UdEQRIMEaCECoudGVzdC5nb29nbGUuZnKCGHdhdGVy
em9vaS50ZXN0Lmdvb2dsZS5iZYISKi50ZXN0LnlvdXR1YmUuY29thwTAqAEDMA0G
CSqGSIb3DQEBCwUAA4GBAJFXVifQNub1LUP4JlnX5lXNlo8FxZ2a12AFQs+bzoJ6
hM044EDjqyxUqSbVePK0ni3w1fHQB5rY9yYC5f8G7aqqTY1QOhoUk8ZTSTRpnkTh
y4jjdvTZeLDVBlueZUTDRmy2feY5aZIU18vFDK08dTG0A87pppuv1LNIR3loveU8
-----END CERTIFICATE-----
EOF
```
第二步创建main.go文件
```
package main
import (
"fmt"
"log"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/sync/errgroup"
)
type Product struct {
Username string `json:"username" binding:"required"`
Name string `json:"name" binding:"required"`
Category string `json:"category" binding:"required"`
Price int `json:"price" binding:"gte=0"`
Description string `json:"description"`
CreatedAt time.Time `json:"createdAt"`
}
type productHandler struct {
sync.RWMutex
products map[string]Product
}
func newProductHandler() *productHandler {
return &productHandler{
products: make(map[string]Product),
}
}
func (u *productHandler) Create(c *gin.Context) {
u.Lock()
defer u.Unlock()
// 1. 参数解析
var product Product
if err := c.ShouldBindJSON(&product); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 2. 参数校验
if _, ok := u.products[product.Name]; ok {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("product %s already exist", product.Name)})
return
}
product.CreatedAt = time.Now()
// 3. 逻辑处理
u.products[product.Name] = product
log.Printf("Register product %s success", product.Name)
// 4. 返回结果
c.JSON(http.StatusOK, product)
}
func (u *productHandler) Get(c *gin.Context) {
u.Lock()
defer u.Unlock()
product, ok := u.products[c.Param("name")]
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": fmt.Errorf("can not found product %s", c.Param("name"))})
return
}
c.JSON(http.StatusOK, product)
}
func router() http.Handler {
router := gin.Default()
productHandler := newProductHandler()
// 路由分组、中间件、认证
v1 := router.Group("/v1")
{
productv1 := v1.Group("/products")
{
// 路由匹配
productv1.POST("", productHandler.Create)
productv1.GET(":name", productHandler.Get)
}
}
return router
}
func main() {
var eg errgroup.Group
// 一进程多端口
insecureServer := &http.Server{
Addr: ":8080",
Handler: router(),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
secureServer := &http.Server{
Addr: ":8443",
Handler: router(),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
eg.Go(func() error {
err := insecureServer.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
return err
})
eg.Go(func() error {
err := secureServer.ListenAndServeTLS("server.pem", "server.key")
if err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
return err
})
if err := eg.Wait(); err != nil {
log.Fatal(err)
}
}
```
运行以上代码:
```
$ go run main.go
```
打开另外一个终端请求HTTP接口
```
# 创建产品
$ curl -XPOST -H"Content-Type: application/json" -d'{"username":"colin","name":"iphone12","category":"phone","price":8000,"description":"cannot afford"}' http://127.0.0.1:8080/v1/products
{"username":"colin","name":"iphone12","category":"phone","price":8000,"description":"cannot afford","createdAt":"2021-06-20T11:17:03.818065988+08:00"}
# 获取产品信息
$ curl -XGET http://127.0.0.1:8080/v1/products/iphone12
{"username":"colin","name":"iphone12","category":"phone","price":8000,"description":"cannot afford","createdAt":"2021-06-20T11:17:03.818065988+08:00"}
```
示例代码存放地址为[webfeature](https://github.com/marmotedu/gopractise-demo/tree/master/gin/webfeature)。
另外Gin项目仓库中也包含了很多使用示例如果你想详细了解可以参考 [gin examples](https://github.com/gin-gonic/examples)。
下面我来详细介绍下Gin是如何支持Web服务基础功能的。
### HTTP/HTTPS支持
因为Gin是基于net/http包封装的一个Web框架所以它天然就支持HTTP/HTTPS。在上述代码中通过以下方式开启一个HTTP服务
```
insecureServer := &http.Server{
Addr: ":8080",
Handler: router(),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
...
err := insecureServer.ListenAndServe()
```
通过以下方式开启一个HTTPS服务
```
secureServer := &http.Server{
Addr: ":8443",
Handler: router(),
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
...
err := secureServer.ListenAndServeTLS("server.pem", "server.key")
```
### JSON数据格式支持
Gin支持多种数据通信格式例如application/json、application/xml。可以通过`c.ShouldBindJSON`函数将Body中的JSON格式数据解析到指定的Struct中通过`c.JSON`函数返回JSON格式的数据。
### 路由匹配
Gin支持两种路由匹配规则。
**第一种匹配规则是精确匹配。**例如,路由为/products/:name匹配情况如下表所示
![](https://static001.geekbang.org/resource/image/11/df/11be05d7fe7f935e01725e2635f315df.jpg?wh=2248x1418)
**第二种匹配规则是模糊匹配。**例如,路由为/products/\*name匹配情况如下表所示
![](https://static001.geekbang.org/resource/image/b5/7b/b5ccd9924e53dd90a64af6002967b67b.jpg?wh=2248x1636)
### 路由分组
Gin通过Group函数实现了路由分组的功能。路由分组是一个非常常用的功能可以将相同版本的路由分为一组也可以将相同RESTful资源的路由分为一组。例如
```
v1 := router.Group("/v1", gin.BasicAuth(gin.Accounts{"foo": "bar", "colin": "colin404"}))
{
productv1 := v1.Group("/products")
{
// 路由匹配
productv1.POST("", productHandler.Create)
productv1.GET(":name", productHandler.Get)
}
orderv1 := v1.Group("/orders")
{
// 路由匹配
orderv1.POST("", orderHandler.Create)
orderv1.GET(":name", orderHandler.Get)
}
}
v2 := router.Group("/v2", gin.BasicAuth(gin.Accounts{"foo": "bar", "colin": "colin404"}))
{
productv2 := v2.Group("/products")
{
// 路由匹配
productv2.POST("", productHandler.Create)
productv2.GET(":name", productHandler.Get)
}
}
```
通过将路由分组,可以对相同分组的路由做统一处理。比如上面那个例子,我们可以通过代码
```
v1 := router.Group("/v1", gin.BasicAuth(gin.Accounts{"foo": "bar", "colin": "colin404"}))
```
给所有属于v1分组的路由都添加gin.BasicAuth中间件以实现认证功能。中间件和认证这里你先不用深究下面讲高级功能的时候会介绍到。
### 一进程多服务
我们可以通过以下方式实现一进程多服务:
```
var eg errgroup.Group
insecureServer := &http.Server{...}
secureServer := &http.Server{...}
eg.Go(func() error {
err := insecureServer.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
return err
})
eg.Go(func() error {
err := secureServer.ListenAndServeTLS("server.pem", "server.key")
if err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
return err
}
if err := eg.Wait(); err != nil {
log.Fatal(err)
})
```
上述代码实现了两个相同的服务分别监听在不同的端口。这里需要注意的是为了不阻塞启动第二个服务我们需要把ListenAndServe函数放在goroutine中执行并且调用eg.Wait()来阻塞程序进程从而让两个HTTP服务在goroutine中持续监听端口并提供服务。
### 参数解析、参数校验、逻辑处理、返回结果
此外Web服务还应该具有参数解析、参数校验、逻辑处理、返回结果4类功能因为这些功能联系紧密我们放在一起来说。
在productHandler的Create方法中我们通过`c.ShouldBindJSON`来解析参数接下来自己编写校验代码然后将product信息保存在内存中也就是业务逻辑处理最后通过`c.JSON`返回创建的product信息。代码如下
```
func (u *productHandler) Create(c *gin.Context) {
u.Lock()
defer u.Unlock()
// 1. 参数解析
var product Product
if err := c.ShouldBindJSON(&product); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 2. 参数校验
if _, ok := u.products[product.Name]; ok {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("product %s already exist", product.Name)})
return
}
product.CreatedAt = time.Now()
// 3. 逻辑处理
u.products[product.Name] = product
log.Printf("Register product %s success", product.Name)
// 4. 返回结果
c.JSON(http.StatusOK, product)
}
```
那这个时候你可能会问HTTP的请求参数可以存在不同的位置Gin是如何解析的呢这里我们先来看下HTTP有哪些参数类型。HTTP具有以下5种参数类型
* 路径参数path。例如`gin.Default().GET("/user/:name", nil)` name就是路径参数。
* 查询字符串参数query。例如`/welcome?firstname=Lingfei&lastname=Kong`firstname和lastname就是查询字符串参数。
* 表单参数form。例如`curl -X POST -F 'username=colin' -F 'password=colin1234' http://mydomain.com/login`username和password就是表单参数。
* HTTP头参数header。例如`curl -X POST -H 'Content-Type: application/json' -d '{"username":"colin","password":"colin1234"}' http://mydomain.com/login`Content-Type就是HTTP头参数。
* 消息体参数body。例如`curl -X POST -H 'Content-Type: application/json' -d '{"username":"colin","password":"colin1234"}' http://mydomain.com/login`username和password就是消息体参数。
Gin提供了一些函数来分别读取这些HTTP参数每种类别会提供两种函数一种函数可以直接读取某个参数的值另外一种函数会把同类HTTP参数绑定到一个Go结构体中。比如有如下路径参数
```
gin.Default().GET("/:name/:id", nil)
```
我们可以直接读取每个参数:
```
name := c.Param("name")
action := c.Param("action")
```
也可以将所有的路径参数,绑定到结构体中:
```
type Person struct {
ID string `uri:"id" binding:"required,uuid"`
Name string `uri:"name" binding:"required"`
}
if err := c.ShouldBindUri(&person); err != nil {
// normal code
return
}
```
Gin在绑定参数时是通过结构体的tag来判断要绑定哪类参数到结构体中的。这里要注意不同的HTTP参数有不同的结构体tag。
* 路径参数uri。
* 查询字符串参数form。
* 表单参数form。
* HTTP头参数header。
* 消息体参数会根据Content-Type自动选择使用json或者xml也可以调用ShouldBindJSON或者ShouldBindXML直接指定使用哪个tag。
针对每种参数类型Gin都有对应的函数来获取和绑定这些参数。这些函数都是基于如下两个函数进行封装的
1. ShouldBindWith(obj interface{}, b binding.Binding) error
非常重要的一个函数很多ShouldBindXXX函数底层都是调用ShouldBindWith函数来完成参数绑定的。该函数会根据传入的绑定引擎将参数绑定到传入的结构体指针中**如果绑定失败只返回错误内容但不终止HTTP请求。**ShouldBindWith支持多种绑定引擎例如 binding.JSON、binding.Query、binding.Uri、binding.Header等更详细的信息你可以参考 [binding.go](https://github.com/gin-gonic/gin/blob/v1.7.2/binding/binding.go#L72)。
2. MustBindWith(obj interface{}, b binding.Binding) error
这是另一个非常重要的函数很多BindXXX函数底层都是调用MustBindWith函数来完成参数绑定的。该函数会根据传入的绑定引擎将参数绑定到传入的结构体指针中**如果绑定失败返回错误并终止请求返回HTTP 400错误。**MustBindWith所支持的绑定引擎跟ShouldBindWith函数一样。
Gin基于ShouldBindWith和MustBindWith这两个函数又衍生出很多新的Bind函数。这些函数可以满足不同场景下获取HTTP参数的需求。Gin提供的函数可以获取5个类别的HTTP参数。
* 路径参数ShouldBindUri、BindUri
* 查询字符串参数ShouldBindQuery、BindQuery
* 表单参数ShouldBind
* HTTP头参数ShouldBindHeader、BindHeader
* 消息体参数ShouldBindJSON、BindJSON等。
每个类别的Bind函数详细信息你可以参考[Gin提供的Bind函数](https://github.com/marmotedu/geekbang-go/blob/master/Gin%E6%8F%90%E4%BE%9B%E7%9A%84Bind%E5%87%BD%E6%95%B0.md)。
这里要注意Gin并没有提供类似ShouldBindForm、BindForm这类函数来绑定表单参数但我们可以通过ShouldBind来绑定表单参数。当HTTP方法为GET时ShouldBind只绑定Query类型的参数当HTTP方法为POST时会先检查content-type是否是json或者xml如果不是则绑定Form类型的参数。
所以ShouldBind可以绑定Form类型的参数但前提是HTTP方法是POST并且content-type不是application/json、application/xml。
在Go项目开发中我建议使用ShouldBindXXX这样可以确保我们设置的HTTP ChainChain可以理解为一个HTTP请求的一系列处理插件能够继续被执行。
## Gin是如何支持Web服务高级功能的
上面介绍了Web服务的基础功能这里我再来介绍下高级功能。Web服务可以具备多个高级功能但比较核心的高级功能是中间件、认证、RequestID、跨域和优雅关停。
### 中间件
Gin支持中间件HTTP请求在转发到实际的处理函数之前会被一系列加载的中间件进行处理。在中间件中可以解析HTTP请求做一些逻辑处理例如跨域处理或者生成X-Request-ID并保存在context中以便追踪某个请求。处理完之后可以选择中断并返回这次请求也可以选择将请求继续转交给下一个中间件处理。当所有的中间件都处理完之后请求才会转给路由函数进行处理。具体流程如下图
![](https://static001.geekbang.org/resource/image/f0/80/f0783cb9ee8cffa969f846ebe8eae880.jpg?wh=2248x1655)
通过中间件,可以实现对所有请求都做统一的处理,提高开发效率,并使我们的代码更简洁。但是,因为所有的请求都需要经过中间件的处理,可能会增加请求延时。对于中间件特性,我有如下建议:
* 中间件做成可加载的,通过配置文件指定程序启动时加载哪些中间件。
* 只将一些通用的、必要的功能做成中间件。
* 在编写中间件时,一定要保证中间件的代码质量和性能。
在Gin中可以通过gin.Engine的Use方法来加载中间件。中间件可以加载到不同的位置上而且不同的位置作用范围也不同例如
```
router := gin.New()
router.Use(gin.Logger(), gin.Recovery()) // 中间件作用于所有的HTTP请求
v1 := router.Group("/v1").Use(gin.BasicAuth(gin.Accounts{"foo": "bar", "colin": "colin404"})) // 中间件作用于v1 group
v1.POST("/login", Login).Use(gin.BasicAuth(gin.Accounts{"foo": "bar", "colin": "colin404"})) //中间件只作用于/v1/login API接口
```
Gin框架本身支持了一些中间件。
* **gin.Logger()**Logger中间件会将日志写到gin.DefaultWritergin.DefaultWriter默认为 os.Stdout。
* **gin.Recovery()**Recovery中间件可以从任何panic恢复并且写入一个500状态码。
* **gin.CustomRecovery(handle gin.RecoveryFunc)**类似Recovery中间件但是在恢复时还会调用传入的handle方法进行处理。
* **gin.BasicAuth()**HTTP请求基本认证使用用户名和密码进行认证
另外Gin还支持自定义中间件。中间件其实是一个函数函数类型为gin.HandlerFuncHandlerFunc底层类型为func(\*Context)。如下是一个Logger中间件的实现
```
package main
import (
"log"
"time"
"github.com/gin-gonic/gin"
)
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
t := time.Now()
// 设置变量example
c.Set("example", "12345")
// 请求之前
c.Next()
// 请求之后
latency := time.Since(t)
log.Print(latency)
// 访问我们发送的状态
status := c.Writer.Status()
log.Println(status)
}
}
func main() {
r := gin.New()
r.Use(Logger())
r.GET("/test", func(c *gin.Context) {
example := c.MustGet("example").(string)
// it would print: "12345"
log.Println(example)
})
// Listen and serve on 0.0.0.0:8080
r.Run(":8080")
}
```
另外,还有很多开源的中间件可供我们选择,我把一些常用的总结在了表格里:
![](https://static001.geekbang.org/resource/image/67/10/67137697a09d9f37bd87a81bf322f510.jpg?wh=1832x1521)
### 认证、RequestID、跨域
认证、RequestID、跨域这三个高级功能都可以通过Gin的中间件来实现例如
```
router := gin.New()
// 认证
router.Use(gin.BasicAuth(gin.Accounts{"foo": "bar", "colin": "colin404"}))
// RequestID
router.Use(requestid.New(requestid.Config{
Generator: func() string {
return "test"
},
}))
// 跨域
// CORS for https://foo.com and https://github.com origins, allowing:
// - PUT and PATCH methods
// - Origin header
// - Credentials share
// - Preflight requests cached for 12 hours
router.Use(cors.New(cors.Config{
AllowOrigins: []string{"https://foo.com"},
AllowMethods: []string{"PUT", "PATCH"},
AllowHeaders: []string{"Origin"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
AllowOriginFunc: func(origin string) bool {
return origin == "https://github.com"
},
MaxAge: 12 * time.Hour,
}))
```
### 优雅关停
Go项目上线后我们还需要不断迭代来丰富项目功能、修复Bug等这也就意味着我们要不断地重启Go服务。对于HTTP服务来说如果访问量大重启服务的时候可能还有很多连接没有断开请求没有完成。如果这时候直接关闭服务这些连接会直接断掉请求异常终止这就会对用户体验和产品口碑造成很大影响。因此这种关闭方式不是一种优雅的关闭方式。
这时候我们期望HTTP服务可以在处理完所有请求后正常地关闭这些连接也就是优雅地关闭服务。我们有两种方法来优雅关闭HTTP服务分别是借助第三方的Go包和自己编码实现。
方法一借助第三方的Go包
如果使用第三方的Go包来实现优雅关闭目前用得比较多的包是[fvbock/endless](https://github.com/fvbock/endless)。我们可以使用fvbock/endless来替换掉net/http的ListenAndServe方法例如
```
router := gin.Default()
router.GET("/", handler)
// [...]
endless.ListenAndServe(":4242", router)
```
方法二:编码实现
借助第三方包的好处是可以稍微减少一些编码工作量但缺点是引入了一个新的依赖包因此我更倾向于自己编码实现。Go 1.8版本或者更新的版本http.Server内置的Shutdown方法已经实现了优雅关闭。下面是一个示例
```
// +build go1.8
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.GET("/", func(c *gin.Context) {
time.Sleep(5 * time.Second)
c.String(http.StatusOK, "Welcome Gin Server")
})
srv := &http.Server{
Addr: ":8080",
Handler: router,
}
// Initializing the server in a goroutine so that
// it won't block the graceful shutdown handling below
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
// Wait for interrupt signal to gracefully shutdown the server with
// a timeout of 5 seconds.
quit := make(chan os.Signal, 1)
// kill (no param) default send syscall.SIGTERM
// kill -2 is syscall.SIGINT
// kill -9 is syscall.SIGKILL but can't be catch, so don't need add it
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// The context is used to inform the server it has 5 seconds to finish
// the request it is currently handling
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("Server exiting")
}
```
上面的示例中需要把srv.ListenAndServe放在goroutine中执行这样才不会阻塞到srv.Shutdown函数。因为我们把srv.ListenAndServe放在了goroutine中所以需要一种可以让整个进程常驻的机制。
这里我们借助了有缓冲channel并且调用signal.Notify函数将该channel绑定到SIGINT、SIGTERM信号上。这样收到SIGINT、SIGTERM信号后quilt通道会被写入值从而结束阻塞状态程序继续运行执行srv.Shutdown(ctx)优雅关停HTTP服务。
## 总结
今天我们主要学习了Web服务的核心功能以及如何开发这些功能。在实际的项目开发中 我们一般会使用基于net/http包进行封装的优秀开源Web框架。
当前比较火的Go Web框架有 Gin、Beego、Echo、Revel、Martini。你可以根据需要进行选择。我比较推荐GinGin也是目前比较受欢迎的Web框架。Gin Web框架支持Web服务的很多基础功能例如 HTTP/HTTPS、JSON格式的数据、路由分组和匹配、一进程多服务等。
另外Gin还支持Web服务的一些高级功能例如 中间件、认证、RequestID、跨域和优雅关停等。
## 课后练习
1. 使用 Gin 框架编写一个简单的Web服务要求该Web服务可以解析参数、校验参数并进行一些简单的业务逻辑处理最终返回处理结果。欢迎在留言区分享你的成果或者遇到的问题。
2. 思考下如何给iam-apiserver的/healthz接口添加一个限流中间件用来限制请求/healthz的频率。
欢迎你在留言区与我交流讨论,我们下一讲见。