# 2.5 绑定网关

# 2.5.1 基础介绍

在分布式游戏服务器中开发中,网关作为客户端与服务器交互的唯一入口,主要负责维护客户端连接、转发客户端消息到节点服、转发节点服消息到客户端。绑定网关主要是为了将网关服(GID)、客户端连接(CID)、用户(UID)三者建立一定的绑定关系,从而使得消息能够顺畅地在集群中进行流转。

# 2.5.2 为何绑定网关

在节点服处理消息时,为了标识某条消息来自于哪个用户(UID),我们通常会在网关服上将客户端建立的连接(CID)与用户(UID)建立一个映射关系。这样,在网关服转发客户端消息到节点服时,也会将网关服标识(GID)、连接(CID)、用户(UID)连同消息一起转发到节点服进行处理。同时,在节点服下发消息到客户端时,也可以通过用户(UID)或连接(CID)来定位客户端所连接的网关服,从而通过网关服转发节点服消息到客户端。

许多从其它游戏服务器框架迁移过来的开发者,都会有一个疑问:在众多开源游戏服务器框架中,为何只有due框架有绑定网关这个操作呢? 要回答这个问题就需要深刻理解due框架 高内聚、低耦合、模块化 的设计思想。

其实,其它许多所谓的游戏服务器框架,都是将用户登录和绑定网关一并写入到网关服中。这样框架就与登录业务高度耦合了,当登录业务发生变化时,还需要重新部署新的网关服,这样会导致网关服的用户集体断线,影响体验。

due框架则不同,它将用户登录和绑定网关这两个操作分离开来,互不干扰。网关服完全不具备可开发的能力,也就无法在网关服中编写登录业务相关的代码。所有与登录业务相关的代码,都需要在节点服或网格服中完成。

due框架的这种设计,不但使得网关服的功能更加纯粹和高效,而且还可以在不编写登录业务的前提下将服务器作为一个无状态的长连接服务器来使用。

通过以上分析,我们可以总结出绑定网关的三个核心作用:

  1. 明确某条消息来自于哪台网关服(GID)的哪个连接(CID)
  2. 明确某条消息来自于哪个用户(UID)
  3. 明确下发消息的目标网关服(GID)的目标连接(CID)

# 2.5.3 如何绑定网关

在明确了为何要绑定网关之后,我们就可以回答如何绑定网关这个问题了,其实绑定网关用一句通俗的话表示就是:谁(UID)在哪台网关服(GID)跟哪个连接(CID)建立了绑定关系

通过以上的分析,我们可以得出以下结论:

  1. 绑定网关前,需要先授权登录获取用户ID(UID)。
  2. 绑定网关时,需要指定网关服ID(GID)和长连接ID(CID),那么该操作只能在节点服中完成。

# 2.5.4 示例代码

以下完整示例详见:bind-gate-example (opens new window)

  1. 创建项目
$ mkdir bind-gate-example
1
  1. 安装依赖
$ cd bind-gate-example
$ go mod init bind-gate-example
$ go get github.com/dobyte/due/v2@v2.4.2
$ go get github.com/dobyte/due/locate/redis/v2@e5cd009
$ go get github.com/dobyte/due/registry/consul/v2@e5cd009
1
2
3
4
5
  1. 启动配置

文件位置:bind-gate-example/etc/etc.toml (opens new window)

# 进程号
pid = "./run/due.pid"
# 开发模式。支持模式:debug、test、release(设置优先级:配置文件 < 环境变量 < 运行参数 < mode.SetMode())
mode = "debug"
# 统一时区设置。项目中的时间获取请使用xtime.Now()
timezone = "Local"
# 容器关闭最大等待时间。支持单位:纳秒(ns)、微秒(us | µs)、毫秒(ms)、秒(s)、分(m)、小时(h)、天(d)。默认为0
shutdownMaxWaitTime = "0s"

[cluster.node]
    # 实例ID,集群中唯一。不填写默认自动生成唯一的实例ID
    id = ""
    # 实例名称
    name = "node"
    # 内建RPC服务器监听地址。不填写默认随机监听
    addr = ":0"
    # 是否将内部通信地址暴露到公网。默认为false
    expose = false
    # 编解码器。可选:json | proto。默认为proto
    codec = "json"
    # RPC调用超时时间,支持单位:纳秒(ns)、微秒(us | µs)、毫秒(ms)、秒(s)、分(m)、小时(h)、天(d)。默认为3s
    timeout = "3s"
    # 节点权重,用于节点无状态路由消息的加权轮询策略,权重值必需大于0才生效。默认为1
    weight = 1
    # 实例元数据
    [cluster.node.metadata]
        # 键值对,且均为字符串类型。由于注册中心的元数据参数限制,建议将键值对的数量控制在20个以内,键的字符长度控制在127个字符内,值得字符长度控制在512个字符内。
        key = "value"

[locate.redis]
    # 客户端连接地址
    addrs = ["127.0.0.1:6379"]
    # 数据库号
    db = 0
    # 用户名
    username = ""
    # 密码
    password = ""
    # 私钥文件
    keyFile = ""
    # 证书文件
    certFile = ""
    # CA证书文件
    caFile = ""
    # 最大重试次数
    maxRetries = 3
    # key前缀
    prefix = "due:locate"

[registry.consul]
    # 客户端连接地址,默认为127.0.0.1:8500
    addr = "127.0.0.1:8500"
    # 是否启用健康检查,默认为true
    healthCheck = true
    # 健康检查时间间隔(秒),仅在启用健康检查后生效,默认为10
    healthCheckInterval = 10
    # 健康检查超时时间(秒),仅在启用健康检查后生效,默认为5
    healthCheckTimeout = 5
    # 是否启用心跳检查,默认为true
    heartbeatCheck = true
    # 心跳检查时间间隔(秒),仅在启用心跳检查后生效,默认为10
    heartbeatCheckInterval = 10
    # 健康检测失败后自动注销服务时间(秒),默认为30
    deregisterCriticalServiceAfter = 30

[packet]
    # 字节序,默认为big。可选:little | big
    byteOrder = "big"
    # 路由字节数,默认为2字节
    routeBytes = 2
    # 序列号字节数,默认为2字节
    seqBytes = 2
    # 消息字节数,默认为5000字节
    bufferBytes = 5000
    # 是否携带服务器心跳时间
    heartbeatTime = false

[log]
    # 日志输出级别,可选:debug | info | warn | error | fatal | panic
    level = "info"
    # 堆栈的最低输出级别,可选:debug | info | warn | error | fatal | panic
    stackLevel = "error"
    # 时间格式,标准库时间格式
    timeFormat = "2006/01/02 15:04:05.000000"
    # 输出栈的跳过深度
    callSkip = 2
    # 是否启用调用文件全路径
    callFullPath = true
    # 日志输出终端
    terminals = ["console", "file"]
    # 控制台同步器配置
    [log.console]
        # 日志输出格式,可选:text | json
        format = "text"
    # 文件同步器配置
    [log.file]
        # 输出文件路径
        path = "./log/due.log"
        # 日志输出格式,可选:text | json
        format = "text"
        # 文件最大留存时间,d:天、h:时、m:分、s:秒
        maxAge = "7d"
        # 文件最大尺寸限制,支持单位: B | K | KB | M | MB | G | GB | T | TB | P | PB | E | EB | Z | ZB,默认为100M
        maxSize = "100M"
        # 文件翻转方式,可选:none | year | month | week | day | hour,默认为none
        rotate = "none"
        # 文件翻转时是否对文件进行压缩
        compress = false
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
  1. 编写示例

文件位置:bind-gate-example/main.go (opens new window)

package main

import (
	"github.com/dobyte/due/locate/redis/v2"
	"github.com/dobyte/due/registry/consul/v2"
	"github.com/dobyte/due/v2"
	"github.com/dobyte/due/v2/cluster/node"
	"github.com/dobyte/due/v2/codes"
	"github.com/dobyte/due/v2/errors"
	"github.com/dobyte/due/v2/log"
)

const (
	defaultUID      = 1
	defaultAccount  = "fuxiao"
	defaultPassword = "123456"
)

// 路由号
const login = 1

func main() {
	// 创建容器
	container := due.NewContainer()
	// 创建用户定位器
	locator := redis.NewLocator()
	// 创建服务发现
	registry := consul.NewRegistry()
	// 创建节点组件
	component := node.NewNode(
		node.WithLocator(locator),
		node.WithRegistry(registry),
	)
	// 初始化应用
	initApp(component.Proxy())
	// 添加节点组件
	container.Add(component)
	// 启动容器
	container.Serve()
}

// 初始化应用
func initApp(proxy *node.Proxy) {
	proxy.Router().AddRouteHandler(login, loginHandler)
}

// 请求
type loginReq struct {
	Account  string `json:"account"`
	Password string `json:"password"`
}

// 响应
type loginRes struct {
	Code int `json:"code"`
}

// 路由处理器
func loginHandler(ctx node.Context) {
	ctx.Task(func(ctx node.Context) {
		req := &loginReq{}
		res := &loginRes{}
		ctx.Defer(func() {
            if err := ctx.Response(res); err != nil {
                log.Errorf("response message failed: %v", err)
            }
        })

		if err := ctx.Parse(req); err != nil {
			log.Errorf("parse request message failed: %v", err)
			res.Code = codes.InternalError.Code()
			return
		}

		// 执行登录操作
		uid, err := doLogin(req)
		if err != nil {
			res.Code = codes.Convert(err).Code()
			return
		}

		// 绑定网关
		if err = ctx.BindGate(uid); err != nil {
			log.Errorf("bind gate failed: %v", err)
			res.Code = codes.InternalError.Code()
			return
		}

		res.Code = codes.OK.Code()
	})
}

// 执行登录操作
func doLogin(req *loginReq) (int64, error) {
	if req.Account != defaultAccount || req.Password != defaultPassword {
		return 0, errors.NewError(codes.InvalidArgument)
	}

	return defaultUID, nil
}
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
  1. 运行示例
$ cd bind-gate-example
$ go run main.go
                    ____  __  ________
                   / __ \/ / / / ____/
                  / / / / / / / __/
                 / /_/ / /_/ / /___
                /_____/\____/_____/
┌──────────────────────────────────────────────────────┐
| [Website] https://github.com/dobyte/due              |
| [Version] v2.4.2                                     |
└──────────────────────────────────────────────────────┘
┌────────────────────────Global────────────────────────┐
| PID: 38716                                           |
| Mode: debug                                          |
| Time: 2025-10-30 09:51:33.7492184 +0800 CST          |
└──────────────────────────────────────────────────────┘
┌─────────────────────────Node─────────────────────────┐
| ID: f685908d-b532-11f0-83d2-f4f19e1f0070             |
| Name: node                                           |
| Link: 192.168.2.202:58906                            |
| Codec: json                                          |
| Locator: redis                                       |
| Registry: consul                                     |
| Encryptor: -                                         |
| Transporter: -                                       |
└──────────────────────────────────────────────────────┘
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