Django的传统概念围绕着HTTP请求和响应展开:服务器收到一个请求,Django调起为其服务,生成响应并发送,然后Django离开、等着下一个请求。
当互联网只包含简单的浏览器行为时,这没什么问题。但现代网络包含了WebSocket和HTTP 2服务器推送等功能,允许网站在传统请求/响应循环之外进行其他通信。
除此之外,还有许多非关键的任务,需要应用程序将其分发出去,在响应发送后继续处理——比如保存缓存,或为新上传的图像生成缩略图。
Channels将Django的运行方式改为“面向事件”——不仅仅响应请求,而是响应通道中发送的大量事件。这里仍然没有持久状态——每个事件处理程序,也可以叫事件消费者,都是以独立的方式调用的,就像调用视图一样。
让我们先看看通道是什么。
通道是什么?
理所当然,核心就是叫做通道的数据结构。通道是什么?它是一个有序的、先进先出的队列,消息会过期,最多发一次,一次只发给一个监听应用。
可以将其类比为任务队列——生产者将消息发送到一个通道,再发给一个监听该通道的消费者。
最多发一次是指,一条消息要么有一个消费者收到,要么没人收到(例如通道突然崩溃了)。与之对应的是至少一次:正常情况下,一条消息有一个消费者收到,但程序崩溃时,它极有事后可能再重新发送,因此还会有其他消费者重复收到。后者不是我们想要的。
还有其他几个限制——消息必须是可序列化的类型,并且必须保持在一定的大小限制之内——但是在接触更高级的用法之前,无需担心这些实现细节。
通道有容量,即便没有消费者,生产者也可以先向通道中写入大量消息,消费者稍后出现再接收队列中的消息。
如果你使用过Go通道:Go通道与Django通道相当像,关键区别在于Django通道是网络透明的,在不同进程甚至不同计算机上运行的生产者和消费者,都可以通过网络访问到我们的通道。
在网络中,我们通过名称字符串来唯一地标识通道——任何计算机可以将消息发送到任何命名通道,只要它们接入了同一个通道后端。例如两台不同的计算机都写入叫做“http.request”的通道,他们写入的就是同一个通道。
如何使用通道?
那么Django如何使用这些通道呢?在Django中,您可以编写一个函数来使用通道:
def my_consumer(message):
pass
然后在通道路由中为其分配一个通道:
channel_routing = {
"some-channel": "myapp.consumers.my_consumer",
}
该通道每收到一条消息,Django都调用这个消费者函数,并传入message参数。message带有content、channel和其他一些属性,其中content属性一定是字典(dict)类型,channel属性用来标明发送消息的通道名称。
Channels将Django从传统的请求/响应模式,改为工作进程模式:监听所有分配了消费者的通道,收到消息就运行对应的消费者。现在,Django不只是在一个连接到WSGI服务器的进程中运行,而是在三个独立的层中运行:
- 接口服务器,用于Django与外部世界之间的通信。这包括一个WSGI适配器以及一个单独的WebSocket服务器——这在运行接口服务器中进行了介绍。
- 通道后端,其中包括可扩展的Python代码,以及用来传输消息的数据存储机制(例如Redis或共享内存段)。
- 工作进程,监听所有相关的通道,并在消息就绪时运行消费者代码。
这或许看起来很简单,但我们就是这样计划的:与其尝试完全异步的架构,不如将现有的Django视图再稍微复杂抽象一点。
A view takes a request and returns a response; a consumer takes a channel message and can write out zero to many other channel messages.
现在,让我们建立一个请求通道(取名http.request
),以及面向单个客户端的响应通道(例如http.response.o4f2h2fd
),请求通道中的消息带有一个reply_channel属性,对应响应通道的名称。这样,消息消费者就很类似一个视图:
# 监听http.request
def my_consumer(message):
# 将请求信息从message中解码,生成Request对象
django_request = AsgiRequest(message)
# 运行视图
django_response = view(django_request)
# 将响应编码为message格式
for chunk in AsgiHandler.encode_response(django_response):
message.reply_channel.send(chunk)
这就是通道的机制。接口服务器将外部连接(HTTP、WebSocket等)转换为通道中的消息,你编写工作进程来处理这些消息。正常情况下,普通的HTTP请求还是交给Django内置的消费者,后者将请求传入视图/模板系统,但如果需要,也可以重写它以添加功能。
关键的部分在于,你可以运行代码来响应任何事件——包括你自己创建的事件,运行的代码还可以进一步发消息。你可以在保存Model、其他Views和Forms的代码中触发事件。这样可以方便的写推送程序,例如使用WebSocket或HTTP长轮询来实时通知客户端(比如聊天信息,或者Admin实时查看其他用户编辑更新的内容)。
通道类型
通道主要有两种用途。第一种显而易见,就是把工作分配给消费者——通道中新增一条消息,任何一个工作进程都可以接收消息并运行消费者。
第二种通道用于响应(HTTP、WebSocket)请求。值得注意的是,只有接口服务器才会监听这种通道的消息。每个响应通道名字都不一样,并且必须路由回到特定的接口服务器。
两者区别不大——它们都符合通道的核心定义——但当服务器集群扩大规模时会有问题。对于第一类普通通道,我们可以在通道服务器集群和工作进程中无差别的做负载均衡,任何工作进程都可以通用的处理这些消息。但响应消息只能发送到通道服务器群中、正在监听该响应的通道服务器上。
因此,Channels将其视为两种不同的通道类型,在响应通道名称中包含感叹号!
来标识,例如http.response!f5g3fe21f
。普通通道名称不包含感叹号。此外,通道名称都只能包含字符a-z A-Z 0-9 - _
,并且长度小于200个字符。
对于后端实现来说,不一定要处理这个区别——只有扩大服务器集群规模时,才有必要分别处理这两类通道。有关规模化的更多信息,以及编写后端或接口服务器时如何处理通道类型,请参见:规模化。
分组
通道只能向一个监听方发送消息,不能广播。如果要向一批客户端发送消息,你需要记录所有客户端对应的响应通道。
如果我有一个Liveblog,想在发布新帖子时推送更新,我可以注册一个程序处理post_save
信号,同时得记下要发送更新的通道(这个例子使用Redis):
redis_conn = redis.Redis("localhost", 6379)
@receiver(post_save, sender=BlogUpdate)
def send_update(sender, instance, **kwargs):
# 遍历所有的reply channels,发送更新
for reply_channel in redis_conn.smembers("readers"):
Channel(reply_channel).send({
"text": json.dumps({
"id": instance.id,
"content": instance.content
})
})
# 连接到websocket.connect
def ws_connect(message):
# 添加到readers集合
redis_conn.sadd("readers", message.reply_channel.name)
这段代码可以运行,但还有点小问题——客户端中断连接的时候,我们要记得把他们从readers中删除。为此我们要添加另一个消费者程序,监听和处理websocket.disconnect
消息。而且,接口服务器可能遇到强退、断电等情况,来不及发送disconnect信号,你的代码永远收不到disconnect通知,但此时响应通道已经完全失效了,为此我们还需要添加过期机制,发送到响应通道的消息等待一段时间后会过期、被删除,
由于通道的设计基础是无状态的,所以如果接口服务器中断连接,通道服务器也无所谓“关闭”一条通道——通道的任务就是保存消息直到消费者出现(对某些类型的接口服务器比如SMS网关,理论上可以为来自任何接口服务器的任何客户端提供服务)。
我们不太介意断开连接的客户端是不是没收到消息——是它自己断开的——但持续维护这些断开的客户端会让通道后端很乱,累积造成通道名称重复、发错消息,这让我们很介意。
现在,我们回到前面的示例,需要添加过期集合、跟踪过期时间等等,但使用框架的意义就是替你实现这些重复的工作。于是Channels将此抽象实现为一个核心概念,叫做组:
@receiver(post_save, sender=BlogUpdate)
def send_update(sender, instance, **kwargs):
Group("liveblog").send({
"text": json.dumps({
"id": instance.id,
"content": instance.content
})
})
# 连接到websocket.connect
def ws_connect(message):
# 添加到readers组
Group("liveblog").add(message.reply_channel)
# Accept the connection request
message.reply_channel.send({"accept": True})
# 连接到websocket.disconnect
def ws_disconnect(message):
# Remove from reader group on clean disconnect
Group("liveblog").discard(message.reply_channel)
组不仅有自己的send()方法(后端可以提供高效的实现),而且还自动管理组成员过期——当通道消息没人读而开始过期时,我们找到所有包含该通道的组,将其从中删除。当然,如果能正常收到disconnect消息的话,你还是应该在断开连接时将渠道从组中删除,过期机制是为了解决无法正常收到disconnect消息的情况。
组通常只用于响应通道(包含感叹号!
的通道),但如果你愿意,也可以用于普通通道。
下一步
这是对通道和组的高级概述,帮助你形成一些初步概念。要记住,Django提供了一些通道,但您可以自由地创建和使用自己的通道,所有通道都是网络透明的。
另外,通道不保证消息发送成功。如果你需要确保任务完成,请使用专门设计的、带有重试和持久化功能的系统(例如Celery)。你也可以创建管理命令,如果有任务没完成,就再次向通道提交消息(换言之,自己维护重试逻辑)。
我们将在其余文档中更多地介绍什么任务适合用通道,现在让我们进到开始来写些代码。