Skip to content

Websockets

引言

Websocket 是在 RFC 6455 中定义的一种协议,允许使用单个 TCP 连接进行双工通信。这对于客户端和服务器之间的实时通信特别有用。例如,Websockets 可用于聊天应用程序。

Websocket 连接具有以下生命周期:

  • 客户端在专用路由上使用 HTTP 请求服务器。客户端使用 HTTP 头表示希望升级其连接。
  • 服务器通过切换协议来升级连接。
  • 连接保持活动状态,双方可以以任意方式进行通信。
  • 客户端或服务器决定关闭连接。在 TCP 连接关闭之前执行关闭握手。

Goyave 使用 gorilla/websocket 并为其添加了一层抽象,使其更易于使用。您不必编写连接升级逻辑或关闭握手。就像常规的 HTTP 处理程序一样,websocket 处理程序受益于可靠的错误处理和 panic 恢复。

首先,导入 websocket 包:

go
import "goyave.dev/goyave/v5/websocket"

您可能也需要 gorilla/weboscket 包。如果需要,请使用别名(例如 ws)导入它:

go
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 实现“回声”功能的简单示例:

go
// 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 接口手动注册升级路由:

go
func (ctrl *Controller) RegisterRoute(router *goyave.Router, handler goyave.Handler) {
	router.Get("", handler).ValidateQuery(JoinRequest)
}

连接升级

现在我们有一个可工作的 websocket 控制器,我们需要注册一个旨在将连接升级为 websocket 连接的路由

go
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”。

WARNING

升级的连接被 劫持。建议阅读关于在 Goyave 中劫持的含义 这里

在 websocket 处理程序返回后,连接会自动关闭,如果可能,使用 RFC 6455 第 1.4 节定义的关闭握手。如果 websocket 处理程序返回的错误不是 CloseError,则将执行 Upgrader 的错误处理程序,发送给客户端的关闭帧将具有状态码 1011(内部服务器错误)和消息“Internal server error”。如果启用了调试,消息将是 websocket 处理程序返回的错误消息。否则,关闭帧将具有状态码 1000(正常关闭)和消息“Server closed connection”。

INFO

  • 当路由注册时,提供给升级器的控制器会像往常一样自动初始化
  • 默认情况下,升级路由使用空前缀GET 方法。建议使用具有您选择路径的子路由器。如果您的控制器实现了 websocket.Registrer,则此设置将被覆盖。

升级选项

UpgradeErrorHandler

websocket.UpgradeErrorHandler 是一个接口,允许您的控制器处理升级过程失败的情况。这发生在连接被劫持之前

go
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 时定义自定义行为。这发生在连接被劫持之后。在此错误处理之后立即自动执行关闭握手。

默认情况下,如果您的控制器未实现此接口,则会记录错误级别错误。

go
func (ctrl *Controller) OnError(_ *goyave.Request, err error) {
	ctrl.Logger().Error(err)
}

OriginChecker

websocket.ErrorHandler 是一个接口,允许您的控制器定义自定义的 Origin 头检查行为。如果您的控制器未实现此接口,则使用安全的默认值:如果存在 Origin 请求头且来源主机不等于请求 Host 头,则返回 false

此类方法应仔细验证请求来源,以防止跨站请求伪造。

go
func (ctrl *Controller) CheckOrigin(request *goyave.Request) bool {
	//...
	return true
}

HeaderUpgrader

websocket.HeaderUpgrader 是一个接口,允许您的控制器在协议切换响应中定义自定义 HTTP 头。返回的头被添加到必需的头中。您可以使用它来指定 Set-Cookie 头。

go
func (ctrl *Controller) UpgradeHeaders(request *goyave.Request) http.Header {
	headers := http.Header{}
	headers.Set("X-Custom-Header", "value")
	return headers
}

设置

您可以通过修改 Goyave 的 Upgrader.Settings 来更改底层的 gorilla 升级器。但是,更改 ErrorCheckOrigin 值不会产生任何效果。请改用上面解释的接口。

go
upgrader := websocket.New(hub)
upgrader.Settings.HandshakeTimeout = time.Second * 3
upgrader.Settings.EnableCompression = true
upgrader.Settings.ReadBufferSize = 512
//...

INFO

查看 gorilla/websocket 文档 获取更多详情。

测试

要测试 websockets,您必须从测试中打开一个客户端连接,向其写入数据,然后发送一个关闭帧。以下代码片段是对前面示例中看到的“回声”控制器的测试:

go
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()
}