使用Django-channels/Vue3&websocket实现一个即时聊天室—Part 4 完成聊天室

欢迎来到使用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",)

简单解释一下:

  • 通过关联RoomUser表模型,确定聊天消息的归属。
  • 设置排序规则,让消息按添加日期排列

注:记得使用migrationsmigrate合并数据库

构建序列化器

接下来,构建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来捕获异常:

  1. try块中的代码尝试从数据库中获取与特定聊天室相关的最新 20 条消息记录。
  2. 如果获取消息成功,它将对这些消息进行序列化并创建包含消息、聊天室信息和响应状态的字典,然后返回带有这些数据的 HTTP 响应(状态码为 HTTP_200_OK,表示请求成功)。
  3. 如果在尝试中出现了异常,except块会捕获该异常,并提供一个备选的处理方法。
  4. 在这个情况下,无论出现什么异常,都会返回一个仅包含聊天室信息和成功消息的 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

相关推荐
运维-大白同学29 分钟前
将django+vue项目发布部署到服务器
服务器·vue.js·django
喜欢猪猪31 分钟前
Django:从入门到精通
后端·python·django
星星会笑滴2 小时前
vue+node+Express+xlsx+emements-plus实现导入excel,并且将数据保存到数据库
vue.js·excel·express
Backstroke fish2 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
临枫5412 小时前
Nuxt3封装网络请求 useFetch & $fetch
前端·javascript·vue.js·typescript
RAY_CHEN.2 小时前
vue3 pinia 中actions修改状态不生效
vue.js·typescript·npm
酷酷的威朗普2 小时前
医院绩效考核系统
javascript·css·vue.js·typescript·node.js·echarts·html5
_Legend_King2 小时前
vue3 + elementPlus 日期时间选择器禁用未来及过去时间
javascript·vue.js·elementui
陈王卜3 小时前
django+boostrap实现发布博客权限控制
java·前端·django
景天科技苑3 小时前
【vue3+vite】新一代vue脚手架工具vite,助力前端开发更快捷更高效
前端·javascript·vue.js·vite·vue项目·脚手架工具