conda create --name chat_env python=3.8
conda activate chat_env
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
pip install django==4.2.5
pip install channels==3.0.5
pip install channels-redis==4.1.0
pip install pillow==10.0.1
pip install pypinyin
django-admin startproject Chat_Website_Tutorial
cd Chat_Website_Tutorial
python manage.py startapp users
python manage.py startapp chat
- 下载链接:https://github.com/tporadowski/redis/releases
- 选择最新版本(Redis for Windows 5.0.14.1)
- 解压到Chat_Website_Tutorial文件夹,重命名为redis
- 在Chat_Website_Tutorial文件夹下创建templates、media、static文件夹
- 在settings.py中将以下代码进行修改
ALLOWED_HOSTS = []
TIME_ZONE = "UTC"
STATIC_URL = "static/"
修改为:
ALLOWED_HOSTS = ['*']
TIME_ZONE = "Asia/Shanghai"
import os
STATIC_URL = "static/"
STATICFILES_DIRS = [
os.path.join(BASE_DIR, 'static'),
os.path.join(BASE_DIR, 'static/css'),
os.path.join(BASE_DIR, 'static/js'),
]
- 在TEMPLATES的DIRS中添加'./templates'
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
'DIRS': ['./templates'],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
- 添加以下代码
MEDIA_ROOT = BASE_DIR / 'media'
MEDIA_URL = '/media/'
ASGI_APPLICATION = 'Chat_Website_Tutorial.asgi.application'
- 在templates中新建users目录,在里面创建log.html并设计登录界面
- 在static文件夹下创建css、js、images三个文件夹,分别放css文件、javascript文件和静态图片
- 在Chat_Website_Tutorial/settings.py中添加
- 在users/models.py中添加用户类,以下是一个样例
from django.db import models
from django.contrib.auth.models import AbstractUser
class User(AbstractUser):
email = models.EmailField('email address', primary_key=True, unique=True)
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ["username"]
- users/admin.py中注册User类
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User
admin.site.register(User, UserAdmin)
- 由于用户注册和登录涉及到表单的填写与提交,因此需要在Users中创建forms.py文件,并在其中创建相关表单
from django.contrib.auth.forms import UserCreationForm
from django import forms
from .models import User
class RegisterForm(UserCreationForm):
email = forms.CharField()
email_code = forms.CharField()
class Meta:
model = User
fields = ("username", "email", "email_code", "password1", "password2")
class LoginForm(forms.Form):
login_email = forms.CharField()
login_password = forms.CharField()
- django在处理Http请求时是通过自定义的视图函数进行处理的,需要在views.py中添加处理登录或者注册请求的函数
from django.shortcuts import render, redirect
from django.http import HttpRequest
from .forms import LoginForm, RegisterForm
from django.contrib.auth import login, authenticate
from django.contrib import messages
def log(request: HttpRequest):
# action when request method is GET
if request.method == 'GET':
register_form = RegisterForm()
login_form = LoginForm()
# context to render
context = {
"register_form": register_form,
"login_form": login_form,
}
# action when request method is POST
elif request.method == 'POST':
# transform the request post to Forms
register_form = RegisterForm(request.POST)
login_form = LoginForm(request.POST)
# check whether login success
if login_form.is_valid():
email = login_form.cleaned_data["login_email"]
password = login_form.cleaned_data["login_password"]
# We check if the data is correct
user = authenticate(email=email, password=password)
if user: # If the returned object is not None
login(request, user) # we connect the user
return redirect('chat:my')
else: # otherwise an error will be displayed
context = {
"register_form": register_form,
"login_form": login_form,
"login_error": "Error email or Error password!"
}
# check whether regist success
elif register_form.is_valid():
user = register_form.save()
login(request, user)
messages.success(request, "Congratulations, you are now a registered user!")
return redirect('chat:my')
# collect errors
else:
# return errors for user
username_errors = register_form.errors.get('username')
email_errors = register_form.errors.get('email')
password_errors = register_form.errors.get('password2')
context = {
"register_form": register_form,
"login_form": login_form,
"username_errors": username_errors,
"email_errors": email_errors,
"password_errors": password_errors,
}
return render(
request=request,
template_name='users/log.html',
context = context
)
- log函数的输入是一个Http请求,函数首先判断请求的方式是GRT还是POST,然后分别进行处理,最后调用我们在4.1中制作完成的users/log.html模板,并将模板中需要的参数以字典的形式传入
- 在users中建立url.py文件
from django.urls import path
from users import views
urlpatterns = [
path('', views.log, name='log'),
]
- 修改Chat_Website_Tutorial/urls.py如下
from django.contrib import admin
from django.urls import path
from django.conf.urls import include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path("admin/", admin.site.urls),
path('', include(('users.urls', 'users'), namespace='users'), name='users'),
]
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
- 添加url之后,当在浏览器访问根目录(对应'')时,就会到log界面,调用log函数处理http请求
- 在根目录下创建update.py文件,内容如下
import os
os.system("python manage.py makemigrations")
os.system("python manage.py migrate")
os.system("python manage.py runserver")
- 在浏览器中输入 http://127.0.0.1:8000/, 可以看到如下界面
- 在根目录下创建superuser.py文件
import os
os.system("python manage.py createsuperuser")
- 根据注册要求注册test001和test002两个账号
# check whether regist success
elif register_form.is_valid():
user = register_form.save()
login(request, user)
messages.success(request, "Congratulations, you are now a registered user!")
return redirect('chat:my')
- 当注册成功时会重定向到chat模块的my页面,但是由于我们暂时还没有写这一部分,因此会报错
- 当我们用4.10中创建的超级用户登录管理员界面 http://127.0.0.1:8000/admin, 可以看到两个账户已经被创建成功了
- 将一个页面分成4个part部分
- side_menus:侧边栏
- leftsidebar:左侧部分
- body:主体部分
- rightsidebar:右侧部分
- 利用django特有的block机制,首先设计一个base.html,所有的页面都继承base.html,然后每一个html页面根据需求按照上述四个部分(不一定全部需要)分别设计
- 例如my页面设计如下:
{% extends 'chat/base.html' %}
<!-- side_menu -->
{% block side_menu %}
{% include "chat/side_menus/side_menu_my.html" %}
{% endblock %}
<!-- side_menu -->
<!-- leftsidebar -->
{% block leftsidebar %}
{% include "chat/leftsidebar/leftsidebar_my.html" %}
{% endblock %}
<!-- leftsidebar -->
<!-- body -->
{% block chat_conversation %}
{% include "chat/body/body_my.html" %}
{% endblock %}
<!-- body -->
- 编写好相关的html模板,如下所示
- 与第四章介绍的一样,我们需要创建一些类、表单、路径、视图函数等,具体代码由于过长就不在这里一一列出,详细请直接参考代码文件包,以下是需要创建的相关内容。
- Profile类:用户的个人画像类
- EditProfileForm表单:修改Profile
- PasswordChangeForm表单:修改密码
- my函数,用户主页视图函数
- settings函数,用户设置页面视图函数
- chatroom函数,用户聊天界面视图函数(在第六章实现,这里需要放一个空函数)
- innerroom函数,用户聊天界面内部视图函数(在第六章实现,这里需要放一个空函数)
@login_required
def chatroom(request: HttpRequest, dark=False):
pass
@login_required
def innerroom(request: HttpRequest, room_name, post_name, dark=False):
pass
- 在设计User类的时候,并没有考虑到Profile类的初始化。实际上,我们希望的是在User类被创建的时候,就创建一个对应的Profile类,这需要通过信号函数实现
- 在chat文件夹下创建signal.py文件
from django.db.models.signals import post_save
from django.dispatch import receiver
from users.models import User
from .models import Profile
@receiver(post_save, sender=User)
def create_profile(sender, instance, created, **kwargs):
if created:
Profile.objects.create(user=instance)
- 修改chat/apps.py如下
from django.apps import AppConfig
class ChatConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "chat"
def ready(self):
from . import signals
- 在执行了上述几个步骤后运行update,会发现以下情况
- 这是因为我们在注册用户之前还没有考虑过5.3,这时候需要我们登录管理员账号删除原本创建的两个用户,然后重新注册即可
- 在settings界面上传个人图片并修改个人介绍,地点等
-
什么是WebSocket
- WebSocket 是一种网络传输协议,可在单个 TCP 连接上进行全双工通信,位于 OSI 模型的应用层
-
为什么使用WebSocket
- WebSocket可以在浏览器里使用且使用很简单
- 客户端和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。
-
WebSocket建立过程
- 客户端发送一个 HTTP GET 请求到服务器,请求的路径是 WebSocket 的路径(类似 ws://http://example.com/socket)。请求中包含一些特殊的头字段,如 Upgrade: websocket 和 Connection: Upgrade,以表明客户端希望升级连接为 WebSocket。
- 服务器收到这个请求后,会返回一个 HTTP 101 状态码(协议切换协议)。同样在响应头中包含 Upgrade: websocket 和 Connection: Upgrade,以及一些其他的 WebSocket 特定的头字段,例如 Sec-WebSocket-Accept,用于验证握手的合法性。
- 客户端和服务器之间的连接从普通的 HTTP 连接升级为 WebSocket 连接。之后,客户端和服务器之间的通信就变成了 WebSocket 帧的传输,而不再是普通的 HTTP 请求和响应。
- 与5.2一样,这里列出用户聊天相关类、表单等的简介
- models
- Room类:聊天室
- Tag类:标签
- Post类:帖子
- RoomMessage:聊天内容
- forms
- RoomForm:创建聊天室的表单
- PostForm:创建帖子的表单
- AttachmentForm:附件相关表单
- ChangeRoomForm:修改聊天室信息表单
- EditPostForm:编辑帖子信息的表单
- ConfirmDeletePostForm:删除帖子的表单
- ConfirmDeleteChatroomForm:删除聊天室的表单
- views
- chatroom:聊天室
- innerroom:帖子
- 帖子和聊天室的关系
- 每一个聊天室作为聊天的基本空间单位
- 每一个聊天室在创建时会自动创建一个以chatting_开头的帖子,用于聊天
- 在聊天室内部可以任意创建帖子,用于讨论相关话题
- 在chat文件夹中创建roomers.py文件
import json
from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer
from .models import Room, RoomMessage, Post
class Roommers(WebsocketConsumer):
"""
The member of the Room
"""
def __init__(self, *args, **kwargs):
super().__init__(args, kwargs)
self.room_name = None
self.room_group_name = None
self.room = None
self.user = None
self.user_inbox = None
-
Roomers继承了Django库中一个重要的通信消费者类WebsocketConsumer
-
Django Channels是一个用于构建实时Web应用程序的扩展,它使用了WebSocket和其他协议来实现实时通信。
-
WebsocketConsumer用于处理WebSocket连接和消息的消费者,以下是它的一些重要方法
- connect(self):当一个WebSocket连接建立时,Channels将调用connect方法,在这个方法中执行与连接相关的初始化工作
- disconnect(self, close_code):当WebSocket连接关闭时,Channels将调用disconnect方法,执行一些清理工作。
- receive(self, text_data=None, bytes_data=None):当WebSocket接收到消息时,Channels将调用receive方法,处理接收到的消息。
- send(self, text_data=None, bytes_data=None, close=False):通过WebSocket发送消息给客户端。
- group_send(self, group, message):将消息发送给一个指定的组,用于实现广播消息或群聊功能。
-
以下是我对WebsocketConsumer类的connect的重构
- 首先通过scope中包含的信息获得当前聊天室名称和帖子名称
- room_group_name是一个特殊的变量,用于唯一标志特定的群组
- 通过聊天室名称确定用户所在的room,并向当前用户发送他所在room的所有用户列表
- 判断用户合法性,若合法,则向所在room的群组广播当前用户进入的信息
- 对room实体的online变量添加当前用户
def connect(self):
# read info from self.scope
self.room_name = self.scope['url_route']['kwargs']['room_name']
self.post_name = self.scope['url_route']['kwargs']['post_name']
self.room_group_name = f'chat_chatroom_{self.room_name}_{self.post_name}'
self.room = Room.objects.get(name=self.room_name)
self.user = self.scope['user']
self.user_inbox = f'inbox_{self.user.username}'
self.accept()
async_to_sync(self.channel_layer.group_add)(
self.room_group_name,
self.channel_name,
)
# send all online users by self.send func
self.send(json.dumps({
'type': 'user_list',
'users': [user.username for user in self.room.online.all()],
}))
# check if the user is valid
if self.user.is_authenticated:
# create a user inbox for private messages
async_to_sync(self.channel_layer.group_add)(
self.user_inbox,
self.channel_name,
)
# send the join event to the room
async_to_sync(self.channel_layer.group_send)(
self.room_group_name,
{
'type': 'user_join',
'user': self.user.username,
}
)
self.room.online.add(self.user)
- disconnect原理与connect一致,代码不在这里展示
- 以下是我对WebsocketConsumer类的receive的重构
- 这一部分与后面将会介绍的websocket.js相关
- 当用户收到了消息,首先用json解析出其中的消息字典
- 然后判断消息字典中是否存在"uid"的键值,若存在,说明这是一个删除聊天记录的命令
- 若不存在,说明这是一个添加聊天的命令,需要向所在群组的所有用户广播这个聊天信息
def receive(self, text_data=None, bytes_data=None):
text_data_json = json.loads(text_data)
if "uid" in text_data_json:
uid = text_data_json['uid']
rm = RoomMessage.objects.get(uid = uid)
rm.delete()
else:
message = text_data_json['message']
post_name = text_data_json['post_name']
# check if the user is valid
if not self.user.is_authenticated:
return
async_to_sync(self.channel_layer.group_send)(
self.room_group_name,
{
'type': 'chat_message',
'message': message,
'user': self.user.username,
}
)
RoomMessage.objects.create(
user=self.user,
room=self.room,
belong_post = Post.objects.get(title=post_name, belong_room=self.room),
content=message
)
- 在创建完毕roomers文件之后,需要再创建一个routing.py文件
from django.urls import re_path
from .roomers import Roommers, FRRoommers
websocket_urlpatterns = [
re_path(r'ws/chat/chatroom/(?P<room_name>\w+)/(?P<post_name>\w+)$',
Roommers.as_asgi()),
]
- 然后修改Chat_Website_Tutorial/asgi.py如下
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'website.settings')
django.setup()
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter,URLRouter
from channels.auth import AuthMiddlewareStack
import chat.routing
application = ProtocolTypeRouter({
'http': get_asgi_application(),
'websocket': AuthMiddlewareStack(
URLRouter(
chat.routing.websocket_urlpatterns
)
),
})
- 这么做的主要原因是为了将http请求和websocket请求分发到不同的处理程序
-
在本报告中很少涉及css和js的介绍,这是因为作者认为读者应当自己掌握,但是websocket.js中涉及到了通信相关的内容,因此需要单独讲解一下其中涉及到的重要函数
-
以下是添加消息函数,这里采用的是当用户发送消息的时候,采用在js中添加html文本来实现实时聊天内容的增加
// add message
function add_message(user, message){
img_url = user_img_urls[user];
var flag = 0;
if (img_url == undefined && flag == 0) {
location.reload();
flag = 1;
}
if (user != cur_user){
chatLog.innerHTML +=
`<li><div class="conversation-list" style="max-width: 40%;>
<!-- HIS OR HER AVATAR -->
<div class="chat-avatar"><img src=${img_url} alt=""></div>
<!-- HIS OR HER AVATAR -->
<!-- CONTENT MAIN -->
<div class="user-chat-content" style="max-width: 100%;">
<div class="ctext-wrap">
<!-- CONTENT & TIME -->
<div class="ctext-wrap-content" style="max-width: 100%;">
<p class="mb-0" style="word-break:break-all;">
${message}
</p>
</div>
<!-- CONTENT & TIME -->
</div>
<!-- HIS OR HER NAME -->
<div class="conversation-name">${user}</div>
</div>
<!-- CONTENT -->
</div></li>`}
else{
chatLog.innerHTML +=
`<li class="right"><div class="conversation-list" style="max-width: 40%;">
<!-- HIS OR HER AVATAR -->
<div class="chat-avatar"><img src=${img_url} alt=""></div>
<!-- HIS OR HER AVATAR -->
<!-- CONTENT MAIN -->
<div class="user-chat-content" style="max-width: 100%;">
<div class="ctext-wrap">
<!-- CONTENT & TIME -->
<div class="ctext-wrap-content" style="max-width: 100%;">
<p class="mb-0" style="word-break:break-all; text-align: left;">
${message}
</p>
</div>
<!-- CONTENT & TIME -->
</div>
<!-- HIS OR HER NAME -->
<div class="conversation-name">${user}</div>
</div>
<!-- CONTENT -->
</div></li>`
}
}
- 以下是有用户进入或者离开时的处理
# onlineUsersSelectorAdd
function onlineUsersSelectorAdd(user){
if (document.querySelector("option[value='" + user + "']")) return;
let newOption = document.createElement("option");
newOption.value = user;
newOption.innerHTML = user;
onlineUsersSelector.appendChild(newOption);
}
# removes an option from 'onlineUsersSelector'
function onlineUsersSelectorRemove(user) {
let oldOption = document.querySelector("option[value='" + user + "']");
if (oldOption !== null) oldOption.remove();
}
- 以下是连接函数
- 其中"chat_message"、"user_list"这些type都是与chat/roomers.py中发送的消息字典对应的
- new WebSocket( ) 中的路径是与chat/routing.py中的路径是对应的
// connect
function connect() {
chatSocket = new WebSocket("ws://" +
window.location.host +
"/ws/chat/chatroom/" +
room_name +"/" +
cur_post
);
// connect the WebSocket
chatSocket.onopen = function(e) {
console.log("Successfully connected to the WebSocket.");
}
// deal with connection error
chatSocket.onclose = function(e) {
console.log("WebSocket connection closed unexpectedly.
Trying to reconnect in 2s...");
setTimeout(function() {
console.log("Reconnecting...");
connect();
}, 2000);
};
// deal with message error
chatSocket.onerror = function(err) {
console.log("WebSocket encountered an error: " + err.message);
console.log("Closing the socket.");
chatSocket.close();
}
// send message
chatSocket.onmessage = function(e) {
const data = JSON.parse(e.data);
switch (data.type) {
case "chat_message":
add_message(data.user, data.message);
break;
case "user_list":
for (let i = 0; i < data.users.length; i++)
onlineUsersSelectorAdd(data.users[i]);
break;
case "user_join":
onlineUsersSelectorAdd(data.user);
break;
case "user_leave":
onlineUsersSelectorRemove(data.user);
break;
default:
console.error("Unknown message type!");
break;
}
chatLog_container.scrollTop = chatLog.scrollHeight;
}
}
- 由于要求创建聊天室时自动创建一个帖子,并且我们希望聊天室在创建时会有一个创建成功的聊天记录,因此需要在chat/signal.py文件添加:
from .models import Profile, RoomMessage, Room, Post, Tag
from django.shortcuts import get_object_or_404
@receiver(post_save, sender=Room)
def create_rm(sender, instance, created, **kwargs):
if created:
# create the default chatting_post
author = User.objects.get(username=instance.owner_name)
profile = get_object_or_404(Profile, user=author)
post = Post.objects.create(
title = "chatting_" + instance.name,
author = author,
author_profile = profile,
about_post = "The special and default post for chatting",
belong_room=instance,
)
try:
post.tags.add(get_object_or_404(Tag, name="default"))
post.tags.add(get_object_or_404(Tag, name="chatting"))
except:
Tag.objects.create(name="default")
Tag.objects.create(name="chatting")
post.tags.add(get_object_or_404(Tag, name="default"))
post.tags.add(get_object_or_404(Tag, name="chatting"))
# create the defult success message for the chatting_post
content = "Congratulations to {} for creating a new chatroom \
named {}".format(instance.owner_name, instance.name)
RoomMessage.objects.create(
user=author,
belong_post = post,
room=instance,
content=content
)
- Redis(Remote Dictionary Server)是一个开源的内存数据存储系统,它提供了高性能、可扩展和灵活的键值存储。
- 在Chat_Website_Tutorial/settings.py添加以下代码,把channels的后端设置成redis
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('localhost', 6379)],
},
},
}
- 聊天软件的制作有三大难点,一个是前端页面的制作,一个是django的系统学习,一个是websocket的熟练运用,这些都需要大量的前置学习
- 如何设计美观的界面是制作软件前必须要思考的问题,需要花较长时间去寻找和制作合适的html模板
- websocket、django的WebsocketConsumer类以及websocket.js以及asgi这些对象或者文件的关系需要熟练掌握
- 课程建议:建议可以将大作业改为多人合作
- 目前软件实现了多人聊天、互传照片与文件等,后续还可以增加一些好友等其他功能
- 软件可以尝试搭建在服务器上,添加域名、升级成https和wss等
- 【启动redis.mp4】windows平台命令行启动redis server
- 【注册登录用户界面.mp4】如何注册账户以及修改用户个人信息
- 【用户聊天.mp4】两个用户的基本实时聊天(可支持多个用户)
- 【文件传输.mp4】用户之间文件和照片的传输,需要刷新显示
- 【消息撤回.mp4】撤回发送后的消息,需要刷新显示
- 【模式切换与退出.mp4】黑夜与白天模式的切换以及退出登录