关于简单网页聊天室开发的记录(基于flask flask_socketio)

由于专业原因,平时课程没有设计网络开发,因此趁着假期学习写网络开发相关知识,先从项目入手,写的磕磕碰碰还有一些功能没有实现一些bug亟待改进。
一、工具
使用语言:python、js、html
借助工具包:flask_socketio、flask、eventlet
二、背景:
首先熟悉些专业术语:
Python路由:Web 服务器如何根据用户请求的 URL 找到对应的处理逻辑。
路由:路由的任务就是为每个数据包选择一条最优路径(比如距离最短、速度最快、最稳定的路线),确保数据高效、准确地到达。
URL:类似网址、它的核心作用是告诉浏览器或其他网络工具:如何找到并访问某个具体的资源。通常包含协议(http|https...)、域名或者主机名、端口、查询参数、片段(只在客户端生效)。
三、项目规定:
本项目是个多房间聊天室遵循以下规则:
(1)多房间聊天室有多个房间
(2)用户作为user只能在一个房间(前端控制但是后端允许一个user多房间)
(3)用户作为creator可以在多个房间
(5)只有creator才有权利关闭房间
(6)user才能发送接受信息
(7)创建房间后user不会自动加入user需要手动加入
(8)一个房间只能有一个creator
(9)由于user只能在一个房间内因此离开房间只能离开当前房间
(10)用户一登录就会进入大厅,大厅不允许关闭不允许离开,离开大厅只能通过返回登录
聊天室的前后端交互事件:
1、房间事件
create_room->room_created
close_room->room_closed
room_list->room_list
room_members->room->members(前端暂未实现处理)
2、用户事件定义
join_room->user_joined
leave_room->user_left
room_members->room_members
client_message->client_message
message_query->message_got
3、系统事件
error
connect/disconnect
(ps:左边是后端处理消息右边是前端处理消息)
后端维护容器:
rooms:字典容器,存储房间以及user和creator,格式rooms[room]={'user':[],'creator':}
message:字典容器,存储每个房间消息,格式message[room]={[{'username':,'message':,'message_id':},.......]}(这里的message_id直接使用时间戳)
user_session:字典容器,保存username和sid的映射关系,格式user_session[sid]=username
username_to_sids:字典容器,保存sid和username的映射关系,usrname_to_sids[username]=sid
message_id_index:字典容器,保存message_id和message的index之间的关系,方便快速查找定位消息
前端维护容器:
messageFlag={
hasMore:,
newRoom:,
Next:,(本例还未使用)
Prev:,
}四个bool型变量状态机用于处理加载消息方式
currentRoom:当前房间
next_message_id:当前message_id
isLoading:模拟加载锁后面会讲
localMessage:本地消息存储后面会讲
四、工程中的问题以及解决方法:
1、前端怎么实现消息加载:
由于用户在进入聊天室时可能前面已经有大量的消息但是一次性传输这么多消息到客户端信息量太大,可能会导致网络堵塞而用户也可能不需要那么多消息,因此采用分页加载逻辑(感觉网络上参考比较少!!!!!!!!啊啊啊),规则如下:
(1)用户一进入房间就加载最新一页消息到localMessage
(2)用户滚轮向上滚动到顶部就加载之前一页消息到localMessage
(3)进入房间后每由用户发送消息直接保存到localMessage,滚轮滚动到底部时直接渲染
(4)每次渲染都是渲染整个localMessage内容,滚轮位置依据情况而定(滚轮位置接下来会讲,这样将渲染和滚轮位置拆分开理解的方法感觉思路会比较清晰)
关于分页的方法,最先打算直接用原始的按编号分页的方法但是这样会有诸多问题:
按照代码逻辑最新的消息会被插到列表尾部而当要加载最新消息时要找到尾部所以我们规定从尾部开始往前一页为第0页(现实中最后页可能不完整因为消息数不是页数的整数倍,这里直接将剩余的消息直接放最后页)
| x | x-1 | x-2 |.....| 0 |
但是这个列表在浏览时不是静态的,随时都有可能有新消息进来,因此这是一个动态变化的列表,那么要怎么保存之前的浏览位置以及根据要求更新消息呢?
比如之前的列表变成:
| x+1 | x | x-1 |.....| 1 | 0 |
那之前的第0页变成了第一页如果按前端保存的页数继续读就会出错,因此需要有什么东西标记下消息位置,由于列表长度一直在变化因此我们就不能使用下标啊什么的,应该让消息本身就有方便查找的特征,即messsage_id,这里是模拟了游标分页的方法来实现消息加载,为什么是模拟呢,因为根据查到的资料可知,游标分页在数据库处理时用的比较多因为数据库为了平衡存储效率、查询性能和灵活性所以内存是不连续的会用类似链表的方式组织起来,因此如果用常用的LIMIT+offset的方法查找不是像c语言数组一样直接定位的,而是要从头开始在扫描知道找到为止,所以在offset很大时非常慢,而游标分页将message_id和索引直间直接建立映射,申请消息时只要携带下一页或者之前一页开头或者末尾的message_id就可以实现快速定位(本例偷懒直接传递的是当前页的message_id因为实际是顺序存储的不影响使用[汗]),这在特定应用场景下就可以提升查找速度,但游标分页不支持随机跳页,但是这对于聊天室这样的应用场景而言并不影响使用。

后端响应前端获取消息获取消息代码
def get_message(room,start_id,sid,type):
    #新建房间
    if type=='newRoom':
        #起始和结束索引
        end=len(message[room])
        start=end-PAGE_COUNT
    #向前
    elif type=='Next':
        #异常处理
        index=search_message_by_id(msg_id=start_id,room=room)
        if index==-1:
            emit('error',{'err_msg':'消息id不存在'},to=sid)
            return []
        start=index+PAGE_COUNT
        end=start+PAGE_COUNT
    elif type=='Prev':
        #异常处理
        index=search_message_by_id(msg_id=start_id,room=room)
        if index==-1:
            emit('error',{'err_msg':'消息id不存在'},to=sid)
            return []
        end=index
        start=end-PAGE_COUNT
    else:
        return []
    #异常控制
    if start<0:
        start=0
    if end<0:
        return {'message':[],'next_id':None}
    #如果是空列表
    if start==end:
        return {'message':[],'next_id':None}
    #如果超出范围
    if start>=len(message[room]):
        return {'message':[],'next_id':None}
    #向后
    return {'message':message[room][start:end],'next_id':message[room][start]['message_id']}
#查找消息索引
def search_message_by_id(msg_id,room):
    # 检查房间是否在索引表中
    if room not in message_id_index:
        return -1
    # 直接从索引表获取索引(O(1) 操作)
    return message_id_index[room].get(msg_id, -1)

2、前端怎么对滚轮位置进行控制
为了方便用户阅读,需要在渲染同时保持滚轮位置,这里的规则规定如下:
(1)当用户在底部时有新消息来直接渲染并滚动到底部
(2)当用户在浏览时有新消息来时保留当前页面位置只提示有新消息
(3)如果用户自己发送消息则滚动到底部显示
滚轮需要知道几个基本概念:
(1)scrollTop:元素内容向上滚动的像素距离(容器顶部到内容顶部的隐藏距离)。
(2)scrollHeight:元素内容的总高度(包括可见区域和隐藏区域,不包含滚动条宽度)。
(3)clientHeight:元素的可见高度(包含内边距 padding,不包含边框、滚动条和外边距)。
具体解释差不多就下面这张图了(原谅我拙劣的画技好吧[哭]):
265796a77838d67da78e0eefae37d55
从规则可知我们需要判断什么时候在底部什么时候在顶部,代码如下:

底部顶部判断代码
function isAtBottom(tolerance = 10) {
  const { scrollTop, clientHeight, scrollHeight } = messageContainer;
  return (scrollTop + clientHeight) >= (scrollHeight - tolerance);
}
function isAtTop(tolerance=5) {
  
  // 滚动距离小于等于5px视为已到顶部(兼容浏览器像素误差)
  return messageContainer.scrollTop <= tolerance;

}
这里tolerance是为了防止浏览器精度问题会出现小数。 还需要能直接滚动到底部的代码如下:
滚轮滚动到底部
function scroll_to_bottom() {
  messageContainer.scrollTop=messageContainer.scrollHeight-messageContainer.clientHeight;
}
消息渲染以及后续滚轮位置处理
消息渲染
//前端渲染消息
function renderMessage() {
  // 记录渲染前的关键状态
  const scrollTopBefore = messageContainer.scrollTop; // 滚动条位置
  const clientHeight = messageContainer.clientHeight; // 可视区域高度
  const scrollHeightBefore = messageContainer.scrollHeight; // 内容总高度
  //渲染消息
  messageList.innerHTML = '';
  const fragment = document.createDocumentFragment();
  localMessage.forEach((msg) => {
    fragment.appendChild(create_message_element(msg.username, msg.message, msg.message_id));
  });
  messageList.appendChild(fragment);

  
}
//消息显示处理
function handleMessageRender() {
 //不在底部以及消息够多提醒有条新消息
  if(!isAtBottom()&&!isContentNotEnough()){
    //不是更新新消息展示提示
    if(!(isAtTop()&&isScroll())){
      
      showNewMessageHint();
    }
    //更新新消息渲染
    //else{
      renderMessage();
    //}
  }
  else{
  renderMessage();
  scroll_to_bottom();
  }
}
if(!(isAtTop()&&isScroll()))是为了不会在滑动到顶部需要加载旧消息时会出现有新消息的提示框,这部分代码有冗余和缺陷待改进。 3、后端怎么处理并发 要处理并发就需要理解什么是线程什么是协程,首先线程是由操作系统内核调度的并发单位,属于 “内核级线程”,其对于并发的实现关键在于操作系统通过时间分片,即让 CPU 在多个线程间切换(如 A 线程执行 10ms,切换到 B 线程执行 10ms),宏观上表现为多个任务同时进行,协程则是由程序(用户态) 自身调度的并发单位,属于 “用户级线程”,其对并发实现在于程序通过主动 “让出 CPU”(如yield操作),在多个协程间切换,无需内核参与,例如,A 协程执行到 IO 操作时主动挂起,切换到 B 协程执行,以python asyncio为例,有点像在main函数里写个while循环,然后维护两个队列,一个任务队列,一个等待队列,执行协程过程就是取出一个协程并执行,直到它遇到 await 关键字(表示需要等待某个操作完成,如 IO),此时,协程被暂停,事件循环保存其上下文(状态),并将其放入 “等待队列”,然后处理 IO 事件,事件循环检查 “等待队列” 中协程的 IO 操作是否完成(如网络响应到达、文件读取完毕),若 IO 完成,将对应的协程从 “等待队列” 移回 “任务队列”,等待下次执行。简单来说线程有操作系统控制让cpu在当前任务空闲时运行另一个任务,而协程由用户本身掌握何时由哪个任务接管cpu,也可以看出,线程适合cpu密集型任务即多核场景下能发挥出优势,而协程则适合io密集型任务,基于聊天室吞吐频繁的特点,本例就采用了单线程的多协程开发,实际应用中可以使用多线程多协程仪器合作,本例在后端进行容器操作时要特别注意加锁,避免在查询修改容器时,另一个线程对容器进行修改。要处理并发还要理解HTTP和WebSocket协议,HTTP 是客户端主动发起请求的协议,服务器发送响应,响应完成后,连接断开,因此客户端得不断发生请求,服务端也无法主动提供数据,效率较低,而WebSocket则只在握手阶段客户端通过 HTTP 请求发起握手(携带 Upgrade: websocket 头),服务器同意升级协议后,连接转为WebSocket 长连接,客户端和服务器可随时发送消息,无需重复握手,而断开连接则是任意一方主动关闭连接,而WebSocket是怎么建立长连接的呢,主要是通过心跳机制,WebSocket通过帧来发送消息,服务器可发送 Ping 帧(Opcode 0x9)给客户端,客户端必须回复 Pong 帧(Opcode 0xA),内容与 Ping 帧一致,通过定时发送 Ping/Pong,维持连接活跃,确保长连接不被断开,若需断开连接,任何一方发送 关闭帧(Opcode 0x8),对方确认后关闭 TCP 连接,避免资源泄漏。本例需要客户端与服务端建立起长连接就很适合使用WebSocket协议。 4、前端怎么处理加载信息和渲染先后问题: 由于请求信息是客户端向服务端发送请求,客户端接收完后再渲染消息,但是渲染很可能在信息还未完全加载完成前发生,为避免这种情况发生可以手动模拟锁,就是之前的isLoading,具体机制如下:
申请时加锁
function loadEarlierMessage() {
  // 加载锁:如果正在加载则直接返回
  if (isLoading) return;
  // 标记为加载中
  isLoading = true;
  // 还有消息显示加载提示(有更多消息且向前请求)
  if(messageFlag.hasMore&&messageFlag.Prev){
    showLoader();
  }
  // 设置加载历史消息标志
  messageFlag.Prev = true;
  // 发送消息加载请求
  apply_new_message();
}
加载完成后解锁
socket.on('message_got',function (data) {
  const{room,message}=data;
  //状态机更新
  if(room==currentRoom){
    //没有更多消息了
    if(message.message.length<PAGE_COUNT){
      messageFlag.hasMore=false;
    }
    //是空的话不刷新
    if(message.message.length===0){
      //加载完成后渲染
      isLoading=false;
      return false;
    }
    //是新房间
    if(messageFlag.newRoom===true){
      //更新标志
      messageFlag.newRoom=false;
      MessageCover(message);
    }
    //冗余设计实际本项目不会使用到
    else if(messageFlag.Next===true){
      //更新标志
      messageFlag.Next=false;
      
      MessageInsertTail(message);
    }
    else if(messageFlag.Prev===true){
      //更新标志
      messageFlag.Prev=false;
      
      MessageInsertHead(message);
    }
  } 
  //加载完成后渲染
  isLoading=false;
  //关闭提示窗口
  // 6. 显示/隐藏加载提示
  hideLoader();
  //渲染消息
  //renderMessage();
  handleMessageRender();
  
});
(ps:好吧看起来有点蠢[汗])

五、代码架构组织
1、后端:
比较简单就是一堆响应前端消息并发送消息的函数,实际上本例基本是前端发送后后端才会响应。
2、前端:
connect/disconeect:处理基本的登录,存储用户名等等(有缺陷,不能正常运行,但是登录界面可以正常运行)
消息事件:client_message:接受消息存入localMessage,如果是自己消息直接滚动到底部
message_got:主要状态机根据状态变量方式更新localMessage并直接渲染(Prev:是在hasMore为真时加载之前消息,NewRoom:是新房间直接加载最新的一页消息)
apply_new_message():根据用户情况出发发送message_query
render_message():渲染消息(清空列表直接显示,简单粗暴)
handleMesssageRender():根据滚轮状况选择要不要滚动
房间事件根据规则跟新currentRoom即可。

六、效果

be95e131ce62eb1617b433dfa85b1490

(ps:界面是ai帮忙美化的[汗])

七、完整代码
等我学下Git怎么上传[哭]
/------分割线:好了现在会了------/
项目地址
https://gitee.com/aa0101001/sos.git

posted @ 2025-08-07 16:05  懒懒烂烂  阅读(42)  评论(0)    收藏  举报