在网关层(上)中我们实现了多Reactor模型监听用户连接的FD,不需要再为每个连接FD开启两个阻塞监听的读写协程,大大节省了服务器的资源。在这一节中我们需要实现网关层的核心逻辑,收发消息和转发请求给下游。
当前网关层分析
现在的网关层主要由Epoll监听器和Worker协程池组成,前者负载监听所有TCP连接的读写事件,后者主要负责处理具体的业务逻辑,避免阻塞Epoll协程。Epoll协程一直在死循环中调用Epollwait,当某个FD有数据了,Epollwait就返回,但是Epoll协程不进行IO操作,而是立刻把这个FD扔给Ants协程池,而且由于设置了EPOLLONESHOT,Epoll 暂时不再关注这个 FD,防止其他线程抢占。
协程池里的一个空闲Worker收到任务,就调用Read(FD, buf)直接从内核读取二进制数据,注意,这里读到的是裸字节流,我们需要自己在上层做判断,处理字节流的格式,粘包拆包处理,自己定义协议。这部分太底层了,我的想法是直接使用现成的开源websocket协议。Worker读完数据后,直接在当前函数中进行业务逻辑的处理,这里是测试代码,也就是直接调用Write(FD, buf)直接echo回客户端。
第一个问题就是操作Epoll得到的事件是底层FD的读写事件,需要我们自己处理字节流,协议这部分底层的部分,这里使用了gobwas/ws协议库帮助我们处理,这个协议库可以解析数据,拿出FD中的字节流解析成Websocket帧,还可以处理HTTP升级Websocket的握手协议。Epoll负责监控socket何时可读,设置EPOLLONESHOT,避免并发冲突,gobwas/ws负责拿出FD缓冲区的字节流,解析成Websocket帧。
第二个问题就是目前网关层涉及到了业务处理逻辑,比如说echo,由于业务逻辑是经常需要变更的,每次上线新业务或者修改代码都会导致网关机器重启,引发海量用户断连。所以我们要将其进行拆分,使得网关层只负责纯粹的连接管理,负责 WebSocket 协议转换与 Epoll 调度,下层拆分出state server负责业务逻辑,网关层只需要把消息转发给state server进行处理就好。
协议部分
其实网关层的代码大部分都没变,核心思路还是启动多个Epoll实例均匀监听所有FD,触发之后就把任务交给协程池,让Worker协程去FD上读取数据然后转发就好,主要变更的就是协程池工作线程的处理代码handleClientEvent。
首先是协议升级阶段:
if !c.IsWs {
_, err := c.WsUpgrader.Upgrade(c.File)
if err != nil {
// 如果是 EAGAIN,说明数据没读完,直接返回等待下次 epoll
if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK {
return
}
log.Printf("Upgrade failed: %v", err)
s.closeClient(c, ep)
return
}
// 握手成功,标记状态
c.IsWs = true
log.Printf("Client %d upgraded to WebSocket", c.FD)
return
}
首先通过c.IsWs字段判断客户端是否已经建立了Websocket连接,如果是false的话就尝试将连接升级为Websocket连接。c.WsUpgrader会尝试从对应FD读取数据,检查是不是HTTP尝试升级为Websocket的请求,如果是的话就会往FD中写入响应。这里处理错误为EAGAIN的情况,因为Upgrade是非阻塞的,客户端发送的 HTTP 请求头可能比较大(比如带了很多 Cookie),或者网络比较卡,Epoll 通知可读了,但内核缓冲区里可能只到了前 100 个字节,而完整的 HTTP 头需要 500 字节,所以Upgrade直接return,把协程还给协程池,等剩下400字节到了再次触发Epoll,我们再次进入这个函数,再次尝试 Upgrade ,直到读完为止。
之后的代码就是消息处理循环,这里采用死循环的方式,因为Epoll触发一次,可能意味着缓冲区里堆积了 多条 WebSocket 消息,如果不一次性读完,剩下的消息可能要等很久才会被处理,所以原则就是只要可以读,就一直读。
header, err := ws.ReadHeader(c.File)
type Header struct {
Fin bool
Rsv byte
OpCode OpCode
Masked bool
Mask [4]byte
Length int64
}
首先读取Websocket的帧头,获取消息的元数据,这里需要注意可能ReadHeader读取当前缓冲区读取不到一个完整的Header,此时需要进行错误处理:
if err != nil {
if err == syscall.EAGAIN || err == syscall.EWOULDBLOCK {
// 数据读完了,退出循环,等待下一次 epoll 事件
return
}
if err != io.EOF {
log.Printf("Read header error: %v", err)
}
s.closeClient(c, ep)
return
}
如果没有错误的话就会组成完整的头部,此时不会走到这个逻辑内,如果在上一次循环把数据读空了,那么调用ReadHeader的时候就会导致err为syscall.EAGAIN,此时是正常结束,等待下一次Epoll触发即可。下面的io.EOF分支表示ws.ReadHeader读完了缓冲区但是不够填充Header数组,就会抛出io.ErrUnexpectedEOF,所以此时会简单的执行断开连接,为什么读不到完整Header就断开连接呢,因为在绝大多数局域网或高速网络环境下,TCP 粘包通常是“多包粘在一起”,或者是“整个包都在缓冲区里”。 Header 被切断 (比如 2 个字节只到了 1 个)的概率极低,但在弱网环境下确实会发生,至于弱网环境优化的事情后面有机会实现再说吧。
payload := make([]byte, header.Length)
_, err = io.ReadFull(c.File, payload)
主要就是准备 Payload 缓冲区,将FD缓冲区中的字节流读取到payload中,io.ReadFull承诺,要么读满要么报错,然后是错误处理逻辑,如果io.ReadFull 读了 500 字节后,遇到 EAGAIN 。它会停止并返回错误,理想状况下我们应该先把这500字节存起来,记录 needBytes = 500 ,然后 return 。等下次 Epoll 来了再读剩下的 500 字节。但这里为了代码简单,我直接选择断开连接,在局域网或 Demo 中,WebSocket 帧通常很小(几百字节),几乎总是能一次性读完。如果读不完,我们选择放弃这个连接,让客户端重连。
atomic.AddInt64(&s.msgBytes, int64(len(payload)))
resp, err := s.stateClient.ReceiveMessage(context.Background(), &pb.ReceiveMessageRequest{
Uid: c.Uid,
Payload: payload,
GatewayId: s.Addr,
})
原子操作增加 msgBytes 计数器,用于监控系统负载(流量统计)然后直接把payload转发给state server,将 WebSocket 的二进制 payload 包装进 gRPC 的 ReceiveMessageRequest 对象,其中Uid字段告诉state server这个消息是哪个用户说的,GatewayId告诉state server这个用户连接在哪个网关上(以便 StateServer 将来能推消息回来)。
responseFrame := ws.NewTextFrame(resp.ResponsePayload)
c.writeLock.Lock()
if err := ws.WriteFrame(c.File, responseFrame); err != nil {
log.Printf("Failed to echo: %v", err)
}
c.writeLock.Unlock()
resp.ResponsePayload 是从 StateServer 拿回来的裸数据(比如字符串 "Hello"),ws.NewTextFrame 创建了一个 WebSocket 帧对象,设置 OpCode 为 Text (0x1),设置 FIN 位为 1(表示这是完整的消息,不是分片)。之后加锁走FD的write逻辑,因为多线程下通常会有锁竞争,可能会有多个用户同时给A发送消息,那么就会有多个线程同时给A的FD发送消息,所以需要加锁,ws.WriteFrame就是将封装好的帧(Header + Payload)直接写入底层的 TCP Socket,然后交由内核走TCP的逻辑发送。
stateServer
由于网关层专注于长连接的维护与字节流的切分解析,本身不具备业务处理能力,因此必须通过RPC框架将解析后的消息载荷(Payload)投递至StateServer,这样可以实现连接与逻辑的物理隔离,网关层保持轻量与高可用,而业务层则利用 RPC 的强类型契约,实现复杂逻辑的快速迭代与水平扩展。
下面解释一下.proto文件。
// StateService 负责处理从网关转发过来的业务消息
// 同时也负责向网关推送消息
service StateService {
// ReceiveMessage: 网关收到客户端消息后,转发给 StateServer
rpc ReceiveMessage (ReceiveMessageRequest) returns (ReceiveMessageResponse);
}
message ReceiveMessageRequest {
string uid = 1; // 用户ID (临时用 FD 代替)
bytes payload = 2; // 原始消息内容
string gateway_id = 3; // 网关ID (用于回调)
}
message ReceiveMessageResponse {
// 简单回显模式:直接把要回显的数据放在这里
bytes response_payload = 1;
}
rpc服务定义了一个RPC方法ReceiveMessage,当网关收到用户发来的消息时,它不进行具体业务处理,而是直接调用这个接口,把消息甩给StateServer进行处理,StateServer处理完业务后,如果需要给用户发消息(比如 A 给 B 发消息),State Server 应该主动调用网关的接口把消息推给 B。这里为了快速跑通流程,采用的是Echo模式,Server 处理完逻辑后,直接把要返回给用户的数据放在 ReceiveMessageResponse 里返回给网关,网关拿到返回值后直接发回给同一个用户。按理来说应该是要实现一个PushMessage方法的。
请求数据包中的uid标识着消息发送者,在没有重构完用户系统之前网关只可以使用这个标识一个用户建立的长连接,payload就是网关转发过来的负载,类型是 bytes ,意味着 State Server 这一层并不关心它是 JSON 字符串还是二进制协议,拿到了再去解析。gateway主要是State Server需要主动给用户推消息,它必须知道这个用户连接在 哪一台 网关服务器上,这个 ID 就是网关的“回信地址”。
响应数据包这里就只有一个字段,这个字段就是StateServer处理业务逻辑之后的返回字段,网关收到响应之后,会把字段里的内容直接返回给客户端。
根据proto文件生成了服务定义文件和消息结构文件之后,接下来的使用步骤分为两个部分,服务端的实现注册和客户端(Gateway)的调用。在服务端,我们需要完成接口实现和服务启动这两件事:
type Service struct {
// 必须嵌入这个,保证向前兼容
pb.UnimplementedStateServiceServer
}
var (
instance *Service
once sync.Once
)
// NewService 创建服务实例
func NewService() *Service {
once.Do(func() {
instance = &Service{}
})
return instance
}
// ReceiveMessage 实现 proto 中定义的接口
func (s *Service) ReceiveMessage(ctx context.Context, req *pb.ReceiveMessageRequest) (*pb.ReceiveMessageResponse, error) {
// 获取请求参数
uid := req.Uid
payload := req.Payload
gatewayID := req.GatewayId
log.Printf("[State] 收到消息: uid=%s, gateway=%s, len=%d", uid, gatewayID, len(payload))
// 执行业务逻辑 (这里是 Echo 示例)
// 真实场景:解析 payload -> 存库 -> 查路由 -> 推送给接收方
respData := []byte(fmt.Sprintf("State Server 已收到你的消息: %s", string(payload)))
// 构造响应
return &pb.ReceiveMessageResponse{
ResponsePayload: respData,
}, nil
}
这里需要创建一个结构体(例如 Service ),并嵌入生成的 UnimplementedStateServiceServer 以满足接口兼容性要求。然后,重写 ReceiveMessage 方法来填入真正的业务逻辑。下面就是启动并且注册服务:
// main.go
func main() {
// 监听 TCP 端口
lis, err := net.Listen("tcp", ":9000")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// 创建 gRPC 服务器
s := grpc.NewServer()
// 注册服务:将你的业务实现注册到 gRPC Server 中
// 注意:RegisterStateServiceServer 是生成代码提供的辅助函数
pb.RegisterStateServiceServer(s, state.NewService())
log.Printf("StateServer listening at %v", lis.Addr())
// 启动服务
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
主要就是pb.RegisterStateServiceServer(s, state.NewService())这段代码,它建立了一个映射表。告诉 gRPC 服务器 s:“如果有人发来一个请求说要找 StateService 下的方法,你就直接转交给这个 state.NewService() 实例去处理。”之后就是grpc服务启动进入运行状态,Serve是一个阻塞调用,它会启动一个无限循环,不断地从 lis(监听器)中接收新的连接。每当有一个网关通过grpc连过来,s都会自动启动一个新的协程来处理那个连接。
之后是注册客户端,然后通过服务定义文件中的客户端方法去调用。这部分感兴趣可以去了解grpc,这里不过多说明。
测试结果
我们使用如下代码进行测试:
func main() {
u := url.URL{Scheme: "ws", Host: "localhost:8002", Path: "/"}
log.Printf("Connecting to %s", u.String())
// 建立 WebSocket 连接
c, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
log.Fatal("dial:", err)
}
defer c.Close()
done := make(chan struct{})
// 启动接收协程 (Receive Loop)
go func() {
defer close(done)
for {
_, message, err := c.ReadMessage()
if err != nil {
log.Println("read:", err)
return
}
log.Printf("recv: %s", message)
}
}()
// 发送消息测试 (Send Loop)
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt)
for {
select {
case <-done:
return
case t := <-ticker.C:
// 每秒发一条消息
msg := []byte("Hello Gateway at " + t.Format(time.RFC3339))
err := c.WriteMessage(websocket.TextMessage, msg)
if err != nil {
log.Println("write:", err)
return
}
log.Printf("sent: %s", msg)
case <-interrupt:
// 优雅退出
log.Println("interrupt")
err := c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
if err != nil {
log.Println("write close:", err)
return
}
select {
case <-done:
case <-time.After(time.Second):
}
return
}
}
}
主要就是先和gateway建立Websocket长连接,之后启动两个协程,一个负责读取一个负责写,然后监听信号实现优雅退出。注意这段优雅退出部分:
select {
case <-done:
// 情况 A: 服务器很配合,迅速关闭了连接。
// `done` channel 是在接收协程里关闭的。
// 如果接收协程读到了 EOF 或错误,就会 close(done),这里就会解除阻塞。
case <-time.After(time.Second):
// 情况 B: 服务器反应太慢,或者网络卡住了。
// 我们只等 1 秒,如果不回复,我就不管了,强制退出。
}
return
进程收到信号之后,客户端会发送websocket.CloseMessage,服务端收到后,也回发一个 CloseMessage,客户端收到服务端的回复后,才正式关闭 TCP 连接。