欢迎来到使用Django&Channels、Vue3&WebSocket实现一个即时聊天室教程的第四部分。本次教程的主要内容是让用户发送信息成为可能,而且已经登录的其他用户也可以同时接收到用户发出的消息。
这是本次教程的待办事项:
- 实现发送消息
- 存储聊天记录
- 聊天记录滚动
发送消息
发送消息之前,我们要在前端代码中的聊天室页面中获取到以下信息:
- 当前聊天室的信息。如名称、标识等;
- 当前用户的信息。它可以确定此时谁在发消息。
获取当前聊天室信息
获取用户的信息的功能已经在Part 2完成,没有查看的朋友们可以跳转看看。
现在我们要继续定义一个pinia
,当用户进入到某个聊天室时,在当前页能获取到当前聊天室的信息:
与store/user.js
类似,定义room.js
js
import { defineStore } from 'pinia'
import useAxios from '../composables/useAxios'
const axios = useAxios()
export const useRoomStore = defineStore({
id: 'room',
state: () => ({
room: {
name: null,
slug: null,
},
}),
actions: {
async initStore(room_slug) {
console.log('room initStore')
const token = localStorage.getItem('user.token')
if (token) {
await axios.get(`/chat/${room_slug}/`).then((response) => {
this.room.name = response.room.name
this.room.slug = response.room.slug
})
}
},
},
})
这里获取了当前聊天室的所有信息(名称和标识),通过向服务端请求当前聊天室的所有信息然后存储到pinia
中,以便于我们全局调用当前聊天室的信息。
在聊天室页面发送信息
room.js
编写完成后,我们回到ChatView
中,实现在页面中发送信息:
vue
<template>
<main>
<div class="p-10 text-center lg:p-20">
<h1 class="text-3xl text-white lg:text-6xl">{{ roomStore.room.name }}</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" id="chat-messages">
<div v-for="msg in msgList" class="p-4 bg-gray-200 rounded-xl">
<p class="font-semibold">{{ msg.username }}</p>
<p>{{ msg.content }}</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="请发送消息..."
v-model="chatMsg"
/>
<button
@click="sendMessage()"
class="px-5 py-3 rounded-xl text-white bg-teal-600 hover:bg-teal-700"
>
发送
</button>
</div>
</div>
</main>
</template>
<script setup>
import { useRoute } from 'vue-router'
import { useUserStore } from '../stores/user'
import { useRoomStore } from '../stores/room'
import { onMounted, ref } from 'vue'
const userStore = useUserStore()
const roomStore = useRoomStore()
const route = useRoute()
const axios = useAxios()
const chatSocket = ref('')
const chatMsg = ref('')
const msgList = ref([])
const room_slug = route.params.room_slug
roomStore.initStore(room_slug)
function initChatSocket() {
chatSocket.value = new WebSocket('ws://127.0.0.1:8000/ws/' + room_slug + '/')
// 从服务器收到消息时调用该方法
chatSocket.value.onmessage = function (e) {
console.log('onmessage')
// 将JSON对象转换回原始对象
const msg = JSON.parse(e.data)
// 检查msg的内容并对其进行操作
if (msg.content) {
msgList.value.push(msg)
} else {
alert('消息为空!')
return
}
}
chatSocket.value.onclose = function (e) {
console.log('onclose')
}
}
// 发送消息
function sendMessage() {
// 将msg对象转换为JSON格式的字符串
let msg = JSON.stringify({
content: chatMsg.value,
username: userStore.user.name,
room: roomStore.room.slug,
})
chatSocket.value.send(msg)
chatMsg.value = ''
}
onMounted(() => {
initChatSocket()
})
</script>
页面修改完成之后,还需要修改服务端的consumers
来接收来自客户端的消息:
python
# server/chat_api/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
self.room_group_name = "chat_%s" % self.room_name
await self.channel_layer.group_add(self.room_group_name,
self.channel_name)
await self.accept()
async def disconnect(self, close_code):
await self.channel_layer.group_discard(self.room_group_name,
self.channel_name)
async def receive(self, text_data):
data = json.loads(text_data)
content = data["content"]
username = data["username"]
room = data["room"]
await self.channel_layer.group_send(
self.room_group_name,
{
"type": "chat_message",
"content": content,
"username": username,
"room": room,
},
)
async def chat_message(self, event):
content = event["content"]
username = event["username"]
room = event["room"]
await self.send(text_data=json.dumps({
"content": content,
"username": username,
"room": room
}))
下面让我稍微解释一下新添加的两个方法:
receive()
方法:通过对客户端发起的Websocket帧进行解码后,调用该帧。我们可以在这里发送文本信息text_data
或者图片/视频内容bytes_data
。
-
- 使用
json
库中的loads
方法解析通过客户端连接发送的JSON字符串(用户的输入信息),并将其转换为Python字典 - 通过
data['fields_name']
获取字段的内容 self.channel_layer.group_send
:通过Channels提供的通道层发送消息到房间组chat_(room_name)
中。该消息对象有一个特殊的类型键名,它对应于接收消息对象的使用者调用方法的名称。我们将类型键设置为"type":"chat_message"
,因此我们需要声明一个名为chat_message
的处理函数,该函数将接收这些消息对象并将它们转换为Websocket帧。
- 使用
chat_message()
方法:处理发送给我们的消息对象(客户端的msg
消息对象)。它通过event[fields_name]
的方式从房间组接收消息并将其发送给客户端。
现在客户端和服务端均已设置完成,让我们尝试一下效果:
看起来非常不错!但是现在有一个问题:当我刷新页面时,聊天记录会消失。这说明聊天记录没有保存到数据库内。接下来我们将编写代码把聊天记录存进数据库并通过客户端展示在页面上。
存储聊天记录
创建模型
如果要存储聊天记录,首先需要创建一个用于存储用户消息的数据模型:
python
# chat/models.py
from accounts.models import User
class Message(models.Model):
room = models.ForeignKey(Room, related_name="messages", on_delete=models.CASCADE)
user = models.ForeignKey(User, related_name="messages", on_delete=models.CASCADE)
content = models.TextField()
date_added = models.DateTimeField(auto_now=True)
class Meta:
ordering = ("date_added",)
简单解释一下:
- 通过关联
Room
和User
表模型,确定聊天消息的归属。 - 设置排序规则,让消息按添加日期排列
注:记得使用migrations
和migrate
合并数据库
构建序列化器
接下来,构建MessageSerializer
序列化器来存放需要序列化的数据:
python
# chat/serializers.py
from rest_framework import serializers
from .models import Message
# ...
class MessageSerializer(serializers.ModelSerializer):
username = serializers.SerializerMethodField()
class Meta:
model = Message
fields = ("content", "username")
# 获取该条消息的发送者
def get_username(self, obj):
return obj.user.username
构建接口
序列化器准备好之后,往views.py
添加内容以读取用户发送的消息:
python
# 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, Message
from .serializers import RoomSerializer, MessageSerializer
@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
try:
# 尝试获取与用户所处聊天室相关联的前20条消息
message = Message.objects.filter(room=room)[0:20]
serializer = MessageSerializer(message, many=True)
serializer_data = serializer.data
# 返回带有消息和聊天室信息的响应
return Response(
{
"message": "进入房间成功!",
"room": room_detail,
"message_list": serializer_data
},status=status.HTTP_200_OK
)
except:
# 如果发生异常(没有聊天记录等异常),返回仅带有聊天室信息的响应
return Response(
{"message": "进入房间成功!", "room": room_detail},
status=status.HTTP_200_OK
)
这里使用了try-except
来捕获异常:
try
块中的代码尝试从数据库中获取与特定聊天室相关的最新 20 条消息记录。- 如果获取消息成功,它将对这些消息进行序列化并创建包含消息、聊天室信息和响应状态的字典,然后返回带有这些数据的 HTTP 响应(状态码为 HTTP_200_OK,表示请求成功)。
- 如果在尝试中出现了异常,
except
块会捕获该异常,并提供一个备选的处理方法。 - 在这个情况下,无论出现什么异常,都会返回一个仅包含聊天室信息和成功消息的 HTTP 响应。
这里编写异常捕获的原因是避免因为数据库没有聊天记录而导致视图崩溃报错。
在consumer
中保存消息
让我们继续回到consumers.py
,编写保存聊天记录的方法:
先定义保存消息的方法:
python
# chat/consumers.py
from asgiref.sync import sync_to_async
from accounts.models import User
from .models import Message, Room
@sync_to_async
def save_message(self, username, room, content):
user = User.objects.get(username=username)
room = Room.objects.get(slug=room)
Message.objects.create(user=user, room=room, content=content)
在receive
方法中使用该方法:
python
# chat/consumers.py
async def receive(self, text_data):
data = json.loads(text_data)
content = data["content"]
username = data["username"]
room = data["room"]
# 服务端收到消息后将其保存至数据库
await self.save_message(username, room, content)
await self.channel_layer.group_send(
self.room_group_name,
{
"type": "chat_message",
"content": content,
"username": username,
"room": room,
},
)
客户端获取聊天记录
修改在ChatView
中的代码,完成服务端聊天记录的获取:
vue
<template>
<main>
<div class="p-10 text-center lg:p-20">
<h1 class="text-3xl text-white lg:text-6xl">{{ roomStore.room.name }}</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" id="chat-messages">
<div v-for="msg in msgList" class="p-4 bg-gray-200 rounded-xl">
<p class="font-semibold">{{ msg.username }}</p>
<p>{{ msg.content }}</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="请发送消息..."
v-model="chatMsg"
/>
<button
@click="sendMessage()"
class="px-5 py-3 rounded-xl text-white bg-teal-600 hover:bg-teal-700"
>
发送
</button>
</div>
</div>
</main>
</template>
<script setup>
import { useRoute } from 'vue-router'
import { useUserStore } from '../stores/user'
import { useRoomStore } from '../stores/room'
import { onMounted, ref } from 'vue'
import useAxios from '../composables/useAxios'
const userStore = useUserStore()
const roomStore = useRoomStore()
const route = useRoute()
const axios = useAxios()
const chatSocket = ref('')
const chatMsg = ref('')
const msgList = ref([])
const room_slug = route.params.room_slug
roomStore.initStore(room_slug)
function initChatSocket() {
chatSocket.value = new WebSocket('ws://127.0.0.1:8000/ws/' + room_slug + '/')
chatSocket.value.onmessage = function (e) {
console.log('onmessage')
const msg = JSON.parse(e.data)
if (msg.content) {
msgList.value.push(msg)
} else {
alert('消息为空!')
return
}
}
chatSocket.value.onclose = function (e) {
console.log('onclose')
}
}
function sendMessage() {
let msg = JSON.stringify({
content: chatMsg.value,
username: userStore.user.name,
room: roomStore.room.slug,
})
chatSocket.value.send(msg)
chatMsg.value = ''
}
onMounted(async () => {
// 获取当前聊天室聊天记录
await axios.get(`/chat/${route.params.room_slug}/`).then((response) => {
msgList.value = response.message_list
})
initChatSocket()
})
</script>
很好!现在已经完成了聊天消息的保存,让我们来测试一下!
先发送消息:
刷新后,页面没有任何变化。让我们通过查看数据库再确认一下:
具体信息:
注:记得在chat/admin.py
中注册Message
表
测试成功!在结束之前,我们对客户端的显示做一下优化。
聊天记录滚动
当我们的聊天室页面有很多消息的时候,页面会变得很长。因此,我们可以为聊天页面添加最多高度并自动滚动到底部。
在assets/main.css
中添加滚动条样式
css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-teal-600;
}
}
.chat-messages {
height: 450px;
overflow-y: auto;
}
通过ref
获取聊天页面中的DOM
html
<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"
id="chat-messages"
ref="chatContainer"
>
<div v-for="msg in msgList" class="p-4 bg-gray-200 rounded-xl">
<p class="font-semibold">{{ msg.username }}</p>
<p>{{ msg.content }}</p>
</div>
</div>
</div>
编写滚动方法
-
(url)
import { useRoute } from 'vue-router'
import { useUserStore } from '../stores/user'
import { useRoomStore } from '../stores/room'
import { onMounted, ref, nextTick } from 'vue'
import useAxios from '../composables/useAxios'
const userStore = useUserStore()
const roomStore = useRoomStore()
const route = useRoute()
const axios = useAxios()
const chatSocket = ref('')
const chatMsg = ref('')
const msgList = ref([])
const chatContainer = ref(null)
const room_slug = route.params.room_slug
roomStore.initStore(room_slug)
function initChatSocket() {
chatSocket.value = new WebSocket('ws://127.0.0.1:8000/ws/' + room_slug + '/')
chatSocket.value.onmessage = function (e) {
console.log('onmessage')
const msg = JSON.parse(e.data)
if (msg.content) {
msgList.value.push(msg)
// 发送信息的时候执行滚动方法
scrollToButtom()
} else {
alert('消息为空!')
return
}
}
chatSocket.value.onclose = function (e) {
console.log('onclose')
}
}
function sendMessage() {
...
}
// 控制聊天记录滚动
function scrollToButtom() {
nextTick(() => {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
})
}
onMounted(async () => {
// 获取当前聊天室详情
await axios.get(`/chat/${route.params.room_slug}/`).then((response) => {
msgList.value = response.message_list
})
// 进入聊天室执行滚动方法
scrollToButtom()
initChatSocket()
})
测试结果
从结果来看非常完美!
写在最后
恭喜你已经完成了这个简单的聊天室应用!我们在整个教程中涵盖了很多的知识:Django、Django Rest_ Framework、Django Channels、Websockets和Vue。到目前为止,我们的聊天室应用只有最少的基本功能。欢迎你使用相关开发基础知识来尝试并为其添加更多功能。例如:
- 显示在线用户
- 用户间私聊
- 消息支持发送图片或视频
- 添加AI
- 等等....
我希望通过这个实战项目让您能够大致了解这一切是如何工作的,并可以在您的下一个项目中使用它。感谢您的观看,项目中有什么问题或者优化建议,欢迎大家在评论区中给我反馈!
项目完整源代码:github