Bootstrap

Django+Nginx+uwsgi网站使用Channels+redis+daphne实现简单的多人在线聊天及消息存储功能

网站部署在华为云服务器上,Debian系统,使用Django+Nginx+uwsgi搭建。最终效果如下图所示。

一、响应逻辑顺序

1. 聊天页面请求

客户端请求/chat/(输入聊天室房间号界面)和/chat/room_name(某个聊天室页面)链接时,由Nginx转到Django由urls.py解析并返回相应页面,在返回的聊天室页面内置了javascript程序,请求建立wss:/ws/chat/room_name的websocket连接。

2. websocket连接请求

客户端向Nginx发送websocket连接请求,根据Ngnix配置文件location /ws {}中设置的反向代理,请求被转到本地的7001端口( http://127.0.0.1:7001),将由运行在该端口上的由daphne托管的asgi服务解析并响应(配置文件为myproject/asgi.py)。

3. asgi.py解析并响应

asgi.py中定义的application定义了websocket的解析规则,具体由/myproject/routing.py中的websocket_urlpatterns进行地址解析,通过Channels由myapp/comsumers.py进行异步信息处理并响应。

二、配置Channels和asgi

1. 安装Channels
sudo pip3 install channels
2. settings.py中添加应用并配置channels和asgi
INSTALLED_APPS = [
    'django.contrib.auth',
    'django.contrib.admin', 
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'channels',
    'myapp',
]

ASGI_APPLICATION = 'myproject.asgi.application'

# Channels_Layers相关配置
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}

# 配置认证后端
AUTHENTICATION_BACKENDS = (
    'django.contrib.auth.backends.ModelBackend',
)

Django Channels利用 Redis 作为通道层来管理客户端和服务器之间的通信。当客户端通过 WebSocket 连接发送消息时,Django Channels 会使用 Redis 将消息分发到所有连接的客户端。Redis 充当消息代理,实时有效地处理消息的路由和传递。

ASGI_APPLICATION指向Django项目下的asgi.py文件中的application。

3.  配置asgi.py文件
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
django.setup()

from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from myproject import routings

application = ProtocolTypeRouter({
    'http':get_asgi_application(),                          #支持http请求
    'websocket':AuthMiddlewareStack(
            URLRouter(routings.websocket_urlpatterns)  #支持webscoket请求
    ),
})

此处django.setup()和DJANGO_SETTINGS_MODULE设置应放在导入模块前,否则容易出现如下报错: 

django.core.exceptions.AppRegistryNotReady: Apps aren't loaded yet.
Requested setting DEBUG, but settings are not configured. 
You must either define the environment variable DJANGO_SETTINGS_MODULE 
or call settings.configure() before accessing settings...

 WSGI和ASGI 的主要区别是同步VS异步,WSGI是同步的,每个请求必须等待前一个请求完成。而ASGI是异步的,可以同时处理多个请求。 WSGI主要用于HTTP协议,ASGI旨在支持WebSocket、HTTP2等协议。ASGI的主要特点是异步非阻塞,它能够更好地处理并发请求。

上述设置中websocket地址解析由Django项目下的routings.py完成,其功能与urls.py类似。

4. 配置routings.py
from django.urls import path
from myapp import consumers

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

 myapp为新建的应用,其下的consumers.py文件定义了websocket的响应函数ChatConsumer。

5. 配置myapp/consumers.py
import json
from channels.generic.websocket import AsyncWebsocketConsumer
from asgiref.sync import sync_to_async
from myapp.models import ChatMessage

 
class ChatConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        self.room_name = self.scope["url_route"]["kwargs"].get("room_name") #获取传输的房间号
        if self.room_name:
            self.room_group_name = f"chat_{self.room_name}"

            # Join room group
            await self.channel_layer.group_add(self.room_group_name, self.channel_name)
            await self.accept()
        else:
            await self.close()
 
    async def disconnect(self, close_code):
        # Leave room group
        await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
 
    # Receive message from WebSocket
    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json["message"]
        username = text_data_json["username"]

        #保存消息
        await self.save_message(username, self.room_name, message)
        # 向群组内的所有客户端发送消息,type为“chat_message"
        await self.channel_layer.group_send(
            self.room_group_name, {"type": "chat_message", "message": message,'username':username}
        )
 
    # Receive message from room group
    async def chat_message(self, event):
        message = event["message"]
        username = event['username']
        # Send message to WebSocket
        await self.send(text_data=json.dumps({"message": message,'username':username}))
    
    # 信息写入数据库
    @sync_to_async()
    def save_message(self, username, room, message):
        if len(message) !=0 :
            newmsg = ChatMessage.objects.create(username=username, room=room, content=message)
            newmsg.save()

文件中定义了websocket的连接,消息的接收、发送和存储,每条消息在发送给群组内的所有客户端前先存储到数据库。

三、消息存储

1. 配置myapp/models.py,用于消息存储
from django.db import models

class ChatMessage(models.Model):
    username = models.CharField(max_length=100,verbose_name='用户名')
    room = models.CharField(max_length=100)
    content = models.TextField(verbose_name='内容')
    create_time = models.DateTimeField(auto_now_add=True,verbose_name='写入时间')
    
    def __str__(self):
        return f"房间号:{self.room}--用户:{self.username}({self.content})"
2. 配置myapp/admin.py,便于在Django后台处理存储的消息
from django.contrib import admin

# Register your models here.
from .models import ChatMessage
admin.site.register(ChatMessage)

 效果如下:

 四、安装redis并启动

1. 安装redis
sudo apt-get install redis-server

安装后redis-server会自动启动,可以使用sudo systemctl start/stop/restart/status redis.service来启动/停止/重启/查看服务器。

$sudo systemctl status redis.service 

● redis-server.service - Advanced key-value store
     Loaded: loaded (/lib/systemd/system/redis-server.service; enabled; vendor preset: enabled)
     Active: active (running) since Tue 2024-11-19 23:38:28 CST; 20h ago
       Docs: http://redis.io/documentation,
             man:redis-server(1)
   Main PID: 123767 (redis-server)
     Status: "Ready to accept connections"
      Tasks: 5 (limit: 2321)
     Memory: 7.2M
        CPU: 1min 36.022s
     CGroup: /system.slice/redis-server.service
             └─123767 /usr/bin/redis-server 127.0.0.1:6379

五、安装daphne,并启动asgi服务

1. 安装daphne

 Daphne 是 Django Channels 项目的一部分,专门用于为 Django 提供支持 WebSocket和 ASGI 协议的异步服务器。

sudo pip3 install daphne

启动asgi服务的命令如下,监听端口为7001。

daphne -p 7001 myproject.asgi:application

asgi服务的配置文件即为前面已经完成的asgi.py。

为了管理方便,将其做成系统服务开机启动。

2. 设置daphne.service服务

新建/etc/systemd/system/daphne.service文件

sudo nano /etc/systemd/system/daphne.service

内容如下: 

[Unit]
Description=Daphne Server
After=network.target

[Service]
Type=exec
WorkingDirectory=/home/yislwll/Django/myproject
ExecStart=/usr/bin/daphne -p 7001 myproject.asgi:application
Restart=always
User=yislwll
Group=Yisl
Environment=PYTHONPATH=/home/yislwll/Django/myproject

[Install]
WantedBy=multi-user.target

然后启动daphne服务:

sudo systemctl enable daphne.service
sudo systemctl start daphne.service

需要特别注意的是,修改consumers.py文件后一定要重启daphne服务,否则对websocket通讯内容的更改无法更新。

sudo systemctl restart daphne.service

 六、配置Nginx

根据响应逻辑,Ngnix应该对websocket的连接“wss://域名/ws/chat/room_name”做出响应,请求将被代理到本地的7001端口,由运行在该端口上由daphne托管的asgi服务解析并响应。

1. 配置/etc/nginx/nginx.conf

网站为https://连接,所以webscoket连接为wss://

	server {
		listen       443  ssl;
		server_name  xxxx.com;
 
		charset      utf-8;

    ssl_certificate /path/to/fullchain.pem;  # SSL证书的路径
    ssl_certificate_key /path/to/privkey.pem;  # 私钥的路径
 
    # SSL 配置
    ssl_session_timeout 1d;
    ssl_session_cache shared:MozSSL:10m;  # about 4000 sessions
    ssl_session_tickets off;
 
    # 指定加密套件
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers 'xxxxxxxxx';
    ssl_prefer_server_ciphers on;

                location / {#由django响应
                uwsgi_pass 127.0.0.1:8080;
                include uwsgi_params;
                include /etc/nginx/uwsgi_params;
                uwsgi_param UWSGI_SCRIPT iCourse.wsgi;
                uwsgi_param UWSGI_CHDIR /iCourse;
                index index.html index.htm;
                client_max_body_size 35m;
                index index.html index.htm;
                }

				location /ws {#wss协议由asgi服务响应
				proxy_http_version 1.1;
				proxy_set_header Host  $host;
				proxy_set_header X-Real-Ip $remote_addr;
				proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
				proxy_set_header X-Nginx-Proxy true;
				proxy_redirect off;
				client_max_body_size 10m;
				proxy_pass http://127.0.0.1:7001;
				proxy_set_header Upgrade $http_upgrade;
				proxy_set_header Connection "upgrade";
				proxy_connect_timeout 300s;
				proxy_read_timeout 300s;
				proxy_send_timeout 300s; 
				}
		}
2. 重启Nginx服务
sudo systemctl restart nginx.service

七、客户端界面

1. 配置urls.py,响应客户端界面请求 
from myapp import views as channelsview

urlpatterns = [
    ......
    path('chat/',channelsview.chat, name='webchat'),
    path('chat/<str:room_name>/', channelsview.chatroom, name='chatroom'),
    ......
]

客户端页面请求将由myapp/views.py来处理。

2. 配置views.py
from django.shortcuts import render
from django.contrib.auth.decorators import login_required
from .models import ChatMessage

# Create your views here.
@login_required(login_url='/login/')
def chat(request):
    return render(request,"channels/chattingindex.html")

@login_required(login_url='/login/')
def chatroom(request,room_name):
    username = request.session.get('username','游客')
    msgs = ChatMessage.objects.filter(room=room_name).order_by('-create_time')[0:20]
    return render(request,"channels/chattingroom.html",{'room_name':room_name,'username':username, 'msgs':msgs})

在chatroom函数中,对当前聊天室历史消息的最近20条进行了查询,并由页面展示出来。数据库查询的时候使用'-create_time'倒序选取前20条,在模板文件chattingroom.html中还要使用{% for m in msgs reversed %}来正序展示。

3. 聊天页面设计及通讯

chattingroom.html文件内容大致如下:

{% extends "newdesign/newbase.html" %}

    {% load django_bootstrap5 %}
        {% block mytitle %}
        <title>{{room_name}}号聊天室</title>
            <style>
                .chat-window {
                    max-width: 900px;
                    height: 500px;
                    overflow-y: scroll; /* 添加垂直滚动条 */
                    margin: auto;
                    background-color: #f1f1f1;
                    border: 2px solid #09e3f7;
                    border-radius: 5px;
                    padding: 10px;
                }
                
                .chat-message {
                    clear: both;
                    overflow: hidden;
                    margin-bottom: 10px;
                    text-align: left;
                }
                
                .chat-message .message-content {
                    border-radius: 5px;
                    padding: 8px;
                    max-width: 500px;
                    float: left;
                    clear: both;
                }
                
                .chat-message.right .message-content {
                    background-color: #428bca;
                    color: white;
                    float: right;
                    width:420px;
                }
                .chat-message.right .user-content {
                    background-color: #f7e91d;
                    border-radius:4px;
                    color: black;
                    float: right;
                    width: auto;
                    text-align: right;
                    padding-left:10px;
                    padding-right:10px;
                }
                
                .chat-message.left .message-content {
                    background-color: #2ef3be;
                    border-color: #ddd;
                    float:left;
                    width:420px;
                }
                .chat-message.left .user-content {
                    background-color: #f7e91d;
                    border-radius:4px;
                    border-color: #ddd;
                    float: left;
                    width: auto;
                    text-align: left;
                    padding-left:8px;
                    padding-right:8px;
                }

            </style>
        {% endblock %}
    {% block maincontent %} 
    <div class="container">
    <div id="chat-record" class="chat-window">
    {% for m in msgs reversed %}
        {% if m.username == request.user.username %}
        <div class="chat-message right"><div class="user-content">{{m.username}}</div><div class="message-content"><span>{{m.content}}</span></div></div>
        <br>
        {% else %}
        <div class="chat-message left"><div class="user-content">{{m.username}}</div><div class="message-content"><span>{{m.content}}</span></div></div>
        <br>
        {% endif %}
    {% endfor %}
    </div>
    </div>
    <br>
    <div class="mx-auto px-auto" style="text-align: center;">
    <input id="chat-message-input" type="text" style="width:900px;" size="150">
    <br>
    <br>
    <input id="chat-message-submit" type="button" value="发送消息">
    </div>
    {{ room_name|json_script:"room-name" }}
    {{ username|json_script:"username" }}

    <script>
        window.onload = function() {
        var scrollableDiv = document.getElementById('chat-record');
        // 设置scrollTop使得滚动条向下翻
        scrollableDiv.scrollTop = scrollableDiv.scrollHeight;
        };
        const roomName = JSON.parse(document.getElementById('room-name').textContent);
        const username = JSON.parse(document.getElementById('username').textContent);
 
        const chatSocket = new WebSocket(
            'wss://scybbd.com/ws/chat/' + roomName + '/'
            );
 
        chatSocket.onmessage = function(e) {
            const data = JSON.parse(e.data);
            //data为收到的后端发出来的数据
            console.log(data);
            if (data['message']) {
                if(data['username'] == username){
                    document.querySelector('#chat-record').innerHTML += ('<div class="chat-message right"><div class="user-content">' + data['username'] + '</div><div class="message-content"><span>' +data['message'] + '</span></div></div><br>');
                }else{
                    document.querySelector('#chat-record').innerHTML += ('<div class="chat-message left"><div class="user-content">' + data['username'] + '</div><div class="message-content"><span>' + data['message'] + '</span></div></div><br>');
                }
            } else {
            alert('消息为空!')
            }
            var scrollableDiv = document.getElementById('chat-record');
            // 设置scrollTop使得滚动条向下翻
            scrollableDiv.scrollTop = scrollableDiv.scrollHeight;
        };

 
        chatSocket.onclose = function(e) {
            console.error('聊天端口非正常关闭!');
        };
 
        document.querySelector('#chat-message-input').focus();
        document.querySelector('#chat-message-input').onkeyup = function(e) {
            if (e.keyCode === 13) {  // enter, return
                document.querySelector('#chat-message-submit').click();
            }
        };
 
        document.querySelector('#chat-message-submit').onclick = function(e) {
            const messageInputDom = document.querySelector('#chat-message-input');
            const message = messageInputDom.value;
            chatSocket.send(JSON.stringify({
                'message': message,
                'username':username
            }));
            messageInputDom.value = '';
        };
    </script>
{% endblock %}

聊天界面比较简陋,客户端与服务器的wss://连接和通讯由页面中javacript脚本完成。

至此,一个简单的多人在线聊天的页面基本完成。

;