【实战项目之网页聊天室】

news2024/11/23 20:17:01

目录

项目背景

需求分析 

1.用户管理模块

注册

登录

2.主界面

个人信息模块

会话列表模块

好友列表模块

消息区域模块

消息传输模块

添加好友模块

编写项目

1.创建项目添加依赖

2.配置项目信息

3.功能实现

用户管理模块

个人信息模块 

好友列表模块

消息区域模块

消息传输模块

添加好友模块

扩展模块


项目背景

实现⼀个网页版的聊天室程序,类似于网页版微信,可以直接在网页上进行聊天,主要目的是为了巩固自己所学的知识,提升技术水平并积累实践经验。

项目gitee地址:https://gitee.com/themyth_ws/sping-family-bucket/tree/master/chatterbox

需求分析 

1.用户管理模块

注册

实现一个注册页面,输入用户名和密码,进行用户注册.

登录

实现一个登录页面,输入用户名和密码,进行用户登录.

2.主界面

个人信息模块

在左上角显示当前用户的信息(用户名).

会话列表模块

左侧罗列出当前用户有哪些会话.选择某个表项,就会在右侧消息区显示出历史消息. 

好友列表模块

左侧罗列出当前所有的好友信息.点击好友列表中的表项,就会跳转到会话列表,同时给会话列表新增⼀个表项.并且提供了⼀个"新增好友"的按钮,点击后跳转到新增好友页面.

消息区域模块

右侧显示消息区域.最上面显示会话名称.中间是消息列表.下方显示一个消息输入框,可以输入消息并发送

消息传输模块

选中好友,则会在会话列表中⽣成⼀个会话.点击选中会话,会在右侧区域加载出历史消息列表.接下来在输⼊框中输⼊消息,点击发送按钮即可发送消息.

ps:如果对方在线,就会即刻提示实时消息.如果对方不在线,后续上线后就会看到历史消息.

添加好友模块

在左上⻆的输⼊框中输⼊要查找的⽤⼾,则会根据⽤⼾名进⾏模糊匹配,匹配结果放到右侧列表区中.可以输⼊⼀个验证消息,并点击按钮发送好友申请.对⽅会在会话列表中收到⼀个提⽰信息.点击接收按钮则通过好友申请.点击拒绝按钮则忽略好友申请.
ps:如果对方不在线,会在后续上线后看到历史的好友申请.

编写项目

1.创建项目添加依赖

2.配置项目信息

# 配置数据库的连接字符串
spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/chatterbox?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8
    username: root
    password: 111111
    driver-class-name: com.mysql.cj.jdbc.Driver
# 设置 Mybatis 的 xml 保存路径
mybatis:
  mapper-locations: classpath:mybatis/*Mapper.xml # 映射文件包扫描
  configuration: # 配置打印 MyBatis 执行的 SQL
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl #默认日志级别是info,而这需要的日志级别是debug
  # 配置打印 MyBatis 执行的 SQL
  logging:
    level:
      com:
        example:
          chatterbox: debug #默认info > debug,只有设置为debug才能看到日志
    pattern:
      console: "[%-5level] - %msg%n"
      pattern:
        dateformat: HH:mm:ss.SSS

ps:这里要先写上数据库相关的配置,否则直接打包可能失败.

3.功能实现

用户管理模块

在进行代码编写之前,我们先明白主要的开发流程

一.数据库设计/代码实现

创建一个“用户表”           

create database if not exists chatterbox charset utf8;
use chatterbox;
drop table if exists user;
create table user (
    userId int primary key auto_increment,
    username varchar(20) unique, -- 用户名, 用于登录.
    password varchar(20)
);

insert into user values(null, 'zhangsan', '123');
insert into user values(null, 'lisi', '123');
insert into user values(null, 'wangwu', '123');

注册操作,就是给用户插入新的记录 

登录操作,就是从用户表进行查询   

编写数据库操作代码

1.创建实体类 User类

@Data
public class User {
    private int userId;
    private String username = "";
    private String password = "";
}

2.编写 Mapper 接口

@Mapper
public interface UserMapper {
    // 把用户插入到数据库中 -> 注册
    int insert(User user);
    // 根据用户查询用户信息 -> 登录
    User selectByName(String username);
}

3.编写 xml,借助 MyBatis 自动生成数据库操作的实现

    <select id="selectByName" resultType="com.example.chatterbox.model.User">
        select * from user where username = #{username}
    </select>

    <insert id="insert" useGeneratedKeys="true" keyProperty="userId">
        insert into user values(null, #{username}, #{password})
    </insert>

二.前后端交互接口的设计

此处要设计两个接口

注册

请求:

POST /register

Content-Type:application/x-www-form-urlencoded

username=zhangsan&password=123

响应:

HTTP/1.1 200 OK

Content-Type:application/json

{

        userId: 1,

        username: 'zhangsan'

}

约定:如果注册失败,仍然返回200,body里返回的user对象,给一个空对象(userId为0,username为'',password为'')

登录

请求:

POST /login

Content-Type:application/x-www-form-urlencoded----此时前端使用form来提交请求(方式不限)

username=zhangsan&password=123

响应:

HTTP/1.1 200 OK

Content-Type:application/json

{

        userId: 1,

        username: 'zhangsan'

}

如果登录成功,直接返回一个当前登录用户的身份信息,让客户端/浏览器可以保存用户的身份状态。

约定:如果登录失败,仍然返回200,body里返回的user对象,给一个空对象(userId为0,username为'',password为'')

上述交互接口设计的过程,也就相当于”自定义应用层协议“。

三.服务器代码开发

注册接口和登录接口

package com.example.chatterbox.api;

import com.example.chatterbox.model.User;
import com.example.chatterbox.model.UserMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

@RestController
public class UserAPI {
    @Autowired
    UserMapper userMapper;

    @PostMapping("/login")
    @ResponseBody
    public Object login(String username, String password, HttpServletRequest req) {
        //1.先去数据库中查表,查看 username 能否找到对应的 user 对象
        //如果能找到,先看一下密码是否匹配
        User user = userMapper.selectByName(username);
        if (user == null || !password.equals(user.getPassword())) {
            //登录失败!同时返回一个空对象即可
            System.out.println("登录失败!用户名或密码错误!" + user);
            return new User();
        }
        //2.如果都匹配,登陆成功!创建会话,将用户信息存入对话。
        HttpSession session = req.getSession();
        session.setAttribute("user", user);
        //在返回user对象之前,把password置空,避免返回不必要的信息
        user.setPassword("");
        return user;
    }

    @PostMapping("/register")
    @ResponseBody
    public Object register(String username, String password) {
        User user = null;
        try {
            user = new User();
            user.setUsername(username);
            user.setPassword(password);
            int ret = userMapper.insert(user);
            System.out.println("注册 ret:" + ret);
        } catch (DuplicateKeyException e) {
            //如果 insert 方法抛出上述异常,说明名字重复了,注册失败
            user = new User();
            System.out.println("注册失败!username = " + username);
        }
        //返回 user 对象之前,置空password
        user.setPassword("");
        return user;
    }
}

退出登录接口(这个接口是在个人信息模块之后设计的,我只是先把放这里了。)

/*
    实现退出登录
    要么把 HttpSession干掉要么把user干掉
    只要干掉一个,就行了!
    如果有会话,没有user对象也视为是未登录!
    HttpSession对象要想干掉,还麻烦点.
    getSession能够创建/获取会话.没有删除会话的方法
    */
    @PostMapping("/exit")
    public void exit(HttpServletRequest req) { //返回值为Object 返回user也可以,前端通过判断userId<=0,然后跳转到登录页面
        User user = new User();
        //1.先从请求中获取到会话
        HttpSession httpSession = req.getSession(false);
        if(httpSession == null) {
            //会话不存在,用户未登录,此时返回一个空对象
            System.out.println("[exit] 当前获取不到 session 对象!");
            return;//return user;
        }
        //2.从会话中删除之前保存的用户对象
        //方法1:直接删除session中的user对象,有session无user对象,也认为是未登录,
        // 当删除session中的user时候,获取个人信息调用getUserInfo(),此时user为null,说明当前未登录,
        // 所以会再次强制跳转到登录页location.assign('login.html');
        httpSession.removeAttribute("user");
        //方法2:退出,即注销session:
        //httpSession.invalidate();
        //return user;
    }

下面进行简单的冒烟测试:

登录成功

登录失败

注册成功

注册失败

四.客户端代码开发(先给出核心代码,后续会把gitee地址发出)

登录

注册 

退出登录 

个人信息模块 

简单来说就是获取当前是哪个用户登录的,让左上角显示当前用户的信息(用户名).。

一.设计前后端交互接口

请求:

GET /userInfo

响应:

HTTP/1.1 200 OK

Content-Type:application/json

{

        userId: 1,

        username: 'zhangsan'

}

二.实现后端代码

@GetMapping("/userInfo")
    @ResponseBody
    public Object getUserInfo(HttpServletRequest req) {
        //1.先从请求中获取到会话
        HttpSession session = req.getSession(false);
        if (session == null) {
            //会话不存在,用户未登录,此时返回一个空对象
            System.out.println("[getUserInfo] 当前获取不到 session 对象!");
            return new User();
        }
        //2.从会话中获取到之前保存的用户对象
        User user = (User) session.getAttribute("user");
        if (user == null) {
            System.out.println("[getUserInfo] 当前获取不到 user 对象!");
            return new User();
        }
        user.setPassword("");
        return user;
    }

三.实现前端代码

好友列表模块

一.数据库设计

每个用户都有哪些好友?使用数据库存储当前好友的关联关系,相关实体:1.用户   2.好友

实体之间的关系:多对多

一个用户,可以有多个好友.一个好友也可以被多个用户添加

把一个表(用户表)里面的两条数据联系到一起

-- 创建好友表
drop table if exists friend;
create table friend (
    userId int,
    friendId int
);

insert into friend values(1, 2);
insert into friend values(2, 1);
insert into friend values(1, 3);
insert into friend values(3, 1);
insert into friend values(1, 4);
insert into friend values(4, 1);

像聊天软件,好友关系,属于“强好友"关系。A是B的好友,B也就是A的好友了。
另外,像抖音/微博/B站,还有“弱好友"关系A关注了B,但是B不一定关注A。
当前还有两个重要的问题:

1.如果用户很多,每个用户的好友都很多,这个表就会非常大,怎么办?

假设聊天程序有1亿用户,平均每个用户有100个好友。此时这个表里面的数据量就有100亿。

如何解决上述问题?

分库分表

典型的思路:以 userId 进行切分,比如针对 userId 来计算一个hashCode(计算hashCode方式有很多),然后针对hashCode进行切分,假设分成100张表(编号friend0-friend99),此时hashCode % 100 => 结果是几,就把这个记录放到第几个表里。

后续比如需要查询某个用户的好友列表,还是按照相同的方法来查,还是先把 userId 按照相同的算法,算hashCode之后再进行 % 100 => 结果是几,就去第几个表里查询。

2.在分库分表的时候,我们希望每个表分得都相对均匀,但是有些用户可能好友非常多,就可能导致分表的结果不均衡了,如何解决呢?

冷热数据分环处理

具体问题,具体分析,特殊情况,特殊处理,正常用户的好友其实一般不多,只有极少用户的好友很多,可以针对好友比较多的用户的 userId 单独分表(用一个特殊的表,记录当前有哪些 userId 属于好友比较多的用户),再用专门的表来保存号有比较多的用户的好友关系。让好友多的用户的表和普通用户的表分离开。

二.设计前后端交互接口

让客户端从服务器获取到好友列表

请求:

GET /frendList

响应:

HTTP/1.1 200 OK

Content-Type:application/json

[

        {

                friendId: 2,  ---friendId 就是 lis这个用户的 userId,可以通过这个 userId 去数据查询这个用户了。

                friendName: 'lisi'

        }

        {

                friendId: 3,

                friendName: 'wangwu'

        }

]

注意:

网络上交互的数据,都是字符串(二进制的字节流)(换而言之,网络传输中就没有“对象"概念)
服务器返回响应的时候,需要先把要返回的对象通过json库,转成json格式的“字符串"然后才能网络传输,浏览器收到的,也是"字符串”.正常来说,浏览器收到响应body中的字符串(json格式),需要先使用JSON.parse把字符串转换回成js对象数组.
但是,对于响应Content-Type为 application/json这种情况来说,这个手动转换的活,由jquery的ajax自动完成了!!!因此代码里不必手动转换.咱们代码里回调函数的参数 body 已经是JSON.parse转换之后,得到的 js 对象数组了.

三.编写后端代码

1.创建实体类 User类

@Data
public class Friend {
    private int friendId;
    private String friendName;
}

2.编写 Mapper 接口

@Mapper
public interface FriendMapper {
    // 查询指定的用户id的好友列表
    List<Friend>  selectFriendList(int userId);
}

注意:在前端发起 http 请求时候是不带 userId 的,但是数据库查询有需要知道 userId,userId 从哪里来?当前页面显示是登陆过的,当前是谁登录可以从会话中获取(登录的时候,已经把 userId 保存到会话中了),从而通过当前登录用户的 userId 查询他相应的好友列表。

3.编写 xml,借助 MyBatis 自动生成数据库操作的实现

    <!--找出用户的朋友,并显示他的朋友的ID和名字
    子查询
    1)第一次查询,拿着参数的userld,去 friend表里面查.就得到了一组friendld.
    2)再进一步针对user表再查询一次,看看哪些userld是落到上述friendld的集合中了.
    -->
    <select id="selectFriendList" resultType="com.example.chatterbox.model.Friend">
        select userId as friendId, username as friendName from user
        where userId in
        (select friendId from friend where userId = #{userId})
    </select>

4.编写controller

@RestController
public class FriendAPI {
    @Autowired
    private FriendMapper friendMapper;

    @GetMapping("/friendList")
    @ResponseBody
    public Object getFriendList(HttpServletRequest req) {
        //1.先从会话中获取到userId
        HttpSession session = req.getSession(false);
        if (session == null) {
            //会话不存在,用户未登录,直接返回一个空列表
            System.out.println("[getFriendList] 当前获取不到 session 对象!");
            return new ArrayList<Friend>();
        }
        User user = (User) session.getAttribute("user");
        if (user == null) {
            //当前用户对象没在会话中存在
            System.out.println("[getFriendList] 当前获取不到 user 对象!");
            return new ArrayList<Friend>();
        }
        //2.根据 userId 从数据库查询数据即可
        return friendMapper.selectFriendList(user.getUserId());
    }
}

四.编写前端代码 

消息区域模块

有消息就有会话的产生,我们先处理会话管理模块,接下来先进行会话数据库设计

我们先明白一个会话会存在3个实体类:会话、用户、消息

确定了实体之后,我们再来确定实体之间的关系。

会话和用户    多对多

一个会话里包含多个用户,

一个用户也能出现在多个会话中。

会话和消息    1对多

一个会话里包含多个消息,

一个消息只能从属于一个会话。

用户和消息    多对多   其实这里不用过多考虑,因为我们可通过会话将用户和消息关联起来

一个用户可以发送多条消息,

一条消息也可以由多个用户发送。

先设计会话表、会话和用户的关联表

后续写到消息功能的时候,再来考虑消息表和会话之间的关联表

-- 创建会话表
drop table if exists message_session;
create table message_session (
    sessionId int primary key auto_increment,
    -- 上次访问时间(通过时间针对会话列表排序)
    lastTime datetime
);
insert into message_session values(1, '2023-05-20 00:00:00');
insert into message_session values(2, '2023-06-01 00:00:00');

-- 创建会话和用户的关联表
drop table if exists message_session_user;
create table message_session_user (
    sessionId int,
    userId int
);

-- 1 号会话里有张三和李四
insert into message_session_user values(1, 1), (1, 2);
-- 2 号会话里有张三和王五
insert into message_session_user values(2, 1), (2, 3);

注意:一个会话里,可以有两个用户,也可以有多个。

两个用户的会话,称为“单聊”

多个用户的会话,称为“群聊”

会话管理主要需要考虑两个核心功能:

1.获取会话信息

1.1约定前后端交互接口

请求

GET /sessionList   ---此处和好友列表类似,需要借助userId获取哪一个用户的会话,但是这里可以通过登录状况得到userId,所以不需要提交参数了。

响应

HTTP/1.1 200 OK

Content-Type: application/json

[

        {

                sessionId: 1,

                friends: [

                        {        

                                friendName: ‘lisi’,

                                friendId: 2

                        }

                ],

                lastMessage: ‘晚上吃什么?’

        }

]

返回出当前用户(登陆状态可以获取当前用户)的所有会话,同时按照这些会话的最后访问时间进行降序排序,针对每个会话,都要获取到这个会话是和哪些个用户产生的(每个会话包含的好友信息)。

另外还需要获取到这个会话里最后一条消息(放到界面上展示)

响应数据的格式,就是根据当前客户端需要啥就给啥。

先根据客户端的需要,明确想要获取哪些数据,再根据具体的数据,来考虑服务器如何实现。

1.2编写前端代码

1.3编写后端代码

1.3.1编写会话实体类

@Data
//这个类表示一个会话
public class MessageSession {
    private int sessionId;
    private List<Friend> friends;
    private String lastMessage;
}

1.3.2编写mapper

@Mapper
public interface MessageSessionMapper {
    //1.根据 userId 获取到该用户都在哪些会话中存在(获取该用户的所有会话)
    //返回结果是一组sessionId
    List<Integer> getSessionIdByUserId(int userId);

    //2.根据 sessionId 来查询这个会话包含哪些用户(要去除用户自己本身)
    List<Friend> getFriendBySessionId(int sessionId, int selfUserId);
}

1.2.3编写xml

    <select id="getSessionIdByUserId" resultType="java.lang.Integer">
        select sessionId from message_session
        where sessionId in
        (select sessionId from message_session_user where userId = #{userId})
        order by lastTime desc
        <!--  这里子查询的目的:确定哪些会话是属于这个用户的
              套一层查询是为了按照上次会话时候降序排序
        -->
    </select>
    <select id="getFriendBySessionId" resultType="com.example.chatterbox.model.Friend">
        select userId as friendId, userName as friendName from user
        where userId in
        (select userId from message_session_user where sessionId = #{sessionId} and userId != #{selfUserId})
        <!--    1.根据 sessionId 来查询这个会话包含哪些用户(要去除用户自己本身) 得到一组userId
                2.根据 userId 来查询user表看看哪些结果落在userId的集合中
                3.让查询结果列名相匹配
        -->
    </select>

1.2.4 api

@RestController
public class MessageSessionAPI {
    @Autowired
    private MessageSessionMapper messageSessionMapper;

    @GetMapping("/sessionList")
    @ResponseBody
    public Object getMessageSessionList(HttpServletRequest req) {
        List<MessageSession> messageSessionList = new ArrayList<>();
        //1.获取当前用户的 userId(从session获取)
        HttpSession session = req.getSession(false);
        if (session == null) {
            System.out.println("[getMessageSessionList] session == null");
            return messageSessionList;
        }
        User user = (User) session.getAttribute("user");
        if (user == null) {
            System.out.println("[getMessageSessionList] user == null");
        }
        //2.根据 userId 查询数据库,查出来有哪些会话id
        for (Integer sessionId : messageSessionMapper.getSessionIdByUserId(user.getUserId())) {
            MessageSession messageSession = new MessageSession();
            messageSession.setSessionId(sessionId);
            //3.遍历会话id,查询出每个会话里涉及到的好友都有谁
            List<Friend> friends = messageSessionMapper.getFriendBySessionId(sessionId, user.getUserId());
            messageSession.setFriends(friends);
            //4.遍历会话id,查询出每个会话的最后一条消息 TODO(需要后续消息表构建好了,才能实现)
            messageSession.setLastMessage("晚上吃什么呀?");
            messageSessionList.add(messageSession);
        }
        //最终的目标:构造出一个 MessageSession 对象数组
        return messageSessionList;
    }
}

2.新增会话

当用户点击了好友列表的某个好友时候,此时就会触发新增会话的效果。

点击一个好友,触发的操作有两种情况:

A.如果会话不存在,则创建会话.
1)需要在客户端上创建出一个对应的li标签,放到会话列表中,这个标签应该处于被选中的高亮状态,同时置顶,还要切换到会话列表标签页这里

2)要给服务器发送一个请求,告诉服务器有了个新的会话,让服务器保存这个会话的信息
web 程序都是通过服务器来持久化保存数据的,否则页面关闭/刷新数据可能就没了
B.如果会话已经存在,则把之前的会话找到
1)把标签页切换到会话列表,找到指定的会话,置顶并且设为选中状态

2)给服务器发送个请求,获取到该会话的历史消息列表,显示到右侧区域
这里暂时还没有涉及到历史消息,看后续

2.1约定前后端交互接口:针对客户端告知服务器,新建一个会话。

请求

POST /session?toUserId=2   

此处使用POST主要是因为POST方法是表示"提交一个数据给服务器"

而GET则表示是"从服务器获取一个数据"

当然http方法也不一定完全要遵守

响应

HTTP/1.1 200 OK

Content-Type: application/json

{

      sessionId: 1,  ---此处拿到的id,可以把这个值放在li的属性中,以备后用

 }

2.2编写客户端代码

2.2.1给好友列表里的每个元素,增加个点击事件

总结:

所谓"创建会话,让服务器保存",
就是让服务器的数据库来在这两个表中记录数据,比如,我现在是zhangsan,我点击了好友列表中的 lisi,创建了新的会话。会涉及到三个数据库操作:
1.先在message_session表里新增一个数据项.
新增的数据项就表示当前的这个会话,同时获取到新会话的自增主键sessionId

2.给message_session_user表插入记录

100,1 (zhangsan包含在会话100中)  

3.给message_session_user表插入记录.
100, 2 (lisi包含在会话100中)

2.3编写后端代码

首先创建实体类

// 表示 message_session_user 表里的一个记录
@Data
public class MessageSessionUserItem {
    private int sessionId;
    private int userId;
}

添加新增会话的mapper接口

@Mapper
public interface MessageSessionMapper {
    //1.根据 userId 获取到该用户都在哪些会话中存在(获取该用户的所有会话)
    //返回结果是一组sessionId
    List<Integer> getSessionIdByUserId(int userId);

    //2.根据 sessionId 来查询这个会话包含哪些用户(要去除用户自己本身)
    //返回结果是一组userId(光是这样还不够)
    //因为我们要得到的是Friend => friendId(相当于userId)   friendName(相当于username)
    //所以还要通过userId去user表中查询,看哪些结果包含在上述userId集合中
    //保留查询结果中的 userId 和 userName
    List<Friend> getFriendBySessionId(int sessionId, int selfUserId);

    //3.给 message_session 表新增一个会话记录,通过 MessageSession 对象返回会话的id
    int addMessageSession(MessageSession messageSession);

    //4.给 message_session_user 表也新增对应的记录
    void addMessageSessionUser(MessageSessionUserItem messageSessionUserItem);
}

创建xml

    <insert id="addMessageSession" useGeneratedKeys="true" keyProperty="sessionId">
        insert into message_session values(null, now());
    </insert>
    <insert id="addMessageSessionUser">
        insert into message_session_user values(#{sessionId}, #{userId});
    </insert>

api

package com.example.chatterbox.api;

import com.example.chatterbox.model.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;


@RestController
public class MessageSessionAPI {
    @Autowired
    private MessageSessionMapper messageSessionMapper;

    @GetMapping("/sessionList")
    @ResponseBody
    public Object getMessageSessionList(HttpServletRequest req) {
        List<MessageSession> messageSessionList = new ArrayList<>();
        //1.获取当前用户的 userId(从session获取)
        HttpSession session = req.getSession(false);
        if (session == null) {
            System.out.println("[getMessageSessionList] session == null");
            return messageSessionList;
        }
        User user = (User) session.getAttribute("user");
        if (user == null) {
            System.out.println("[getMessageSessionList] user == null");
        }
        //2.根据 userId 查询数据库,查出来有哪些会话id
        for (Integer sessionId : messageSessionMapper.getSessionIdByUserId(user.getUserId())) {
            MessageSession messageSession = new MessageSession();
            messageSession.setSessionId(sessionId);
            //3.遍历会话id,查询出每个会话里涉及到的好友都有谁
            List<Friend> friends = messageSessionMapper.getFriendBySessionId(sessionId, user.getUserId());
            messageSession.setFriends(friends);
            //4.遍历会话id,查询出每个会话的最后一条消息 TODO(需要后续消息表构建好了,才能实现)
            messageSession.setLastMessage("晚上吃什么呀?");
            messageSessionList.add(messageSession);
        }
        //最终的目标:构造出一个 MessageSession 对象数组
        return messageSessionList;
    }

    @PostMapping("/session")
    @ResponseBody
    //@SessionAttribute:这是一个Spring MVC的注解,用于从请求的会话中获取属性值。
    //如果属性不存在,那么将会创建一个新的属性并添加到会话中。
    //@SessionAttribute("user") User user的含义是:从会话中获取名为"user"的属性,并将其值赋给一个名为"user"的User类型的变量。
    public Object addMessageSession(int toUserId, @SessionAttribute("user") User user) {
        HashMap<String,Integer> resp = new HashMap<>();
        //进行数据库插入操作
        //1.给 message_session 表插入记录,主要是为了获取自增主键sessionId
        //MesssageSession 里的 friends 和 lastMessage 属性此处用不上
        MessageSession messageSession = new MessageSession();
        messageSessionMapper.addMessageSession(messageSession);
        //2.给 message_session_user 表插入记录
        MessageSessionUserItem item1 = new MessageSessionUserItem();
        item1.setSessionId(messageSession.getSessionId());
        item1.setUserId(user.getUserId());
        messageSessionMapper.addMessageSessionUser(item1);
        //3.给 message_session_user 表插入记录
        MessageSessionUserItem item2 = new MessageSessionUserItem();
        item2.setSessionId(messageSession.getSessionId());
        item2.setUserId(toUserId);
        messageSessionMapper.addMessageSessionUser(item2);
        System.out.println("[addMessageSession] 新增会话成功!sessionId=" 
        + messageSession.getSessionId() + " userId1=" + user.getUserId() + " userId2=" 
        + toUserId);
        resp.put("sessionId", messageSession.getSessionId());
        //返回的对象可以是普通对象,或者是一个 Map 也可以,jackson 都能够进行处理
        return resp;
    }
}

消息传输模块

本质来说就是,张三把消息通过服务器转发给李四,李四通过服务器转发的消息进行接受。

在这之前,我们先解决之前遗留的一个问题:我们获取会话的最后一条消息时候是写死的

首先先创建消息表:

-- 创建消息表
drop table if exists message;
create table message (
    messageId int primary key auto_increment,
    fromId int, -- 消息是哪个用户发送的
    sessionId int, -- 消息属于哪个会话
    content varchar(2048), -- 消息的正文
    postTime datetime   -- 消息的发送时间
);

-- 构造几个消息数据, 方便测试
-- 张三和李四发的消息
insert into message values (1, 1, 1, '今天晚上吃啥啊?', '2023-05-20 17:00:00');
insert into message values (2, 2, 1, '你想吃啥?', '2023-05-20 17:01:00');
insert into message values (3, 1, 1, '吃烤肉?', '2023-05-20 17:02:00');
insert into message values (4, 2, 1, '不想吃', '2023-05-20 17:03:00');
insert into message values (5, 1, 1, '那你想吃啥', '2023-05-20 17:04:00');
insert into message values (6, 2, 1, '随便。。。', '2023-05-20 17:05:00');

insert into message values (11, 1, 1, '那吃炒菜?', '2023-05-20 17:06:00');
insert into message values (8, 2, 1, '不想吃', '2023-05-20 17:07:00');
insert into message values (9, 1, 1, '那你想吃啥?', '2023-05-20 17:08:00');
insert into message values (10, 2, 1, '随便。。。', '2023-05-20 17:09:00');

-- 张三和王五发的消息
insert into message values(7, 1, 2, '晚上一起约?', '2023-05-21 12:00:00');
insert into message values(12, 3, 2, '走起!?', '2023-05-21 12:05:00'); 

注意体会这里的关系:

不是说消息从一个用户发给另一个用户

因为一条消息只能属于一个会话,所以通过消息通过会话来联系

创建mapper

@Mapper
public interface MessageMapper {
    String getLastMessageBySessionId(int sessionId);
}

创建xml:

    <!-- 此处只需要查出一条消息放在会话列表中,预览效果就行了 -->
    <select id="getLastMessageBySessionId" resultType="java.lang.String">
        select * from message where sessionId = #{sessionId}
        order by postTime desc limit 1
    </select>

修改api: 

package com.example.chatterbox.api;

import com.example.chatterbox.model.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;


@RestController
public class MessageSessionAPI {
    @Autowired
    private MessageSessionMapper messageSessionMapper;
    @Autowired
    private MessageMapper messageMapper;

    @GetMapping("/sessionList")
    @ResponseBody
    public Object getMessageSessionList(HttpServletRequest req) {
        List<MessageSession> messageSessionList = new ArrayList<>();
        //1.获取当前用户的 userId(从session获取)
        HttpSession session = req.getSession(false);
        if (session == null) {
            System.out.println("[getMessageSessionList] session == null");
            return messageSessionList;
        }
        User user = (User) session.getAttribute("user");
        if (user == null) {
            System.out.println("[getMessageSessionList] user == null");
        }
        //2.根据 userId 查询数据库,查出来有哪些会话id
        for (Integer sessionId : messageSessionMapper.getSessionIdByUserId(user.getUserId())) {
            MessageSession messageSession = new MessageSession();
            messageSession.setSessionId(sessionId);
            //3.遍历会话id,查询出每个会话里涉及到的好友都有谁
            List<Friend> friends = messageSessionMapper.getFriendBySessionId(sessionId, user.getUserId());
            messageSession.setFriends(friends);
            //4.遍历会话id,查询出每个会话的最后一条消息 
            //messageSession.setLastMessage("晚上吃什么呀?");
            String lastMessgae = messageMapper.getLastMessageBySessionId(sessionId);
            //有可能会出现按照会话id查询不到消息的情况,新创建会话的时候还没有来得及发消息
            if (lastMessgae == null) {
                messageSession.setLastMessage("");
            } else {
                messageSession.setLastMessage(lastMessgae);
            }
            messageSessionList.add(messageSession);
        }
        //最终的目标:构造出一个 MessageSession 对象数组
        return messageSessionList;
    }

    @PostMapping("/session")
    @ResponseBody
    //@SessionAttribute:这是一个Spring MVC的注解,用于从请求的会话中获取属性值。
    //如果属性不存在,那么将会创建一个新的属性并添加到会话中。
    //@SessionAttribute("user") User user的含义是:从会话中获取名为"user"的属性,并将其值赋给一个名为"user"的User类型的变量。
    public Object addMessageSession(int toUserId, @SessionAttribute("user") User user) {
        HashMap<String, Integer> resp = new HashMap<>();
        //进行数据库插入操作
        //1.给 message_session 表插入记录,主要是为了获取自增主键sessionId
        //MesssageSession 里的 friends 和 lastMessage 属性此处用不上
        MessageSession messageSession = new MessageSession();
        messageSessionMapper.addMessageSession(messageSession);
        //2.给 message_session_user 表插入记录
        MessageSessionUserItem item1 = new MessageSessionUserItem();
        item1.setSessionId(messageSession.getSessionId());
        item1.setUserId(user.getUserId());
        messageSessionMapper.addMessageSessionUser(item1);
        //3.给 message_session_user 表插入记录
        MessageSessionUserItem item2 = new MessageSessionUserItem();
        item2.setSessionId(messageSession.getSessionId());
        item2.setUserId(toUserId);
        messageSessionMapper.addMessageSessionUser(item2);
        System.out.println("[addMessageSession] 新增会话成功!sessionId=" + messageSession.getSessionId()
                + " userId1=" + user.getUserId() + " userId2=" + toUserId);
        resp.put("sessionId", messageSession.getSessionId());
        //返回的对象可以是普通对象,或者是一个 Map 也可以,jackson 都能够进行处理
        return resp;
    }
}

现在来获取指定会话的历史消息,毕竟消息是存储再数据库中的,就需要前端先访问服务器,再让服务器查询数据库。

约定前后端交互接口

请求

GET /message?sessionId=1  

响应

HTTP/1.1 200 OK

Content-Type: application/json

[

        {

                messsageId: 1,

                fromId: 1,

                fromName: 'zhangsan',        //发送消息的用户名字

                sessionId: 1,

                content: '晚上吃什么呀?'

        },

        

        {

                messsageId: 1,

                fromId: 2,

                fromName: 'lisi',        //发送消息的用户名字

                sessionId: 1,

                content: '你想吃啥?'

        },

        ......

]

注意:

这里的fromName在数据库messsage表里面没有这个字段,这时候就需要联合user表进行联合查询,使用 fromId(就相当于用户id) 和 userId 作为连接条件。

编写后端代码

新建消息表对应的实体类:

@Data
public class Message {
    private int messageId;
    private int fromId;//发送者用户 id
    private String fromName;//表示发送者的用户名
    private int sessionId;
    private String content;
}

修改mapper接口:

@Mapper
public interface MessageMapper {
    //获取指定会话的最后一条消息
    String getLastMessageBySessionId(int sessionId);
    //获取指定会话的历史消息
    //此处做出一个限制,默认只取最近的100条消息
    List<message> getHistoryMessagesBySessionId(int sessionId);
}

修改xml:

    <!-- 此处只需要查出一条消息放在会话列表中,预览效果就行了 -->
    <select id="getLastMessageBySessionId" resultType="java.lang.String">
        select content from message where sessionId = #{sessionId}
        order by postTime desc limit 1
    </select>
    <select id="getHistoryMessagesBySessionId" resultType="com.example.chatterbox.model.message">
        select messageId, fromId, username as fromName, sessionId, content
        from user, message
        where user.userId = message.fromId
        and message.sessionId = #{sessionId}
        order by postTime desc limit 100
    </select>

注意:

上面这个查询操作是按照消息的时间降序排序的(最新的消息在最前面,最旧的消息在最后面),但是真正在前端显示的时候,应该还是得按照升序排。

如何解决上述矛盾?

查出来的结果还是按照降序的方式查出来,只不过把查出来的结果进行一个逆置操作就行了。

新建api:

@RestController
public class MessageAPI {
    @Autowired
    private MessageMapper messageMapper;

    @GetMapping("/message")
    @ResponseBody
    public Object getHistoryMessages(int sessionId) {
        List<Message> historyMessagesList = messageMapper.getHistoryMessagesBySessionId(sessionId);
        //针对查询结果,进行逆置操作,毕竟界面上需要的是按照时间升序排列的消息,此处得到的是降序排列的消息
        Collections.reverse(historyMessagesList);
        return messageMapper;
    }
}

实现前端代码

现在来进行消息的发送和接受,首先我们需要了解到:

张三和李四,不能直接通信(NAT)
必须是张三发消息给服务器,服务器转发给李四.(服务器有外网IP,张三李四都能访问到)
张三发给服务器,张三是客户端,聊天程序是服务器,
客户端主动发消息给服务器是很正常的(本来客户端就是主动发起请求的一方),
服务器把消息转发给李四,李四也是客户端,聊天程序是服务器,
服务器要主动发响应给客户端了?这个事情是不太寻常的,

所以此时基于HTTP来实现这个功能有点鸡肋,以上情况称为“服务器主动向客户端推送数据”,类似手机app淘宝类发送推送消息,
因为http都是客户端主动发起请求,而服务器被动接受请求然后做出响应,

所以http不太适用于以上情况。

当然也可以用http来模拟消息推送的实现,比如张三发消息给李四,消息先到服务器之后,然后李四通过轮询的方式向服务器发起请求,比如每隔500ms发送请求:问有没有我的消息,如果有就先获取,没有就先sleep。

轮询方式存在的问题:

1.消耗更多的系统资源,接收方在等待过程中,需要频繁给服务器发送请求,而这些请求大多数都是“空转的”。

2.不能够及时获取消息,也就是说消息不是实时的,需要蹬到下一个周期才能获取到,如果提高轮询的频率,此时获取消息就及时了,但是消耗的系统消耗资源又多了,如果降低沦胥的频率,获取消息的速度就变慢了,但是系统资源消耗又少了,很影响用户体验。

基于上述两个问题,就引入了一个更好的方案来解决上述“消息推送问题”

WebSocket 

和http地位是对等的,都是基于传输层TCP实现的一个广泛被使用的应用层协议

Socket和WebSocket的关系?

就如同Java和JavaScript的关系......

Socket是传输层的东西,操作系统提供API进行网络编程。

WebSocket是应用层的一个协议,内部实现依赖Socket。

WebSocket协议可以实现服务器给客户端主动推送数据这样的功能,

本身传输层TCP就是可以让服务器给客户端推送数据的。

但是到了HTTP这里,就把之前的服务器推数据的功能弄没了。

事情得追溯到以前互联网时代,以前的网页主要是用于展示报纸、杂志、图像+文本这样的信息,没有考虑后人会把这个网页/http玩出花样,在设计初衷就没有考虑更多的问题。

http虽然你不够用了,但是WebSocket可以起到补充效果。

WebSocket的报文格式

 

FIN:表示书否要关闭 websocket
注意区分,这里的FIN不是tcp的FIN,只不过是需要通过FIN触发应用层的协议断开连接的操作,这个东西在底层肯定会触发TCP的四次挥手。
RSV:保留位(3个保留位,现在先不用,先占个位置说不定以后会用)

opcode:操作码,描述当前这个websocket数据帧起到什么作用,这里的取值有很多,没有必要记下来。常见的如下所示:

websocket协议,既可以传输二进制数据,也可以传输文本数据。

MASK:是否开启掩码操作,掩码操作主要是为了避免“缓冲区溢出”。

Payload len:载荷的长度,数据报上要携带的具体数据的长度(大小)。7个bit位表示的范围:2^7=128,

也就是0-127,单位是字节,也就是意味着一个websocket最多保存127个字节?这样的数据是不是太少了?所以引出了三种模式:

1)7 bit,能表示的范围比较小

2)16 bit,能表示的范围比较大

3)64 bit,能表示的范围非常非常大

最初的这个 7 bit 的payload length  < 126,采用模式1

如果7 bit 的值是126,此时是模式2,16个 bit 生效

如果7 bit 的值是127,此时是模式3,64个 bit 生效

Payload Data:真正要传输的载荷数据

小结:

websocket协议报文并不难,关键信息就三个部分:

1.opcode

2.payloade length(三种模式)

3.payload data  

websocket握手过程

这个过程类似于一个段子:

小明给英国朋友李华对话

小明:Can you speak Chinese?

李华:Yes,I can.

..........................

如何基于 websocket 编写代码?

在 Java 中有两种形式来使用websocket

1.直接使用 tomcat 提供的原生的 websocket api

2.使用spring 提供的 websocket api

基于 spring 提供的 websocket api,编写一个简单的 hello world

1)服务器部分

引入xml

先创建一个类作为WebSocketHandler(处理websocket中的各个通信流程)

@Component
public class TestWebSocketAPI extends TextWebSocketHandler {
    /**
     * @param session 连接中对应的会话
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        //在websocket连接成功后,被自动调用
        System.out.println("TestAPI 连接成功");
    }

    /**
     * @param message 收到的消息
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        //在websocket收到消息时候,被自动调用的
        System.out.println("TestAPI 收到消息!" + message.toString());
        //session 是个会话,里面就记录了通信双方是谁,(session 中持有了 websocket 的通信连接)
        session.sendMessage(message);
    }

    /**
     * @param exception 异常信息
     */
    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        //连接出现异常的时候,被自动调用的
        System.out.println("TestAPI 连接异常!");
    }

    /**
     * @param status 关闭的状态
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        //连接正常关闭后,被自动调用
        System.out.println("TestAPI 连接关闭!");
    }
}

把上述类的实例,注册到spring里,配置路由(关联上哪个路径对应到上述的handler),创建配置类

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Autowired
    private TestWebSocketAPI testWebSocketAPI;
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        //通过此方法,把刚才创建好的 handler 类给注册到具体的路径上
        //此时当浏览器,websocket的请求路径是"/test"的时候,就会调用到TestWebSocketAPI里面的方法
        registry.addHandler(testWebSocketAPI, "/test");
    }
}

2)客户端部分

刚才我们进行了websocket的基础学习,接下来我们继续完善项目中消息传输的实现,首先我们先建立websocket的api,以及websocket的配置类

@Component
public class WebSocketAPI extends TextWebSocketHandler {
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("[WebSocketAPI] 连接成功!");
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        System.out.println("[WebSocketAPI] 收到消息!" + message.toString());
        //todo: 后续主要实现的是这个方法
        //处理消息的接受、转发、以及消息的保存记录
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        System.out.println("[WebSocketAPI] 连接异常!" + exception.toString());
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        System.out.println("[WebSocketAPI] 连接关闭!" + status.toString());
    }
}
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    @Autowired
    private TestWebSocketAPI testWebSocketAPI;

    @Autowired
    private WebSocketAPI webSocketAPI;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // 通过这个方法, 把刚才创建好的 Handler 类给注册到具体的 路径上.
        // 此时当浏览器, websocket 的请求路径是 "/test" 的时候, 就会调用到 TestWebSocketAPI 这个类里的方法.
        //registry.addHandler(testWebSocketAPI, "/test");
        registry.addHandler(webSocketAPI, "/WebSocketMessage");
    }
}

前端先编写好基础websocket代码

约定前后端接口

注意:此处的请求不是 HTTP 了,直接使用 json 格式的数据作为 payload 来表示传输的内容

请求

{

        type: "message",  ----针对 websocket 中传输的数据做个简单的区分

        sessionId: 1,         ----当前消息是发送给哪个会话的.(会话就是MessageSession)

        会话id在会话列表li中的属性中获取,因为之前我们在服务器返回会话列表的时候就设置每个会话的属性,也就是会话id

        用户在输入框中输入内容,点击发送按钮,就会触发这个按钮

        content: "今天晚上吃什么呀?" ----消息的正文

}

响应 同样也是 json 表示

{

        type: "message",  ----针对 websocket 中传输的数据做个简单的区分

        fromId: 1,              ----消息发送者的用户id

        fromName: “zhangsan”  ----消息发送者的用户名

        sessionId: 1,         ----消息是属于哪个会话的

        content: "今天晚上吃什么呀?" ----消息的正文

}

通过画图来理解这个请求和响应:

1.实现客户端发送消息

2.实现服务器接受/转发消息

需要能够维护一个重要的映射关系  userId => WebSocketSession

此时可以确认的是键值对中的value已经准备就绪了(现成的WebSocketSession),关键是 key (userId) 如何获取呢?为了能够维护键值对映射关系,就需要知道当前 websocket 连接的是哪个userId进行的,在这个请求中是没有携带userId参数的,但是在 HttpSession 中是有的,最初用户在登录的时候,给 HttpSession 里存了当前的 user 对象,既然信息在 HttpSession中,在当前的 websocket 代码中

如何拿到 HttpSession 呢?

基于上述情况,设计 websocket 的大佬们早都考虑过了,提供了一个办法能够把 HttpSession 里面的东西(其实也就是键值对,用户登录的时候,存的“user” => User对象)通过特殊手段,把 HttpSession 中的这些 Attribute 键值对拷贝到 WebSocketSession 中,此时就可以通过 WebSocketSession 拿到 user 对象了。

提供这个办法就是:

在最初注册 WebSocket Handler 这里,在注册的同时指定一个特殊的拦截器。

前面注册了拦截器,就可以让每个往 HttpSession 中加入的 Attribute 在 WebSocketSession 也被加入一份了。

注意:

此处提供的API是一个 getAttributes() 这样的方法,返回的是一个 Map 然后再通过 Map 的get方法获取到 key 对应的 value。下面通过简单抓包:

下面来创建映射关系类:

// 记录当前用户在线的状态(维护了 userId 和 WebSocketSession 之间的映射)
@Component
public class OnlineUserManager {
    //此处 哈希表 要考虑到线程安全问题
    //因为当有多个用户访问服务器,都要跟服务器建立连接,那么多个客户端都会执行 afterConnectionEstablished 方法
    //多个用户肯定是多线程的方式进行操作的,如果是串行操作就不太靠谱了
    private ConcurrentHashMap<Integer, WebSocketSession> sessions = new ConcurrentHashMap<>();
    //1)用户上线,哈希表插入键值对
    public void online(int userId, WebSocketSession webSocketSession) {
        //此处需要考虑:两个不同的客户端,使用同一个账号登陆(多开)
        //常见的两种设定:
        //1.很多聊天程序都有这样的设定,我们用qq同时在不同的客户端登录(多开)的时候,后一个登录的qq会把上一个登录的qq挤下线
        //保证同一时刻一个qq号只能登录一次
        //2.还有一种设定,例如:后一个账号登录,会登录失败,会提示“账号已经登录”
        //此时我们采用第2种简单粗暴的方式
        if (sessions.get(userId) != null) {
            //说明用户在线了,就登陆失败,不会记录这个映射关系
            //如果不记录这个映射关系,后续就收不到任何消息(毕竟这里是通过映射关系来实现消息转发的)
            System.out.println("用户 [" + userId + "] 已经被登录了,登录失败!");
            return;
        }
        sessions.put(userId, webSocketSession);
        System.out.println("用户 [" + userId + "] 上线!");
    }
    //2)用户下线,哈希表进行删除元素
    public void offline(int userId, WebSocketSession webSocketSession) {
        WebSocketSession existSession = sessions.get(userId);
        if (existSession == webSocketSession) {
            //同一个session说明是同一个用户的,才真正进行下线操作,否则什么也不做
            sessions.remove(userId);
            System.out.println("用户 [" + userId + "] 下线!");
        }
    }
    //3)根据 userId 获取到 WebSocketSession
    public WebSocketSession getSession(int userId) {
        return sessions.get(userId);
    }
}

画图解释多开情况:

接下来我们就需要在WebSocketAPI调用这个映射关系的类来把我们这个映射关系维护起来。

@Component
public class WebSocketAPI extends TextWebSocketHandler {
    @Autowired
    private OnlineUserManager onlineUserManager;
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("[WebSocketAPI] 连接成功!");
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            return;
        }
        //第一次连接要把这个键值对存起来
        onlineUserManager.online(user.getUserId(), session);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        System.out.println("[WebSocketAPI] 收到消息!" + message.toString());
        //todo: 后续主要实现的是这个方法
        //处理消息的接受、转发、以及消息的保存记录
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        System.out.println("[WebSocketAPI] 连接异常!" + exception.toString());
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            return;
        }
        //下线操作
        onlineUserManager.offline(user.getUserId(), session);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        System.out.println("[WebSocketAPI] 连接关闭!" + status.toString());
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            return;
        }
        //下线操作
        onlineUserManager.offline(user.getUserId(), session);
    }
}

接下里处理消息转发,首先创建消息请求和响应对应的实体类:

//一个消息请求
@Data
public class MessageRequest {
    private String type = "message";
    private int sessionId;
    private String content;
}
//一个消息的响应
@Data
public class MessageResponse {
    private String type = "message";
    private int fromId;
    private String fromName;
    private int sessionId;
    private String content;
}

我们先考虑一种情况,如果张三给李四发了消息,李四在线,就应该立即收到消息,如果不在线呢?消息就丢了吗?画图解释:

继续实现WebSocketAPI消息转发的功能:

@Component
public class WebSocketAPI extends TextWebSocketHandler {
    @Autowired
    private OnlineUserManager onlineUserManager;
    @Autowired
    MessageSessionMapper messageSessionMapper;
    @Autowired
    private MessageMapper messageMapper;

    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        System.out.println("[WebSocketAPI] 连接成功!");
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            return;
        }
        //第一次连接要把这个键值对存起来
        onlineUserManager.online(user.getUserId(), session);
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        System.out.println("[WebSocketAPI] 收到消息!" + message.toString());
        //1.先获取当前用户的信息,消息转发的时候需要
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            System.out.println("[WebSocketAPI] user == null,未登录用户,无法进行消息转发!");
            return;
        }
        //2.针对请求进行解析,把 json 格式的字符串,转成一个 Java 中的对象
        MessageRequest req = objectMapper.readValue(message.getPayload(), MessageRequest.class);
        if ("message".equals(req.getType())) {
            //消息转发
            transferMessage(user, req);
        } else {
            System.out.println("[WebSocketAPI] req.type有误!" + message.getPayload());
        }
    }

    //通过这个方法完成消息实际的转发
    private void transferMessage(User fromUser, MessageRequest req) throws IOException {
        //1.先构造一个待转发的响应对象,MessageResponse
        MessageResponse resp = new MessageResponse();
        resp.setType("message");//这里不设置也可以,默认就是 message
        resp.setFromId(fromUser.getUserId());
        resp.setFromName(fromUser.getUsername());
        resp.setSessionId(req.getSessionId());
        resp.setContent(req.getContent());
        //把这个 Java 对象转成 json 格式的字符串
        String respJson = objectMapper.writeValueAsString(resp);
        System.out.println("[transferMessage] respJson: " + respJson);
        //2.根据请求中的 sessionId,获取到这个 MessageSession 里面都有哪些用户,通过查询数据库就知道了
        List<Friend> friends = messageSessionMapper.getFriendBySessionId(req.getSessionId(), fromUser.getUserId());
        //注意上述查询好友列表会把 发消息的用户 排除掉,而最终转发消息的时候,也需要给发消息的用户自己转发一份
        Friend myself = new Friend();
        myself.setFriendId(fromUser.getUserId());
        myself.setFriendName(fromUser.getUsername());
        friends.add(myself);
        //3.循环遍历查询出来的的这个好友列表,给列表中的每个用户都发一份响应消息
        //注意:除了给查询到的好友发送消息也要给自己发一份,方便实现自己在客户端上显示自己发送的消息
        //一个会话中,可能有多个用户(群聊),虽然客户端没有支持群聊的(前端实现起来相对麻烦)
        // 后端无论是API,还是数据库都是支持群聊的,此处转发逻辑也一样让它支持群聊
        for (Friend friend : friends) {
            //知道了每个用户的 userId,通过 OnlineUserManager 就知道对应的 WebSocketSession 从而进行发送消息
            WebSocketSession webSocketSession = onlineUserManager.getSession(friend.getFriendId());
            if (webSocketSession == null) {
                //如果该用户不在线,则不发送,直接跳过此用户
                continue;
            }
            //Send a WebSocket message: either TextMessage or BinaryMessage
            webSocketSession.sendMessage(new TextMessage(respJson));
        }
        //4.把转发的消息保存到数据库,后续用户如果下线了,重新上线,可以查看历史消息
        Message message = new Message();
        message.setFromId(fromUser.getUserId());
        message.setSessionId(req.getSessionId());
        message.setContent(req.getContent());
        //像自增主键,时间属性,都可以让sql在数据库中生成
        messageMapper.add(message);
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        System.out.println("[WebSocketAPI] 连接异常!" + exception.toString());
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            return;
        }
        //下线操作
        onlineUserManager.offline(user.getUserId(), session);
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        System.out.println("[WebSocketAPI] 连接关闭!" + status.toString());
        User user = (User) session.getAttributes().get("user");
        if (user == null) {
            return;
        }
        //下线操作
        onlineUserManager.offline(user.getUserId(), session);
    }
}

新增一条消息给数据库:

@Mapper
public interface MessageMapper {
    //获取指定会话的最后一条消息
    String getLastMessageBySessionId(int sessionId);
    //获取指定会话的历史消息
    //此处做出一个限制,默认只取最近的100条消息
    List<Message> getHistoryMessagesBySessionId(int sessionId);
    //新增一条消息
    void add(Message message);
}
    <insert id="add">
        insert into message values(null, #{fromId}, #{sessionId}, #{content}, now())
    </insert>

3.实现客户端接收消息

添加好友模块

1.查询好友

先输入用户名,通过点击页面的搜索按钮,此时会给服务器发送一个 ajax 请求(HTTP),服务器就根据用户名进行匹配,把名字符合的结果都显示到界面上。

显示到右侧界面上,右侧内容先清空,右侧标题改成“好友查询结果”,右侧的主消息区显示每个搜索结果,每个搜索结果包含三个部分:

1)搜索结果的用户名

2)输入框,输入添加好友的理由

3)添加好友的按钮

2.发送添加好友请求

输入理由之后,点击添加好友按钮,就能发送一个“添加好友请求”(HTTP)

服务器就需要把这个好友请求,记录到数据库中,先设计一个表,来保存好友请求

3.对方要能够收到这个好友请求,决定是否要接受好友请求

接受过程分为两个维度:

1)该用户离线,在用户下次上线的时候,从刚才的数据库这里获取到之前的好友请求都有啥. ajax HTTP

该用户在线,用户要立即就能看到好友请求,使用websocket. 

前面发送消息/接收消息, websocket引入了type: "message",现在针对好友请求,可以引入一个新的type,根据新的 type,识别出这个websocket响应是一个添加好友请求,服务器就可以实时把这个添加好友请求发送给在线客户端了。

4.当用户点击接受好友,就发起 ajax 请求 HTTP,可以在会话列表中,显示出好友请求的li标签,例如:

 服务器收到之后,就把刚才这个好友关系加入到数据库中,同时就可以把刚才添加好友请求表里面的数据删掉了。

当用户点击拒绝好友,发起 ajax 请求 HTTP,服务器收到之后,只是把好友请求表里对应的记录删掉即可,就不修改好友表了。

扩展模块

等等,还有已读,历史消息搜索,消息撤回等操作。 


 

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1092129.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

FPmarkets:MT4中Renko图表工具有哪些?怎么用

以下FPmarkets总结的在MT4中使用Renko图表的最有趣的工具&#xff1a; 第一个是KT Renko实时图表指标&#xff0c;这是一个简单的指示器&#xff0c;仅显示砖块&#xff0c;未添加其他元素&#xff0c;因此与其他自定义指标和顾问兼容。 第二个是Renko Live Chart开发人员提供…

Kafka消费者使用案例

本文代码链接&#xff1a;https://download.csdn.net/download/shangjg03/88422633 1.消费者和消费者群组 在 Kafka 中&#xff0c;消费者通常是消费者群组的一部分&#xff0c;多个消费者群组共同读取同一个主题时&#xff0c;彼此之间互不影响。Kafka 之所以要引入消费者群组…

源码选择指南:比较流行的同城外卖跑腿系统解决方案

随着现代生活的快节奏和数字化转型的浪潮&#xff0c;外卖和跑腿服务成为了不可或缺的一部分。不仅在城市&#xff0c;就连小镇和乡村也可以享受到这些便捷的服务。如果您计划开发或改进一个同城外卖和跑腿系统&#xff0c;选择适合的源码解决方案是至关重要的。在本文中&#…

Jenkins 结合 ANT 发送测试报告

目录 全局变量配置 新建任务 插件安装 HTML 报告配置 邮件配置 全局变量配置 点击 ManageJenkins进入Jenkins 管理 点击 Global Tool Configuration 进入全局变量配置 配置 Ant &#xff0c;Name 自己定义一个比较好理解的名称。 去掉 Install automatically 勾选&#x…

毕业设计选题Java+springboot校园新闻资讯系统源码 开题 lw 调试

&#x1f495;&#x1f495;作者&#xff1a;计算机源码社 &#x1f495;&#x1f495;个人简介&#xff1a;本人七年开发经验&#xff0c;擅长Java、Python、PHP、.NET、微信小程序、爬虫、大数据等&#xff0c;大家有这一块的问题可以一起交流&#xff01; &#x1f495;&…

VBA技术资料MF69:添加和删除工作表中的分页符

我给VBA的定义&#xff1a;VBA是个人小型自动化处理的有效工具。利用好了&#xff0c;可以大大提高自己的工作效率&#xff0c;而且可以提高数据的准确度。我的教程一共九套&#xff0c;分为初级、中级、高级三大部分。是对VBA的系统讲解&#xff0c;从简单的入门&#xff0c;到…

黑马JVM总结(三十三)

&#xff08;1&#xff09;运行期优化-逃逸分析 在运行期间java虚拟机会对我们代码做一些优化&#xff0c;时间会变短&#xff1a; 字节码反复调用&#xff0c;到达一定的阈值&#xff0c;会启用编译器对自己饿吗编译执行&#xff0c;从0层上升为1层C1 C1和C2他俩的区别是解释…

C语言进阶第六课-----------字符分类函数和内存的开辟

作者前言 &#x1f382; ✨✨✨✨✨✨&#x1f367;&#x1f367;&#x1f367;&#x1f367;&#x1f367;&#x1f367;&#x1f367;&#x1f382; ​&#x1f382; 作者介绍&#xff1a; &#x1f382;&#x1f382; &#x1f382; &#x1f389;&#x1f389;&#x1f389…

【Python从入门到进阶】39、使用Selenium自动验证滑块登录

接上篇《38、selenium关于Chrome handless的基本使用》 上一篇我们介绍了selenium中有关Chrome的无头版浏览器Chrome Handless的使用。本篇我们使用selenium做一些常见的复杂验证功能&#xff0c;首先我们来讲解如何进行滑块自动验证的操作。 一、测试用例介绍 我们要通过sel…

websocket逆向-protobuf序列化与反序列化

系列文章目录 训练地址&#xff1a;https://www.qiulianmao.com 基础-websocket逆向基础-http拦截基础-websocket拦截基础-base64编码与解码基础-protobuf序列化与反序列化视频号直播弹幕采集实战一&#xff1a;Http轮询更新中 websocket逆向-protobuf序列化与反序列化基础 系…

操作系统 内存对齐

文章目录 内存管理内存对齐为什么需要内存对齐内存对齐的规则举例说明两个函数 内存管理 内存是计算机的重要组成部分&#xff0c;内存是与cpu沟通的桥梁&#xff0c;用来暂存cpu中的运算数据。在早期&#xff0c;程序直接运行在物理内存中&#xff0c;直接操作物理内存&#…

MathType7.5最新版本升级教程

MathType7.5是MathType6.9a的升级版本&#xff0c;这是一款好用的数学公式编辑器&#xff0c;软件支持win、mac等操作系统&#xff0c;可以与各类办公软件兼容&#xff0c;能够快速在office文档中进行各类数学公式、符号的输入和运算等操作&#xff0c;coco玛奇朵小编为大家带来…

LiveGBS流媒体平台GB/T28181常见问题-如何禁用删除已注册设备国标设备如何删除

LiveGBS常见问题-如何禁用删除已注册设备国标设备如何删除 1、禁用删除设备2、找到需要删除的设备3、接入控制黑名单4、配置到黑名单5、删除设备6、搭建GB28181视频直播平台 1、禁用删除设备 有的时候&#xff0c;需要将接入到平台的某些设备禁用&#xff0c;并删除。改如何操…

池化技术在真实业务中的实践

一些废话 作为一名Java开发人员&#xff0c;池化技术或多或少在业务代码中使用。常见的包括线程池、连接池等。也是因为Java语言超级丰富的基建&#xff0c;基本上这些池化能力都有着相对成熟的“工具”。比如&#xff0c;需要使用线程池的时候常常会选择Spring提供的 ThreadP…

DL Homework 4

目录 1 整理一下理论收获 1.1 基础理论 1.2 应用到机器学习 1.3 参数学习 1.4 反向传播算法 2.激活函数 3.神经网络流程推导(包含正向传播和反向传播) 4.数值计算 - 手动计算 5.代码实现 - numpy手推 6.代码实现 - pytorch自动 7.激活函数Sigmoid用PyTorch自带函数torc…

蓝桥杯(七段码,C++)

思路&#xff1a; 1、把灯管的连接转为图结构&#xff0c;相邻的灯管即认为有边。 2、用深度搜索&#xff0c;去计算有多少种不同字符。 3、因为有每种字符都会重复算两遍&#xff0c;最后的结果需要数以2。 #include <iostream> using namespace std;int graph[7][7…

震惊!阿里卷成这样?不吃饭了,上厕所、团建都要聊工作,人均上厕所小于一天三次...

上一篇&#xff1a;雷军被小米封号 你敢相信吗&#xff1f;最近一篇名为《坐标阿里 我们组已经不吃饭了》的帖子在大厂社区上引发热议。 作者爆料&#xff1a;坐标阿里&#xff0c;组里已经卷到不吃饭了&#xff0c;之前卷到上厕所要聊工作、团建都要聊工作&#xff0c;现在已经…

B2R靶机渗透

B2R靶机渗透 视频参考&#xff1a;ajest &#xff1a;https://www.zhihu.com/zvideo/1547357583714775040?utm_id0 原文参考&#xff1a;ajest &#xff1a;https://www.zhihu.com/column/c_1403138803561816064 文章目录 B2R靶机渗透1 启动靶机&#xff0c;查看后网卡为ma…

数据特征选择 | Lasso特征选择(Python)

文章目录 效果一览文章概述源码设计小结效果一览 文章概述 Lasso算法是一种经典的线性回归算法,被广泛应用于特征选择和降维问题。相较于传统的线性回归算法,Lasso算法能够在保持预测准确性的同时,自动筛选出对目标变量影响较大的特征变量,从而达到降低模型复杂度、提高泛化…

CSS悬停卡片翻转明信片效果源码附注释

运行效果演示: HTML页面代码: <!DOCTYPE html> <html lang="en" > <head>