目录
项目背景
实现⼀个网页版的聊天室程序,类似于网页版微信,可以直接在网页上进行聊天,主要目的是为了巩固自己所学的知识,提升技术水平并积累实践经验。
需求分析
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表里新增一个数据项.
新增的数据项就表示当前的这个会话,同时获取到新会话的自增主键sessionId2.给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响应
上述过程的大致流程图:
接受添加好友请求
点击接受按钮触发以下操作:
- 在 add_friend_request 表中删除这个记录.
- 修改 friend 表,加上两项
- 如果对⽅在线,则通过 websocket 通知对⽅,弹出提⽰并刷新好友列表.
- 删除当前的请求好友项
- 刷新好友列表
约定前后端交互接口
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 响应
拒绝添加好友请求
点击拒绝按钮触发以下操作:
- 在 add_friend_request 表中删除这个记录.
- 删除当前的请求好友项
其它什么也不用做了。
约定前后端交互接口
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,后来才想起是同一个客户端,耗费了很多不必要的时间。
查找好友
添加好友
成功添加好友(点击接受)
发送/接收消息
拒绝添加好友
扩展模块
等等,还有已读,历史消息搜索,消息撤回等操作。