Websockets
引言
Websocket 是在 RFC 6455 中定义的一种协议,允许使用单个 TCP 连接进行双工通信。这对于客户端和服务器之间的实时通信特别有用。例如,Websockets 可用于聊天应用程序。
Websocket 连接具有以下生命周期:
- 客户端在专用路由上使用 HTTP 请求服务器。客户端使用 HTTP 头表示希望升级其连接。
- 服务器通过切换协议来升级连接。
- 连接保持活动状态,双方可以以任意方式进行通信。
- 客户端或服务器决定关闭连接。在 TCP 连接关闭之前执行关闭握手。
Goyave 使用 gorilla/websocket
并为其添加了一层抽象,使其更易于使用。您不必编写连接升级逻辑或关闭握手。就像常规的 HTTP 处理程序一样,websocket 处理程序受益于可靠的错误处理和 panic 恢复。
首先,导入 websocket 包:
import "goyave.dev/goyave/v5/websocket"
您可能也需要 gorilla/weboscket
包。如果需要,请使用别名(例如 ws
)导入它:
import ws "github.com/gorilla/websocket"
TIP
您可以在 websocket-example 项目 中找到使用 websockets 制作的聊天应用程序的完整示例。
Websocket 处理程序
Websocket 处理程序与常规处理程序略有不同。它们接收一个 *websocket.Conn
而不是 *goyave.Response
,并且必须命名为 Serve
以符合 websocket.Controller
接口。
request
参数包含原始的已升级的 HTTP 请求。
为了保持连接活动,这些处理程序应运行一个无限 for 循环,该循环可以在出错时返回或以可预测的方式退出。它们还可以启动用于读取和写入的 goroutine,但不应在两者都完成之前返回。处理程序负责同步它启动的 goroutine,并确保在返回时没有读取器或写入器仍在活动。
如果 websocket 处理程序返回 nil
,则表示一切正常,连接可以正常关闭。另一方面,websocket 处理程序可以返回一个错误(例如写入错误)以指示连接不应正常关闭。
默认情况下,服务器关闭不会等待被劫持的连接正常关闭。建议注册一个关闭钩子,阻塞直到所有连接都使用 *websocket.Conn.CloseNormal()
正常关闭。
以下 websocket 控制器 是一个使用 websockets 实现“回声”功能的简单示例:
// http/controller/echo/echo.go
import (
"goyave.dev/goyave/v5"
"goyave.dev/goyave/v5/websocket"
"goyave.dev/goyave/v5/util/errors"
)
type Controller struct {
goyave.Component
}
func (ctrl *Controller) Serve(c *websocket.Conn, request *goyave.Request) error {
for {
mt, message, err := c.ReadMessage()
if err != nil {
return errors.New(err)
}
ctrl.Logger().Debug("recv", "message", string(message))
err = c.WriteMessage(mt, message)
if err != nil {
return errors.Errorf("write: %w", err)
}
}
}
TIP
在 gorilla/websocket 文档 中了解更多关于可用函数的信息。
您仍然可以从 websocket 处理程序访问查询和正文数据,以及经过身份验证的用户,以及原始请求中的任何其他信息,就像您通常所做的那样。但是,您仍应确保此数据经过验证。为此,通过实现 websocket.Registrer
接口手动注册升级路由:
func (ctrl *Controller) RegisterRoute(router *goyave.Router, handler goyave.Handler) {
router.Get("", handler).ValidateQuery(JoinRequest)
}
连接升级
现在我们有一个可工作的 websocket 控制器,我们需要注册一个旨在将连接升级为 websocket 连接的路由。
import (
"my-project/http/controller/echo"
"goyave.dev/goyave/v5"
"goyave.dev/goyave/v5/websocket"
)
func Register(server *goyave.Server, router *goyave.Router) {
router.Subrouter("/echo").Controller(websocket.New(&echo.Controller{}))
}
websocket.New()
返回一个 Upgrader
,一个特殊的控制器,它将自动注册升级路由,处理可能的错误并执行关闭握手。成功升级后,HTTP 响应状态设置为“101 Switching Protocols”。
在 websocket 处理程序返回后,连接会自动关闭,如果可能,使用 RFC 6455 第 1.4 节定义的关闭握手。如果 websocket 处理程序返回的错误不是 CloseError
,则将执行 Upgrader
的错误处理程序,发送给客户端的关闭帧将具有状态码 1011(内部服务器错误)和消息“Internal server error”。如果启用了调试,消息将是 websocket 处理程序返回的错误消息。否则,关闭帧将具有状态码 1000(正常关闭)和消息“Server closed connection”。
INFO
- 当路由注册时,提供给升级器的控制器会像往常一样自动初始化。
- 默认情况下,升级路由使用空前缀和
GET
方法。建议使用具有您选择路径的子路由器。如果您的控制器实现了websocket.Registrer
,则此设置将被覆盖。
升级选项
UpgradeErrorHandler
websocket.UpgradeErrorHandler
是一个接口,允许您的控制器处理升级过程失败的情况。这发生在连接被劫持之前。
func (ctrl *Controller) OnUpgradeError(response *goyave.Response, _ *goyave.Request, status int, reason error) {
message := map[string]string{
"error": reason.Error(),
}
response.JSON(status, message)
}
ErrorHandler
websocket.ErrorHandler
是一个接口,允许您的控制器在 Serve()
方法返回错误或发生 panic 时定义自定义行为。这发生在连接被劫持之后。在此错误处理之后立即自动执行关闭握手。
默认情况下,如果您的控制器未实现此接口,则会记录错误级别错误。
func (ctrl *Controller) OnError(_ *goyave.Request, err error) {
ctrl.Logger().Error(err)
}
OriginChecker
websocket.ErrorHandler
是一个接口,允许您的控制器定义自定义的 Origin
头检查行为。如果您的控制器未实现此接口,则使用安全的默认值:如果存在 Origin
请求头且来源主机不等于请求 Host
头,则返回 false
。
此类方法应仔细验证请求来源,以防止跨站请求伪造。
func (ctrl *Controller) CheckOrigin(request *goyave.Request) bool {
//...
return true
}
HeaderUpgrader
websocket.HeaderUpgrader
是一个接口,允许您的控制器在协议切换响应中定义自定义 HTTP 头。返回的头被添加到必需的头中。您可以使用它来指定 Set-Cookie
头。
func (ctrl *Controller) UpgradeHeaders(request *goyave.Request) http.Header {
headers := http.Header{}
headers.Set("X-Custom-Header", "value")
return headers
}
设置
您可以通过修改 Goyave 的 Upgrader.Settings
来更改底层的 gorilla 升级器。但是,更改 Error
和 CheckOrigin
值不会产生任何效果。请改用上面解释的接口。
upgrader := websocket.New(hub)
upgrader.Settings.HandshakeTimeout = time.Second * 3
upgrader.Settings.EnableCompression = true
upgrader.Settings.ReadBufferSize = 512
//...
INFO
查看 gorilla/websocket 文档 获取更多详情。
测试
要测试 websockets,您必须从测试中打开一个客户端连接,向其写入数据,然后发送一个关闭帧。以下代码片段是对前面示例中看到的“回声”控制器的测试:
import (
"fmt"
"sync"
"testing"
"time"
ws "github.com/gorilla/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"goyave.dev/goyave/v5"
"goyave.dev/goyave/v5/util/testutil"
"goyave.dev/goyave/v5/websocket"
)
func TestEcho(t *testing.T) {
server := testutil.NewTestServer(t, "config.test.json")
server.RegisterRoutes(func(_ *goyave.Server, router *goyave.Router) {
router.Subrouter("/echo").Controller(websocket.New(&Controller{}))
})
wg := sync.WaitGroup{}
wg.Add(1)
server.RegisterStartupHook(func(_ *goyave.Server) {
defer wg.Done()
addr := fmt.Sprintf("ws://%s/echo", server.Host())
conn, _, err := ws.DefaultDialer.Dial(addr, nil)
require.NoError(t, err)
defer conn.Close()
message := []byte("hello world")
assert.NoError(t, conn.WriteMessage(ws.TextMessage, message))
messageType, data, err := conn.ReadMessage()
assert.NoError(t, err)
assert.Equal(t, ws.TextMessage, messageType)
assert.Equal(t, message, data)
m := ws.FormatCloseMessage(ws.CloseNormalClosure, "Connection closed by client")
assert.NoError(t, conn.WriteControl(ws.CloseMessage, m, time.Now().Add(time.Second)))
})
go func() {
assert.NoError(t, server.Start())
}()
defer server.Stop()
wg.Wait()
}