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

目录

项目背景

需求分析 

1.用户管理模块

注册

登录

2.主界面

个人信息模块

会话列表模块

好友列表模块

消息区域模块

消息传输模块

添加好友模块

编写项目

1.创建项目添加依赖

2.配置项目信息

3.功能实现

用户管理模块

个人信息模块 

好友列表模块

消息区域模块

消息传输模块

添加好友模块

扩展模块


项目背景

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

需求分析 

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;
    }
}

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

请求:

POST /exit

响应:

HTTP/1.1 200 OK

/*
    实现退出登录
    要么把 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;
    }

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

登录成功

登录失败

注册成功

注册失败

四.客户端代码开发

登录

注册 

退出登录 

个人信息模块 

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

一.设计前后端交互接口

请求:

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 就是 lisi这个用户的 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");
            return messageSessionList;
        }
        //2.根据 userId 查询数据库,查出来有哪些会话id
        List<Integer> sessionList = messageSessionMapper.getSessionIdByUserId(user.getUserId());
        //3.查询出每个会话里涉及到的好友都有谁
        for (Integer sessionId : sessionList) {
            MessageSession messageSession = new MessageSession();
            messageSession.setSessionId(sessionId);
            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;
    }

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);
}

新增消息的xml:

    <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

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

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

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

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

当用户点击拒绝好友,发起 ajax 请求 HTTP,服务器收到之后,只是把好友请求表里对应的记录删掉即可,就不修改好友表了。下面我们来具体实现一下这个功能。

页面实现

将搜索到的结果放在右侧消息区

显⽰添加好友请求,显⽰在session-list中

搜索查找用户

约定前后端交互接口

请求:

GET /findFriend?friendName=zhangsan

响应:

HTTP/1.1 200 OK
Content-Type:application/json
[
        {
                friendName: 'zhangsan',
                friendId: 1,
        },
        {
                friendName: 'zhangsanfeng',
                friendId: 5,
        }
]

注意:查找用户接口⽀持模糊匹配

实现服务器代码

1.修改FriendMapper,新增findFriend⽅法

// 找出包含 friendName 的用户信息. 抛出掉 selfUserId 本身和他的好友.
List<Friend> findFriend(int selfUserId, String friendName);

2.修改FriendMapper.xml

    <!--此处要支持模糊匹配
    1)第一次查询,查询出自己的朋友有哪些,得到一组friendId
    2)进一步对user表再查询,首先要排除查找的好友抛出自己和自己本身的好友,并且进行模糊查询
    -->
    <select id="findFriend" resultType="com.example.chatterbox.model.Friend">
        select userId as friendId, username as friendName from user
        where userId != #{selfUserId}
        and username like concat('%', #{friendName}, '%')
        and userId not in
        (select friendId from friend where userId = #{selfUserId})
    </select>

3.修改FriendAPI,新增findFriend方法

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

实现客户端代码
实现查找匹配的用户功能
查找结果放到右侧消息列表中
每个结果项里面有

用户名      输⼊框(填写添加理由)       添加按钮

发送添加好友请求

设计数据库
保存添加好友请求,用数据库保存是保证即使用户不在线,也能在下次上线时拿到请求

-- 添加好友请求表
drop table if exists add_friend_request;
        create table add_friend_request (
        fromUserId int, -- 请求是谁发的
        toUserId int,     -- 请求要发给谁
        reason varchar(100) -- 添加好友的理由
);

约定前后端交互接口

请求:

GET /addFriend?friendId=5?reason=想认识下你!

响应:

HTTP/1.1 200 OK

后端代码实现

首先创建一个添加好友请求的实体类(注意:这个是用来发送给被添加用户的,通过websocket 返回给客户端)

//表示一个添加好友的请求
@Data
public class AddFriendRequest {
    private String type;
    private int fromUserId;
    private String fromUserName;
    private String reason;
}

修改FriendMapper,新增addFriendRequest,将添加好友请求保存在数据库中:

// 保存添加好友的请求
void addFriendRequest(int fromUserId, int toUserId, String reason);

修改FriendMapper.xml,实现addFriendRequest:

<insert id="addFriendRequest">
    insert into add_friend_request values (#{fromUserId}, #{toUserId}, #{reason})
</insert>

修改FriendAPI,添加addFriend方法:

    @GetMapping("/addFriend")
    @ResponseBody
    public Object addFriend(int friendId, String reason, HttpServletRequest req) throws IOException {
        //1.先从会话中获取到userId
        HttpSession session = req.getSession(false);
        if (session == null) {
            //会话不存在,用户未登录,直接返回一个空列表
            System.out.println("[addFriend] 当前获取不到 session 对象!");
            return "";
        }
        User user = (User) session.getAttribute("user");
        if (user == null) {
            //当前用户对象没在会话中存在
            System.out.println("[addFriend] 当前获取不到 user 对象!");
            return "";
        }
        //2.将添加好友请求保存到到数据库
        System.out.println("用户[" + user.getUserId() +"]请求添加用户[" + friendId +"为好友");
        friendMapper.addFriendRequest(user.getUserId(), friendId, reason);
        //3.获取被添加的用户在线状态
        WebSocketSession webSocketSesion = onlineUserManager.getSession(friendId);
        if (webSocketSesion != null) {//被添加的用户在线
            AddFriendRequest addFriendRequest = new AddFriendRequest();
            addFriendRequest.setType("addFriend");
            addFriendRequest.setFromUserId(user.getUserId());
            addFriendRequest.setFromUserName(user.getUsername());
            addFriendRequest.setReason(reason);
            //就直接实时发送添加好友请求(websocket请求)给他,提示有用户添加好友
            webSocketSesion.sendMessage(new TextMessage(objectMapper.writeValueAsString(addFriendRequest)));
        }
        return "";
    }

前端代码实现

以上实现了发送添加好友请求的功能,且同时还让在线的且被添加的用户能实时获取到添加好友的请求,现在还需要实现离线的用户下次上线之后能获取到添加好友的请求

约定前后端交互接口

请求:

GET /getFriendRequest

响应:

HTTP/1.1 200 OK

Content-Type:application/json

[
        {
                fromUserId: 5,
                fromUserName: 'zhangsanfeng',
                reason: '想认识下你!'
        },
        .........
]

后端代码实现

    @GetMapping("/getFriendRequest")
    @ResponseBody
    public Object getFriendRequest(HttpServletRequest req) {
        //1.先从会话中获取到userId
        HttpSession session = req.getSession(false);
        if (session == null) {
            //会话不存在,用户未登录,直接返回一个空列表
            System.out.println("[getFriendRequest] 当前获取不到 session 对象!");
            return new ArrayList<AddFriendRequest>();
        }
        User user = (User) session.getAttribute("user");
        if (user == null) {
            //当前用户对象没在会话中存在
            System.out.println("[getFriendRequest] 当前获取不到 user 对象!");
            return new ArrayList<AddFriendRequest>();
        }
        //2.查询该用户的所有的好友请求有哪些
        return friendMapper.getFriendRequest(user.getUserId());
    }

修改FriendMapper,新增getFriendRequest

// 获取添加好友的请求(可能有多个,所以是一组集合)
List<AddFriendRequest> getFriendRequest(int toUserId);

修改FriendMapper.xml

    <!-- 此处需要联合user表进行查询,因为需要查询出是谁(包含他的名字)发送的好友请求  -->
    <select id="getFriendRequest" resultType="com.example.chatterbox.api.AddFriendRequest">
        select fromUserId, user.username as fromUserName, reason
        from add_friend_request, user
        where toUserId = #{toUserId} and fromUserId = user.userId
    </select>

前端代码实现

修改websocket响应

上述过程的大致流程图:

接受添加好友请求

点击接受按钮触发以下操作:

  1. 在  add_friend_request  表中删除这个记录.
  2. 修改 friend 表,加上两项
  3. 如果对⽅在线,则通过 websocket 通知对⽅,弹出提⽰并刷新好友列表.
  4. 删除当前的请求好友项
  5. 刷新好友列表

约定前后端交互接口

zhangsanfeng 向 zhangsan 提出好友申请,
zhangsan点击接受,触发 HTTP 请求.

请求:

GET /acceptFriend?friendId=5    ---friendId就是zhangsanfeng的id

响应:

HTTP/1.1 200 OK

zhangsanfeng如果在线,就会立即收到 websocket 响应.
{
        type: 'acceptFriend',   
        fromUserId: 1,
        fromUserName: 'zhangsan',     ---zhangsan同意zhangsanfeng的好友申请
}

后端代码实现

修改FriendMapper,新增 deleteFriendRequest 和 addFriend:

// 删除之前的添加好友的请求
void deleteFriendRequest(int fromUserId, int toUserId);
// 在好友列表中新增一项
void addFriend(int userId, int friendId);

实现FriendMapper.xml:

    <!--只能删除对应用户添加本人的好友请求,不能删除其他人的添加好友请求,因为此时这里拒绝添    
    加的好友只有一个-->
    <delete id="deleteFriendRequest">
        delete from add_friend_request where fromUserId = #{fromUserId} and toUserId = #{toUserId}
    </delete>
    <insert id="addFriend">
        insert into friend values (#{userId}, #{friendId}), (#{friendId}, #{userId})
    </insert>

修改FriendAPI,新增 acceptFriend 方法:
注意:此处需要多次操作数据库,因此使用事务保证原子性.

    @GetMapping("/acceptFriend")
    @ResponseBody
    @Transactional
    public Object acceptFriend(int friendId, HttpServletRequest req) throws IOException {
        //1.先从会话中获取到userId
        HttpSession session = req.getSession(false);
        if (session == null) {
            //会话不存在,用户未登录,直接返回一个空列表
            System.out.println("[acceptFriend] 当前获取不到 session 对象!");
            return "";
        }
        User user = (User) session.getAttribute("user");
        if (user == null) {
            //当前用户对象没在会话中存在
            System.out.println("[acceptFriend] 当前获取不到 user 对象!");
            return "";
        }
        //2.删除好友请求表的存档
        friendMapper.deleteFriendRequest(friendId, user.getUserId());
        //3.修改好友表
        friendMapper.addFriend(friendId, user.getUserId());
        //4.获取添加本人好友的在线状态
        WebSocketSession webSocketSession = onlineUserManager.getSession(friendId);
        if (webSocketSession != null) {//如果他在线
            AddFriendRequest addFriendRequest = new AddFriendRequest();
            addFriendRequest.setType("acceptFriend");
            addFriendRequest.setFromUserId(user.getUserId());
            addFriendRequest.setFromUserName(user.getUsername());
            //那么他会实时知道我接受了好友请求(自动弹窗)(通过 websocket 通知对方并且刷新好友列表)
            webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(addFriendRequest)));
        }
        return "";
    }

前端代码实现

继续处理 websocket 响应

拒绝添加好友请求

点击拒绝按钮触发以下操作:

  1. 在 add_friend_request 表中删除这个记录.
  2. 删除当前的请求好友项

其它什么也不用做了。

约定前后端交互接口

zhangsanfeng 向 zhangsan 提出好友申请,
zhangsan点击拒绝,触发 HTTP 请求.

请求:

GET /rejectFriend?friendId=5    ---friendId就是zhangsanfeng的id

响应:

HTTP/1.1 200 OK

zhangsanfeng如果在线,就会立即收到 websocket 响应.
{
        type: 'rejectFriend',   
        fromUserId: 1,
        fromUserName: 'zhangsan',     ---zhangsan拒绝zhangsanfeng的好友申请
}

后端代码实现

修改FriendAPI,新增 rejectFriend 方法:
 

    @GetMapping("/rejectFriend")
    @ResponseBody
    @Transactional
    public Object rejectFriend(int friendId, HttpServletRequest req) throws IOException {
        //1.先从会话中获取到userId
        HttpSession session = req.getSession(false);
        if (session == null) {
            //会话不存在,用户未登录,直接返回一个空列表
            System.out.println("[rejectFriend] 当前获取不到 session 对象!");
            return "";
        }
        User user = (User) session.getAttribute("user");
        if (user == null) {
            //当前用户对象没在会话中存在
            System.out.println("[rejectFriend] 当前获取不到 user 对象!");
            return "";
        }
        //2.删除好友请求表的存档
        friendMapper.deleteFriendRequest(friendId, user.getUserId());
        //3.获取添加本人好友的在线状态
        WebSocketSession webSocketSession = onlineUserManager.getSession(friendId);
        if (webSocketSession != null) {//如果他在线
            AddFriendRequest addFriendRequest = new AddFriendRequest();
            addFriendRequest.setType("rejectFriend");
            addFriendRequest.setFromUserId(user.getUserId());
            addFriendRequest.setFromUserName(user.getUsername());
            //那么他会实时知道我拒绝了好友请求(自动弹窗)(通过 websocket 通知对方)
            webSocketSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(addFriendRequest)));
        }
        return "";
    }

实现FriendMapper.xml:

前端代码实现

接下来我们进行查找好友,添加好友,成功添加好友,发送/接收消息,拒绝添加好友的测试:

注意!!!我们一定要在不同的客户端进行测试,如果在同一个客户端操作可能会出现各种各样的问题,例如:因为websocket会把这个添加好友的请求又响应给自己,在添加好友的时候多次添加,且接受添加好友会把自己添加自己的成好友,显然逻辑有问题等等其它一系列问题,因为刚开始在测试的时候一直bug,后来才想起是同一个客户端,耗费了很多不必要的时间。

查找好友

添加好友

成功添加好友(点击接受)

发送/接收消息

拒绝添加好友

​​​​​​​

​​​​​​​

扩展模块

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

评论 19
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

TheMythWS

你的鼓励,我的动力。

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值