Bootstrap

快速搭建一个在线聊天网站

目录

1. 项目简介

1.1 产品预览

1.2 开发工具及环境

1.3 项目规划

2. 用户模块

2.1 项目创建与配置

2.2 模块功能及数据库环境

2.3 约定应用层协议

2.4 后端代码

2.4.1 创建实体类

2.4.2 构造数据库接口

2.4.3 编写服务器API

2.5 前端代码

3. 好友模块

3.1 功能及数据库环境

3.2 约定应用层协议

3.3 后端代码

3.3.1 创建实体类

3.3.2 构造数据库接口

3.3.3 编写服务器API

3.4 前端代码

4. 会话模块

4.1 功能及数据库环境

4.2 约定应用层协议

4.3 后端代码

4.3.1 创建实体类

4.3.2 构造数据库接口

4.3.3 编写服务器API

4.4 前端代码

5. 消息模块

5.1 功能及数据库环境

5.2 约定应用层协议

5.3 后端代码

5.3.1 创建实体类

5.3.2 构造数据库接口

5.3.3 编写服务器API

5.4 前端代码

5.5 编写 WebSocket 代码

5.5.1 WebSocket 后端代码

5.5.2 WebSocket 前端代码

5.6 完善会话模块 

6. 线上部署


1. 项目简介

        本篇文章我们介绍 如何快速搭建起一个在线聊天网站。

1.1 产品预览

        在线聊天网站主要涉及两个网页 - 登陆(注册)页面聊天主页。 (页面的结构与样式可根据个人喜好进行调整)

1.2 开发工具及环境

开发工具及环境
前端HTML,CSS,JS,JQuery,VSCode,浏览器
后端JAVA(JDK 8),IDEA,SpringBoot(2.x),SpringWeb,MyBatis
数据库MySQL(5.x)

        使用到的应用层 通信协议:HTTP 协议,WebSocket 协议 

1.3 项目规划

       上图为在线聊天网站的运转基本结构,我们需要对前端,通信协议及数据交互格式,后端,数据库等方面进行开发。

        为了更好地实现开发,我们将项目进行模块划分:用户模块,好友模块,会话模块,消息模块。对于每个模块,我们都要进行每个模块各自的 前端,后端,通信协议格式,数据库等方面的专门设计与编写。

        本篇文章将会基于这四个模块进行展开。

        项目源码:chat: chat项目的源代码 - Gitee.com

        结合源代码与本篇文章的讲解希望能对您在快速搭建一个在线聊天网站上有所帮助。

2. 用户模块

2.1 项目创建与配置

  • 在创建项目前,先在 MySQL 数据库中创建一个库,专门用于存储本项目的数据。
create database if not exists chat charset utf8;

       我们创建一个 SpringBoot 项目(Maven构建),在创建时导入 4 个重要的依赖


        这四个依赖分别在网络通信,操作数据库等方面提供支持。

  • 项目创建好,创建一个 yml文件(平替 properties 文件)进行数据库配置
spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/chat?characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8
    username: root
    password: 831332
    driver-class-name: com.mysql.cj.jdbc.Driver

mybatis:
  mapper-locations: classpath:mapper/**Mapper.xml

logging:
  pattern:
    console: "[%-5level] - %msg%n"

        源码中的配置如上,需要进行三处修改以适配你本地的 MySQL 数据库

  1. 第三行 url 的值,将 127.0.0.1:3306/ 后的 “chat” 修改成你刚刚在 MySQL 数据库中创建的库的库名。(MySQL数据库默认端口号为3306,如有不同也需修改路径)
  2. 第四行 username 的值:修改成你的数据库用户名(默认为 root )
  3. 第五行 password 的值:修改成你的数据库密码(如果没有即可置空)

2.2 模块功能及数据库环境

        完成好项目的创建与配置后,我们进行第一个模块的开发 - 用户模块

        在用户模块中,我们主要实现三个功能:

  1. 用户注册
  2. 用户登录
  3. 获取用户信息:当用户登录成功进入主页后,客户端需要从服务器拉取该用户的信息。 

        所以,我们需要维护一个数据库表 - 用户表(user 表),用来存储我们的聊天网站项目中有哪些用户及其基本信息。用户表为实体数据表,每条数据可以理解为一个用户实体。

  • 在数据库中构造一个用户表(user 表)
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(1, 'hans', '123');
insert into user values(2, 'ben', '123');
insert into user values(3, 'michael', '123');
insert into user values(4, 'cassie', '123');
insert into user values(5, 'jane', '123');
insert into user values(6, 'lisa', '123');
insert into user values(7, 'tony', '123');

此表涉及三个字段,用户ID,用户名,用户密码。我们在表中插入了几条测试数据。


        我们可以推断出每个功能的关键实现核心:

功能实现核心
用户注册构造一条新记录插入用户表
用户登录查询用户表
获取用户信息从 Session 中拿到用户数据

        表中涉及到的 session 为我们此项目中使用到的 HTTP 协议中的 Session :

        在 HTTP 协议中,Session 是一种用于在客户端和服务器之间维持状态的方法。HTTP 是一种无状态协议,这意味着每个请求都是独立的,不会自动保留任何之前请求的信息。为了在多个请求之间维持用户的状态和数据(例如用户登录信息、购物车内容等),引入了 Session 的概念。在本项目后续的后端代码会有体现。

2.3 约定应用层协议

        用户模块所使用到的应用层协议为 HTTP 协议

        每个功能的实现都需要前后端进行数据交流,因此我们针对每个功能进行应用层格式的约定,即 约定每个功能所涉及到的请求与响应的格式。

        我们进行如下约定:


 用户登录

        请求:

                POST /login
                Content-Type: application/x-www-form-urlencoded(使用 form 表单来进行请求交互)
                username=zhangsan&password=123

        响应:

                HTTP/1.1 200 OK
                Content-Type: application/json(使用 json 来进行响应交互)
                {
                    userId: 1,
                    username: 'zhangsan'
                }
(如果登录成功,则返回当前登录用户的基本信息,给客户端保存;如果登录失败,则返回空对象)


用户注册:

        请求:
                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'
                }
(如果注册失败,则返回空对象)


获取用户信息

        请求:
                GET /userInfo

        响应:
                HTTP/1.1 200 OK
                Content-Type: application/json
                {
                    userId: 1,
                    username: 'zhangsan'
                }


        以上我们针对三个功能完成了请求 / 响应格式的约定,接下来后端代码与前端代码的实现将遵循这个约定。

2.4 后端代码

        我们进行 用户模块 的后端代码的编写。主要围绕 创建实体类,构造数据库接口,编写服务器API 进行展开。

        其中,创建实体类 和 构造数据库接口 都是为 编写服务器API 做准备。

2.4.1 创建实体类

        对于用户模块,后端代码主要涉及到一个实体类 - 用户实体类

  • 构造 用户实体类(User 类)
public class User {
    private int userId;
    private String username = "";
    private String password = "";
    // get / set 方法
}

        该实体类的结构我们可以对应数据库中用户表(2.1 小节)的字段,同样包含用户ID,用户名,用户密码等基本信息。

2.4.2 构造数据库接口

        我们已经准备好了数据库环境(用户表),接下来我们构造出操作数据库的接口,并通过 MyBatis 的 xml 文件实现这些接口。

  • 构造接口
@Mapper
public interface UserMapper {

    // 插入一条用户
    int insert(User user);

    // 查询用户
    User selectByName(String username);
}

        我们在此处使用到了 @Mapper 注解, 是 MyBatis 框架中的一个重要注解,用于将一个 Java 接口标记为 MyBatis 的 Mapper(数据访问对象,DAO)。使用 @Mapper 注解可以将接口与 SQL 映射文件(此处我们用到 .xml 文件)关联起来。

  • 通过 .xml 文件实现接口
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.chat.model.UserMapper">

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

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

</mapper>
2.4.3 编写服务器API

        对于用户模块,我们已经准备好了实体类和操作数据库接口。

        那么我们接下来进行编写用户模块的服务器API。

  • 创建 用户模块API类(UserAPI 类)
@RestController
public class UserAPI {
    @Resource
    UserMapper userMapper;

    // 三个功能分别所对应的方法
    // TODO
}

此处我们又接触到两个注解:

  1. @RestController 注解:用于将 Java 类标记为控制器,允许其处理 HTTP 请求,并将结果直接写入 HTTP 响应体。

  2. @Resource 注解:是 Java 中的一个通用依赖注入注解。它用于在 Java 类中标记需要注入的外部资源(如 Java 实例对象、环境资源等)。在 Spring 框架中,@Resource 也被支持用于注入 Spring 管理的组件。

        创建好 用户模块API类 后,我们进行方法填充。每个功能对应着一个方法。

  • 填充用户登录方法(login 方法)
 @PostMapping("/login")
    @ResponseBody
    public Object login(String username, String password, HttpServletRequest req) {
        // 使用数据库接口查询用户
        User user = userMapper.selectByName(username);
        // 检查 用户是否存在 或 密码是否正确
        if (user == null || !user.getPassword().equals(password)) {
            System.out.println("登陆失败!用户名或密码错误 " + user);
            return new User();
        }
        // 在 Http 的 session 中保存该用户对象,以便后用
        HttpSession session = req.getSession(true);
        session.setAttribute("user", user);
        // 密码等敏感数据不返回给客户端,置空
        user.setPassword("");

        return user;
    }

此处我们又接触到两个注解:

  1. @PostMapping 注解:是 Spring 框架中用于处理 HTTP POST 请求的一种快捷注解。它是 Spring MVC 中的注解之一,用于将 HTTP 请求映射到具体的处理方法上。
  2. @ResponseBody 注解:是 Spring 框架中用于将控制器方法的返回值直接写入 HTTP 响应体的重要注解。它的作用是将 Java 对象转换为 HTTP 响应内容(例如 JSON 或 XML),并将其直接发送给客户端。
  • 填充用户注册方法(register 方法)
    @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);

            user.setPassword("");
        } catch (DuplicateKeyException e) {
            user = new User();
            System.out.println("注册失败! username = " + username);
        }
        return user;
    }
  •  填充 获取用户信息方法(getUserInfo方法)
@GetMapping("/userInfo")
    @ResponseBody
    public Object getUserInfo(HttpServletRequest req) {

        // 这里我们用到 Http 的 session,获取到用户实体数据

        HttpSession session = req.getSession(false);
        if (session == null) {
            System.out.println("[getUserInfo] 当前请求中获取不到 session对象!");

            return new User();
        }
        User user = (User) session.getAttribute("user");
        if (user == null) {
            System.out.println("[getUserInfo] 当前会话中获取不到 user对象");
            return new User();
        }
        user.setPassword("");
        return user;
    }

        值得注意的是,此处我们通过 HTTP 的 Session 获取用户实体数据,而 不是 通过数据库中获取。session中的数据在用户登录方法中进行存储。

        在这个类中我们又接触到了一个新的注解 - @GetMapping:是 Spring 框架中用于处理 HTTP GET 请求的一个快捷注解。与 @PostMapping 注解类似,它用于将特定的 URL 路径映射到控制器方法上。

        上述三个方法的输入与输出,即接收的请求与发送的响应,格式按照 2.3 小节 约定的格式进行接收与创建。

2.5 前端代码

        目前,我们已经完成了 用户模块 的后端代码部分,接下来我们需要完成另一项核心工作 - 搭建客户端网页

        对于客户端网页的搭建,我们使用 HTML 和 CSS 标签语言完成这两个页面的搭建,具体代码不在文章中展示,详细请参考源码中的 static 目录(源码于第一章)。

        我们着重介绍 客户端使用 JavaScript 与 JQuery 完成与服务器的数据互动。我们针对不同的功能进行分别介绍:

  • 登录功能 - Javascript 代码
// 获取登录按钮
let submitButton = document.querySelector('#submit');
// 当用户触发点击操作时,将运行此方法
submitButton.onclick = function() {
    let username = document.querySelector('#username').value;
    let password = document.querySelector('#password').value;
    if (username == "" || password == "") {
        alert("请填写完整!");
        return
    }
    // 使用 JQuery 的 ajax 方法进行与服务器数据交互
    $.ajax({
        // 发送请求
        type: 'post',
        url: '/login',
        data: {
            username: username,
            password: password
        },
        // 接收响应
        success: function(body) {
            if (body != null && body.userId > 0) {
                alert("登陆成功!");
                location.assign('/client.html');
            } else {
                alert("登陆失败!");
            }
        }
    });
}

        在上述的 JavaScript 代码中,我们使用到了JQuery:jQuery 是一个快速、小巧且功能丰富的 JavaScript 库,旨在简化 HTML 文档的遍历和操作、事件处理、动画以及 Ajax 交互等任务。

        通过 Ajax 方法 进行与服务器数据的交互,这里构造的数据格式遵循了 2.3 小节 约定的格式

        注册功能的JS代码与登录功能的JS代码十分相似,详细请看源码。

  • 获取用户信息 - Javascript 代码
function getUserInfo() {
    $.ajax({
        type: 'get',
        url: 'userInfo',

        success: function(body) {
            if (body.userId && body.userId > 0) {
                let userDiv = document.querySelector('.main .left .user');
                userDiv.innerHTML = body.username;

                userDiv.setAttribute("user_id", body.userId);
            } else {
                alert("用户尚未登陆!");
                location.assign('login.html');
            }
        }
    });
}
getUserInfo();

         当用户登录成功进入聊天主网页后,客户端(即浏览器)会自动加载该JS方法(获取用户信息)。 

3. 好友模块

3.1 功能及数据库环境

        接下来,我们进行第二个模块的开发 - 好友模块

        在用户模块中,我们主要实现一个功能:

        获取好友列表功能:当用户登录成功后,进入聊天主网页时,客户端需要向服务器 拉取到该用户的好友数据,以显示到好友列表中。


        所以,我们需要新维护一个数据库表 - 好友关系表(friend 表),与用户表不同的是,用户表储存的每条数据代表着一个用户实体。而好友关系表存储的每条数据代表的是某个用户的一个好友关系

  • 在数据库中构造一个好友关系表(friend 表)
drop table if exists friend;
create table friend(
    userId int,
    friendId int
);

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

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

insert into friend values(1, 7);
insert into friend values(7, 1);

insert into friend values(4, 6);
insert into friend values(6, 4);

insert into friend values(1, 6);
insert into friend values(6, 1);

此表涉及两个字段:用户ID,该用户一个好友的ID。

我们在表中插入了几条测试数据。因为好友关系是相互的,所以每个好友关系由两条数据构成。


        我们可以推断出该功能的关键实现核心:

功能实现核心
获取好友列表功能 先查询好友关系表,拿到一组好友ID
再查询用户表,将 好友ID 转化为用户实体

3.2 约定应用层协议

        好友模块所使用到的应用层协议为 HTTP 协议

        我们针对 获取好友列表功能 进行应用层格式的约定,即 约定 获取好友列表功能 所涉及到的请求与响应的格式。

        我们进行如下约定:


 获取好友列表功能

        请求:

                GET /friendList

        响应:

                HTTP/1.1 200 OK
                Content-Type: application/json
                [
                    {
                        friendId: 2,
                        friendName: 'lisi'
                    }
                    {
                        friendId:3,
                        friendName: 'wangwu'
                    }
                ]


        以上我们针对好友模块完成了请求 / 响应格式的约定,接下来后端代码与前端代码的实现将遵循这个约定。

3.3 后端代码

        我们进行 好友模块 的后端代码的编写。主要围绕 创建实体类,构造数据库接口,编写服务器API 进行展开。

        其中,创建实体类 和 构造数据库接口 都是为 编写服务器API 做准备。

3.3.1 创建实体类

        对于好友模块,后端代码主要涉及到一个实体类 - 好友实体类

        值得注意的是,我们在数据库中并 没有 像用户表一样 构造 “好友表” 这样专门存放好友实体的表,“好友” 实体本质上就是 “用户” 实体,所以在这个模块中我们也会涉及到用户表。

        但是在后端代码中,我们 需要一个 “好友实体类” 来接收数据以更好地实现该模块的服务器功能。

  • 构造 用户实体类(User 类)
public class Friend {
    private int friendId;
    private String friendName;
    // get / set 方法
}

3.3.2 构造数据库接口

        我们已经准备好了数据库环境(好友表,用户表),接下来我们构造出操作数据库的接口,并通过 MyBatis 的 xml 文件实现这些接口。

  • 构造接口
@Mapper
public interface FriendMapper {

    // 通过 用户ID 获取一组好友实体对象
    List<Friend> selectFriendList(int userId);
}
  • 通过 .xml 文件实现接口
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.chat.model.FriendMapper">

    <select id="selectFriendList" resultType="com.example.chat.model.Friend">
        select userId as friendId, username as friendName from user where userId in
        (select friendId from friend where userId = #{userId})
    </select>

</mapper>

        此处我们的 SQL 用到了子查询,先查询了好友关系表,获取到 一组好友ID 后再用其查询用户实体表,从而将好友ID 转化为好友用户实体。

3.3.3 编写服务器API

        对于好友模块,我们已经准备好了实体类和操作数据库接口。

        那么我们接下来进行编写好友模块的服务器API。

  • 创建 好友模块API类(FriendAPI类) 并填充 获取好友列表方法(getFriendList 方法)
@RestController
public class FriendAPI {

    @Resource
    private FriendMapper friendMapper;

    @GetMapping("/friendList")
    @ResponseBody
    public Object getFriendList(HttpServletRequest req) {

        // 通过 HttpSession 获取用户实体
        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>();
        }

        // 通过用户ID获取到其好友列表
        List<Friend> friendList = friendMapper.selectFriendList(user.getUserId());

        return friendList;
    }
}

3.4 前端代码

        目前,我们已经完成了 好友模块 的后端代码部分,接下来,介绍 使用 JavaScript 与 JQuery 完成客户端与服务器的数据互动。(HTML与CSS代码请看源码)。

  • 获取好友列表功能 Javascript 代码
function getFriendList() {
    $.ajax({
        type: 'get',
        url: 'friendList',

        success: function(body) {
            let friendListUL = document.querySelector('#friend_list');
            friendListUL.innerHTML = '';

            for (let friend of body) {
                let li = document.createElement('li');
                li.innerHTML = '<h4>' + friend.friendName + '</h4>';

                // 标签属性设置(在前端存储好友的 用户Id )
                li.setAttribute('friend_id', friend.userId);

                friendListUL.appendChild(li);

                li.onclick = function() {
                    clickFriend(friend);
                }
            }
        },
        error: function() {
            console.log('获取好友列表失败!');
        }
    });
}
getFriendList();

        在这里有一个设定值得我们注意:我们将 好友的 ID 存储在了 HTML 标签属性中,以某种方式实现了在前端临时存储数据(存储好友ID),方便后续使用。

4. 会话模块

4.1 功能及数据库环境

        我们已经完成了用户模块与好友模块,那么下一步我们就可以推进聊天功能。当一个用户与一个好友聊天时,就需要在一个 会话 底下互相发送 消息 。这便又引出两个模块,会话模块消息模块,在本节我们先介绍会话模块。

        会话:消息的载体。在一个会话中如果有两个用户,就是私聊,如果一个会话中有三个即三个以上的用户,即为群聊。(在本项目中为了达到快速搭建的效率,我们暂时只考虑私聊的情况,群聊的情况日后我们可以再进行拓展)

       上图中,当顶部按钮选择气泡图标时,左侧的列表则显示为会话列表,右侧为会话框。

在会话模块中,我们主要实现两个功能:

  1. 获取历史会话列表:用户登录进聊天主页后,左侧的会话列表需要展现出之前已有的历史会话。
  2. 新增会话:点击好友列表中的某个好友,即可转跳到与该好友的会话框。如果还未与该好友聊过天,则自动创建一个会话。

        所以,我们需要新维护两个数据库表 - 会话表,会话用户关系表。

        会话表:会话的实体表,存储了有哪些会话及其基本信息。

        会话用户关系表:并不是一个实体表,它将 会话 与 该会话下的用户 进行关联。

  • 在数据库中构造会话表(message_session 表)和 会话用户关系表(message_session_user 表)
drop table if exists message_session;
create table message_session (
    sessionId int primary key auto_increment,
    lastTime datetime
);

insert into message_session values(1, '2000-05-01 00:00:00');
insert into message_session values(2, '2000-06-01 00:00:00');
insert into message_session values(3, '2000-07-01 00:00:00');

        该SQL中出现了 datatime 属性:是一种用于存储日期和时间的属性。它可以存储从1000-01-01 00:00:00到9999-12-31 23:59:59范围内的日期和时间。

drop table if exists message_session_user;
create table message_session_user (
    sessionId int,
    userId int
);

insert into message_session_user values(1, 1);
insert into message_session_user values(1, 3);

insert into message_session_user values(2, 4);
insert into message_session_user values(2, 6);

insert into message_session_user values(3, 1);
insert into message_session_user values(3, 7);

        在这两个表中,同样我们在表中插入了几条测试数据。因为每个会话都至少有两个用户(聊天),所以两条SQL为一组数据进行插入 会话用户关系表。

        对于这两个表的命名,为了与 HTTP 里的 Session 作出区分,我们在表名前面加上 message 前缀,表示 消息 的 session 。


        我们可以推断出该功能的关键实现核心:

功能实现核心
获取历史会话列表  先查询会话表,拿到一组会话 ID
再查询用户会话关系表,拿到好友
再查询消息表,拿到最后一条历史消息
新增会话分别构造一条或一组新数据,
分别插入会话表和用户会话关系表

4.2 约定应用层协议

        会话模块所使用到的应用层协议为 HTTP 协议

        我们针对 会话模块中的两个功能 分别进行应用层格式的约定,即 约定这两个功能所涉及到的请求与响应的格式。

        我们进行如下约定:


获取历史会话列表 功能

请求:
        GET /sessionList

响应:
        HTTP/1.1 200 OK
        Content-Type: application/json
        [
            {
                sessionId: 1,
                friends: [
                    {
                        friendName: 'lisi',
                        friendId: 2
                    }
                ],
                lastMessage: '晚上吃啥'
            },
            {
                sessionId: 2,
                friends: [
                    {
                        friendName: '王五',
                        friendId: 3
                    }
                ],
                lastMessage: '你好呀'
            }
        ]


新增会话 功能:

请求:
        POST /session?toUserId=1
        (一般情况下,提交数据给服务器 习惯用POST,从服务器获取数据习惯用GET)

响应:
        HTTP/1.1 200 OK
        Content-Type: application/json
        {
            sessionId: 1
        }


 同样的接下来的后端代码与前端代码的实现将遵循这个约定。

4.3 后端代码

        我们进行 会话模块 的后端代码的编写。同样围绕 创建实体类,构造数据库接口,编写服务器API 进行展开。

        其中,创建实体类 和 构造数据库接口 都是为 编写服务器API 做准备。

4.3.1 创建实体类
  • 对于会话模块,我们首先创建一个 会话实体类(MessageSession 类)
public class MessageSession {
    private int sessionId;
    private List<Friend> friends;
    private String lastMessage;
    // set / get 方法
}

        这个类在命名方面,同样的为了与 HTTP 的 Session 做出区分,我们在加了 Message 前缀来命名。

        值得注意的是,此处设置的字段属性并没有与数据库会话实体表的字段属性相匹配,也就是说,会话实体在数据库中和服务器中以不同的结构存在,我们在实现接口代码封装类时需加以注意。

  • 此外,我们同样再构建一个会话用户关系类(MessageSessionUserItem 类)
public class MessageSessionUserItem {
    private int sessionId;
    private int userId;
    // get / set 方法
}
4.3.2 构造数据库接口

        我们已经准备好了数据库环境(会话表,会话用户关系表 等),接下来我们构造出 操作数据库 的接口,并通过 MyBatis 的 xml 文件实现这些接口。

  • 构造接口
@Mapper
public interface MessageSessionMapper {

    // 通过 用户Id 来拿到与之有关的 一组会话Id
    List<Integer> getSessionIdsByUserId(int userId);

    // 通过会话Id 和 用户Id 来拿到 该会话中的好友实体(一个或者多个)
    List<Friend> getFriendsBySessionId(int sessionId, int selfUserId);

    // 新增一个会话实体
    int addMessageSession(MessageSession messageSession);

    // 新增一条会话用户关系
    int addMessageSessionUser(MessageSessionUserItem messageSessionUserItem);
}

此处为了方便,我们将操作 会话表 和 会话用户关系表 的方法统一在了一个接口上。

  • 通过 .xml 文件实现接口
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.chat.model.MessageSessionMapper">

    <!-- 先在 用户会话表 中,通过指定 用户Id ,查询拿到 一组会话Id -->
    <!-- 再在 会话表 中,利用 lastTime字段 将拿到的一组会话Id 降序排序 -->
    <select id="getSessionIdsByUserId" 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>

    <!-- 先在 用户会话表 中,通过指定 会话Id 和 用户Id ,查询拿到该会话中的一个或多个用户Id(除了自己)-->
    <!-- 再在 用户表 中,通过刚刚拿到的用户Id ,查询拿到一个或多个好友(用户)-->
    <select id="getFriendsBySessionId" resultType="com.example.chat.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})
    </select>

    <!-- 在 消息会话表 中,插入一条传来的数据 -->
    <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>

</mapper>
4.3.3 编写服务器API

        我们已经准备好了实体类和操作数据库接口。

        那么我们接下来进行编写会话模块的服务器API。同样地,我们根据两个功能分别创建两个方法。

  • 先创建出 API 类
@RestController
public class MessageSessionAPI {

    @Resource
    private MessageSessionMapper messageSessionMapper;

    // 两个功能所对应的方法
    
}
  • 填充 获取历史会话列表 方法(getMessageSessionList 方法)
@GetMapping("/sessionList")
    @ResponseBody
    public Object getMessageSessionList(HttpServletRequest req) {

        List<MessageSession> messageSessionList = new ArrayList<>();

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

        List<Integer> sessionIdList = messageSessionMapper.getSessionIdsByUserId(user.getUserId());
        for (int sessionId : sessionIdList) {
            List<Friend> friends = messageSessionMapper.getFriendsBySessionId(sessionId, user.getUserId());

            // 此处需要涉及到消息模块的数据库操作接口,暂空
            String lastMessage = null;

            MessageSession messageSession = new MessageSession();
            messageSession.setSessionId(sessionId);
            messageSession.setFriends(friends);
            if (lastMessage == null) {
                messageSession.setLastMessage("");
            } else {
                messageSession.setLastMessage(lastMessage);
            }

            messageSessionList.add(messageSession);
        }

        return messageSessionList;
    }

        注意,在此代码中我们对 会话类 的 最后一条消息(lastmessage)属性 置空。

        所谓 最后一条消息(lastmessage)属性,其存在的原因是我们在实现 获取历史会话功能 的时候,需要将该会话中最后一条消息一起显示到会话列表中,如图所示:

        但此时,我们还并没有实现消息模块,没有其有关的数据表和操作接口。在下一章实现了消息模块后,我们可以将这块功能补充完整(于 6.6 小节)。

  • 填充  新增会话 方法(addMessageSession 方法)
@PostMapping("/session")
    @ResponseBody
    @Transactional // 事务,使数据库操作原子化,一体化
    public Object addMessageSession(int toUserId, HttpServletRequest req) {

        HashMap<String, Integer> resp = new HashMap<>();

        HttpSession session = req.getSession(false);
        if (session == null) {
            System.out.println("[addMessageSession]: session == null");
            return null;
        }

        User user = (User)session.getAttribute("user");
        if (user == null) {
            System.out.println("[addMessageSession]: user == null");
            return null;
        }
        // 添加会话实体
        MessageSession messageSession = new MessageSession();
        messageSessionMapper.addMessageSession(messageSession);

        // 添加两组会话用户关系
        MessageSessionUserItem messageSessionUserItem1 = new MessageSessionUserItem();
        messageSessionUserItem1.setSessionId(messageSession.getSessionId());
        messageSessionUserItem1.setUserId(user.getUserId());
        messageSessionMapper.addMessageSessionUser(messageSessionUserItem1);

        MessageSessionUserItem messageSessionUserItem2 = new MessageSessionUserItem();
        messageSessionUserItem2.setSessionId(messageSession.getSessionId());
        messageSessionUserItem2.setUserId(toUserId);
        messageSessionMapper.addMessageSessionUser(messageSessionUserItem2);

        System.out.println("[addMessageSession] 新增会话成功! sessionId = " + messageSession.getSessionId()
                + " userId1 = " + user.getUserId() + " userId2 = " + toUserId);

        resp.put("sessionId", messageSession.getSessionId());

        return resp;
    }

        此处我们接触到了一个新的注释 @Transactional 注释:是 Spring 框架 中用于声明式事务管理的重要注解。它用于在方法或类级别上启用事务支持,从而使数据库操作具有原子性、一致性、隔离性和持久性(ACID 属性)。在使用 @Transactional 时,Spring 框架会自动管理事务的开启、提交和回滚操作。

4.4 前端代码

        接下来,对于会话模块,介绍 使用 JavaScript 与 JQuery 完成其客户端与服务器的数据互动。(HTML与CSS代码请看源码)。

  • 获取历史会话列表功能 Javascript 代码
function getSessionList() {
    $.ajax({
        type: 'get',
        url: '/sessionList',

        success: function(body) {
            let sessionListUL = document.querySelector('#session_list');
            sessionListUL.innerHTML = '';

            for (let session of body) {

                // 处理 最近的一条消息;判断其长度是否超过 10 个字,若超过,则截断
                if (session.lastMessage > 10) {
                    session.lastMessage = session.lastMessage.substring(0, 10) + '...';
                }

                let li = document.createElement('li');
                // 给该会话标签 设置属性,存储会话 id 信息(前端临时存储数据)
                li.setAttribute('message_session_id', session.sessionId);

                // 给该会话标签 命名会话标题
                li.innerHTML = '<h3>' + session.friends[0].friendName + '<h3>' +
                    '<p>' + session.lastMessage + '<p>';

                sessionListUL.appendChild(li);

                li.onclick = function() {
                    clickSession(li);
                }
            }
        }
    });
}
getSessionList();

        同样的,我们将 会话的 ID 存储在了 HTML 标签属性中,方便后续使用。

        由 5.2小节 约定的应用层协议格式,我们可以知道后端返回来的响应是一个对象集合,我们可以使用 for of 循环来进行遍历处理。

  •  新增会话功能 Javascript 代码
// 当点击好友列表里的某个未聊过天的好友时调用该方法
function clickFriend(friend) {
    // 拿到 消息会话 或 无效空对象
    let sessionLi = findSessionByName(friend.friendName);
    // 拿到 消息会话列表下的 所有消息会话(数组)
    let sessionListUL = document.querySelector("#session_list");
    // 判断拿到的消息会话是否为有效会话
    if (sessionLi != null) {
        // 用 insertBefore()方法 将指定消息会话置顶(数组的自带方法)
        sessionListUL.insertBefore(sessionLi, sessionListUL.children[0]);
        // 模拟点击
        sessionLi.click();
    } else {
        // 创建一个消息会话标签
        // 赋值
        // 置顶
        let sessionLi = document.createElement('li');
        sessionLi.innerHTML = '<h3>' + friend.friendName + '</h3>' + '<p></p>';
        sessionListUL.insertBefore(sessionLi, sessionListUL.children[0]);
        // 给消息会话标签 设置 点击事件反馈
        sessionLi.onclick = function() {
            // 选中
            clickSession(sessionLi);
        }
        // 模拟点击
        sessionLi.click();
        // 创建一个消息会话
        createSession(friend.friendId, sessionLi);
    }
    let tabSession = document.querySelector('.tab .tab_session');
    tabSession.click();
}
// 通过 指定用户名 返回其所在会话(如果没有会话则返回无效空对象)
function findSessionByName(username) {
    // // 拿到会话列表标签下的 所有会话标签(数组)
    let sessionLis = document.querySelectorAll('#session_list>li');
    for (let sessionLi of sessionLis) {
        // 拿到 消息会话中的 用户名字标签
        // 判断是否为指定用户名
        let h3 = sessionLi.querySelector('h3');
        if (h3.innerHTML == username) {

            // 判定为指定用户名,返回该用户名所在的消息会话
            return sessionLi;
        }
    }
    // 没有指定用户名,返回无效空对象
    return null;
}
function createSession(friendId, sessionLi) {
    $.ajax({
        type: 'post',
        url: 'session?toUserId=' + friendId,
        success: function(body) {
            console.log("会话创建成功! sessionId = " + body.sessionId);
            sessionLi.setAttribute('message_session_id', body.sessionId);
        },
        error: function() {
            console.log('会话创建失败');
        }
    });
}

        当点击好友列表里的某个未聊过天的好友时,触发该 JS 方法

5. 消息模块

5.1 功能及数据库环境

        接下来,我们进行最后一个模块 - 消息模块。

        在消息模块中,我们主要实现两个功能:

  1. 获取历史消息:用户登录进聊天主页后,需要加载出历史聊天记录。
  2. 实时消息传输:实现用户可以在线实时聊天

        所以,我们需要新维护一个数据库表 - 消息表。该表存放了所有的消息实体。

  • 构造消息表(message 表)
drop table if exists message;
create table message (
    // 消息ID
    messageId int primary key auto_increment,

    // 发出消息的用户ID
    fromId int,

    // 消息所在的会话ID
    sessionId int,

    // 消息内容
    content varchar(2048),

    // 消息发送的时间
    postTime datetime
);

insert into message values(1, 1, 1, '晚上吃什么', '2000-5-2 17:23:48');
insert into message values(2, 3, 1, '都行', '2000-5-2 17:25:33');
insert into message values(3, 1, 1, '火锅?', '2000-5-2 17:25:43');
insert into message values(4, 3, 1, '太辣', '2000-5-2 17:26:22');
insert into message values(5, 1, 1, '粤菜?', '2000-5-2 17:27:00');
insert into message values(6, 3, 1, '太淡', '2000-5-2 17:27:08');
insert into message values(7, 1, 1, '螺蛳粉', '2000-5-2 17:27:50');
insert into message values(8, 1, 1, '怎么样', '2000-5-2 17:27:53');
insert into message values(9, 3, 1, '太臭', '2000-5-2 17:28:30');
insert into message values(10, 1, 1, '那你想吃什么', '2000-5-2 17:28:59');
insert into message values(11, 1, 1, '??', '2000-5-2 17:29:01');
insert into message values(12, 3, 1, '都行', '2000-5-2 17:29:18');
insert into message values(13, 1, 1, '......', '2000-5-2 17:29:30');

        如上述设计,每条消息涉及到多个重要字段:消息ID,发送消息的用户ID,消息所在的会话ID,消息内容,消息发送的时间。

        同样的我们在表中插入了几条测试数据。


        我们可以推断出该功能的关键实现核心:

功能实现核心
获取历史消息  查询消息表,拿到消息
实时消息传输使用 WebSocket 协议 实时交流
构造新记录插入消息表

5.2 约定应用层协议

        消息模块所使用到的应用层协议为 HTTP 协议,WebSocket 协议

        与之前不同的是,实现第二个功能 - 实时消息传输功能,HTTP 协议并不适用,需要使用更合适的 WebSocket 协议。具体原因如下:

  1. 全双工通信:通过上图我们可以知道,在实现 实时消息传输 的过程中,涉及到服务器需要主动发消息给用户B,对于服务器主动发消息给客户端的情况,只满足单向通信的请求 - 响应模型的 HTTP 协议来说难以实现(轮询性价比太差)。而 WebSocket 协议支持双向通信,在建立连接后,客户端和服务器都可以主动发送数据,从而更好的实现即时聊天的效果。
  2. 连接开销:HTTP 协议中每次的请求相对独立,需要频繁地断开与建立连接,并且每次请求和响应都包含较大的头部数据,这使得在频繁地即时聊天的业务需求下开销较大。而 WebSocket 协议在建立连接后可以持续保持连接的状态,客户端和服务器可以持续交互数据,并且使用较小的数据帧,减少了传输开销。

        我们针对 消息模块中的两个功能 分别进行应用层格式的约定。

        我们进行如下约定:


获取历史消息 功能(使用 HTTP 协议)

请求:
        GET /message?sessionId=1

响应:
        HTTP/1.1 200 OK
        Content-Type: application/json
        [
            {
                messageId: 1,
                fromId: 1,
                fromName: 'zhangsan',
                sessionId: 1,
                content: '晚上吃什么'
            },
            {
                messageId: 2,
                fromId: 2,
                fromName: 'lisi',
                sessionId: 1,
                content: '都行'
            }
        ]


实时消息传输 功能(使用 WebSocket 协议,使用 json 格式):

用户给数据库发送数据时的格式( “ 请求 ” ):
        {
            type: "message",
            sessionId: 1,
            content: "晚上吃啥"
        }

服务器给用户发送数据时的格式( “响应” ):
        {
            type: "message",
            fromId: 1,
            fromName: 'zhangsan',
            sessionId: 1,
            content: '晚上吃啥'
        } 


 同样的接下来的后端代码与前端代码的实现将遵循这个约定。

5.3 后端代码

        我们进行 消息模块 的后端代码的编写。同样围绕 创建实体类,构造数据库接口,编写服务器API 进行展开。

        其中,创建实体类 和 构造数据库接口 都是为 编写服务器API 做准备。

5.3.1 创建实体类
  • 对于消息模块,我们需要引入一个 消息实体类(Message 类)
public class Message {
    private int messageId;
    private int fromId;

    // 消息发送者的用户名
    private String fromName;

    private int sessionId;
    private String content;
    // get / set 方法
}

        和会话模块类似,此处设置的字段属性并没有与数据库消息表的字段属性相匹配,也就是说,消息实体在数据库中和服务器中以不同的结构存在。我们在这里多补充了一个 消息发送者的用户名 字段(fromName),我们在实现接口代码封装类时需加以注意。

        此外,针对于 WebSocket 协议使用过程中涉及到的 “请求” 与 “响应” ,我们也在后端构造出响应的实体类,以便更好地实现业务功能。

  • 构建 请求实体类(MessageRequest 类)和 响应实体类(MessageResponse 类)
public class MessageRequest {
    private String type = "message";
    private int sessionId;
    private String content;
    // get / set 方法
}
public class MessageResponse {
    private String type = "message";
    private int fromId;
    private String fromName;
    private int sessionId;
    private String content;
    // get / set 方法
}

 这两个类只用在 WebSocket 协议相关的代码

5.3.2 构造数据库接口

        我们已经准备好了数据库环境(消息表),接下来我们构造出操作数据库的接口,并通过 MyBatis 的 xml 文件实现这些接口。

  • 构造接口
@Mapper
public interface MessageMapper {
    // 获取历史消息中的最后一条消息
    String getLastMessageBySessionId(int sessionId);
    // 获取历史消息
    List<Message> getMessagesBySessionId(int sessionId);
    // 新增消息
    void add(Message message);
}
  • 通过 .xml 文件实现接口
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.chat.model.MessageMapper">

    <!-- 从 消息表 中,通过指定会话Id 查询拿到 最近的一条消息内容 -->
    <select id="getLastMessageBySessionId" resultType="java.lang.String">
        select content from message where sessionId = #{sessionId} order by postTime desc limit 1
    </select>

    <!-- 联合查询 消息表 和 用户表 -->
    <select id="getMessagesBySessionId" resultType="com.example.chat.model.Message">
        select messageId, fromId, username as fromName, sessionId, content
        from message , user
        where user.userId = message.fromId and message.sessionId = #{sessionId}
        order by postTime desc limit 100
    </select>

    <!-- 将传来的数据存入 消息表 中 -->
    <insert id="add">
        insert into message values(null, #{fromId}, #{sessionId}, #{content}, now())
    </insert>
</mapper>
5.3.3 编写服务器API

        我们已经准备好了实体类和操作数据库接口,那么我们接下来进行编写消息模块的服务器API。

  • 先创建出 API 类
@RestController
public class MessageAPI {
    @Resource
    private MessageMapper messageMapper;
    // 模块功能方法
}
  • 填充 获取历史消息 方法(getMessage 方法)
    @GetMapping("/message")
    public Object getMessage(int sessionId) {

        // 用 mapper 从数据库中通过 会话Id 拿到和该会话里的 所有消息对象(数组,不超过00个)
        // 此处得到的 消息对象 已经按照其 发送时间(属性) 降序排序
        List<Message> messages = messageMapper.getMessagesBySessionId(sessionId);

        // 逆置,将降序的消息对象转变为升序;先小后大,先老后新(消息)
        Collections.reverse(messages);

        return messages;
    }

        实时消息传输功能因涉及到 WebSocket 协议,具体实现在 6.5 小节介绍。

5.4 前端代码

        接下来,对于消息模块的 获取历史消息 功能,介绍 使用 JavaScript 与 JQuery 完成其客户端与服务器的数据互动。(HTML与CSS代码请看源码)。

  • 获取历史消息功能 Javascript 代码
function getHistoryMessage(sessionId) {
    console.log("获取历史消息 sessionId = " + sessionId);
    let titleDiv = document.querySelector('.right .title');
    titleDiv.innerHTML = '';
    let messageShowDiv = document.querySelector('.right .message_show');
    messageShowDiv.innerHTML = '';
    let selectedH3 = document.querySelector('#session_list .selected>h3');
    if (selectedH3 != null) {
        titleDiv.innerHTML = selectedH3.innerHTML;
    }
    $.ajax({
        type: 'get',
        url: 'message?sessionId=' + sessionId,

        success: function(body) {
            for (let message of body) {
                addMessage(messageShowDiv, message);
            }
            scrollBotton(messageShowDiv);
        }
    });
}

        上述使用的 addMessage 方法的相关代码在这里不进行展示,详细请参考源码。

5.5 编写 WebSocket 代码

        消息模块的第二个功能 - 实时消息传输功能 用到了 WebSocket 协议,所以我们在实现这个功能的时候,对于 前后端 ,都需要专门建一个 WebSocket 的代码模块,来处理 WebSocket 协议涉及到的前后端数据交互。

5.5.1 WebSocket 后端代码

         在后端,我们专门创建一个 WebSocketAPI 类,但在创建之前,我们先做好 准备工作,在后端创建两个类:

  1. 在线用户管理类(OnlineUserManager 类):用户统计用户在线情况(用户在线数量),同时在这个类中我们实现限制用户多设备登录。
  2. WebSocket 配置类(WebSocketConfig 类):当我们要使用 WebSocket 协议的类时,需要将类通过 配置类 进行 注册 后才能使用
  • 创建这两个类:
// 用户在线管理器
@Component
public class OnlineUserManager {

    private ConcurrentHashMap<Integer, WebSocketSession> sessions = new ConcurrentHashMap<>();

    public void online(int userId, WebSocketSession session) {
        if (sessions.get(userId) != null) {
            System.out.println("userId[" + userId + "] 上线失败,已在其它地方上线");
            return;
        }

        sessions.put(userId, session);

        System.out.println("userId[" + userId + "] 上线");
    }

    public void offline(int userId, WebSocketSession session) {

        WebSocketSession existSession = sessions.get(userId);

        // 当多地登录时,登录失败后会触发 offline() ,因此需要核验当前删除的会话是否为本地会话
        if (existSession == session) {

            sessions.remove(userId);

            System.out.println("userId[" + userId + "] 下线");
        }
    }

    public WebSocketSession getSession(int userId) {
        return sessions.get(userId);
    }
}
@Configuration
@EnableWebSocket
// 注册 所构造的websocketAPI
public class WebSocketConfig implements WebSocketConfigurer {

    @Autowired
    private WebSocketAPI webSocketAPI;

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {

        // 绑定路径信息
        registry.addHandler(webSocketAPI, "/webSocketMessage")

                // 调用拦截器,使得 WebsocketSession 从 HttpSession 中拿到数据
                .addInterceptors(new HttpSessionHandshakeInterceptor());
    }
}

 我们有解除到了几个新的注解:

  1. @Component 注解:是Spring 框架中最基本的注解之一,用于将类标记为一个 Spring 组件(bean),以便 Spring 容器能够自动检测、实例化和管理该类的对象。
  2. @Configuration 注解:是 Spring 框架中用于定义配置类的注解。配置类用于替代传统的 XML 配置文件,在其中可以通过方法来声明和配置 Spring 容器中的 bean。
  3. @EnableWebSocket 注解:是 Spring 框架中用于启用 WebSocket 支持的注解。它通过自动配置必要的组件,使应用程序能够处理 WebSocket 连接和消息。
  4. @Autowired 注解:是 Spring 框架中用于实现依赖注入的注解。它可以自动装配 bean,将所需的依赖注入到目标对象中。

  • 完成准备工作后,我们可以编写 WebSocketAPI 类
@Component
public class WebSocketAPI extends TextWebSocketHandler {
    @Autowired
    private MessageSessionMapper messageSessionMapper;
    @Autowired
    private MessageMapper messageMapper;
    @Autowired
    private OnlineUserManager onlineUserManager;
    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());

        User user = (User)session.getAttributes().get("user");
        if (user == null) {
            System.out.println("[WebSocketAPI] user == null,用户未登录");
            return;
        }

        // 用 objectMapper 来进行 Java对象 和 Json格式的字符串 之间的转化
        // getPayload()方法:从消息对象中获取实际的有效负载(payload)即消息数据部分
        MessageRequest req = objectMapper.readValue(message.getPayload(), MessageRequest.class);

        // 判断请求类型是否符合
        if (req.getType().equals("message")) {
            transferMessage(user, req);
        } else {
            System.out.println("[WebSocketAPI] req.type 有误!" + message.getPayload());
        }
    }

    private void transferMessage(User fromUser, MessageRequest req) throws IOException {
        MessageResponse resp = new MessageResponse();
        resp.setType("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);

        // 通过 sessionId 拿到指定会话中 所有的用户(除了当前用户)
        List<Friend> friends =
                messageSessionMapper.getFriendsBySessionId(req.getSessionId(), fromUser.getUserId());

        // 构造一个自己,加入 friends 中,一起作为准备返回消息的目标用户
        Friend myself = new Friend();
        myself.setFriendId(fromUser.getUserId());
        myself.setFriendName(fromUser.getUsername());
        friends.add(myself);

        for (Friend friend : friends) {

            // 从 在线用户管理器 中获得准备返回消息的目标用户的会话
            WebSocketSession webSocketSession = onlineUserManager.getSession(friend.getFriendId());
            if (webSocketSession == null) {
                // 目标用户不在线,结束当前循环,直接在后续进行消息插入数据库即可
                continue;
            }

            // 目标用户在线
            // 用 respJson字符串 构造出 一个消息对象 利用 webSocketSession 发送到前端
            webSocketSession.sendMessage(new TextMessage(respJson));
        }

        // 消息存入数据库
        Message message = new Message();
        message.setFromId(fromUser.getUserId());
        message.setSessionId(req.getSessionId());
        message.setContent(req.getContent());

        messageMapper.add(message);
    }

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

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

        在这个类中,我们继承了 TextWebSocketHandler类(SpringWeb 框架的类) 并重写了四个方法:

  1. afterConnectionEstablished():连接建立,当 WebSocket 连接建立后调用此方法。可以在这里进行一些初始化工作(比如记录连接的客户端信息)。
  2. handleTextMessage():消息处理,当接收到客户端发送的文本消息时调用此方法。可以在这里处理接收到的消息,并返回给客户端响应。我们在这个方法中,实现了 实时消息传输功能 的代码
  3. afterConnectionClosed():连接关闭,当 WebSocket 连接关闭时调用此方法。可以在这里进行一些清理工作,比如释放资源或更新连接状态。
  4. handleTransportError():错误处理,当 WebSocket 传输出现错误时调用此方法。可以在这里处理错误,进行日志记录或通知客户端错误信息。

5.5.2 WebSocket 前端代码

        接下来我们介绍使用 WebSocket 协议的前端部分。

  • JS文件中写入以下代码
let websocket = new WebSocket('ws://127.0.0.1:8080/webSocketMessage');
websocket.onopen = function() {
    console.log("websocket 连接成功!");
}

websocket.onmessage = function(e) {
    console.log("websocket 收到消息! " + e.data);

    let resp = JSON.parse(e.data);

    if (resp.type == 'message') {
        // 实现实时消息传输的方法
        handleMessage(resp);
    } else {
        console.log("resp.type 不符合要求!");
    }
}

websocket.onclose = function() {
    console.log("websocket 连接关闭!");
}

websocket.onerror = function() {
    console.log("websocket 连接异常!");
}

        在这份代码中,我们首先使用 JS 的构造函数 - WebSocket() 来构造出了一个 websocket 对象,该构造函数中的参数 匹配 后端 WebSocket配置类 中的路径参数。websocket 对象有多个事件处理程序:

  1. onopen方法:连接成功时触发。
  2. onmessage方法:收到服务器消息时触发。
  3. onerror方法:发生错误时触发。
  4. onclose方法:连接关闭时触发。
  • 接着我们补充实现 实时消息传输功能 的方法
// 新消息弹出
function handleMessage(resp) {
    let curSessionLi = findSessionLi(resp.sessionId);
    let sessionListUL = document.querySelector("#session_list");

    if (curSessionLi == null) {
        getSessionList()
        sessionListUL.insertBefore(curSessionLi, sessionListUL.children[0]);
    }

    // 更新 预览文本
    let p = curSessionLi.querySelector('p');
    p.innerHTML = resp.content;
    if (p.innerHTML.length > 10) {
        p.innerHTML = p.innerHTML.substring(0, 10) + '...';
    }

    // 拿到 会话列表
    // 将 指定会话 在 会话列表 中置顶
    sessionListUL.insertBefore(curSessionLi, sessionListUL.children[0]);

    // 判断如果 指定会话 为选中状态
    if (curSessionLi.className == 'selected') {

        // 拿到 消息框
        let messageShowDiv = document.querySelector('.right .message_show');

        // 用 消息对象 构造一个 消息标签,放入 消息框 中
        addMessage(messageShowDiv, resp);

        // 将 消息框 设置 可滚动
        scrollBotton(messageShowDiv);
    }
}

function findSessionLi(targetSessionId) {
    let sessionLis = document.querySelectorAll('#session_list li');
    for (let li of sessionLis) {
        let sessionId = li.getAttribute('message_session_id');
        if (sessionId == targetSessionId) {
            return li;
        }
    }
    return null;
}

5.6 完善会话模块 

       至此我们已经完成了消息模块,但我们在实现会话模块的获取历史会话列表功能时遗留下了一个空缺:我们需要在获取的历史会话列表中显示最后一条聊天记录。现在我们已经完成了消息模块,可以完善这一空缺:

public Object getMessageSessionList(HttpServletRequest req) {
    // 其它代码

    String lastMessage = messageMapper.getLastMessageBySessionId(sessionId);

    // 其它代码
}

        

6. 线上部署

        到目前为止,在线聊天网站已经构造完成。

        但是,当项目在本地设备上运行后,由于我们的设备没有外网IP,故无法被其它设备通过网络进行访问

        一般的解决办法是,我们可以租一个云服务器(阿里云,腾讯云等),使用 Xshell 将我们的项目进行远程部署到云服务器上,这样我们就可以使用任意一台设备在浏览器输入网址 通过网络 访问到我们的在线聊天网站

具体步骤:

  1. 租一个云服务器。
  2. 下载安装 Xshell,将其与我们租好的云服务器进行连接。
  3. 搭建运行环境:值得注意的是,云服务器与我们的本地开发设备是两个设备

         如上图所示,我们需要在云服务器上创建与本地环境一个一模一样的数据库(建同样的库与表)

      4. 调整项目代码:对于后端代码,需要调整 yml 文件中的数据库配置代码,需要将 url ,username,password 等属性的值调整成与云服务器中的数据库相对应(可参考 2.1 小节);对于前端代码,需要将 JS 代码中的 WebSocket 构造方法中的参数进行修改,不再只连接本地路径,代码修改如下

let websocket = new websocket("ws//" + location.host + "/webSocketMessage");

      5. 通过 Maven 将项目打包(Jar 包),并将该 Jar 包上传到云服务器

      6. 运行 Jar 包

nohup java -jar (jar 包名字) &

         至此,在线聊天网站已搭建完毕。

;