# C++ 微服务即时通讯系统(三):网关设计——HTTP 请求、Session 鉴权与 WebSocket 通知上一篇

上一篇介绍了 brpc 与 Etcd:调用方发现服务地址,再通过 Channel 直接访问目标服务。但桌面客户端没有必要理解内部六个业务服务的地址,也不应该自行拼装所有内部 RPC。

客户端面向的是网关。网关接收外部请求、识别登录用户、调用内部服务,并把在线事件推送给对应客户端。

本文沿着"一次修改昵称请求"和"一条实时连接"介绍这部分设计。这里的网关是项目自己的业务进程,不是 brpc 自动提供的转发服务器。

1. 一个网关进程,为什么有两种入口

即时通讯客户端既要主动操作,也要被动接收事件:修改昵称、查询历史消息属于前者;好友申请、新消息提醒属于后者。

项目将这两种交互分开:

| 入口 | 项目中的职责 | 默认端口 | 数据格式 |

|---|---|---|---|

| HTTP | 登录注册、资料修改、好友操作、消息提交、历史查询、文件与语音请求 | 9000 | POST 正文中的 Protobuf 二进制 |

| WebSocket | 建立连接后验证身份,再接收服务器通知 | 9001,路径 `/ws` | 二进制消息中的 Protobuf |

特别注意:**当前客户端发送聊天消息走 HTTP,不是 WebSocket**。WebSocket 在这里主要承担通知通道的角色。

HTTP 使用固定的 cpp-httplib 0.16.0,WebSocket 使用 websocketpp 0.8.2。选择两个库,是当前工程的实现组合,不是"HTTP 和 WebSocket 天生不能使用同一个库"。HTTP 也不等于每次请求都必须新建 TCP 连接。cpp-httplib 对应版本文档(https://github.com/yhirose/cpp-httplib/blob/v0.16.0/README.md)介绍了 HTTP 服务端接口及其阻塞式 I/O 模型。

两个入口最终运行在同一个 `im_gateway_server` 进程中,共享 Session 访问接口和连接注册表。下图省略响应返回线与 Etcd 发现过程,突出入口之间的协作。

图中的端口是程序入口,不等于已经向公网开放。默认 Docker 部署将网关端口发布到宿主机 `127.0.0.1`;容器内监听地址与宿主机端口绑定是不同配置。当前这部分传输代码本身不是 HTTPS/WSS,不能把回环绑定或 Protobuf 编码当成传输加密。

2. 先把网络收发与业务判断分开

如果把 HTTP 回调、Redis 查询、RPC 调用和连接关闭全部写进 `main()`,一个请求失败时很难判断问题在哪一层。

项目将网关分成两组模块:

  • 核心层:`GatewayHttpProcessor` 处理路由与请求身份,`GatewayWebSocketCore` 判断鉴权和关闭行为,`ConnectionRegistry` 管理用户与连接的对应关系。

  • 传输层:`GatewayHttpServer` 负责 HTTP 收发,`GatewayWebSocketServer` 负责 WebSocket 连接、二进制消息和定时器。

核心逻辑通过 `GatewayRpcCaller`、`GatewaySession`、`NotificationSink` 接口访问外部能力。测试可以替换这些依赖,不必每次都启动 Redis 和六个业务服务。

其中 `NotificationSink` 很重要:HTTP 处理器只需要说"给这个用户发送这条通知",不需要知道 websocketpp 的连接句柄怎样使用。

3. HTTP 路由怎样对应到内部服务

`GatewayRoutes()` 维护了 28 条 POST 路由。每条路由包含路径、目标服务类型、访问要求和 RPC 方法名,例如:

```cpp

{"/service/user/set_nickname", GatewayServiceKind::user,

GatewayRouteAccess::authenticated, "SetUserNickname"},

```

这是路由表中的一个元素,不是完整函数。它表达的是:客户端访问这个 HTTP 路径时,网关需要先验证身份,再调用用户服务的 `SetUserNickname`。

28 条路由中有 5 条公开接口:获取手机验证码、用户名注册与登录、手机号注册与登录。其余 23 条要求有效 Session。公开并不是没有业务校验,而是不能要求用户"先登录才能登录"。

传输层遍历路由表注册 POST 回调,检查 `Content-Type` 是否为 `application/x-protobuf`,再把路径和正文交给处理器。当前 HTTP 正文统一设置了 16 MiB 上限;这不是附件业务约束的完整替代。

所以客户端协议不是"发一个 JSON,网关猜调用谁",而是:明确的路径决定接口,约定的 Protobuf 类型决定正文结构。

4. Session 决定"你是谁",而不是客户端填写的 user_id

假设用户 A 已登录,却在修改昵称请求中把 `user_id` 填成用户 B。如果网关直接相信这个字段,就可能把客户端可控参数当成操作身份。

当前受保护接口的处理顺序是:

  1. 将正文解析为对应的 Protobuf 请求。

  2. 读取请求中的 `session_id`。

  3. 通过 Redis 中的 Session 映射查询可信 `user_id`。

  4. 用查询结果覆盖请求中的操作人 `user_id`。

  5. 再调用目标业务服务。

例如,请求自称操作人是 B,但 Session 属于 A,转发给内部服务的操作人仍然是 A。Session 查不到或者 Redis 读取失败时,不继续转发;实现也区分了这两种失败原因。

这里的 Session 指登录凭证,不是聊天会话 ID。用户服务完成登录校验并建立 `session_id → user_id` 映射,网关使用这个映射识别后续请求,不在每个接口里重新校验密码。

这解决了"身份来源可信"的问题,却不自动解决全部资源权限问题:A 是否属于某个聊天会话、是否允许读取某个文件,还需要对应业务的授权规则。不能因为覆盖了 `user_id`,就宣称整个系统所有越权问题都已经消失。

5. 从 HTTP 请求到 brpc 调用,还要判断哪几层结果

身份确定后,`BrpcGatewayRpcCaller::Call()` 根据服务名称向 `ServiceManager` 选择 Channel,再通过 Protobuf 方法描述符调用目标 RPC。

它的核心调用片段是:

```cpp

brpc::Controller controller;

controller.set_timeout_ms(_timeout_ms);

channel->CallMethod(method, &controller, &request, response, nullptr);

```

最后一个参数为 `nullptr`,这里使用同步调用;网关配置的默认 RPC 等待上限是 3000 毫秒。它限制这个调用点的等待,不表示下游所有操作都必然在同一时刻停止。

客户端需要区分三个层次:

| 层次 | 要检查什么 | 例子 |

|---|---|---|

| HTTP 传输 | 是否收到响应、状态码、正文能否解析 | 连接失败、未知路径、响应格式错误 |

| 网关转发 | 身份是否有效、是否有节点、RPC 是否成功 | Session 不存在、服务池为空、RPC 超时 |

| 下游业务 | Protobuf 响应的 `success` 与 `errmsg` | 昵称冲突、业务参数不合法 |

项目现有协议有一个需要明确记住的约定:**已知路由的许多错误仍返回 HTTP 200,并在对应的 Protobuf 响应中设置 `success=false`**。正文解析失败、Session 校验失败、下游调用失败都有这样的路径;未知路由则是 404。

因此,HTTP 200 只能作为一层判断,不能直接显示"操作成功"。同样,brpc 的 `Controller` 没有失败,也不等于业务响应里的 `success` 一定为真。

6. WebSocket 握手成功,不等于用户已经登录

WebSocket 包含建立连接的握手和后续消息帧;项目使用的经典握手通过 HTTP Upgrade 建立协议通道,但它不会自动验证业务 Session。RFC 6455(https://www.rfc-editor.org/rfc/rfc6455)分别规定了握手与消息帧机制。

在项目中,验证回调先检查资源路径必须为 `/ws`。连接建立后分配进程内的 `connection_id`,此时还没有可信用户身份。

客户端随后发送首条二进制鉴权消息,其协议是:

```protobuf

message ClientAuthenticationReq {

string request_id = 1;

string session_id = 2;

}

```

`GatewayWebSocketCore::Authenticate()` 解析它,检查必要字段,查询 Redis Session,再将连接与可信用户绑定。服务器返回 `ClientAuthenticationRsp`;失败时发送失败结果并关闭连接。

当前默认鉴权等待时间为 5000 毫秒。超时仍未完成鉴权的连接会被关闭;文本消息、无效正文、无效 Session 和同一连接上的重复鉴权,也会被拒绝。

不要据此认为"鉴权后就能往这个 WebSocket 发任意业务请求"。目前消息入口仍按鉴权请求处理,重复鉴权会失败;客户端业务提交继续走 HTTP。

7. 网关怎样把通知发给某个用户

连接鉴权后,网关需要解决一个实际问题:业务代码只有 `user_id`,网络库却需要具体连接。项目使用两层映射:

  • `ConnectionRegistry` 保存用户当前拥有的连接记录,以及按连接 ID 查找的记录。

  • WebSocket 传输层保存连接 ID 与 websocketpp 连接对象、句柄之间的对应关系。

`Send(user_id, notification)` 先查当前连接记录,再找连接对象,将 `NotifyMessage` 序列化后发送二进制消息。

现有通知包括好友申请、好友申请处理结果、新聊天会话、新消息和删除好友。以新消息为例:

  1. HTTP 处理器验证发送者身份,调用消息转发服务的 `GetTransmitTarget`。

  2. 下游返回组装好的消息和目标用户列表。

  3. 网关排除发送者,忽略空目标并去重,生成 `CHAT_MESSAGE_NOTIFY`。

  4. 通过 `NotificationSink` 尽力向本机持有连接的目标用户发送通知。

  5. 将业务响应返回给发送方。

这里的"尽力"有明确含义:实现忽略通知 `Send()` 的布尔返回值,不因某个接收方离线而把已经成功的业务响应改成失败。

而 `Send()` 返回成功,也只是当前发送接口没有报告错误,**不是对方客户端已经接收、展示或阅读的回执**。本链路没有实现完整的通知 ACK、持久重试与送达确认。消息的异步存储、离线后的历史读取属于另一条链路,不能和实时推送混为一谈。

8. 新连接接管后,旧连接关闭为什么不能直接清理用户

连接回调是异步的。即使网关已经决定让新连接接管,旧连接的关闭回调也可能稍后才到达。

如果关闭回调只按 `user_id` 删除记录,就会出现:新连接刚登记成功,旧连接关闭,把新连接的状态也清掉了。

`ConnectionRegistry` 为记录分配递增的 `generation`,在互斥锁保护下更新当前所有者。删除时同时核对连接 ID 和代次:

| 时刻 | 发生的事件 | 当前所有者 | 关闭时的处理 |

|---|---|---|---|

| T1 | 旧连接鉴权成功 | 旧连接,代次 7 | 暂不关闭 |

| T2 | 新连接用有效 Session 鉴权成功 | 新连接,代次 8 | 返回旧连接记录,通知传输层关闭旧连接 |

| T3 | 旧连接关闭回调到达 | 仍是新连接,代次 8 | 移除旧连接记录,不清理当前用户的 Redis 登录状态 |

| T4 | 当前新连接关闭 | 无当前所有者 | 尝试删除相应 Session 和在线占用标记 |

表中的代次数值只是示意。关键不是数字大小,而是关闭事件是否仍属于当前所有者。

新连接接管也不等于允许同一账号重复登录。登录阶段另有 Redis 在线抢占逻辑;这里解决的是持有有效 Session 的连接替换问题。

这项保护也不是跨 Redis 与内存的原子事务。当前所有者判断完成后,Redis 清理是后续操作,删除 Session 与删除在线标记也不是一个原子命令;并发替换与清理失败仍需要更完整的条件删除和恢复设计。

9. 两个事件循环怎样一起运行

网关入口先组装 Redis、服务发现、Channel 管理器和共享的核心对象,再分别准备 HTTP 与 WebSocket 监听。

HTTP 服务在一个显式创建的线程中运行,WebSocket 的 Asio 循环在主线程运行。HTTP 循环结束时请求 WebSocket 停止;WebSocket 循环返回后停止 HTTP,再 `join()` HTTP 线程。

这说明两个入口不是两个必须独立部署的网关进程,也不说明整个进程只有两个线程:网络库和其他依赖还可能有自己的工作线程。

需要保留一个边界:当前 WebSocket 停止路径在请求关闭连接后调用了事件循环 `stop()`,还不能等同于等待所有关闭握手完成的优雅排空。websocketpp 官方 FAQ(https://docs.websocketpp.org/faq.html)说明了关闭连接与等待异步操作完成之间的区别。

此外,项目显式实现了默认每 60000 毫秒发送一次 Ping,以及 Ping 发送错误时的关闭处理。但没有自行建立完整的 Pong 超时、活跃时间续期和失活状态恢复流程;Session 与在线标记当前也没有 TTL。因此,"有 Ping"不能直接推导出"异常断网时 Redis 在线状态一定会及时清除"。

10. 当前网关的能力与扩展边界

当前实现已经将外部协议、可信身份、内部 RPC 和在线通知串起来,但连接表是单进程内存数据。

如果客户端 A 连到网关 1,接收方 B 连到网关 2,网关 1 的本地连接表无法直接找到 B。共享 Redis Session 只能帮助识别 B 的身份,不能让另一个进程拿到 B 的 socket。

扩展到多网关时,需要设计用户连接归属以及跨网关通知路由,例如维护归属信息,再经网关间 RPC 或消息总线送到持有连接的实例。它是后续方案,不是当前已经实现的能力。

同理,资源权限检查、可靠通知、异常登录状态恢复、完整优雅关停和公网 TLS 入口,都应分别核对与补齐,不能由"网关启动成功"一并推定完成。

到这里,网关的作用可以概括为:HTTP 接收业务请求,Session 确定可信身份,brpc 调用内部服务,WebSocket 将在线事件推给当前连接;连接注册表负责把用户身份与具体连接联系起来。

下一篇进入 MySQL、Redis 与 Elasticsearch,解释长期数据、登录状态和搜索索引如何分工,以及跨存储更新的一致性边界。

上一篇:brpc 与 Etcd(02-brpc-and-etcd.md) · 返回系列目录(README.md)

相关推荐
我命由我123452 小时前
6-2.Python 异常处理 - 异常捕获(捕获指定异常、常见异常类型、异常信息)
java·运维·开发语言·python·http·https·java-ee
C++ 老炮儿的技术栈2 小时前
机器人的坐标,像 XYZ 的坐标值,是用单精度浮点还是用双精度浮点?
c语言·c++·人工智能·单片机·c
zaizai10072 小时前
STL深度剖析2
java·c++·算法
幻智星科技2 小时前
FPGA纯硬件UDP桥接:微秒级响应新范式
c++·fpga开发·硬件架构
青少儿编程课堂2 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会Python/C++考级:位运算实战——二进制权限与最大异或解析
c++·python·算法·bfs·信息学竞赛
刘老高2 小时前
微服务架构实战:ShardingSphere Proxy + Canal 分库分表与 ES 同步全链路
elasticsearch·微服务·架构
青少儿编程课堂2 小时前
2026蓝桥杯青少组图形化真题:校园飞机大战解析(全国青少年信息素养·电子学会图形化考级)
c++·python·算法·bfs·信息学竞赛
CodeYp20192 小时前
第16讲:set 和 map 的使用
开发语言·c++·set·map
傲世仙尊3 小时前
vector起步与底层-三个指针迭代器失效与为模板升级内置类型
c++