Echo实战
Q7nl1s admin

Hello World

服务器

server.go

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

import (
"net/http"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

func main() {
// Echo instance
e := echo.New()

// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())

// Route => handler
e.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "Hello, World!\n")
})

// Start server
e.Logger.Fatal(e.Start(":1323"))
}

客户端

Echo_10


Auto TLS

这个例子演示如何自动从 Let’s Encrypt 获得 TLS 证书。 Echo#StartAutoTLS 接受一个接听 443 端口的网络地址。类似 <DOMAIN>:443 这样。

如果没有错误,访问 https://<DOMAIN> ,可以看到一个 TLS 加密的欢迎界面。

  • 为了增加安全性,你应该在自动TLS管理器中指定主机策略
  • 缓存证书以避免速率限制的问题(https://letsencrypt.org/docs/rate-limits)
  • 为了将HTTP流量重定向到HTTPS,你可以使用重定向中间件

服务器

server.go

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

import (
"crypto/tls"
"golang.org/x/crypto/acme"
"net/http"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"golang.org/x/crypto/acme/autocert"
)

func main() {
e := echo.New()
// e.AutoTLSManager.HostPolicy = autocert.HostWhitelist("<DOMAIN>")
// Cache certificates to avoid issues with rate limits (https://letsencrypt.org/docs/rate-limits)
e.AutoTLSManager.Cache = autocert.DirCache("/var/www/.cache")
e.Use(middleware.Recover())
e.Use(middleware.Logger())
e.GET("/", func(c echo.Context) error {
return c.HTML(http.StatusOK, `
<h1>Welcome to Echo!</h1>
<h3>TLS certificates automatically installed from Let's Encrypt :)</h3>
`)
})

e.Logger.Fatal(e.StartAutoTLS(":443"))
}

func customHTTPServer() {
e := echo.New()
e.Use(middleware.Recover())
e.Use(middleware.Logger())
e.GET("/", func(c echo.Context) error {
return c.HTML(http.StatusOK, `
<h1>Welcome to Echo!</h1>
<h3>TLS certificates automatically installed from Let's Encrypt :)</h3>
`)
})

autoTLSManager := autocert.Manager{
Prompt: autocert.AcceptTOS,
// Cache certificates to avoid issues with rate limits (https://letsencrypt.org/docs/rate-limits)
Cache: autocert.DirCache("/var/www/.cache"),
//HostPolicy: autocert.HostWhitelist("<DOMAIN>"),
}
s := http.Server{
Addr: ":443",
Handler: e, // set Echo as handler
TLSConfig: &tls.Config{
//Certificates: nil, // <-- s.ListenAndServeTLS will populate this field
GetCertificate: autoTLSManager.GetCertificate,
NextProtos: []string{acme.ALPNProto},
},
//ReadTimeout: 30 * time.Second, // use custom timeouts
}
if err := s.ListenAndServeTLS("", ""); err != http.ErrServerClosed {
e.Logger.Fatal(err)
}
}

CRUD

服务端

server.go

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
package main

import (
"net/http"
"strconv"
"sync"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

type (
user struct {
ID int `json:"id"`
Name string `json:"name"`
}
)

var (
users = map[int]*user{}
seq = 1
lock = sync.Mutex{}
)

//----------
// Handlers
//----------

func createUser(c echo.Context) error {
// 防止竞争条件发生(防止一个 id 被多次创建)
lock.Lock()
defer lock.Unlock()
u := &user{
ID: seq,
}
if err := c.Bind(u); err != nil {
return err
}
users[u.ID] = u
seq++
return c.JSON(http.StatusCreated, u)
}

func getUser(c echo.Context) error {
// 防止竞争条件发生(防止一个 user 被获取时的状态不在此时刻)
lock.Lock()
defer lock.Unlock()
id, _ := strconv.Atoi(c.Param("id"))
return c.JSON(http.StatusOK, users[id])
}

func updateUser(c echo.Context) error {
// 防止竞争条件发生(一次访问只更新一次)
lock.Lock()
defer lock.Unlock()
u := new(user)
if err := c.Bind(u); err != nil {
return err
}
id, _ := strconv.Atoi(c.Param("id"))
users[id].Name = u.Name
return c.JSON(http.StatusOK, users[id])
}

func deleteUser(c echo.Context) error {
// 防止竞争条件发生(防止一个 user 被同时多次删除)
lock.Lock()
defer lock.Unlock()
id, _ := strconv.Atoi(c.Param("id"))
delete(users, id)
return c.NoContent(http.StatusNoContent)
}

func getAllUsers(c echo.Context) error {
// 防止竞争条件发生(防止一次响应返回在此状态之后的结果)
lock.Lock()
defer lock.Unlock()
return c.JSON(http.StatusOK, users)
}

func main() {
e := echo.New()

// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())

// Routes
e.GET("/users", getAllUsers)
e.POST("/users", createUser)
e.GET("/users/:id", getUser)
e.PUT("/users/:id", updateUser)
e.DELETE("/users/:id", deleteUser)

// Start server
e.Logger.Fatal(e.Start(":1323"))
}

客户端

curl

注:curl 是一个命令行工具,用来请求 Web 服务器。它的名字就是客户端(client)的 URL 工具的意思。

使用教程请到官网进行查看:curl

创建 User

1
2
3
4
curl -X POST \
-H 'Content-Type: application/json' \
-d '{"name":"Joe Smith"}' \
localhost:1323/users

Response

1
2
3
4
{
"id": 1,
"name": "Joe Smith"
}

获取 User

1
curl localhost:1323/users/1

Response

1
2
3
4
{
"id": 1,
"name": "Joe Smith"
}

更新 User

1
2
3
4
curl -X PUT \
-H 'Content-Type: application/json' \
-d '{"name":"Joe"}' \
localhost:1323/users/1

Response

1
2
3
4
{
"id": 1,
"name": "Joe"
}

删除 User

1
curl -X DELETE localhost:1323/users/1

Response

NoContent - 204

接口测试

根据路由定义发送 POST 请求的 JSON 信息,创建 user

Echo_11

可以得到响应结果

Echo_12

用 PUT 请求对数据进行更新,返回更新结果

Echo_13

调用 DELETE 请求删除数据,可以看到返回空内容

Echo_14

再尝试访问 ID 为 1 的页面,可以看到返回为 null ,说明 users[1] 不存在(被删除或者未创建,在此处是被删除),可以通过 PUT 请求更新数据,使 users[1] 重新有值

Echo_15

访问 127.0.0.1:1323/users 响应返回 users 内的所有数据

Echo_16

注:每次通过 GET 请求创建 user 时,user 总数 seq 都会自动加 1 ,在通过 DELETE 请求删除某个已创建的 user 时,seq 并不会减少(也就是 user 的 ID 值不会改变),例如当前 users 最大的 ID 为 5 ,如果对 127.0.0.1:1323/users/5 进行 DELETE 请求,并再次用 GET 请求新创建一个 user ,那么这个新创建的 user 的 ID 是 6 ,不会因为经过一次 DELETE 就向前延递,seq 的值也为 6。


CORS

跨源资源共享 (CORS)(或通俗地译为跨域资源共享)是一种基于 HTTP 头的机制,该机制通过允许服务器标示除了它自己以外的其它 origin(域,协议和端口),使得浏览器允许这些 origin 访问加载自己的资源。跨源资源共享还通过一种机制来检查服务器是否会允许要发送的真实请求,该机制通过浏览器发起一个到服务器托管的跨源资源的”预检”请求。在预检中,浏览器发送的头中标示有 HTTP 方法和真实请求中会用到的头。

使用被允许的 Origins

server.go

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

import (
"net/http"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

var (
users = []string{"Joe", "Veer", "Zion"}
)

func getUsers(c echo.Context) error {
return c.JSON(http.StatusOK, users)
}

func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())

// CORS default
// Allows requests from any origin wth GET, HEAD, PUT, POST or DELETE method.
// e.Use(middleware.CORS())

// CORS restricted
// Allows requests from any `https://labstack.com` or `https://labstack.net` origin
// wth GET, PUT, POST or DELETE method.
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"https://labstack.com", "https://labstack.net"},
AllowMethods: []string{http.MethodGet, http.MethodPut, http.MethodPost, http.MethodDelete},
}))

e.GET("/api/users", getUsers)

e.Logger.Fatal(e.Start(":1323"))
}

自定义函数来允许 Origins

server.go

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

import (
"net/http"
"regexp"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

var (
users = []string{"Joe", "Veer", "Zion"}
)

func getUsers(c echo.Context) error {
return c.JSON(http.StatusOK, users)
}

// allowOrigin takes the origin as an argument and returns true if the origin
// is allowed or false otherwise.
func allowOrigin(origin string) (bool, error) {
// In this example we use a regular expression but we can imagine various
// kind of custom logic. For example, an external datasource could be used
// to maintain the list of allowed origins.
return regexp.MatchString(`^https:\/\/labstack\.(net|com)$`, origin)
}

func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())

// CORS restricted with a custom function to allow origins
// and with the GET, PUT, POST or DELETE methods allowed.
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOriginFunc: allowOrigin,
AllowMethods: []string{http.MethodGet, http.MethodPut, http.MethodPost, http.MethodDelete},
}))

e.GET("/api/users", getUsers)

e.Logger.Fatal(e.Start(":1323"))
}

HTTP2

HTTP/2 (原本的名字是 HTTP/2.0) 是万维网使用的 HTTP 网络协议的第二个主要版本。HTTP/2 提供了更快的速度和更好的用户体验。

特性

  • 使用二进制格式传输数据,而不是文本。使得在解析和优化扩展上更为方便。
  • 多路复用,所有的请求都是通过一个 TCP 连接并发完成。
  • 对消息头采用 HPACK 进行压缩传输,能够节省消息头占用的网络的流量。
  • Server Push:服务端能够更快的把资源推送给客户端。

怎样运行 HTTP2 和 HTTPS 服务?

Step 1

生成一个自签名的 X.509 TLS 证书(HTTP/2 需要 TLS 才能运行)

1
go run $GOROOT/src/crypto/tls/generate_cert.go --host localhost

上面的命令会生一个cert.pemkey.pem 文件。

这里只是展示使用,所以我们用了自签名的证书,正式环境建议去 CA申请证书。

Step 2

创建一个仅将请求信息输出到客户机的控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
e.GET("/request", func(c echo.Context) error {
req := c.Request()
format := `
<code>
Protocol: %s<br>
Host: %s<br>
Remote Address: %s<br>
Method: %s<br>
Path: %s<br>
</code>
`
return c.HTML(http.StatusOK, fmt.Sprintf(format, req.Proto, req.Host, req.RemoteAddr, req.Method, req.URL.Path))
})

Step 3

使用 cert.pemkey.pem 启动TLS服务器

1
2
3
if err := e.StartTLS(":1323", "cert.pem", "key.pem"); err != http.ErrServerClosed {
log.Fatal(err)
}

或者使用自定义的HTTP服务器和您自己的TLSConfig

1
2
3
4
5
6
7
8
9
10
11
s := http.Server{
Addr: ":8443",
Handler: e, // set Echo as handler
TLSConfig: &tls.Config{
//Certificates: nil, // <-- s.ListenAndServeTLS will populate this field
},
//ReadTimeout: 30 * time.Second, // use custom timeouts
}
if err := s.ListenAndServeTLS("cert.pem", "key.pem"); err != http.ErrServerClosed {
log.Fatal(err)
}

Step 4

启动服务器并到 https://localhost:1323/request 下查看以下输出

1
2
3
4
5
Protocol: HTTP/2.0
Host: localhost:1323
Remote Address: [::1]:60288
Method: GET
Path: /

Source Code

server.go

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

import (
"fmt"
"net/http"

"github.com/labstack/echo/v4"
)

func main() {
e := echo.New()
e.GET("/request", func(c echo.Context) error {
req := c.Request()
format := `
<code>
Protocol: %s<br>
Host: %s<br>
Remote Address: %s<br>
Method: %s<br>
Path: %s<br>
</code>
`
return c.HTML(http.StatusOK, fmt.Sprintf(format, req.Proto, req.Host, req.RemoteAddr, req.Method, req.URL.Path))
})
e.Logger.Fatal(e.StartTLS(":1323", "cert.pem", "key.pem"))
}

最后

客户端

Echo_17

最初使用 postman 尝试访问得到错误响应(即无法验证第一个证书)

Echo_18

再次尝试使用浏览器访问,成功得到返回的响应结果,因为证书无法验证,所以浏览器提示不安全。

Echo_19

在控制台看到几次请求(包括未成功和成功的 Logger 信息)


中间件

怎样自己写一个中间件?

比如有以下需求

  • 通过该中间件去统计请求数目、状态和时间。
  • 中间件向响应写入自定义 Server 标头。

之前在学习 Echo 入门的时候提到过一个统计总请求数的程序,现在让我们对其进行优化和功能的增加。

服务端

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
package main

import (
"fmt"
"net/http"
"strconv"
"sync"
"time"

"github.com/labstack/echo/v4"
)

type (
Stats struct {
Uptime time.Time `json:"uptime"`
RequestCount uint64 `json:"requestCount"`
Statuses map[string]int `json:"statuses"`
mutex sync.RWMutex
}
)

// Stats 的构造函数
func NewStats() *Stats {
return &Stats{
Uptime: time.Now(),
Statuses: map[string]int{},
}
}

// Process is the middleware function.
func (s *Stats) Process(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if err := next(c); err != nil {
c.Error(err)
}
s.mutex.Lock()
defer s.mutex.Unlock()
// 请求数加一
// fmt.Println(s.RequestCount)
s.RequestCount++
// fmt.Println(s.RequestCount)
status := strconv.Itoa(c.Response().Status)
// 对应的响应状态加一
s.Statuses[status]++
return nil
}
}

// Handle is the endpoint to get stats.
func (s *Stats) Handle(c echo.Context) error {
s.mutex.RLock()
defer s.mutex.RUnlock()
// fmt.Println(s.RequestCount)
return c.JSON(http.StatusOK, s)
}

// ServerHeader middleware adds a `Server` header to the response.
func ServerHeader(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderServer, "Echo/3.0")
return next(c)
}
}

func main() {
e := echo.New()

// Debug mode
e.Debug = true

//-------------------
// Custom middleware
//-------------------
// Stats
s := NewStats()
e.Use(s.Process)
e.GET("/stats", s.Handle) // Endpoint to get stats

// Server header
e.Use(ServerHeader)

// Handler
e.GET("/", func(c echo.Context) error {
// fmt.Println(s.RequestCount)
return c.String(http.StatusOK, "Hello, World!")
})

// Start server
e.Logger.Fatal(e.Start(":1323"))
}

客户端

响应头

1
2
3
4
Content-Length:122
Content-Type:application/json; charset=utf-8
Date:Thu, 14 Apr 2016 20:31:46 GMT
Server:Echo/3.0

响应体

1
2
3
4
5
6
7
8
{
"uptime": "2016-04-14T13:28:48.486548936-07:00",
"requestCount": 5,
"statuses": {
"200": 4,
"404": 1
}
}

让我们在本地进行一次尝试

Echo_20

第一次访问 localhost:1323/stats ,看到响应结果中的 requestCount 的值为 0,且 statuses 体为空,这与我们所预想的有所不同,在外面看来,此次访问已经成功,requestCount 应该为 1,且 statuses 应该为 “statuses”: { “200”: 1 } 。

Echo_21

让我们再次进行访问,可以看到此次访问后 requestCount 加了 1 并且 statuses 也确实为 “statuses”: { “200”: 1 } 了,这说明访问计数是生效的,那我们不仅要问一下,为什么第一次访问不为 requestCount 不为 1 且 statuses 为空。

让我们修改下服务器代码,将 Process 中间件改为如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Process is the middleware function.
func (s *Stats) Process(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if err := next(c); err != nil {
c.Error(err)
}
s.mutex.Lock()
defer s.mutex.Unlock()
// 请求数加一
// 打印出此时的 s.RequestCount
fmt.Println(s.RequestCount)
s.RequestCount++
// 打印出此时的 s.RequestCount
fmt.Println(s.RequestCount)
status := strconv.Itoa(c.Response().Status)
// 对应的响应状态加一
s.Statuses[status]++
return nil
}
}

当我们运行程序,并且向服务器进行一次请求后

Echo_22

可以看到程序打印出 0 1 ,这与我们所想的是一样的,即每经过一次访问,中间件 Process 就将 s.RequestCount 的值加一,但是为什么服务器响应的值却是 0 呢。

我们再将 Handle 控制器修改为以下

1
2
3
4
5
6
7
// Handle is the endpoint to get stats.
func (s *Stats) Handle(c echo.Context) error {
s.mutex.RLock()
defer s.mutex.RUnlock()
fmt.Println(s.RequestCount)
return c.JSON(http.StatusOK, s)
}

当我们再次运行程序,并且向服务器进行一次请求后

Echo_23

可以看到程序依次打印出 0 0 1 ,这说明了什么?这说明了对 Handle 控制器的调用时是在 Process 中间件以前的,所以它返回的响应结果才为 1 和 空。那么为什么会这样呢?让我们回到中间件的定义:

在echo框架中中间件(Middleware)指的是可以拦截http请求-响应生命周期的特殊函数,在请求-响应生命周期中可以注册多个中间件,每个中间件执行不同的功能,一个中间执行完再轮到下一个中间件执行。

由于中间件的作用是拦截 http 请求,而上面的案例又明确地告诉我们中间件的执行是在一次请求之后的,即不是在路由的控制器被执行之前先执行的,那么我们不妨大胆的猜测,中间件的执行是在一次请求之后的,即在控制器执行之后的。那么一切都说得通了😸

让我们将上述程序修改到最初的模样,也就是无需调用 fmt 包的模样

Echo_24

让我们先对 localhost:1323/ 进行一次访问,看到响应结果 Hello, World!

Echo_25

再尝试访问一个 localhost:1323/ 下的不存在的路由 localhost:1323/s ,看到响应结果为 404 Not FoundEcho_26

最后尝试访问 localhost:1323/stats ,响应返回至今的请求数,以及成功响应的次数,根据上例我们可以得知,此次响应的结果不包含在内,所以再此次响应之前就已经有了两次请求记录,并且一次为响应状态为 200,一次响应状态为 404,这恰好是我们前两次访问的结果。


流式响应

概念

响应式流(Reactive Streams)是以带非阻塞背压方式处理异步数据流的标准,提供一组最小化的接口,方法和协议来描述必要的操作和实体。

要解决的问题:

系统之间高并发的大量数据流交互通常采用异步的发布-订阅模式。数据由发布者推送给订阅者的过程中,容易产生的一个问题是,当发布者即生产者产生的数据速度远远大于订阅者即消费者的消费速度时,消费者会承受巨大的资源压力(pressure)而有可能崩溃。

解决原理:

为了解决以上问题,数据流的速度需要被控制,即流量控制(flow control),以防止快速的数据流不会压垮目标。因此需要反压即背压(back pressure),生产者和消费者之间需要通过实现一种背压机制来互操作。实现这种背压机制要求是异步非阻塞的,如果是同步阻塞的,消费者在处理数据时生产者必须等待,会产生性能问题。

解决方法:

响应式流(Reactive Streams)通过定义一组实体,接口和互操作方法,给出了实现非阻塞背压的标准。第三方遵循这个标准来实现具体的解决方案,常见的有Reactor,RxJava,Akka Streams,Ratpack等。

原理

  • 当数据产生的时候发送数据
  • 使用分块传输编码(Chunked transfer encoding)的流式 JOSN 响应。

服务端

server.go

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

import (
"encoding/json"
"net/http"
"time"

"github.com/labstack/echo/v4"
)

type (
Geolocation struct {
Altitude float64
Latitude float64
Longitude float64
}
)

var (
locations = []Geolocation{
{-97, 37.819929, -122.478255},
{1899, 39.096849, -120.032351},
{2619, 37.865101, -119.538329},
{42, 33.812092, -117.918974},
{15, 37.77493, -122.419416},
}
)

func main() {
e := echo.New()
e.GET("/", func(c echo.Context) error {
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
c.Response().WriteHeader(http.StatusOK)

enc := json.NewEncoder(c.Response())
for _, l := range locations {
if err := enc.Encode(l); err != nil {
return err
}
c.Response().Flush()
time.Sleep(1 * time.Second)
}
return nil
})
e.Logger.Fatal(e.Start(":1323"))
}

客户端

1
$ curl localhost:1323

输出

1
2
3
4
5
{"Altitude":-97,"Latitude":37.819929,"Longitude":-122.478255}
{"Altitude":1899,"Latitude":39.096849,"Longitude":-120.032351}
{"Altitude":2619,"Latitude":37.865101,"Longitude":-119.538329}
{"Altitude":42,"Latitude":33.812092,"Longitude":-117.918974}
{"Altitude":15,"Latitude":37.77493,"Longitude":-122.419416}

一次请求的响应

Echo_27

可以看到由于 time.Sleep(1 * time.Second) 共执行了五次的作用整个响应的时间为 5.06s 。

注:上述程序中的生产者 locations 在程序开始时就已经发布完了所有资源(数据),而 time.Sleep(1 * time.Second) 仅仅是针对消费者(即客户端)的,它不会影响生产者的再生产,而是对消费者的接收做了限制,即每秒只接受一条记录,这种背压机制是异步非阻塞的。


WebSocket

概念

3.1 WebSocket 诞生背景

早期,很多网站为了实现推送技术,所用的技术都是轮询(也叫短轮询)。轮询是指由浏览器每隔一段时间向服务器发出 HTTP 请求,然后服务器返回最新的数据给客户端。

常见的轮询方式分为轮询与长轮询,它们的区别如下图所示:

Echo_28

为了更加直观感受轮询与长轮询之间的区别,我们来看一下具体的代码:

Echo_29

这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而 HTTP 请求与响应可能会包含较长的头部,其中真正有效的数据可能只是很小的一部分,所以这样会消耗很多带宽资源。

PS:关于短轮询、长轮询技术的前世今身,可以详细读这两篇:《新手入门贴:史上最全Web端即时通讯技术原理详解》、《Web端即时通讯技术盘点:短轮询、Comet、Websocket、SSE》。

比较新的轮询技术是 Comet。这种技术虽然可以实现双向通信,但仍然需要反复发出请求。而且在 Comet 中普遍采用的 HTTP 长连接也会消耗服务器资源。

在这种情况下,HTML5 定义了 WebSocket 协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。

Websocket 使用 ws 或 wss 的统一资源标志符(URI),其中 wss 表示使用了 TLS 的 Websocket。

如:

ws://echo.websocket.org

wss://echo.websocket.org

3.2 WebSocket 简介

WebSocket 是一种网络传输协议,可在单个 TCP 连接上进行全双工通信,位于 OSI 模型的应用层。WebSocket 协议在 2011 年由 IETF 标准化为 RFC 6455,后由 RFC 7936 补充规范。

WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。

介绍完轮询和 WebSocket 的相关内容之后,接下来用一张图看一下 XHR Polling(短轮询) 与 WebSocket 之间的区别。

XHR Polling与 WebSocket 之间的区别如下图所示:

Echo_31

3.3 WebSocket 优点

普遍认为,WebSocket的优点有如下几点:

  • 1)较少的控制开销:在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小;
  • 2)更强的实时性:由于协议是全双工的,所以服务器可以随时主动给客户端下发数据。相对于 HTTP 请求需要等待客户端发起请求服务端才能响应,延迟明显更少;
  • 3)保持连接状态:与 HTTP 不同的是,WebSocket 需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息;
  • 4)更好的二进制支持:WebSocket 定义了二进制帧,相对 HTTP,可以更轻松地处理二进制内容;
  • 5)可以支持扩展:WebSocket 定义了扩展,用户可以扩展协议、实现部分自定义的子协议。

下面介绍 WebSocket 在 Echo 中的实现

使用 net 库的 WebSocket

服务端

server.go

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

import (
"fmt"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"golang.org/x/net/websocket"
)

func hello(c echo.Context) error {
// 创建
websocket.Handler(func(ws *websocket.Conn) {
defer ws.Close()
for {
// Write
err := websocket.Message.Send(ws, "Hello, Client!")
if err != nil {
c.Logger().Error(err)
}

// Read
msg := ""
err = websocket.Message.Receive(ws, &msg)
if err != nil {
c.Logger().Error(err)
}
fmt.Printf("%s\n", msg)
}
}).ServeHTTP(c.Response(), c.Request())
return nil
}

func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// 注意此处的静态资源地址,这与客户端的处理是绑定的,若将此处的"/"修改为其它地址,客户端连接逻辑就要修改
e.Static("/", "../public")
e.GET("/ws", hello)
e.Logger.Fatal(e.Start(":1323"))
}

使用 gorilla 的 WebSocket

服务端

server.go

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

import (
"fmt"

"github.com/gorilla/websocket"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

var (
upgrader = websocket.Upgrader{}
)

func hello(c echo.Context) error {
ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err
}
defer ws.Close()

for {
// Write
err := ws.WriteMessage(websocket.TextMessage, []byte("Hello, Client!"))
if err != nil {
c.Logger().Error(err)
}

// Read
_, msg, err := ws.ReadMessage()
if err != nil {
c.Logger().Error(err)
}
fmt.Printf("%s\n", msg)
}
}

func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
// 注意此处的静态资源地址,这与客户端的处理是绑定的,若将此处的"/"修改为其它地址,客户端连接逻辑就要修改
e.Static("/", "../public")
e.GET("/ws", hello)
e.Logger.Fatal(e.Start(":1323"))
}

客户端

index.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
40
41
42
43
44
45
46
47
48
<!doctype html>
<html lang="en">

<head>
<meta charset="utf-8">
<title>WebSocket</title>
</head>

<body>
<p id="output"></p>

<script>
var loc = window.location;
var uri = 'ws:';

if (loc.protocol === 'https:') {
uri = 'wss:';
}
uri += '//' + loc.host;
uri += loc.pathname + 'ws';
// 如访问 http://localhost:1323/ ,
// 其自动绑定为 http://localhost:1323/ws ,即与服务器连接
// 因为服务器设置了 Static中间件 ,e.Static("/", "../public")
// 如果我们访问 http://localhost:1323/ 这个url路径,
// 实际上就是访问 http://localhost:1323../,
// 而 http://localhost:1323../ 自动匹配其目录下唯一的 html 客户端,于是产生连接
// 此时 uri 为 ws://localhost:1323/ws

// 在客户端创建一个WebSocket
ws = new WebSocket(uri)

ws.onopen = function() {
console.log('Connected')
}

ws.onmessage = function(evt) {
var out = document.getElementById('output');
out.innerHTML += evt.data + '<br>';
}

// 每隔一秒向服务器端推送数据
setInterval(function() {
ws.send('Hello, Server!');
}, 1000);
</script>
</body>

</html>

输出示例

Client

1
2
3
4
5
Hello, Client!
Hello, Client!
Hello, Client!
Hello, Client!
Hello, Client!

Server

1
2
3
4
5
Hello, Server!
Hello, Server!
Hello, Server!
Hello, Server!
Hello, Server!

一个 demo

我们在项目的父目录下创建一个同级目录 pubilc 再创建客户端 index.html

此时大致的目录结构如下(有删减)

1
2
3
4
5
6
7
8
├───public
│ └───index.html
└───learnecho
├───.hintrc
├───go.mod
├───go.sum
├───learnecho.exe
└───server.go

启动服务器,并在浏览器访问 http://localhost:1323/

Echo_32

可以看到客户端和服务器之间已经建立连接了,并且可以互相主动推送数据。

若我们想修改静态资源 url 的前缀,则要修改服务器和客户端两部分的代码。

server.go

修改静态资源地址

1
2
3
e.Static("/", "../public")
->
e.Static("/haha", "../public")

index.html

修改 uri

1
2
3
uri += loc.pathname + 'ws';
->
uri += '/ws';

若只想更改客户端的地址,则只需要修改服务器代码,例如我们想要将客户端放在当前项目的 public 目录下

server.go

1
2
3
e.Static("/", "../public")
->
e.Static("/", "./public")

无需再修改客户端代码

注:在本例下进行请求的时候,请求地址的末必须和静态地址前缀严格一致,例如:定义 e.Static(“/“, “./public”),则请求地址一定为 http://localhost:1323/ ,不可为 http://localhost:1323/index.html 。因为本例中客户端是根据静态资源地址的前缀来关联服务端的。当然,你想将 uri += loc.pathname + 'ws';修改为 uri += '/ws'; 也是可以的。

放一个可以在本地进行 websocket 测试的 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
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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
<!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">
<title>本地websocket测试</title>
<meta name="robots" content="all" />
<meta name="keywords" content="本地,websocket,测试工具" />
<meta name="description" content="本地,websocket,测试工具" />
<style>
.btn-group{
display: inline-block;
}
</style>
</head>
<body>
<input type='text' value='ws://localhost:3000/api/ws' class="form-control" style='width:390px;display:inline'
id='wsaddr' />
<div class="btn-group" >
<button type="button" class="btn btn-default" onclick='addsocket();'>连接</button>
<button type="button" class="btn btn-default" onclick='closesocket();'>断开</button>
<button type="button" class="btn btn-default" onclick='$("#wsaddr").val("")'>清空</button>
</div>
<div class="row">
<div id="output" style="border:1px solid #ccc;height:365px;overflow: auto;margin: 20px 0;"></div>
<input type="text" id='message' class="form-control" style='width:810px' placeholder="待发信息" onkeydown="en(event);">
<span class="input-group-btn">
<button class="btn btn-default" type="button" onclick="doSend();">发送</button>
</span>
</div>
</div>
</body>

<script crossorigin="anonymous" integrity="sha384-LVoNJ6yst/aLxKvxwp6s2GAabqPczfWh6xzm38S/YtjUyZ+3aTKOnD/OJVGYLZDl" src="https://lib.baomitu.com/jquery/3.5.0/jquery.min.js"></script>
<script language="javascript" type="text/javascript">
function formatDate(now) {
var year = now.getFullYear();
var month = now.getMonth() + 1;
var date = now.getDate();
var hour = now.getHours();
var minute = now.getMinutes();
var second = now.getSeconds();
return year + "-" + (month = month < 10 ? ("0" + month) : month) + "-" + (date = date < 10 ? ("0" + date) : date) +
" " + (hour = hour < 10 ? ("0" + hour) : hour) + ":" + (minute = minute < 10 ? ("0" + minute) : minute) + ":" + (
second = second < 10 ? ("0" + second) : second);
}
var output;
var websocket;

function init() {
output = document.getElementById("output");
testWebSocket();
}

function addsocket() {
var wsaddr = $("#wsaddr").val();
if (wsaddr == '') {
alert("请填写websocket的地址");
return false;
}
StartWebSocket(wsaddr);
}

function closesocket() {
websocket.close();
}

function StartWebSocket(wsUri) {
websocket = new WebSocket(wsUri);
websocket.onopen = function(evt) {
onOpen(evt)
};
websocket.onclose = function(evt) {
onClose(evt)
};
websocket.onmessage = function(evt) {
onMessage(evt)
};
websocket.onerror = function(evt) {
onError(evt)
};
}

function onOpen(evt) {
writeToScreen("<span style='color:red'>连接成功,现在你可以发送信息啦!!!</span>");
}

function onClose(evt) {
writeToScreen("<span style='color:red'>websocket连接已断开!!!</span>");
websocket.close();
}

function onMessage(evt) {
writeToScreen('<span style="color:blue">服务端回应&nbsp;' + formatDate(new Date()) + '</span><br/><span class="bubble">' +
evt.data + '</span>');
}

function onError(evt) {
writeToScreen('<span style="color: red;">发生错误:</span> ' + evt.data);
}

function doSend() {
var message = $("#message").val();
if (message == '') {
alert("请先填写发送信息");
$("#message").focus();
return false;
}
if (typeof websocket === "undefined") {
alert("websocket还没有连接,或者连接失败,请检测");
return false;
}
if (websocket.readyState == 3) {
alert("websocket已经关闭,请重新连接");
return false;
}
console.log(websocket);
$("#message").val('');
writeToScreen('<span style="color:green">你发送的信息&nbsp;' + formatDate(new Date()) + '</span><br/>' + message);
websocket.send(message);
}

function writeToScreen(message) {
var div = "<div class='newmessage'>" + message + "</div>";
var d = $("#output");
var d = d[0];
var doScroll = d.scrollTop == d.scrollHeight - d.clientHeight;
$("#output").append(div);
if (doScroll) {
d.scrollTop = d.scrollHeight - d.clientHeight;
}
}


function en(event) {
var evt = evt ? evt : (window.event ? window.event : null);
if (evt.keyCode == 13) {
doSend()
}
}
</script>

</html>

JSONP

JSONP 是一个能够被跨域访问资源的方法。

服务端

server.go

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

import (
"math/rand"
"net/http"
"time"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())

e.Static("/", "JSONP示例")

// JSONP
e.GET("/jsonp", func(c echo.Context) error {
callback := c.QueryParam("callback")
var content struct {
Response string `json:"response"`
Timestamp time.Time `json:"timestamp"`
Random int `json:"random"`
}
content.Response = "Sent via JSONP"
content.Timestamp = time.Now().UTC()
content.Random = rand.Intn(1000)
return c.JSONP(http.StatusOK, callback, &content)
})

// Start server
e.Logger.Fatal(e.Start(":1323"))
}

客户端

index.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
<!DOCTYPE html>
<html>

<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
<title>JSONP</title>
<script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/1/jquery.min.js"></script>
<script type="text/javascript">
var host_prefix = 'http://localhost:1323';
$(document).ready(function() {
// JSONP version - add 'callback=?' to the URL - fetch the JSONP response to the request
$("#jsonp-button").click(function(e) {
e.preventDefault();
// The only difference on the client end is the addition of 'callback=?' to the URL
var url = host_prefix + '/jsonp?callback=?';
$.getJSON(url, function(jsonp) {
console.log(jsonp);
$("#jsonp-response").html(JSON.stringify(jsonp, null, 2));
});
});
});
</script>

</head>

<body>
<div class="container" style="margin-top: 50px;">
<input type="button" class="btn btn-primary btn-lg" id="jsonp-button" value="Get JSONP response">
<p>
<pre id="jsonp-response"></pre>
</p>
</div>
</body>

</html>

启动服务器,在浏览器进行一次请求,Get JSONP response 后获得跨域资源

Echo_33


文件上传

上传单个文件

服务端

server.go

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

import (
"fmt"
"io"
"net/http"
"os"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

func upload(c echo.Context) error {
// Read form fields
name := c.FormValue("name")
email := c.FormValue("email")

//-----------
// Read file
//-----------

// Source
file, err := c.FormFile("file")
if err != nil {
return err
}
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()

// Destination
// 在当前程序所在目录下创建文件,内容为空,文件名与提交的文件的文件名一致
dst, err := os.Create(file.Filename)
if err != nil {
return err
}
defer dst.Close()

// Copy
// 复制上传文件的内容到新创建的文件
if _, err = io.Copy(dst, src); err != nil {
return err
}

return c.HTML(http.StatusOK, fmt.Sprintf("<p>File %s uploaded successfully with fields name=%s and email=%s.</p>", file.Filename, name, email))
}

func main() {
e := echo.New()

e.Use(middleware.Logger())
e.Use(middleware.Recover())

e.Static("/", "文件上传示例")
e.POST("/upload", upload)

e.Logger.Fatal(e.Start(":1323"))
}

客户端

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Single file upload</title>
</head>
<body>
<h1>Upload single file with fields</h1>

<!-- form表单自动提交到/upload下 -->
<!-- 当前请求地址: http://localhost:1323/ -->
<form action="/upload" method="post" enctype="multipart/form-data">
Name: <input type="text" name="name"><br>
Email: <input type="email" name="email"><br>
Files: <input type="file" name="file"><br><br>
<input type="submit" value="Submit">
</form>
</body>
</html>

上传多个文件

服务端

server.go

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

import (
"fmt"
"io"
"net/http"
"os"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

func upload(c echo.Context) error {
// Read form fields
name := c.FormValue("name")
email := c.FormValue("email")

//------------
// Read files
//------------

// Multipart form
form, err := c.MultipartForm()
if err != nil {
return err
}
files := form.File["files"]

for _, file := range files {
// Source
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()

// Destination
dst, err := os.Create(file.Filename)
if err != nil {
return err
}
defer dst.Close()

// Copy
if _, err = io.Copy(dst, src); err != nil {
return err
}

}

return c.HTML(http.StatusOK, fmt.Sprintf("<p>Uploaded successfully %d files with fields name=%s and email=%s.</p>", len(files), name, email))
}

func main() {
e := echo.New()

e.Use(middleware.Logger())
e.Use(middleware.Recover())

e.Static("/", "文件上传示例")
e.POST("/upload", upload)

e.Logger.Fatal(e.Start(":1323"))
}

客户端

index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Multiple file upload</title>
</head>
<body>
<h1>Upload multiple files with fields</h1>

<!-- form表单自动提交到/upload下 -->
<!-- 当前请求地址: http://localhost:1323/ -->
<form action="/upload" method="post" enctype="multipart/form-data">
Name: <input type="text" name="name"><br>
Email: <input type="email" name="email"><br>
<!-- multiple属性表示可以多选 -->
Files: <input type="file" name="files" multiple><br><br>
<input type="submit" value="Submit">
</form>
</body>
</html>

示例(单个文件)

请求 http://localhost:1323/

Echo_34

填好表单后选择要上传的文件,再 Submit

Echo_35

可以看到文件被成功上传,并且表单中的 name 和 email 值也被成功获取。

Echo_36

在服务端所在的目录下,可以看到文件被成功创建并复制。

若先在服务端所在目录下创建一个空的文件 login.png

Echo_37

再尝试上传一个用内容的同名文件 login.png

Echo_38

我们再次查看原来的空文件

Echo_39

发现已经有了和上传的文件一样的内容


子域名

客户端

server.go

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
75
76
77
78
79
80
81
82
package main

import (
"net/http"

"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

type (
Host struct {
// func (*echo.Echo).Any(path string, handler echo.HandlerFunc, middleware ...echo.MiddlewareFunc) []*echo.Route
Echo *echo.Echo
}
)

func main() {
// Hosts
hosts := map[string]*Host{}

//-----
// API
//-----

api := echo.New()
api.Use(middleware.Logger())
api.Use(middleware.Recover())

hosts["api.localhost:1323"] = &Host{api}

api.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "API")
})

//------
// Blog
//------

blog := echo.New()
blog.Use(middleware.Logger())
blog.Use(middleware.Recover())

hosts["blog.localhost:1323"] = &Host{blog}

blog.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "Blog")
})

//---------
// Website
//---------

site := echo.New()
site.Use(middleware.Logger())
site.Use(middleware.Recover())

hosts["localhost:1323"] = &Host{site}

site.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "Website")
})

// Server
e := echo.New()
// Any为所有的HTTP方法(由Echo支持)和路径注册一个新的路由,并在路由器中使用可选的路由级中间件的匹配处理程序。
// 注意:这个方法只添加了特定的支持的HTTP方法集作为处理程序,而不是真正的 "catch-any-arbitrary-method "的方式来匹配请求。
e.Any("/*", func(c echo.Context) (err error) {
req := c.Request()

res := c.Response()
host := hosts[req.Host]

if host == nil {
err = echo.ErrNotFound
} else {
host.Echo.ServeHTTP(res, req)
}

return
})
e.Logger.Fatal(e.Start(":1323"))
}

服务端

Echo_40

Echo_43

Echo_41

通过对原域名和子域名的请求,可以返回不同的响应,也可以在服务端实现对不同子域名提供不同的服务。

Echo_42

在终端我们可以看到请求和响应信息的输出,这说明每次请求都以 e.Any("/*", func(c echo.Context) (err error){...} 作为入口,再匹配各自路由中的控制器函数,来对客户端做出响应。


JWT

概念

什么是JWT?

JWT (JSON Web Token) 是目前最流行的跨域认证解决方案,是一种基于 Token 的认证授权机制。 从 JWT 的全称可以看出,JWT 本身也是 Token,一种规范化之后的 JSON 结构的 Token。

JWT 自身包含了身份验证所需要的所有信息,因此,我们的服务器不需要存储 Session 信息。这显然增加了系统的可用性和伸缩性,大大减轻了服务端的压力。

JWT由哪些部分组成?

Echo_43

JWT 本质上就是一组字串,通过(.)切分成三个为 Base64 编码的部分:

  • Header : 描述 JWT 的元数据,定义了生成签名的算法以及 Token 的类型。
  • Payload : 用来存放实际需要传递的数据
  • Signature(签名) :服务器通过 Payload、Header 和一个密钥(Secret)使用 Header 里面指定的签名算法(默认是 HMAC SHA256)生成。

JWT 通常是这样的:xxxxx.yyyyy.zzzzz

示例:

1
2
3
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

你可以在 jwt.ioopen in new window 这个网站上对其 JWT 进行解码,解码之后得到的就是 Header、Payload、Signature 这三部分。

Header 和 Payload 都是 JSON 格式的数据,Signature 由 Payload、Header 和 Secret(密钥)通过特定的计算公式和加密算法得到。

Echo_44

Header

Header 通常由两部分组成:

  • typ(Type):令牌类型,也就是 JWT。
  • alg(Algorithm) :签名算法,比如 HS256。

示例:

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

JSON 形式的 Header 被转换成 Base64 编码,成为 JWT 的第一部分。

Payload

Payload 也是 JSON 格式数据,其中包含了 Claims(声明,包含 JWT 的相关信息)。

Claims 分为三种类型:

  • Registered Claims(注册声明) :预定义的一些声明,建议使用,但不是强制性的。
  • Public Claims(公有声明) :JWT 签发方可以自定义的声明,但是为了避免冲突,应该在 IANA JSON Web Token Registryopen in new window 中定义它们。
  • Private Claims(私有声明) :JWT 签发方因为项目需要而自定义的声明,更符合实际项目场景使用。

下面是一些常见的注册声明:

  • iss(issuer):JWT 签发方。
  • iat(issued at time):JWT 签发时间。
  • sub(subject):JWT 主题。
  • aud(audience):JWT 接收方。
  • exp(expiration time):JWT 的过期时间。
  • nbf(not before time):JWT 生效时间,早于该定义的时间的 JWT 不能被接受处理。
  • jti(JWT ID):JWT 唯一标识。

示例:

1
2
3
4
5
6
7
8
{
"uid": "ff1212f5-d8d1-4496-bf41-d2dda73de19a",
"sub": "1234567890",
"name": "John Doe",
"exp": 15323232,
"iat": 1516239022,
"scope": ["admin", "user"]
}

Payload 部分默认是不加密的,一定不要将隐私信息存放在 Payload 当中!!!

JSON 形式的 Payload 被转换成 Base64 编码,成为 JWT 的第二部分。

Signature

Signature 部分是对前两部分的签名,作用是防止 JWT(主要是 payload) 被篡改。

这个签名的生成需要用到:

  • Header + Payload。
  • 存放在服务端的密钥(一定不要泄露出去)。
  • 签名算法。

签名的计算公式如下:

1
2
3
4
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)

算出签名以后,把 Header、Payload、Signature 三个部分拼成一个字符串,每个部分之间用”点”(.)分隔,这个字符串就是 JWT 。

可以在这里找到 JWT 中间件配置。

  • JWT 使用 HS256 算法认证。
  • JWT 从 Authorization 请求头取出数据。

服务端

使用自定义claims的服务端

server.go

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
package main

import (
"net/http"
"time"

"github.com/golang-jwt/jwt"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)

// jwtCustomClaims are custom claims extending default ones.
// See https://github.com/golang-jwt/jwt for more examples
type jwtCustomClaims struct {
Name string `json:"name"`
Admin bool `json:"admin"`
jwt.StandardClaims
}

func login(c echo.Context) error {
username := c.FormValue("username")
password := c.FormValue("password")

// Throws unauthorized error
if username != "jon" || password != "shhh!" {
return echo.ErrUnauthorized
}

// Set custom claims
claims := &jwtCustomClaims{
"Jon Snow",
true,
jwt.StandardClaims{
// 设置到期时间,此处为72小时
ExpiresAt: time.Now().Add(time.Hour * 72).Unix(),
},
}

// Create token with claims
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

// 相当于将三部分包装起来,即 HMACSHA256(
// base64UrlEncode(header) + "." +
// base64UrlEncode(payload),
// secret)
// Generate encoded token and send it as response.
t, err := token.SignedString([]byte("secret"))
if err != nil {
return err
}

return c.JSON(http.StatusOK, echo.Map{
"token": t,
})
}

func accessible(c echo.Context) error {
return c.String(http.StatusOK, "Accessible")
}

func restricted(c echo.Context) error {
// Get从上下文检索数据
user := c.Get("user").(*jwt.Token)
claims := user.Claims.(*jwtCustomClaims)
name := claims.Name
return c.String(http.StatusOK, "Welcome "+name+"!")
}

func main() {
e := echo.New()

// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())

// 在 login 控制器中创建 token(有认证)
// Login route
e.POST("/login", login)

// 无认证路由
// Unauthenticated route
e.GET("/", accessible)

// 受限的组
// Restricted group
r := e.Group("/restricted")

// 使用服务器自定义的claims
// Configure middleware with the custom claims type
config := middleware.JWTConfig{
Claims: &jwtCustomClaims{},
SigningKey: []byte("secret"),
}
r.Use(middleware.JWTWithConfig(config))
r.GET("", restricted)

e.Logger.Fatal(e.Start(":1323"))
}

服务端使用用户定义的KeyFunc

server.go

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
75
76
77
78
79
package main

import (
"context"
"errors"
"fmt"
"net/http"

jwt "github.com/golang-jwt/jwt"
echo "github.com/labstack/echo/v4"
middleware "github.com/labstack/echo/v4/middleware"
jwk "github.com/lestrrat-go/jwx/jwk"
)

func getKey(token *jwt.Token) (interface{}, error) {

// For a demonstration purpose, Google Sign-in is used.
// https://developers.google.com/identity/sign-in/web/backend-auth
//
// This user-defined KeyFunc verifies tokens issued by Google Sign-In.
//
// Note: In this example, it downloads the keyset every time the restricted route is accessed.
keySet, err := jwk.Fetch(context.Background(), "https://www.googleapis.com/oauth2/v3/certs")
if err != nil {
return nil, err
}

keyID, ok := token.Header["kid"].(string)
if !ok {
return nil, errors.New("expecting JWT header to have a key ID in the kid field")
}

key, found := keySet.LookupKeyID(keyID)

if !found {
return nil, fmt.Errorf("unable to find key %q", keyID)
}

var pubkey interface{}
if err := key.Raw(&pubkey); err != nil {
return nil, fmt.Errorf("Unable to get the public key. Error: %s", err.Error())
}

return pubkey, nil
}

func accessible(c echo.Context) error {
return c.String(http.StatusOK, "Accessible")
}

func restricted(c echo.Context) error {
user := c.Get("user").(*jwt.Token)
claims := user.Claims.(jwt.MapClaims)
name := claims["name"].(string)
return c.String(http.StatusOK, "Welcome "+name+"!")
}

func main() {
e := echo.New()

// Middleware
e.Use(middleware.Logger())
e.Use(middleware.Recover())

// Unauthenticated route
e.GET("/", accessible)

// Restricted group
r := e.Group("/restricted")
{
config := middleware.JWTConfig{
KeyFunc: getKey,
}
r.Use(middleware.JWTWithConfig(config))
r.GET("", restricted)
}

e.Logger.Fatal(e.Start(":1323"))
}

客户端

curl

登录

使用账号和密码登录获取 token。

1
curl -X POST -d 'username=jon' -d 'password=shhh!' localhost:1323/login

响应

1
2
3
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NjE5NTcxMzZ9.RB3arc4-OyzASAaUhC2W3ReWaXAt_z2Fd3BN4aWTgEY"
}

请求

Authorization 请求头设置 token,发送请求获取资源。

1
curl localhost:1323/restricted -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NjE5NTcxMzZ9.RB3arc4-OyzASAaUhC2W3ReWaXAt_z2Fd3BN4aWTgEY"

响应

1
Welcome Jon Snow!

示例(服务端定义clams)

Echo_45

先通过 POST 传表单数据用于创建 token

注:“”不是 token 的内容,“” 内的值才是

Echo_46

Echo_48

访问受限组的 localhost:1323/restricted (有认证)并在请求头中添加 Authorization 其值为 Bearer Token,得到成功访问的响应

Echo_47

尝试访问无认证路由 localhost:1323/ ,得到 Accessible 的正常访问响应


平滑关闭

服务端

使用 http.Server#Shutdown()

server.go

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

import (
"context"
"net/http"
"os"
"os/signal"
"time"

"github.com/labstack/echo/v4"
"github.com/labstack/gommon/log"
)

func main() {
// Setup
e := echo.New()
e.Logger.SetLevel(log.INFO)
e.GET("/", func(c echo.Context) error {
time.Sleep(5 * time.Second)
return c.JSON(http.StatusOK, "OK")
})
// 正常启动下返回 OK

// 在启动服务器的同时启动一个 Goroutines ,如果启动失败则告诉我们开始关闭服务器了
// Start server
go func() {
if err := e.Start(":1323"); err != nil && err != http.ErrServerClosed {
e.Logger.Fatal("shutting down the server")
}
}()

// 等待信号中断,超用过10秒的时间来平滑关闭服务器。
// 使用一个缓冲通道,以避免错过信号,如 recommended for signal.Notify。
// Wait for interrupt signal to gracefully shutdown the server with a timeout of 10 seconds.
// Use a buffered channel to avoid missing signals as recommended for signal.Notify
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := e.Shutdown(ctx); err != nil {
e.Logger.Fatal(err)
}
}

Requires go1.8+

客户端

启动服务器并发送请求

Echo_49

服务器正常启动,在 5 秒后返回响应 “OK”


资源嵌入

服务端

使用 go 1.16嵌入功能

server.go

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

import (
"embed"
"io/fs"
"log"
"net/http"
"os"

"github.com/labstack/echo/v4"
)

// 读取`app`的内容并将其分配给该注释后面的变量
//go:embed app
var embededFiles embed.FS

func getFileSystem(useOS bool) http.FileSystem {
if useOS {
log.Print("using live mode")
return http.FS(os.DirFS("app"))
}

log.Print("using embed mode")
fsys, err := fs.Sub(embededFiles, "app")
if err != nil {
panic(err)
}

return http.FS(fsys)
}

func main() {
e := echo.New()
useOS := len(os.Args) > 1 && os.Args[1] == "live"
assetHandler := http.FileServer(getFileSystem(useOS))
e.GET("/", echo.WrapHandler(assetHandler))
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", assetHandler)))
e.Logger.Fatal(e.Start(":1323"))
}

Source Code

在创建 app 文件夹后//go:embed app 依旧报错,则重启一下 vs

不懂 //go:embed 参考:读取文件 | 梧席的小站 (wuster.store)

使用 go.rice

server.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import (
"net/http"

"github.com/GeertJohan/go.rice"
"github.com/labstack/echo/v4"
)

func main() {
e := echo.New()
// the file server for rice. "app" is the folder where the files come from.
assetHandler := http.FileServer(rice.MustFindBox("app").HTTPBox())
// serves the index.html from rice
e.GET("/", echo.WrapHandler(assetHandler))

// servers other static files
e.GET("/static/*", echo.WrapHandler(http.StripPrefix("/static/", assetHandler)))

e.Logger.Fatal(e.Start(":1323"))
}

Source Code

示例

使用 embed 模式

Echo_50

使用 live 模式

Echo_51

我们尝试对 http://localhost:1323/ 进行请求

Echo_52

可以得到响应返回的 app 文件夹中的目录

我们尝试对 http://localhost:1323/static/wood.jpg 进行请求

Echo_53

发现 /static/ 已经绑定了 app 文件夹,并实现了资源嵌入

参考文献:

Streaming Response Recipe | Echo - High performance, minimalist Go web framework (labstack.com)

响应式流(Reactive,Streams)_浮生梦浮生的博客-CSDN博客_响应式流

万字长文,一篇吃透WebSocket:概念、原理、易错常识、动手实践 - 腾讯云开发者社区-腾讯云 (tencent.com)

JWT 基础概念详解 | JavaGuide

facebookarchive/grace: Graceful restart & zero downtime deploy for Go servers. (github.com)

Embed Resources Recipe | Echo - High performance, minimalist Go web framework (labstack.com)

用Postman调试跨域问题 - 上课爱睡觉 - 博客园 (cnblogs.com)

JSON Web Tokens - jwt.io

本地测试websocket连接通信案例_冯小东的博客-CSDN博客_本地websocket测试

curl 的用法指南 - 阮一峰的网络日志 (ruanyifeng.com)

facebookarchive/grace: Graceful restart & zero downtime deploy for Go servers. (github.com)

 Comments
Comment plugin failed to load
Loading comment plugin
Powered by Hexo & Theme Keep
Unique Visitor Page View