likes
comments
collection
share

Python daphne 如何实现「顶号」功能(避免让用户多开)

作者站长头像
站长
· 阅读数 16

背景

我开发过《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋象棋等游戏。

其中有一个特性,我斟酌了许久:是否允许用户多开?

  • 如果允许多开,那么开发者必须保存同一用户的多个WebSocket连接,发生变更时,给这些连接同时发送一样的消息。当然这些连接发来的消息也必须都能按顺序处理。
  • 如果不允许多开,那么开发者必须花费精力实现「顶号」逻辑,明确告知被顶号的用户——你被下线了。这样才有较好的用户体验。

最终,我选择了「不允许多开」的方案,如图:

Python daphne 如何实现「顶号」功能(避免让用户多开)

因为我考虑到,「多开」对用户没有任何意义,反而会浪费服务器资源。当我们的服务不允许用户「多开」时,针对每个用户只需要保存1个WebSocket连接,提高了性能。

前提知识

我使用的技术是Python Daphne,所以本文也针对这种技术选型来做分享。

你可以参考《用86行代码写一个联机五子棋WebSocket后端》,文章用 daphne 写了个简单的 WebSocket 服务。

daphne 应用中的参数

我们在用 daphne 开发应用时,需要定义一个应用函数application,它有3个参数,分别是scope receive send

官方文档只告诉你,每当有一个 WebSocket 连接建立时,都会新启动一个application协程,把这个请求的上下文存入 scope (包括header、uri等信息),而开发者可以调用 receive 接收客户端数据,调用 send 向客户端发送数据。

但是官方文档没告诉你关于receivesend的更多秘密和原理。我是通过阅读 daphne 源码,再加上自己了解一点点 Python 异步编程,才掌握了他们的高级用法。分享给大家。

receive 和 send 的本质

因为 Python 中通常不会标注类型,所以很多人只是会用,但不了解 receive send 的本质。

通过 daphne 源码,可以找到这一段:

Python daphne 如何实现「顶号」功能(避免让用户多开)

可以看到,receive仅仅只是asyncio.Queue().get,即异步编程中的队列的get方法。

send则是一个partial函数,即附带了默认参数的函数,它指定了调用handle_reply时的第一个参数为protocol。而protocol则是ws_protocol.py中的WebSocketProtocol的实例。

这个protocol里面存储了非常重要的一个变量:application_queue,这个变量,其实就是receive对应的异步队列。

以上是我看了很多天、很多遍源码,才得到的结论,官方文档完全没有提及,现在我直接分享给你!请多看几遍。

理解 receive send 本质后,可以做什么

之前我们提到,我们可以把send函数引用保存下来,从而实现在用户A的协程中给用户B发消息(反之亦然),也可以实现在任意用户的协程中发送广播消息。

如果我们能够把所有用户的application_queue引用保存下来,那么我们可以做更多事情:

  • 可以主动发个假的消息给它,让它receive到来自服务端的消息(而非来自客户端的消息),从而做出反应。这实现了多用户之间的最快速的后端通信。
  • 可以清空它的receive队列,让它不执行任何客户端发送来的指令。

如何获取application_queue引用

问题来了,我们定义application,只获得了3个参数scope receive send,没办法获得application_queue引用呀?怎么办呢?

别急,你记得send是个partial函数吗?有一个默认参数,这个默认参数其实是可以获取的,通过args即可获取。于是:

queue = send.args[0].application_queue

我们获得了application_queue引用。

如何实现「顶号」逻辑

于是,「顶号」逻辑,也可以实现:

当玩家A在另一处登陆时,他上一个 WebSocket 连接,应该立马结束,意思是这个 WebSocket 不应该再处理任何还未开始处理的请求了。我们需要把application_queue清空。并且,立马主动关掉 WebSocket 连接。

queue = self.send.args[0].application_queue
try:
    while True:
        queue.get_nowait()
except QueueEmpty:
    pass
queue.put_nowait({"type": "websocket.close"})

注意这里{"type": "websocket.close"}其实就是一个自定义事件,标准的事件中,没有这个type

随后,需要在application中,链接建立后的while True 每次receive后,判断一下这个事件类型:

async def application(scope, receive, send):
    # ...
    while True:
        event = await receive()
        if event['type'] == 'websocket.close':
            # 自定义事件,用于服务端主动关闭某用户的链接
            await send({'type': 'websocket.close', 'code': 4001})
            break
        # ...

写在最后

我是HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,联系我,交个朋友),转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋象棋等游戏,不收费无广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加Game Jam 2022。喜欢可以关注我噢~我有空了会分享做游戏的相关技术,会在这2个专栏里分享:《教你做小游戏》《极致用户体验》