Orleans实战——接入WebSocket

35 阅读4分钟

游戏开发中,服务端主动通知客户端是必不可少的需求场景,比如:

  • 玩家A发送一条聊天消息,要推送给所有在线玩家
  • MMO中玩家B移动,要推送给AOI范围内所有玩家

在端游或Native手游中,常用的是TCP或基于框架的可靠UDP(比如enet、kcp);

在H5及小游戏平台,由于平台限制,通常使用WebSocket。

小游戏近些年势头很猛,大部分游戏通常都会发布到小游戏平台,同时Native也支持WebSocket,因此这类游戏可以考虑直接采用WebSocket来做。

那如何在Orleans中集成WebSocket功能呢?

  • 本文不具体介绍如何实现WebSocket服务器,而是讲如何与Orleans配合使用。
  • 本文仅讨论通知客户端的流程。客户端上行流程较为简单,可直接使用WebSocket,也可使用HTTP

单进程

适合小型游戏,单个服务端进程即可承载整个游戏或单服的服务端。

在这种情况下集成较为简单,无需考虑跨进程问题。

image.png

主要逻辑在WSConnectionMgrGrain中,负责管理PlayerId↔ConnectionId,主要接口如下:

public interface IWSConnectionMgrGrain : IGrainWithIntegerKey
{
    /// 添加连接,有连接上来就添加,同时内部可以有定时器清理过期没有验证的连接
    Task AddConnection(string connectionId);

    /// 添加有效连接,验证通过后调用
    Task AddValidConnection(string playerId, int serverId, string connectionId);

    /// 连接断开,移除连接
    Task RemoveConnection(string connectionId);

    /// 下发通知消息
    void Notify(string playerId, byte[] notify);

    // 其他Notify
}
  1. 客户端连接上来后,WebSocket框架会调用AddConnection接口添加连接
await _grainFactory.GetGrain<IWSConnectionMgrGrain>(0).AddConnection(connectionId);

2. 客户端验证通过后,调用AddValidConnection接口添加有效连接

await _grainFactory.GetGrain<IWSConnectionMgrGrain>(0).AddValidConnection(playerId, serverId, connectionId);

3. 客户端断开连接时,调用RemoveConnection接口移除连接

await _grainFactory.GetGrain<IWSConnectionMgrGrain>(0).RemoveConnection(connectionId);

4. 其他业务Grain需要下发通知时,调用Notify接口

_grainFactory.GetGrain<IWSConnectionMgrGrain>(0).Notify(playerId, notify);

具体代码可以参考我写的Sample

单WebSocket节点多Soil

适合中等规模的游戏:整个服务由多个进程组成,但单个WebSocket节点可以承载所有连接。

在此场景下集成较为简单,无需处理跨节点的问题。

image.png

主要类和接口与单进程方案相同,数据流向与单进程方案类似。但部署方式不同:

  • WebSocket框架仅在该节点启动一次,
  • WSConnectionMgrGrain 需要在WebSocket框架节点上
  • 其他业务 Grain 可部署在任意节点。

为了实现这种部署方案,可以使用Orleans的Placement策略,指定这个Grain只部署在提供WebSocket的Soil节点上:

  1. 启动时通过配置指定是否启动WebSocket服务,区分提供WebSocket的Soil节点与其他节点。
  2. 在提供WebSocket的节点上,启动时创建 WSConnectionMgrGrain 实例,并将其Placement指定为LocalPlacement,同时设置 KeepAlive,以保证该Grain仅部署在该节点上。

当然还可以自定义Placement策略来实现只在指定节点上部署Grain实例。具体可以参考Orleans官方文档

具体代码可以参考我写的Sample

多WebSocket节点

这种扩展性最好,适合大型游戏。但是实现起来也比较复杂,需要考虑跨节点问题。

image.png

核心改动:

  • 将之前的 WSConnectionMgrGrain 改为 WSConnectionService,负责管理当前 Soil 节点上的 WebSocket 连接
  • WSConnectionService订阅Orleans的Stream以接收其他节点发送的通知。
  • WSConnectionService主要接口去掉 Notify(由Stream传递),仅保留连接相关操作
  • WSConnectionService线程不安全的,需要自行处理并发导致的线程安全问题
public interface IWSConnectionService
{
    /// 添加连接,有连接上来就添加,同时内部可以有定时器清理过期没有验证的连接
    void AddConnection(string connectionId);

    /// 添加有效连接,验证通过后调用
    void AddValidConnection(string playerId, int serverId, string connectionId);

    /// 连接断开,移除连接
    void RemoveConnection(string connectionId);
}

订阅Stream的示例代码如下(示例中使用JSON序列化):

_streamProvider = _clusterClient.GetStreamProvider("MemoryStreamProvider");
var streamId = StreamId.Create("""WSConnection");
_stream = _streamProvider.GetStream<NotifyStreamMessage>(streamId);
_subscriptionHandle = await _stream.SubscribeAsync(async (msg, token) =>
{
    if (msg.Id == 1)
    {
        // 有其他服务器的玩家连接进来,断开本服务器的该玩家连接

        // 解析消息(使用JSON序列化)
        var json = System.Text.Encoding.UTF8.GetString(msg.Data);
        var obj = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(json);

        // 获取玩家ID和连接ID
        var playerId = obj["PlayerId"];
        var connectionId = obj["ConnectionId"];
        lock (_lock)
        {
            // 检查是否有该玩家的旧连接
            if (_playerId2ConnectionIdDict.TryGetValue(playerId, out var existingConnectionId))
            {
                if (existingConnectionId != connectionId)
                {
                    // 断开旧连接,断开之前可以发个顶号消息
                    var oldConnection = _hubContext.Clients.Client(existingConnectionId) as HubCallerContext;
                    oldConnection?.Abort();
                    _playerId2ConnectionIdDict.Remove(playerId);

                    // ... 其他清理工作 ...
                }
            }
        }
    }
    else if (msg.Id == 2)
    {
        // 向指定玩家发送通知

        // 解析消息(使用JSON序列化)
        var json = System.Text.Encoding.UTF8.GetString(msg.Data);
        var obj = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(json);

        // 获取玩家ID和消息内容
        var playerId = obj["PlayerId"];
        var message = obj["Message"];
        lock (_lock)
        {
            // 查找玩家对应的连接ID
            if (_playerId2ConnectionIdDict.TryGetValue(playerId, out var connectionId))
            {
                // 发送通知
                _ = _hubContext.Clients.Client(connectionId).SendAsync("Notify", System.Text.Encoding.UTF8.GetBytes(message));
            }
        }
    }
});

其他业务Grain需要下发通知时,调用StreamPush方法(本文示例在Controller中调用):

// 发送通知消息给指定玩家
await _clusterClient.GetStreamProvider("MemoryStreamProvider")
    .GetStream<NotifyStreamMessage>(StreamId.Create("""WSConnection"))
    .OnNextAsync(new NotifyStreamMessage
    {
        Id = 2,
        Data = Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(new Dictionary<stringstring>
        {
            { "PlayerId", playerId },
            { "Message", message }
        }))
    });

具体代码可以参考我写的Sample

总结

以上就是我对Orleans集成WebSocket的思路和方案介绍,不同规模的游戏可以选择不同的方案来集成WebSocket功能。希望对大家有所帮助!