WebSocket协议理解并实现在线聊天

写在前面

WebSocket 简称 ws
本文通过介绍ws,然后一步步的使用ws协议进行前后端开发测试,最后使用SpringBootvue 利用ws协议达实现一个在线聊天室的小demo(源码在文章末尾)。

看完此篇后将能够完成这样的小demo(需要会使用springboot和vue2以及element-ui)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

WebSocket介绍

在这里插入图片描述
WebSocket是在HTML5开始提供的一种能在单个TCP连接上进行全双工通讯的网络通信协议
与HTTP协议可以简单理解为:
在这里插入图片描述
WebSocket协议与HTTP协议对比:

  • HTTP协议:HTTP协议是一种无状态(请求完成后,连接彻底断开,节省资源)无连接(客户端端向服务端请求数据,获取数据后即断开连接)单向(连接建立只能由客户端发起)的应用层通信协议,采用请求/响应模型。
    • 特性:客户端有事向服务器询问,服务器告诉它,告诉完毕后客户端没有问题就消失的无影无踪,服务器再也找不到它,只能等到客户端下次有问题去询问服务器它才会出现。服务器在明处,客户端在暗处。
    • 弊端,客户端有事向服务端询问可以,但服务端有事却找不到客户端,因为客户端完成数据获取后,连接被彻底断开,而掌握建立连接的特权只在客户端手中,只能把事憋到客户端与服务端再次建立请求时一并告诉它。当有要紧事的时候,也无法联系客户端,即服务端无法向客户端推送数据
    • 解决思路:让客户端频繁的去与服务端建立连接,这样服务端的数据就不用憋太久
      • 存在的问题:多久去与客户端建立一次连接呢?首先频繁的建立耗费系统大量的资源,而服务端却很少有数据传输;其次,多久建立一次连接呢?连接建立的过于频繁,客户端会承受不住,如果频率低,服务器的数据还是要憋一会,还是打达不到实时。频繁访问耗费巨大资源洒下大网只有极少收获。

要是有一种客户端能向服务器端发送数据,服务器端也能向客户端发送数据的协议就好了!即全双工通讯 。这种协议客户端不能与服务端断开连接,使得服务端端能够找到客户端继续发送数据即有状态连接虽然会耗费资源,但在特定需求下,比HTTP协议那种频繁连接要好上不少。这种协议就是WebSocket协议。总结webSocket就是支持持久连接使得服务端与客户端都能够实时通信的协议

WebSocket的应用场景

  • 及时通信的Web应用:例如要求网页数据实时刷新的实时数据展示网站
  • 游戏应用:屏幕动画能够实时刷新
  • 聊天应用:发送消息能够及时接收,推送

在这里插入图片描述
推荐文章

WebSocket实现

HTTP方式:我们编写前后端分离的代码时,后端编写@RestController接口,用来接收请求,前端编写封装axios用来发送请求获取后端数据。其实这种方式就是遵守HTTP协议进行数据交互的,前端使用axios发送的是HTTP请求,后端接口接收的也是HTTP请求。
WS方式
因为常使用的@RestController接口和axios请求工具都是为HTTP协议服务的,因此WS虽然数据交互是这一流程(后端指定接口,前端获取数据),但要有自己的一套实现工具。使用@ServerEndpoint到达类似于@RestController的效果,使用WebSocket对象达到类似于axios的效果,具体使用后面会介绍。 Endpoint与webScoket的关系就像 Servlet与Http的关系一样


生命周期
生命周期使我理解的,原话为 websocket事件
前端

let ws=new webSocket(ws://ip:端口号/资源名称)
函数名 描述
ws.onopen 建立连接时触发
ws.onmessage 接收到后端消息时触发
ws.onerror 通信过程发生错误时触发
ws.onclose 连接关闭时触发

发送数据时 ws.send()

后端

注解名 描述
@onClose 连接关闭时触发
@onOpen 初始化时触发
@onError 出现错误时触发
@onMessage 初始化后自动触发,获取前端传递的数据

ws实现流程概括
服务端不再像过去一样只负责客户端的数据响应,还可以主动向客户端推送数据
在这里插入图片描述
ws前后端都有一个生命周期的概念,后端的各个生命周期相当于把一个@RestController进行拆分同时多了一个连接者的容器。

  1. 进行请求映射使用@ServerEndpoint("xx")
  2. 请求初始化时将此请求放入所有连接者的容器,方便在发送请求时,能够找到当前与系统建立连接的各个连接,使用@OnOpen标记
  3. 获取前端的数据 @OnMessage,可以使用同@RequestMapping()的注解方式获取数据,如@PathParam("xxxx")注意每次前端发送数据,此方法都会被调用。但初始化时不会调用
  4. 返回给前端数据 不在直接使用return,而是使用获取连接池中保存的连接session,通过session将返回的信息进行传递。
  5. 消息的接收、返回都已经完成,其余的就是补充。关闭连接是调用 @OnClose标注下的方法,出现错误时调用@OnError标注下的方法

webSocket有自己获取参数的获取方式,与HTTP的Controller的方式很像

  • 从请求路径参数中获取数据 @PathParam("xxx") 使用起来与@PathValue("xxx")一样

有的注解下的方法必须含有某些参数

  • @OnError标识下的方法列表中必须含有Throwable对象,否则启动报错
  • @OnMessage此注解标识下的方法,在前端向后端发送消息时自动调用,要求此方法必须含有参数,否则启动报错

问题?

  • 在后端向前端发送信息时,怎样去调用?
    整个生命周期在连接时能够自动调用的有两个方法,一个是在初始化时调用@OnOpen标注下的方法,一次是在接受前端请求时 调用@OnMessage下的方法(此方法前端每次发送数据都会被调用)。可以选用@OnMessage标注下的方法作为后端向前端返回数据的引子,在此方法中调用返回前端的数据方法。

  • 怎样发到即时通讯效果?

  • 解决后端怎样向前端发送数据问题,即可以解决此问题。当A通过ws将发给B的消息传递到服务器,在接收前端数据的方法中自动调用返回前端数据的方法,在此方法中获取消息内容和目的方,然后去连接者容器中找到目的方的连接对象,然后将A发送的数据作为返回给B连接对象返回的数据。**这样思路就要求 B要在连接者的容器中,即B要保持连接 **

  • 怎样使得前台一直接收后端自己产生的数据
    后端自己产生的数据就代表前台只能触发一次,其余的数据不断返回前端都要靠着这一次触发。可以在第一次触发时调用一个类似于监听阻塞阻塞队列的方法,将产生的数据不断的放入队列中,方法中监听到数据就取出返回给前端。这样就是由第一次请求调用监听器,数据产生监听的去触发数据返回

  • 前端怎样接收后端返回的数据?
    当后端有数据返回时前端的对象的onmessage()会被触发,在这里可以就行数据的赋值,页面的渲染

ws参数传递

  • 路径传参:在初始化时通过路径进行参数传递 后端使用要在路径的映射中使用 例如: login/{xxx},然后在方法参数中使用@PathParam("xxx")去接收

  • 消息传参:使用对象发送消息ws.send(“xxx”),可以将一些内容转为JSON形式进行发送,然后后端在@OnMessage下的方法参数中进行接收和转换

  • 请求头传参:使用请求头,因为以上都已经介绍过,这里不加赘述。请求头才是这里重点

    • 前端添加请求头
      ws的请求不像HTTP请求那样灵活,没有api进行传参,且不可以自定义请求头。那怎样进行传递呢?
      • 利用websocket请求头有含Sec-WebSocket-Protocol这个属性,可以在为属性中赋值,后端从这个属性中取出。这个属性赋值没有单独的Api,只能在构造ws对象能够进行赋值
      例如 :
      let ws = new WebSocket("ws://localhost:8888/xxxx", "请求参数1");
      
      后端获取请求头 Sec-WebSocket-Protocol  得到的就是 请求参数1
      
      let ws = new WebSocket("ws://localhost:8888/xxxx", ["请求参数1","请求参数2"]);
      
      后端获取请求头 Sec-WebSocket-Protocol  得到的就是 请求参数1   请求参数2
      
    • 后端获取请求头
      后端这里指的就是springBoot,后端获取请求头没有HTTP协议那样直接在参数列表中使用注解 @RequestHeader直接获取,而是要继承的指定方法中获取

    在这里插入图片描述
    *

    @Configuration
    @Slf4j
    public class WebSocketConfig extends ServerEndpointConfig.Configurator {
          
          
        @Bean
        public ServerEndpointExporter serverEndpointExporter() {
          
          
            return new ServerEndpointExporter();
        }
    
        public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
          
          
    
            //获取请求头 请求头的名字是固定的
            List<String> list = request.getHeaders().get("Sec-WebSocket-Protocol");
    
    
            //当Sec-WebSocket-Protocol请求头不为空时,需要返回给前端相同的响应
            response.getHeaders().put("Sec-WebSocket-Protocol",list);
    
    
            super.modifyHandshake(sec, request, response);
        }
    }
    
    
    • 然后 还要在@ServerEndpoint注释中指名配置类
      在这里插入图片描述

    注意:此类的中方法只会在创建连接时执行,在发送消息、出现错误、断开连接均不执行
    个人理解:既然ws是创建一次就可以多次发送消息,在创建第一次时进行身份校验,同一个连接是不是只校验一次就可以了?http使用拦截器也是校验一次,因为其频繁连接才会频繁的验证?即在这个方法中充当http时的拦截器使用?
    在这里插入图片描述

模拟测试

后端模拟

后端使用springBoot,前端使用postman发送ws请求

后端准备: 注意要tomcat7以后才支持webSocket协议

  • 后端除去环境依赖外,要导入关键的WebSocket依赖

      <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-websocket</artifactId>
      </dependency>
    
  • 环境依赖

     <parent>
       <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.12.RELEASE</version>
        <relativePath/> 
     </parent>
    
     <dependency>
       <groupId>org.springframework.boot</groupId>
       <artifactId>spring-boot-starter-web</artifactId>
     </dependency>
    <dependency>
         <groupId>org.springframework.boot</groupId>
         <artifactId>spring-boot-starter-websocket</artifactId>
     </dependency>
     <dependency>
         <groupId>org.projectlombok</groupId>
         <artifactId>lombok</artifactId>
         <optional>true</optional>
     </dependency>
    
  • 向容器中注入 ServerEndpointExporter对象
    创建一个配置类,在类中将对象注入容器

    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.web.socket.server.standard.ServerEndpointExporter;
    
    @Configuration
    public class WebSocketConfig {
          
          
        @Bean
        public ServerEndpointExporter serverEndpointExporter() {
          
          
            return new ServerEndpointExporter();
        }
    }
    
    
  • 接下来就可以写类似于接口的应用了

前端准备

  • 在postMan中创建ws类型的连接 (不能使用原HTTP的方式
    在这里插入图片描述

第一个测试:建立连接后,前端向后端发送数据,后端接收数据,并向前端发送数据;只有一个连接 (不使用连接的session们容器)


import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;

@ServerEndpoint(value = "/wsserver/{username}")
@Component
@Slf4j
public class WebSocketServer {
    
    
    private Session session;
    @OnOpen
    public void onOpen(@PathParam("username") String username, Session session){
    
    
        this.session=session;
        log.info(username);
        log.info("连接创建初始化");
    }

    @OnMessage
    public void onMessage(Session session,String message) throws IOException, InterruptedException {
    
    
        log.info("接收到数据"+message);
        Thread.sleep(10000);

        for (int i=0;i<10;i++){
    
    
            this.session.getBasicRemote().sendText("来自后端的数据");
        }
    }
    @OnError
    public void onError(Session session, Throwable error) {
    
    
        log.error("发生错误");
        error.printStackTrace();
    }
    @OnClose
    public void onClose(){
    
    
        log.info("关闭连接");
    }
}


注释

在这里插入图片描述

测试结果
在这里插入图片描述
第二个测试:创建两个独立连接分别为A、B。A向B发送数据,B向A发送数据,在数据中要指定发送方

实现思路:

  • 数据中指明发送方接收方以及数据。在路径参数中指明发送方,在数据格式中设定规则,提取出接收方和数据。
  • 每一个连接在初始化时都会向公共的缓存中添加自己的标识session对象,这样就方便根据数据的接收方去找到其连接的session,然后将数据通过接收方的session进行返回
@ServerEndpoint(value = "/wsserver/{username}")
@Component
@Slf4j
public class WebSocketServer {
    
    
    public static final Map<String,Session> sessionMap=new ConcurrentHashMap<>();
    @OnOpen
    public void onOpen(@PathParam("username") String username, Session session){
    
    
        sessionMap.put(username,session);
        log.info("数据来自"+username);
        log.info("连接创建初始化");

        for (Map.Entry<String,Session> entry:sessionMap.entrySet()) {
    
    
            System.out.println(entry);
        }
    }

    @OnMessage
    public void onMessage(Session session,String message) throws IOException, InterruptedException {
    
    
        log.info("接收到数据"+message);
        String[] split = message.split(":");
        sessionMap.get(split[0]).getBasicRemote().sendText(split[1]);
    }
    @OnError
    public void onError(Session session, Throwable error) {
    
    
        log.error("发生错误");
        error.printStackTrace();
    }
    @OnClose
    public void onClose(){
    
    
        log.info("关闭连接");
    }
}


注释
在这里插入图片描述

测试结果
在这里插入图片描述


注意:每一个请求对应一个WebSocketServer对象,每次变动都会调用对象中指定的方法,例如:每个请求连接时都会触发一次@onOpen下的方法、每个请求发送数据都会调用一次@onMessage标识下的方法…

前端页面

创建一个vue项目,因为后面项目会用到。这里也可以不创建,只要有一个能够运行js的环境即可,最简单的方式就是创建一个.html页面然后在浏览器中打开

第一个测试 与服务器建立连接

创建一个ws对象,利用此对象可以向后端发送数据获取后端数据等。创建方式很简单,在创建时就会进行连接(是指执行到这里时,而不是指new出来后)

在这里插入图片描述
页面运行后,服务端会立即创建连接
在这里插入图片描述

第二个测试 在postman中的A向浏览中的C发送数据,C接收数据并打印到页面;

要在上文已经完成后端测试二的基础上

在这里插入图片描述
注意:onmessage是一直进行监听服务端发送过来的数据的

第三个测试 在测试二的基础上向postMan的A发送数据

利用wc对象的send(“xxx”)方法

<template>
    <div>
        {
    
    {
    
    receptionData}}
        <el-input size="mini" v-model="data"></el-input>
        <el-button @click="toSendMsg()">发送</el-button>
    </div>
</template>
<script>
export default {
    
    
    data(){
    
    
        return{
    
    
            data:'',
            ws:undefined,
            receptionData:undefined
        }
    },
    created(){
    
    
      this.ws= new WebSocket('ws://localhost:8080/wsserver/C');
        this.ws.onmessage=(msg)=>{
    
    
            this.receptionData=msg.data
        }
    },
    methods:{
    
    
        toSendMsg(){
    
    
           this.ws.send("A:cccccc")
        }
    }
}
</script>

测试结果:
在这里插入图片描述
注意:当前端网页完毕时,会自动触发后端的关闭处理

此外还有 sc.close()手动进行关闭的一些前端处理、 sc.onerror()出现异常的一些处理,用法与onmessage相同,都是为其赋值一个函数

在测试过程中发现:数据只能被消费一次,即有多个客户端进行连接时,只能有一个客户端接收到服务端的数据。因为不同的客户端连接就有不同的连接session。由于服务端的代码使用map存储,key为唯一标识,value为session。当一个用户在多个客户端登录时,map中相同的key的旧值会被最新的值覆盖,就出现只有最后打开的网页能接收到服务端的数据。



在线聊天系统开发

本系统为自己原创,基于HTTP协议(例如:登录、注册)与ws协议(例如:实时聊天)的混合开发,目的是熟练的使用ws协议,所以在其他方面较为省略,可能会有bug或不对的地方,还望指正

前端部分

前端使用vue和Element组价库(非必要),使用axios发送HTTP的请求,使用webScocket对象发送ws请求。

  1. 使用npm创建一个vue项目
  2. 下载Element-ui并在main.js文件中进行导入
  3. 下载axios,创建一个工具类去封装axios请求,然后在需要的页面中的js部分导入。
  4. 配置路由
  5. 删除无用页面
  6. 然后测试项目能否正常运行,element是否导入成功等

项目的vue项目初始化部分这里不再一一介绍。
登录样式布局

在这里插入图片描述

代码部分:

<template>
  <div class="wrapper">
    <div class="login_div">
   
        <span class="title"><h2> 在线聊天室</h2></span>
        <div class="form_div">
          <el-form ref="form" :model="form" label-width="80px">
            <el-form-item>
              <el-input v-model="form.name" size="mini" placeholder="输入姓名"></el-input>
          </el-form-item>
            <el-form-item>
              <el-input v-model="form.password" size="mini" placeholder="输入密码" type="password"></el-input>
            </el-form-item>
             <el-form-item>
              <a href="#" style="color:blue">还没有账号?点我去注册</a>
          </el-form-item>
          <el-form-item>
            <el-button type="primary" @click="submitForm()" size="small" style="width:100%">提交</el-button>
          </el-form-item>
        </el-form>
        </div>
    </div>
  </div>
</template>

<script>
import {
    
    login} from '../api/userApi'
export default {
    
    
 
  data() {
    
    
    return {
    
    
      form:{
    
    
        name:'',
        password:''

      },
      mock:{
    
    
      
      }
    }
  },
  methods: {
    
    
      submitForm(){
    
    
        login(this.form).then(res=>{
    
    
           
        })
      }
  }
   
}
</script>
<style scoped>
.wrapper {
    
    
  height: 100vh;
  background-image: linear-gradient(to bottom right, #FC466B, #3F5EFB);
  width: 100%;
  overflow: hidden;
  position: relative;
 
}
.login_div{
    
    
  width:500px;
  height: 300px;
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -70%);
  background-color:aliceblue;
  opacity:0.9;  
}
.title{
    
    
  text-align: center  ;
}
.form_div{
    
    
  margin-right: 100px;
  margin-top: 50px;
}
</style>

聊天页面布局
用css画一个渐变色的背景,然后在画出页面的整体布局
在这里插入图片描述

<template>
  <div style="min-height: 100vh" class="window_div">
      <div class="chat_div">
          <div class="chat_header">
            <span class="hrader_title">当前用户:xxx</span>
            <span class="hrader_friend">正在和xxx 聊天</span>
          </div>

          <div class="chat_left">
              <div class="message_div"></div>
               <div class="input_div"></div>
          </div>
          <div class="chat_right">
            <div class="friend_div">
              <div class="friend_list_title"></div>
              <div class="friend_list"></div>
            </div>
            <div class="sys_info_div">
              <div class="sys_info_title"></div>
              <div class="sys_info"></div>
            </div>
          </div>
      </div>
  <div>
    </div>
  </div>
</template>

<script>


export default {
    
    
}
</script>

<style scoped>
.window_div{
    
    
  background-image: linear-gradient(to bottom right, #b1b1c5, #3F5EFB);
  overflow: hidden;
  position: relative;
}
.chat_div{
    
    
  width: 60%;
  height: 65vh;
  border: 1px solid red;
  margin: 10vh auto;
}
.chat_header{
    
    
  display: flex;
  width: 100%;
  height: 10vh;
  border: 1px solid red;
}

.chat_left{
    
    
  width: 70%;
  height: 55vh;
  border: 1px solid red;
  float: left; 
}

.chat_right{
    
    
  width: 30%;
  height: 55vh;
  border: 1px solid red;
  float: right; 
}
.hrader_title{
    
    
  display: flexbox;
  line-height: 10vh;
  margin-left: 6%;
  color: white;
  width: 30%;
  border: 1px solid red;
}
.hrader_friend{
    
    
  display: inline-block;
  margin-top: 6vh;
  height: 2vh;
  color: white;
  width: 25%;

  font-size: 12px;
  border: 1px solid red;
}
.friend_list_title{
    
    
  width: 100%;
  height: 5vh;
  border: 1px solid red;
}
.friend_list{
    
    
  width: 100%;
  height: 20vh;
  border: 1px solid red;
}
.friend_div{
    
    
  width: 100%;
  height: 25vh;
  border: 1px solid black;
}
.sys_info_div{
    
    
  width: 100%;
  height: 30vh;
  border: 1px solid black;
}
.sys_info_title{
    
    
  width: 100%;
  height: 5vh;
  border: 1px solid red;
}
.sys_info{
    
    
  width: 100%;
  height: 25vh;
  border: 1px solid red;
}
.message_div{
    
    
  width: 100%;
  height: 40vh;
  border: 1px solid red;
}
.input_div{
    
    
  width: 100%;
  height: 15vh;
  border: 1px solid red;
}

</style>

去掉边线进行填充后
在这里插入图片描述

此时前端样式除了消息部分已经完成,剩下去调试后端


后端部分

设计功能:

  1. 登录
  2. 显示在线的用户列表
  3. 实时聊天
  4. 上线提醒、下线提醒

设计用户姓名要唯一

流程图

在这里插入图片描述

消息类型
用户之间发送的消息、用户上线通知消息、用户下线通知消息、好友列表消息、系统通知(一些错误等)

在这里插入图片描述

消息体
发送发、接收方、消息类型、消息内容
在这里插入图片描述

思路总结

  1. 怎样确保安全性?原本计划向http请求那样每次请求携带请求头在拦截器中进项校验,经过测试发现,http请求时因为每一次都是不同的连接,才需要每次进行校验。ws可以在创建连接时只校验一次。在登录时返回一个token,服务端使用一个map保存一个着token记录。在ws创建连接时携带此token,服务端从map中进行查找,根据是否找到判断此连接是否合法。
  2. 消息的发送方怎么确定?在发送消息时,在请求体中设置发送发 (可能不咋安全)
  3. 怎样将用户连接放入session的map中?在校验ws的token通过后,将查找到的用户姓名放入LocalThrad中,在放入sessionMap时,从LocalThread取出用户姓名作为key,值为此连接的session
  4. 初始化进行怎样的操作?在初始化时将连接放入session的map中,然后将朋友列表通过此session进行返回,因为此次代码中是使用name作为key的,所以只需要遍历session的map所有的key即可。然后向所有用户发送系统消息,该用户已经上线
  5. 怎样区分不同类型的消息?设置消息格式能将不同类型的消息进行区分,将系统消息,好友列表、好友消息采用统一格式进行发送。
  6. 在线朋友列表怎样更新?初始连接时由后端向此连接发送当前所有在线用户,后续由系统提示上下线时,由前端进行更新
  7. 出现异常和断开连接怎样处理?封装一个通知其他session,此用户已经下线的方法,在此方法中根据参数列表中的session,去sessionMap中根据值去查找key,将key作为消息,同时将消息类型设置为用户下线向其他session中发送。前端接收到用户下线类型的消息,进行提示,同时在 在线朋友列表中去除此name。

代码注释

后端核心代码:(由于篇幅太长,这里只做注释,详细代码已经上传到gitee中,可以自行访问查看)
在这里插入图片描述
在这里插入图片描述
到这里,后端所有功能都已经基本完成,只差前端接收不同类型的消息,并将其赋值后进行渲染

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
此处控制台测试发送消息、接收消息均已成功,接下来就是本次项目最让人头疼的消息显示

采用循环数组的方式,这种方式会和平常聊天习惯存在点差异,这个版本就不去改了
在这里插入图片描述
此外在线人员列表也采用同种方式
在这里插入图片描述

在线聊天1.0 版本到此完成

源码

完整源码已经上传到gitee中 请点击访问 https://gitee.com/wang-yongyan188/websocketchat.git
提示:因为ws创建请求的token存储在了localStorage中,所以同一个浏览器即使的登录不同的账号也是同一个用户,所以要使用不同的浏览器去测试。如果只测试后端,更推荐使用postman去测试
在这里插入图片描述

所有代码皆为原创,博客和代码共用了两天时间,任何错误欢迎提示,如果觉得还可以,就请点个赞吧。
此项目的是理解ws协议,并能够使用springboot实现两种协议的混合开发。
代码页面比较简陋、功能也不完善(注册需要手动去数据库添加,只能和在线用户聊天等),如果喜欢,后续会推出比如:添加好友、消息未读提示、群聊、消息保存等,进一步完善成为一个小项目。

猜你喜欢

转载自blog.csdn.net/m0_52889702/article/details/128246831