使用Django-channels/Vue3&websocket实现一个即时聊天室—Part 3 channels初探

798 阅读10分钟

前言

欢迎来到使用Django&Channels、Vue3&WebSocket实现一个即时聊天室教程的第三部分。本次教程主要是编写聊天室的接口以及channels的基本使用。

这是本次教程的待办事项:

  • 创建聊天室并编写模型
  • 聊天室列表展示
  • 获取聊天室详情
  • channels的配置
  • channels与websocket的通信

创建聊天室并编写模型

创建app

accounts子app一样,输入以下命令创建:

python manage.py startapp chat

settings.py中注册

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # 添加新app
    "accounts",
    "chat",
    # 第三方库,如:rest-framework、channels等...
]

编写模型

我们需要创建一个模型来存放聊天室。在server/chat/models.py下创建聊天室模型:

from django.db import models
from accounts.models import User

class Room(models.Model):
    name = models.CharField(max_length=255)
    slug = models.SlugField(unique=True)
    
    def get_absolute_url(self):
        return f"/{self.slug}/"

    def __str__(self) -> str:
        return self.name

slug即标签的意思,主要用于标记单个数据项,多用于链接地址;如表示某个聊天室的网络地址:http://localhost:8000/rooms/game/,game就是slug字段存储的数据。

编写完成后,让我们合并数据库:

python manage.py makemigrations
python manage.py migrate

添加示例数据

在展示聊天室列表之前,让我们先往数据库内添加几条数据:

让我们打开终端,在python shell中键入以下命令:

# 进入shell
python manage.py shell
# 在python终端下执行命令
>>> from chat.models import Room
>>> items = [
    Room(name='Coding',slug='coding'),
    Room(name='Games',slug='games'),
    Room(name='Work',slug='work'),
    Room(name='Life',slug='life')
]
# 使用bulk_create批量创建数据,注意输入的数据需要保证与数据模型字段对应
>>> Room.objects.bulk_create(items)
[<Room: Coding>, <Room: Games>, <Room: Work>, <Room: Life>]
>>> all_rooms = Room.objects.all()
>>> all_rooms
<QuerySet [<Room: Coding>, <Room: Games>, <Room: Work>, <Room: Life>]>

我们可以在admin页面中查看结果:

再查看一下单条数据的内容是否正确:

非常完美,我们已经成功插入了数据,接下来让它们在前端页面展示吧!

聊天室功能实现

要将数据展示至前端,需要先制作数据接口供前端请求,下面让我们编写数据接口:

构建聊天室api

构建序列化器

# server/chat/serializers.py
from rest_framework import serializers
from .models import Room


class RoomSerializer(serializers.ModelSerializer):
    class Meta:
        model = Room
        fields = ("name", "slug", "get_absolute_url")

构建接口

# server/chat/views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status

from .models import Room
from .serializers import RoomSerializer


@api_view(['GET'])
@permission_classes([IsAuthenticated])
def room_list(requset):
    rooms = Room.objects.all()
    serializer = RoomSerializer(rooms, many=True)
    serializer_data = serializer.data

    return Response({
        "message": "获取房间列表成功!",
        "rooms": serializer_data
    },status=status.HTTP_200_OK)


@api_view(['GET'])
@permission_classes([IsAuthenticated])
def room_detail(request, slug):
    room = Room.objects.get(slug=slug)
    roomSerializer = RoomSerializer(room)
    room_detail = roomSerializer.data
    return Response({
        "message": "进入房间成功!", 
        "room": room_detail
    },status=status.HTTP_200_OK)

这里定义了两个API视图:

  • 聊天室列表(room_list),用于获取聊天室列表以及聊天室的详细信息;
  • 聊天室详情(room_detail),用户获取某个聊天室的数据,需要通过前端传递slug参数来访问特定聊天室。

注:这里使用了IsAuthenticated权限类来对请求进行验证,要求用户在请求时进行认证。

准备路由

# server/chat/urls.py

from django.urls import path

from . import views

urlpatterns = [
    path("", views.room_list, name="rooms_list"),
    path("<slug:slug>/", views.room_detail, name="room_detail"),
]

还需要在主路由上挂载app的路由:

# server/chat_api/urls.py
from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("auth/", include("accounts.urls")),
    # 挂载chat
    path("chat/", include("chat.urls"))
]

测试接口

测试接口前,先运行登录接口获取Token

测试获取聊天室列表接口:

测试接口前为请求添加请求头

测试接口:

测试聊天室详情接口:

测试大获成功!现在我们编写前端页面将数据展示出来。

构建聊天室

页面设计

RoomsView
<template>
  <main>
    <div class="p-10 text-center lg:p-20">
      <h1 class="text-3xl text-white lg:text-6xl">Rooms</h1>
    </div>

    <div class="w-full flex flex-wrap items-center">
      <div class="w-full p-3 lg:w-1/4">
        <div class="p-4 bg-white shadow rounded-xl text-center">
          <h2 class="mb-5 text-2xl font-semibold">Coding</h2>
          <router-link
            :to=""
            class="px-5 py-3 block rounded-xl text-white bg-teal-600 hover:bg-teal-700"
            >加入房间</router-link
                   >
        </div>
      </div>
    </div>
  </main>
</template>
ChatView
<template>
	<main>
		<div class="p-10 text-center lg:p-20">
			<h1 class="text-3xl text-white lg:text-6xl">Coding</h1>
		</div>

		<div class="mx-4 p-4 bg-white rounded-xl lg:w-2/4 lg:mx-auto">
			<div class="chat-messages space-y-3 mb-3">
				<div class="p-4 bg-gray-200 rounded-xl">
					<p class="font-semibold">sanapri</p>
					<p>hello</p>
				</div>
			</div>
      <div class="chat-messages space-y-3 mb-3">
				<div class="p-4 bg-gray-200 rounded-xl">
					<p class="font-semibold">cerelise</p>
					<p>hi</p>
				</div>
			</div>
		</div>

		<div class="mt-6 mx-4 p-4 bg-white rounded-xl lg:w-2/4 lg:mx-auto">
			<div class="flex">
				<input
					type="text"
					class="flex-1 mr-3 focus:outline-none"
					placeholder="请发送消息..."
				/>
				<button
					class="px-5 py-3 rounded-xl text-white bg-teal-600 hover:bg-teal-700"
				>
					发送
				</button>
			</div>
		</div>
	</main>
</template>

添加页面路由

{
	path: '/rooms',
	name: 'rooms',
	component: RoomsView,
},
{
	path: '/chat/:room_slug',
	name: 'chat',
	component: ChatView,
},

添加并测试请求(RoomsView)

在测试前先修改一下错误:在userAxios.js下:

instance.interceptors.request.use(
		(config) => {
			const token = localStorage.getItem('user.token')
			if (token) {
        // 修改为Authorization
				config.headers['Authorization'] = 'Token ' + token
			}
			return config
		},
		(error) => {
			return Promise.reject(error)
		}
	)

修改RoomsView,使聊天室列表展示出来:

<template>
	<main>
		<div class="p-10 text-center lg:p-20">
			<h1 class="text-3xl text-white lg:text-6xl">Rooms</h1>
		</div>
		<div class="w-full flex flex-wrap items-center">
			<div v-for="room in rooms" class="w-full p-3 lg:w-1/4">
				<div class="p-4 bg-white shadow rounded-xl text-center">
					<h2 class="mb-5 text-2xl font-semibold">{{ room.name }}</h2>
					<router-link
						:to="'chat' + room.get_absolute_url"
						class="px-5 py-3 block rounded-xl text-white bg-teal-600 hover:bg-teal-700"
						>加入房间</router-link
					>
				</div>
			</div>
		</div>
	</main>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import useAxios from '../composables/useAxios'

const axios = useAxios()

const rooms = ref([])

function getRoomList() {
	axios.get('chat/').then((res) => {
		console.log(res)
		rooms.value = res.rooms
	})
}

onMounted(() => {
	getRoomList()
})
</script>

修改完成后,启动项目,用浏览器打开http:localhost:3000查看结果:

如上图所示,聊天室列表已成功展示!

注:由于ChatView涉及到channels和websocket的使用,该页面将于下一节讲解

Channels初探

Channels是一个基于Django框架的基础上研发的一个功能库,添加了对Websocket、MQTT(消息队列遥感传输)、聊天机器人等实时功能的支持。在Django,Channels被构建为ASGI服务器。而且它允许开发者选择如何编写代码——使用同步、异步或者两者混搭的方式编写Django视图函数。

值得注意的是,虽然与普通的Django项目不同,使用Channels编写的项目需要ASGI服务支持而不是WSGI,但是Channels并不是Django现有的请求/响应模型的替代,仅仅只是对原有框架的扩展。

为什么需要ASGI服务?

一般使用Django开发的web项目都是使用Django自带的WSGI(web服务器网关接口),它是Python应用处理请求的接口。但是为了处理异步应用,我们需要使用另一个接口:ASGI,即异步服务器网关接口,使用它可以处理websocket请求。

在正式使用channels之前,我们需要先为项目配置ASGI服务

配置ASGI服务

settings.py

首先,在settings.py中,我们找到WSGI_APPLICATION的下方,添加上ASGI服务指向:

# server/chat_api/settings.py

ASGI_APPLICATION = "chat_api.asgi.application"

注意:要正确填写你的项目名称

现在,将Channels添加到INSTALLED_APP

INSTALLED_APPS = [
	...
    "channels",
]

asgi.py

在asgi.py中,添加以下代码:

import os
from channels.routing import ProtocolTypeRouter
from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project_name.settings')

application = ProtocolTypeRouter({
    "http":get_asgi_application(),
    # 现在已经添加了HTTP,我们还可以在该配置中添加其他通信协议
})

什么是ProtocolTypeRouter?

ProtocolTypeRouter是channels使用的一种特殊路由,它允许开发者根据网络请求类型将进入django应用程序的请求重定向到合适的视图。这对于需要支持多种协议类型(如HTTP和Websocket)的应用程序非常有用。ProtocolTypeRouter还允许开发者将每个协议类型映射到对应的视图集中,从而使不同协议各有所属,并帮助开发者在客户端和服务器之间创建安全的通信通道。

Channel Layers

channel layers是一种允许django应用的多个实例进行通信和交换消息的机制。通道层(channnel layers)通常在构建分布式应用程序中使用,因为它们不需要所有消息都通过数据库。

请注意,这里我们可以通过以下两种方式设置通道层:

  • InMemoryChannelLayer
  • Channels_redis

在这里,我会介绍两种设置通道层方式:

InMemoryChannelLayer

CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels.layers.InMemoryChannelLayer"
    }
}

如果应用只是用于测试和本地开发的话,我们可以选择使用内存层中的通道,但是您不应该在生产环境下使用该层,因为:

内存通道层将每个进程作为单独的层进行操作。这意味着不可能进行跨进程消息传递。由于通道层的核心价值是提供分布式消息传递,因此内存使用将导致性能下降,并最终在多实例环境中导致数据丢失等问题。

channels_redis

Channels_redis是官方提供的通道层,使用Redis作为其消息存储的载体。使用Redis,我们可以将数据存储在其缓存实例中,并直接从运行Redis服务的服务器的内存中进行搜索,而不是直接从数据库中查询。一通操作下来,我们可将聊天消息发送到接收者之前将它保存在消息队列中。

设置Channels_redis之前,您需要在本地安装一个redis。根据不同的操作系统,可以使用不同的方法安装redis。为此,您可以查看官方文档或者问度娘。

下一步,在虚拟环境(env)下,安装channels_redis包,以便Channels知道接下来如何与redis进行交互。

pip install channels_redis

接下来是最后一步,在settings.py中添加CHANNEL_LAYERS配置:

# server/chat_api/settings.py
CHANNEL_LAYERS = {
    "default": {
        "BACKEND": "channels_redis.core.RedisChannelLayer",
        "CONFIG": {
            "hosts": [("127.0.0.1", 6379)],
        },
    },
}

由于我们处于开发阶段,而且正在构建一个比较简单的聊天室应用程序,因此我在这不会使用channels_redis的方法。

好了,现在我们已经完成了ASGI的配置,让我们继续下一部分。

后端:初始化channels

consumers.py

现在让我们使用Channels给Django应用添加Websocket。与Django中接收和处理HTTP请求的views类似,在Channels中有一个叫consumers的视图集合,用于Channels接收和处理同步/异步请求,可以让Django应用具备处理Websocket连接的能力。

接下来在chat应用中创建一个名为consumers.py的文件,并输入以下内容:

# server/chat_api/consumers.py
from channels.generic.websocket import AsyncWebsocketConsumer

class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        # 从 URL 路由中提取房间名称
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
        # 使用提取的房间名称为此房间创建一个组名称
        self.room_group_name = "chat_%s" % self.room_name
        # 将通道层(Websocket连接)添加到组中,让其可以将消息广播
        await self.channel_layer.group_add(self.room_group_name,
                                           self.channel_name)
        # 接受来自Websocket的连接
        await self.accept()

    async def disconnect(self, close_code):
        # 连接正在关闭的时候,删除前后端之间的通道
        await self.channel_layer.group_discard(self.room_group_name,
                                               self.channel_name)

这段代码定义通过继承AsyncWebsocketConsumer实现的ChatConsumer类。该类视图函数负责处理聊天室的Websocket连接。当建立连接时,它会将频道(连接)添加到聊天室特定的组中,以便可以向该聊天室中的所有参与者广播消息。当连接关闭时,它会从组中删除通道。

Channels允许异步处理Websocket连接,但使用者类是同步的。您在安装Django的时候,包含ASGI基础库的asgiref包会作为依赖包自动安装。它提供的sync模块用于封装要在同步消费者中使用的异步通道层方法。ChatConsumer类封装了Websocket处理模型,允许其他应用程序在此基础上构建。

任何Consumer都有self.channel_layerself.chanel_name属性,它们分别是指向通道层实例和特定通道名称的指针。

routing.py

与Django的urls.py具有连通views.py的路由类似,Channals也有一个routing.py用于连通consumers.py。因此,我们继续在chat应用中创建routing.py

from django.urls import path

from . import consumers

websocket_urlpatterns = [
    path("ws/<str:room_name>/", consumers.ChatConsumer.as_asgi()),
]

as_asgi()方法类似我们在使用基于类视图时调用的as_view()方法。它的作用是返回一个ASGI应用程序。

asgi.py

接下来,跳转回chat_api/目录下,对asgi.py进行以下修改:

import os
from django.core.asgi import get_asgi_application

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter

from chat import routing

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'server.settings')

application = ProtocolTypeRouter({
    "http":
    get_asgi_application(),
    "websocket":
    AuthMiddlewareStack(URLRouter(routing.websocket_urlpatterns))
})
  • 上面的代码将主路由配置指向chat应用中的routing模块。这意味着当与开发服务器(Channels的开发服务器)建立连接时,ProtocolTypeRouter会检查它是普通HTTP请求还是Websocket请求。
  • 如果它是一个WebsocketAuthMiddlewareStack将从routing中获取它,并使用当前已经通过身份验证的用户填充连接的scope,类似与Django的AuthenticationMiddleware通过当前已经通过验证的用户填充视图函数的请求。
  • 接下来,URLRouter将根据routing提供的url将连接路由提供给特定的使用者。

前端:使用websocket建立即时通信连接

接下来修改前端项目下的viewsChatView以建立前后端的Websocket连接:

<template>...</template>
...
<script setup>
  import { useRoute } from 'vue-router'
  import { onMounted, ref } from 'vue'
  
  const chatSocket = ref('')
  const route = useRoute()

  // 获取当前聊天室连接
  const room_slug = route.params.room_slug

  function initChatSocket() {
    chatSocket.value = new WebSocket(
      'ws://127.0.0.1:8000/ws/' + route.params.room_slug + '/'
    )

    chatSocket.value.onmessage = function (e) {
      console.log('onmessage')
    }

    chatSocket.value.onclose = function (e) {
      console.log('onclose')
    }
  }

  onMounted(async () => {
    initChatSocket()
  })
</script>

script中,创建了一个Websocket连接到指定聊天室。这里使用从route中获取的参数room_slug,作为进入特定聊天室的凭证,向后端发起Websocket请求。

在后端中查看连接测试结果:

现在,前后端的websocket连接已经成功建立!

写在最后

下一章节我会完善聊天功能,并将聊天记录存储至数据库,完成聊天室的制作。

可能我对Channels的认知并不是很透彻,理解有错误的地方欢迎批评指正!

本章节的代码:github