基于vue和django的网络聊天室实现

ryannas 2022-01-18 19:52:34

基本需求介绍

  1. 允许用户注册、登录;

  2. 允许用户创建和加入群聊;

  3. 允许用户将信息发送到群聊内,群聊内的所有用户都可以接收到笑嘻;

  4. 需要存储用户列表、历史信息等;

  5. 使用session记录用户登录信息。

项目设计方案

技术选型

使用Django REST Framework来开发后端,使用vue开发前端。

基本开发思路

后端提供两种API,一种是WebSocket API,用于用户在群聊中发送聊天信息,另一种是Restful API,用于用户登录、注册、历史消息等。

对于WebSocket,后端可以使用Channels来进行开发,前端可以使用HTML原生的WebSocket来进行开发。

项目构建和部署演示

对于前端,基本运行步骤为:

 npm install
 npm run serve

对于后端,首先需要进行数据库迁移:

 python manage.py makemigrations
 python manage.py migrate

Channels需要使用redis,所以在安装好redis后启动redis server,在此之后

 python manage.py runserver

即可运行项目。

在浏览器中打开前端网址,首先看到的是用户登录页面,输入用户名和密码即可登录。

 

如果没有账号,点击login下的click to register即可进入注册页面,注册仅需用户名和密码。

 

 

登录后,即可看到主界面,左侧的加入群组和创建群组是按钮,这之后的是进入某个群组的按钮。

 

点击加入群组,可以从已有的、未加入的群组中选择一个群组加入。

 

例如这里选择room1加入,即可看到左侧多出了一个room1。

  

点击创建群组,只需要输入群组名,即可创建并加入新群组。

 

例如这里创建群组user1_group,即可看到左边多出一个群组。

 

选择某一群组,可以看到其历史消息。

项目源码分析

后端部分

项目结构

|-backend
   |-backend           # 基本配置文件夹
   | |-__init__.py 
   | |-asgi.py         # websocket的相关配置
   | |-settings.py     # 项目设置
   | |-urls.py         # Restful API的全局路由配置
   | |-wsgi.py
   |-chat              # 群聊的实现
   | |-__init__.py
   | |-admin.py
   | |-apps.py
   | |-models.py
   | |-tests.py
   | |-urls.py         # websocket基本路由配置
   | |-views.py        # 使用channels实现的websocket群聊主逻辑
   |-chatroom          # 聊天室的实现
   | |-__init__.py
   | |-admin.py
   | |-apps.py
   | |-models.py       # 聊天室关系
   | |-serializers.py  # 聊天室序列化器
   | |-tests.py
   | |-urls.py         # 聊天室相关的路由配置
   | |-views.py        # 聊天室相关的Restful API实现
   |-message           # 聊天信息的实现
   | |-__init__.py
   | |-admin.py
   | |-apps.py
   | |-models.py       # 聊天信息关系
   | |-serializers.py  # 聊天信息序列化器
   | |-tests.py
   | |-urls.py         # 聊天信息相关的路由配置
   | |-views.py        # 聊天室相关的Restful API实现
   |-static            # 静态文件夹
   | |- default.png
   |-user              # 用户
   | |-__init__.py
   | |-admin.py
   | |-apps.py
   | |-models.py       # 用户关系
   | |-tests.py
   | |-urls.py         # 用户相关的路由配置
   | |-views.py        # 用户相关的Restful API实现

消费者(view)

consumer是channels中websocket的处理者,相当于是一个抽象的websocket连接,它是前端请求的处理者,相当于controller。这个概念和view很类似。对于一个websocket,我们通常需要定义三个回调函数:

  • function onConnect: 当websocket连接建立,就自动调用该函数;

  • function onMessage: 当websocket接收方接收到一个消息,就自动调用该函数;

  • function onDisconnect: 当websocket连接关闭,就自动调用该函数。

channels提供了一个基类WebsocketConsumer,我们的consumer只需要继承该基类,重载其回调函数即可。

不过我们现在需要的功能是群聊,此时后端需要维护多个websocket连接,并且对于每一条websocket连接,后端只要收到消息,就要将其转发给所有的websocket连接,从而实现群聊的功能。

  1. 构造函数

    定义构造函数是因为我们有一些变量需要定义,为了编码规范,就定义了构造函数,在构造函数中定义变量。其实在回调函数中定义变量也是可以的。

    在这个构造函数中,我们定义了三个变量:

    • username: 用户名,必须要加,因为后端会对信息进行一次转发,如果没有这个字段,所有websocket客户端都无法知道信息是谁发送的。当然,直接添加这个字段会有一些安全性的问题,之后我们会考虑使用channels中的认证组件解决;

    • room_name: 聊天室名称,是一个群聊的标志,信息只会在同一个群聊内的websocket连接中转发;

    • room_group_name: channels group名称,基于room_name构建。

     class ChatConsumer(WebsocketConsumer):
         # 构造函数
         def __init__(self, *args, **kwargs):
             """
             initialize the object
             username: the user name of this websocket connection
             room_name: the room name of this websocket connection
             """
             super().__init__(args, kwargs)
             self.username = ...
             self.room_name = ...
             self.room_group_name = ...
         ...
  2. connect回调函数

    建立连接时会自动调用。首先,该函数会从前端访问的url中提取参数room_name,并使用room_name构造出room_group_name,用做channels.layer的组名。其中使用async_to_sync的原因是因为group_add是异步函数,所以要将其异步执行。

    class ChatConsumer(WebsocketConsumer):
         ...
         def connect(self):
             # Get essential params
             self.room_name = self.scope['url_route']['kwargs']['room_name']
             self.room_group_name = 'chat_%s' % self.room_name
             # Join the group
             async_to_sync(self.channel_layer.group_add)(
                 self.room_group_name,
                 self.channel_name
             )
             # Accept client connection
             self.accept()
         ...
  3. disconnect回调函数

    class ChatConsumer(WebsocketConsumer):
         ...
         def disconnect(self, close_code):
             # Close the connection
             self.close()
  4. receive回调函数

    当收到消息,就要向所在组群发消息,群发消息其实是触发事件,type就指定了事件的处理函数。

    class ChatConsumer(WebsocketConsumer):
         ...
         def receive(self, text_data):
             data = json.loads(text_data)
             usr = data['usr']
             msg = data['msg']
             # Send message to group
             async_to_sync(self.channel_layer.group_send)(
                 self.room_group_name,
                 {
                     'type': 'chat_message',  # call chat_message function
                     'usr': usr,
                     'msg': msg
                 }
             )
         ...
  5. chat_message

    该函数是事件触发的处理函数,在receive回调函数中所进行的群发,不过是通知群组内所有连接有一个事件发生,并要求它们对事件进行处理,处理就需要调用该函数。在该函数中,服务器会将信息转发给所有的websocket客户端。 

    class ChatConsumer(WebsocketConsumer):
         def chat_message(self, event):
             usr = event['usr']
             msg = event['msg']
             # Send message to websocket
             self.send(text_data=json.dumps({
                 'usr': usr,
                 'msg': msg
             }))

辅助API

WebSocket仅仅是为了完成群聊的基本功能,除了聊天以外,我们还希望能够记录用户、用户参加的聊天室、群聊的聊天记录等内容,这些API可以通过Restful API实现。

以下是聊天室相关的Restful API。

 # chatroom/views.py
 ​
 class CreateChatRoom(APIView):
     """
     创建群聊的API
     """
     def post(self, request):
         username = request.data.get('username')
         chatroom = request.data.get('chatroom')
         if len(ChatRoom.objects.filter(name=chatroom)) == 0:
             new_room = ChatRoom(name=chatroom)
             new_room.save()
             user = User.objects.get(username=username)
             new_record = UsersInChatRoom(user=user, room=new_room)
             new_record.save()
             return JsonResponse(data={'message': 'success'}, status=status.HTTP_201_CREATED)
         else:
             return JsonResponse(data={'message': 'room already exist'}, status=status.HTTP_409_CONFLICT)
 ​
 ​
 class JoinChatRoom(APIView):
     """
     加入群聊的API
     """
     def post(self, request):
         print('called api')
         username = request.data.get('username')
         chatroom = request.data.get('chatroom')
         try:
             user = User.objects.get(username=username)
         except User.DoesNotExist:
             return JsonResponse(data={'message': 'user does not exist'}, status=status.HTTP_404_NOT_FOUND)
         else:
             try:
                 room = ChatRoom.objects.get(name=chatroom)
             except ChatRoom.DoesNotExist:
                 return JsonResponse(data={'message': 'room does not exist'}, status=status.HTTP_404_NOT_FOUND)
             else:
                 records = UsersInChatRoom.objects.filter(user_id=user.id, room__name=room.name)
                 if len(records) == 0:
                     new_record = UsersInChatRoom(user=user, room=room)
                     new_record.save()
                     return JsonResponse(data={'message': 'success'}, status=status.HTTP_200_OK)
                 else:
                     return JsonResponse(data={'message': 'this user has already joined'},
                                         status=status.HTTP_409_CONFLICT)
 ​
 ​
 class GetUserNotInRooms(APIView):
     """
     获取不包含当前用户的聊天室列表
     """
     def post(self, request):
         username = request.data.get('username')
         user = User.objects.get(username=username)
         records = UsersInChatRoom.objects.filter(user=user)
         has_user_rooms = []
         for record in records:
             room = ChatRoom.objects.get(name=record.room.name)
             has_user_rooms.append(room.name)
         data, rooms = [], ChatRoom.objects.all()
         for room in rooms:
             if room.name not in has_user_rooms:
                 data.append(room.name)
         return JsonResponse(data=data, safe=False, status=status.HTTP_200_OK)
 ​
 ​
 class GetUserJoinedRooms(APIView):
     """
     获取包含当前用户的聊天室列表
     """
     def post(self, request):
         username = request.data.get('username')
         records = UsersInChatRoom.objects.filter(user__username=username)
         data = []
         for record in records:
             room = ChatRoom.objects.get(name=record.room.name)
             data.append(room.name)
         return JsonResponse(data=data, safe=False, status=status.HTTP_200_OK)
 ​
以下是聊天信息相关的API

 class MessageViewSet(ModelViewSet):
     queryset = Message.objects.all().order_by("date")
     serializer_class = MessageSerializer
 ​
     def list(self, request, *args, **kwargs):
         chatroom = request.data.get("chatroom")
         print('get history of ', chatroom)
         filtered_queryset = []
         for message in self.get_queryset():
             if message.chatroom.name == chatroom:
                 filtered_queryset.append(message)
 ​
         serializer = self.get_serializer(filtered_queryset, many=True)
         return JsonResponse(data=serializer.data, safe=False, status=status.HTTP_200_OK)

配置Restful API路由

全局路由如下。

 # backend/urls.py
 ​
 urlpatterns = [
     path('admin/', admin.site.urls),
 ​
     path('user/', include('user.urls')),
     path('chatroom/', include('chatroom.urls')),
     path('message/', include('message.urls'))
 ]

配置websocket路由

 # groupchat/groupchat/asgi.py
 ​
 import os
 from channels.auth import AuthMiddlewareStack
 from django.core.asgi import get_asgi_application
 from channels.routing import ProtocolTypeRouter, URLRouter
 from chat.urls import websocket_urlpatterns as chat_urls
 ​
 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'websocket_demo.settings')
 ​
 # application = get_asgi_application()
 application = ProtocolTypeRouter({
     # Explicitly set 'http' key using Django's ASGI application.
     "http": get_asgi_application(),
     'websocket': AuthMiddlewareStack(
         URLRouter(
             chat_urls,
         )
     ),
 })

前端部分

项目结构

src
 |-App.vue
 |-main.js
 |-api             # 访问后端的API封装
 | |-chatroom.js
 | |-message.js
 | |-user.js
 |-assets
 |-components      # 基本页面实现
 | |-Chat.vue
 | |-ChatRoom.vue
 | |-Login.vue
 | |-Register.vue
 |-router
 | |-index.js       # 前端路由配置
 |-utils            
 | |-requests.js    # 对于Axios的封装
 |-vuex
   |-index.js

App.vue

该页面中仅展示路由匹配到的页面。

 <template>
   <div id="app">
     <router-view></router-view>
   </div>
 </template>
 ​
 <script>
 export default {
   name: 'app'
 }
 </script>
 ​
 <style>
 #app {
   font-family: Avenir, Helvetica, Arial, sans-serif;
   -webkit-font-smoothing: antialiased;
   -moz-osx-font-smoothing: grayscale;
   text-align: center;
   color: #2c3e50;
   margin-top: 60px;
 }
 </style>

main.js

该文件中会导入主要使用的组件,并且进行页面渲染。

 import Vue from 'vue'
 import App from './App.vue'
 import router from './router/index'
 import ElementUI from 'element-ui'
 import JwChat from 'jwchat'
 import 'element-ui/lib/theme-chalk/index.css'
 ​
 Vue.use(ElementUI)
 Vue.use(JwChat)
 ​
 Vue.config.productionTip = false
 ​
 new Vue({
   el: '#app',
   router: router,
   render: c => c(App)
 })

router

在router/index.js中,配置路由。

 import Vue from "vue"
 import Router from "vue-router"
 ​
 import Login from "components/Login.vue"
 import Register from "components/Register.vue"
 import ChatRoom from "components/ChatRoom.vue"
 ​
 Vue.use(Router);
 ​
 export default new Router({
     routes: [
         {
             path: '/login',
             name: 'login',
             component: Login,
             meta: {
                 isLogin: false
             }
         },
         {
             path: '/register',
             name: 'register',
             component: Register,
             meta: {
                 isLogin: false
             }
         },
         {
             path: '/chatroom',
             name: 'chatroom',
             component: ChatRoom,
             meta: {
                 isLogin: false
             }
         },
         {
             path: '/',
             redirect: '/login'
         }
     ]
 })

ChatRoom.vue

该页面展示聊天室的主要界面以及websocket的主要逻辑。

<template>
   <div class="chat-container">
     <JwChat-index
       :config="config"
       :taleList="taleList"
       @enter="bindEnter"
       v-model="inputMsg"
       :toolConfig="tool"
       :showRightBox="true"
       :winBarConfig="winBarConfig"
       style="top: 10%; left: 7%; margin: 0px auto"
     />
 ​
     <el-dialog
       :title="createFormText"
       :visible.sync="createDialogVisible"
       :modal-append-to-body="false"
     >
       <el-form label-width="80px">
         <el-form-item lable="name">
           <el-input v-model="createRoomName"></el-input>
         </el-form-item>
       </el-form>
       <div slot="footer" class="dialog-footer">
         <el-button @click="createDialogVisible = false"> Cancel </el-button>
         <el-button type="primary" @click="handleCreateConfirm">
           Confirm
         </el-button>
       </div>
     </el-dialog>
 ​
     <el-dialog
       :title="joinFormText"
       :visible.sync="joinDialogVisible"
       :modal-append-to-body="false"
     >
       <el-form label-width="80px">
         <el-form-item>
           <el-select v-model="joinRoomName" placeholder="请选择" @change="selectChange">
             <el-option
               v-for="item in notJoinedChatroomList"
               :key="item.value"
               :label="item.label"
               :value="item.value"
             >
             </el-option>
           </el-select>
         </el-form-item>
       </el-form>
       <div slot="footer" class="dialog-footer">
         <el-button @click="joinDialogVisible = false"> Cancel </el-button>
         <el-button type="primary" @click="handleJoinConfirm">
           Confirm
         </el-button>
       </div>
     </el-dialog>
   </div>
 </template>
 ​
 <script>
 import $ from "jquery";
 import {
   createChatRoom,
   joinChatRoom,
   getJoinedChatRoom,
   getNotJoinedChatRoom,
 } from "@/api/chatroom";
 import { getMessageList } from "@/api/message";
 ​
 export default {
   data() {
     return {
       taleList: [],
       joinedChatroomList: [],
       notJoinedChatroomList: [],
       username: '',
       inputMsg: null,
       config: {
         name: "Wichat",
         callback: this.bindCover,
         historyConfig: {
           show: true,
           tip: "加载更多",
           callback: this.bindLoadHistory,
         },
       },
       winBarConfig: {
         active: "",
         width: "160px",
         listHeight: "60px",
         originlist: [
           {
             id: "join",
             name: "加入群组",
             img: "https://img.ixintu.com/download/jpg/201912/0d386ee1fd74c4ca82a64858a7b6feea.jpg!con",
           },
           {
             id: "create",
             name: "创建群组",
             img: "https://img.ixintu.com/download/jpg/201912/0d386ee1fd74c4ca82a64858a7b6feea.jpg!con",
           },
         ],
         list: [
           {
             id: "join",
             name: "加入群组",
             img: "https://img.ixintu.com/download/jpg/201912/0d386ee1fd74c4ca82a64858a7b6feea.jpg!con",
           },
           {
             id: "create",
             name: "创建群组",
             img: "https://img.ixintu.com/download/jpg/201912/0d386ee1fd74c4ca82a64858a7b6feea.jpg!con",
           },
         ],
         callback: this.selectCallBack,
       },
       tool: {
         callback: this.toolEvent,
       },
       ws: null,
       currentRoom: null,
       createDialogVisible: false,
       joinDialogVisible: false,
       createFormText: "创建群组",
       joinFormText: "加入群组",
       createRoomName: "",
       tCreateRoomName: "",
       joinRoomName: ""
     };
   },
   created() {
     this.username = sessionStorage.getItem('user')
     this.fetchJoinedList()
     console.log(sessionStorage.getItem('user'))
   },
   methods: {
     constructMsg(msgText, mine, usr) {
       console.log(msgText);
       var date = new Date()
       var dateString = date.getFullYear() + '-' + date.getMonth() + '-' + date.getDay() + ' ' + date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds()
       const msg = {
         date: dateString,
         text: { text: msgText },
         mine: mine,
         name: usr,
         img: "https://ts1.cn.mm.bing.net/th?id=OIP-C.maao8hq8Cf4UDcIK_HMMNgAAAA&w=127&h=133&c=8&rs=1&qlt=90&o=6&dpr=2&pid=3.1&rm=2",
       };
       return msg;
     },
     constructMsgWithDate(msgText, mine, usr, date) {
       console.log(msgText);
       const msg = {
         date: date,
         text: { text: msgText },
         mine: mine,
         name: usr,
         img: "https://ts1.cn.mm.bing.net/th?id=OIP-C.maao8hq8Cf4UDcIK_HMMNgAAAA&w=127&h=133&c=8&rs=1&qlt=90&o=6&dpr=2&pid=3.1&rm=2",
       };
       return msg;
     },
     bindEnter() {
       if (this.inputMsg === '')
         return
       const msg = this.constructMsg(this.inputMsg, true, this.username);
       const ready_to_send = JSON.stringify({
         usr: this.username,
         msg: msg["text"]["text"],
       });
       console.log(this.username + " sent " + ready_to_send);
       this.ws.send(ready_to_send);
       this.taleList.push(msg);
     },
     ChatWebSocket() {
       const _this = this;
       const url = "ws://127.0.0.1:8000/chat/" + this.currentRoom
       this.ws = new WebSocket(url);
       this.ws.onopen = function () {
         // 获取历史信息列表
         getMessageList({
             'chatroom': _this.currentRoom
         }).then((response) => {
             const roomTaleList = response
             console.log("room table list")
             console.log(_this.currentRoom)
             console.log(roomTaleList)
             _this.taleList = []
             for (var i=0; i<roomTaleList.length; i++) {
                 var isMine = false;
                 if (roomTaleList[i]["sender"] === _this.username) {
                     isMine = true;
                 }
                 const message = _this.constructMsgWithDate(roomTaleList[i]["text"], isMine, roomTaleList[i]["sender"], roomTaleList[i]["date"])
                 _this.taleList.push(message)
             }
         })
       };
       this.ws.onmessage = function (msgText) {
         const recv = $.parseJSON(msgText["data"]);
         console.log(recv["usr"] + " " + _this.username);
         if (recv["usr"] != _this.username) {
           const msg = _this.constructMsg(recv["msg"], false, recv["usr"]);
           _this.taleList.push(msg);
         }
         // const msg = _this.constructMsg(recv['msg'], false, recv['usr'])
         // _this.taleList.push(msg)
       };
       this.ws.onclose = function () {
         this.ws.close();
       };
       this.ws.onerror = function () {
         alert("Cannot connect to server.");
       };
     },
     selectCallBack(play = {}) {
       const { type, data = {} } = play;
       console.log(type);
       if (type === "winBar") {
         const { id, name } = data;
         console.log(id);
         console.log(name);
         if (id === "create") {
           this.createDialogVisible = true;
         } else if (id === "join") {
           this.fetchNotJoinedList()
           this.joinDialogVisible = true;
         } else {
           this.currentRoom = id
           this.ChatWebSocket()
           this.winBarConfig.active = id
         }
       }
     },
     selectChange(value) {
       this.joinChatRoom = value
     },
     fetchJoinedList() {
       getJoinedChatRoom({
           'username': this.username
       }).then(response => {
           console.log('joined')
           console.log(response)
           this.joinedChatroomList = response
           this.winBarConfig.list = this.winBarConfig.originlist
           for (var i=0; i<this.joinedChatroomList.length; i++) {
               this.winBarConfig.list.push({
                   'id': this.joinedChatroomList[i],
                   'name': this.joinedChatroomList[i],
                   'img': 'https://tse1-mm.cn.bing.net/th/id/R-C.d59599278978c2afd4e7529e8381571c?rik=GpSh7qKxaaO1GA&riu=http%3a%2f%2fpm1.narvii.com%2f7119%2fb0abdf491cffde4bdf95850956c1b15a5591a4b5r1-712-707v2_uhq.jpg&ehk=F4J%2bZAzrvoQU9v58KgUP92lXUBFs8mR%2bA4jDhLunfas%3d&risl=&pid=ImgRaw&r=0'
               })
           }
       })
     },
     fetchNotJoinedList() {
       console.log("username: " + this.username)
       getNotJoinedChatRoom({
           'username': this.username
       }).then(response => {
           const roomList = response
           this.notJoinedChatroomList = []
           for (var i=0; i<roomList.length; i++) {
               this.notJoinedChatroomList.push({
                   value: roomList[i],
                   label: roomList[i]
               })
           }
       })
     },
     handleCreateConfirm() {
       this.tCreateRoomName = this.createChatRoom
       createChatRoom({
         'username': this.username,
         'chatroom': this.createRoomName,
       }).then((response) => {
         console.log(response);
         this.fetchJoinedList()
       });
       this.createDialogVisible = false;
     },
     handleJoinConfirm() {
       console.log('prepare to join')
       console.log({
         'username': this.username,
         'chatroom': this.joinChatRoom
       })
       joinChatRoom({
         'username': this.username,
         'chatroom': this.joinChatRoom
       }).then((response) => {
         this.fetchJoinedList()
         console.log(response)
       })
       this.joinDialogVisible = false;
     },
   },
 };
 </script>
 ​
 <style acoped>
 .chat-container {
   margin: 0px auto;
   height: 100%;
   width: 100%;
   background: url("../assets/header.jpg") center center;
   background-size: 100% 100%;
   position: fixed;
   top: 0px;
   left: 0px;
 }
 </style>

作者信息

NP579 赵瑞阳

参考

Django Channels — Channels 3.0.4 documentation

JwChat (gitee.io)

...全文
585 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

571

社区成员

发帖
与我相关
我的任务
社区描述
软件工程教学新范式,强化专项技能训练+基于项目的学习PBL。Git仓库:https://gitee.com/mengning997/se
软件工程 高校
社区管理员
  • 码农孟宁
加入社区
  • 近7日
  • 近30日
  • 至今

试试用AI创作助手写篇文章吧