目录
整体演示
登录页面:
注册页面:
注册账户后,跳转到登录页面,输入正确的账号密码后,进入的主页面:
展示完毕,开始写代码。
准备工作
我的开发环境是 IDEA 2022.1.4 社区版,MySQL 5.7 ,Navicat Premium 15,Postman,Xshell,Visual Studio Code
创建 SpringBoot 项目
鼠标右键:
再点那个右上角的 m 刷一下。
配置文件
这里把 application.properties 的名字改成 application.yml,我们用 yml 来配置。
把下面这段代码复制进 application.yml 中,然后再改一改。
spring:
datasource:
url: jdbc:mysql://127.0.0.1:3306/chat_room_review?characterEncoding=utf8&useSSL=false
username: root
password: 1234
driver-class-name: com.mysql.cj.jdbc.Driver
# 设置 Mybatis 的 xml 保存路径
mybatis:
mapper-locations: classpath:mapper/*Mapper.xml
configuration: # 配置打印 MyBatis 执行的 SQL
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true #自动驼峰转换
# 配置打印 MyBatis 执行的 SQL
logging:
file:
name: logs/springboot.log
在把前端的模板放进项目中。
准备工作就做完了,可以开始思考每个功能改怎么实现了。
登录功能
首先来设计数据库,登录的话需要一张用户表来存储用户的账号密码信息。
所以用户表有三个字段,分别是 userId,userName,password。
第二是约定接口,后端需要去查询数据库,看看用户名和密码是否匹配。
所以后端需要参数 userName 和 password。
我们可以将整个用户对象返回给前端,这样方便前端展示登录用户的信息。
约定完成,我们就可以来写代码了,先写后端代码。
后端
先在 java 目录下创建个文件 db.sql,我们可以在 dq.sql 中存放我们写的数据库代码。
这样可以方便我们后面将项目部署到服务器,就不用重写数据库代码,而是直接拷贝 db.sql 的代码到数据库中。
首先创建个数据库,用来存放改项目的数据。
然后就是根据上面的分析,创建 user 表,为了方便,我们可以先插入几条数据。
写完之后将代码复制一份,打开 Navicat ,然后将代码粘贴到里面,点运行。
刷新下 mysql ,看看数据库是否存在。
都没什么问题,就可以去写代码了。
首先创建四个包,分别是 controller ,service,mapper,model。
然后在 model 里建个 User 类,该类的属性和 user 表里面的字段一一对应。
加个 @Data ,和构造方法,方便我们开发
然后就可以去写操作数据库的代码了。
先在 mapper 里创建个 UserMapper 接口,用来操作有关 user 表的增删查改等。
加上 @Mapper ,让 Spring 管理 UserMapper
因为是校验账户密码是否正确,所以我们可以写一个根据用户名查找 user 表的方法。
然后我们可以把 service 也一起写了,直接复制方法。
然后在 service 里创建个 UserService 类,来调用 mapper。
加个 @Service 注解方便 Spring 进行管理。
通过 @Autowired 将 UserMapper 注入。
然后写个方法调用 mapper 里的方法即可。
然后再来写 controller,在 controller 里新建个 UserController 类。
加上 @RestController 管理,@RequestMapping 映射路径,再将 UserService 注入。
然后写 login 方法。
先进行参数校验,看看 userName 和 password 是否为空,如果为空,就返回空。
如果都不为空,则查询数据库,根据查询结果看看密码是否一样。
如果一样,就将 user 存进 Session中,然后返回 user。
如果不一样,就返回空。
因为这个 user_session 可能会在多个地方用到,所以我直接打算创建一个 constant 包。
并在包底下创建了个 Constant 类,用来存放常量信息。
然后回 UserController 导一下类即可。
后端写完之后,我们来测试看看有没有问题,启动服务。
好家伙,启动失败了,看看说了啥。
说映射价值不允许啥的,一看,yaml报错,那就是配置文件出错了。
回到 yml 文件一看 ,原来是之前那个自带的 properties 配置忘记删了。
把那行配置删掉后,再次启动服务。
启动成功了。
打开 postman,输入 url 后,点击右边那个蓝色的 send。
测试结果:
没啥问题,那就来写前端代码。
前端
用 vs code 打开 login.html,然后监视登录按钮的点击情况。
如果登录按钮被点击了,那么就发送 ajax 请求,获取到两个 input 框里面的内容作为参数。
如果参数为空,那当然不能登录,那就弹框提示用户,然后 return。
如果参数不为空,根据上面约定的信息发送 ajax 请求,然后看看后端返回是否为 null。
如果为 null,说明登录失败,弹个框告诉用户。
如果返回 user 对象,说明登录成功,那就直接跳转到聊天主页面,也就是 client.html 。
写出来就是这个样子的:
登录功能写完了,我们先来测试下看下效果。
启动服务,输入 url。
输入正确账号密码:
输入错误密码:
没输入密码:
看起来没什么问题,那接下来就可以写注册功能了。
注册功能
还是先约定接口,因为是直接注册嘛,直接在数据库里插入数据就行。
但是因为之前创建数据库的时候就已经约定 用户名之间不能相同了。
所以代码可能会抛异常,要注意异常的处理。
先写后端代码。
后端
先写 UserMapper 的代码,直接写插入语句即可。
因为返回的是 user 对象,所以还需要获取到 userId。
然后再写 UserService,直接调用下 mapper 里的方法就行。
然后再去写 controller 的方法。
还是先进行参数校验,为空就返回 null。
刚才也说了,代码可能会抛异常,所以我们可以用 try catch 把代码包住。
调用 service ,校验下结果,然后返回即可。
写完后端,可以来测试下看看功能是否正常,启动服务,打开 postman,写 url。
相同用户名:
后台抛异常,没插入到数据库。
创建新账户:
功能正常,就可以去写前端代码了。
前端
先点开 login.html,监视注册按钮的点击情况,如果注册被点击,那就跳转到 register.html。
在 注册页面完成注册逻辑,逻辑其实跟登录功能差不多。
可以把登录的代码拷贝到注册页面,然后再修改修改。
写完前端后,可以来测试下,启动服务。
点注册可以成功跳转到注册页面:
输入数据库中存在的用户名:
注册账户:
点确定后能跳转到登录页面:
没什么问题,就可以去写下一个功能的代码了。
获取用户信息功能
要获取当前登录用户的名字,可以先约定接口:
后端
我们可以在 session 中获取用户的登录信息,之前登录的时候已经存了 session。
直接在 UserController 类新增一个 getUserInfo 方法,然后从 session 中取 user 信息。
写出来就是这样子的:
然后再去写前端代码。
前端
找到 client.js ,直接定义个函数,然后再调用这个函数。
一进到聊天主页面就触发这个函数,然后发送 ajax 请求。
如果后端返回结果为 null,则说明当前用户尚未登录,那就直接跳转到登录页面。
如果不为空,那就将得到的用户名赋值。
写出来就是这样子的:
写完之后,我们启动服务,来测试下功能。
没登录直接访问聊天主页面:
然后跳转到登录页面,再输入账号密码
点击登录:
可以正确展示,没啥问题就可以去写下一个模块的代码了。
获取消息会话列表功能
从之前的演示上,我们可以看到,消息列表的每一条消息分为两块:
一块是消息会话的名字,一块是消息会话的最后一条消息。
因为前端只能支持私聊,所以消息会话的名字就是好友的名字。
首先来设计数据库,在上面的分析中,出现了四个对象:用户,会话,消息,好友
可以围绕这四个对象来设计数据库。
首先是用户和会话:
一个用户可以有多个会话,一个会话可以被多个用户所拥有。所以用户和会话是多对多的关系。
然后是用户和消息:
一个用户可以发多条消息,一条消息只能被一个用户所发送。所以用户和消息是一对多的关系。
其次是用户和好友:
一个用户可以有多个好友,一个好友可以被多个用户所拥有。所以用户和好友是多对多的关系,但是好友本身也属于用户。
最后是会话和消息:
一个会话可以有多条消息,一条消息只能存在于一个会话中。所以会话和消息是一对多的关系。
多对多的话得再设计一个关系表来表示他们的关系,一对多的话只用多出一个字段来表示从属关系
所以数据库表可以设计为(用户表已经设计过了):
设计好数据库之后,我们可以约定接口:
然后就可以去写后端代码了。
后端
先写数据库代码,点开 db.sql,按照之前的数据库分析来写 SQL 语句。
写出来就是这样子的:
-- 创建好友表
drop table if exists friend;
create table friend (
user_id int,
friend_id 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);
-- 创建会话表
drop table if exists message_session;
create table message_session(
session_id int primary key auto_increment,
last_time datetime DEFAULT now()
);
insert into message_session values(1, '2000-05-01 00:00:00');
insert into message_session values(2, '2000-06-01 00:00:00');
-- 创建会话和用户的关联表
drop table if exists message_session_user;
create table message_session_user(
session_id int ,
user_id int
);
-- 1 号会话里有张三和 mika
insert into message_session_user values(1, 1), (1, 2);
-- 2 号会话里有李四和 mika
insert into message_session_user values(2, 1), (2, 3);
-- 创建消息表
drop table if exists message;
create table message(
message_id int primary key auto_increment,
from_id int not null, -- 消息是哪个用户发的
session_id int not null, -- 消息发给哪个会话
content varchar(5000), -- 消息的内容
post_time datetime default now() -- 消息的发送时间
);
-- mika 和 zhangsan 的对话
insert into message values(1,1,1,"星露谷联机?","2024-03-21 17:00:00");
insert into message values(2,2,1,"来来来!","2024-03-21 17:00:03");
insert into message values(3,1,1,"正好 1.6 更新了!!!","2024-03-21 17:00:07");
insert into message values(4,1,1,"我还没开始玩 1.6 呢","2024-03-21 17:00:12");
insert into message values(5,2,1,"行,那接电话,开麦玩","2024-03-21 17:00:16");
insert into message values(6,1,1,"OK","2024-03-21 17:00:20");
-- mika 和 lisi 的对话
insert into message values(7,1,2,"葬送的芙莉莲完结了,快去看!!!","2024-03-23 18:01:00");
insert into message values(8,3,2,"最近在忙,要学习没空","2024-03-23 18:02:00");
insert into message values(9,1,2,"那就吃饭的时候看呗","2024-03-23 18:02:15");
insert into message values(10,3,2,"忙,没空","2024-03-23 18:03:15");
然后将写好的代码粘贴到 navicat 中运行。
刷新一下表,再点开表看看数据有没有。
数据都在,就可以继续写后端代码了。
再来看看刚刚约定的接口:
要返回会话列表,那就得先有一个会话类。
先在 model 包下面创建一个 MessageSession 类。
因为前端需要消息会话的名字(好友名字) 和 最后一条消息。
所以这个类的属性需要包括 sessionId,friends(好友列表) ,lastMessage。
还需要一个 Friend 类,那也新建个 Friend 类,里面一个包含 friendId,friendName。
有了这两个类,我们就可以很方便的写代码了。
先写 mapper 代码,新建个 MessageSessionMapper 接口。
因为最终要返回的是消息会话列表,所以就需要三个值:
分别是 会话ID,好友列表,最后一条消息。
因为前端什么参数都没传,所以我们得从 session 中取出 user。
user 中有 userId,我们可以先获取 会话ID 列表,然后再根据对应的 会话ID 获取朋友列表,然后再根据 会话ID 获取最后一条消息。
所以我们得写三个 SQL:
第一个:根据 userId,在 message_session_user 查找 sessionId 列表。
第二个:根据 sessionId 和 userId,在 message_session_user 和 user 中查找 Friend 列表。
第三个:根据 sessionId,在 message 中查找最后一条消息。
分析完了,就可以来写 mapper 了,首先在 mapper 包里创建一个 MessageSessionMapper 接口,加上 @Mapper 注解。
然后根据上面的分析来写代码,写几个联合查询就行。
写出来就是这样子的
写完 mapper 之后去写 service。
还是先创建个 MessageSessionService 类,加上注解。
然后注入依赖,再写三个方法,分别调用 mapper 的方法即可。
最后来写 controller,先创建 MessageSessionController 类,加上对应注解,并注入依赖。
然后根据之前的约定,来写方法。
先校验用户是否登录,没登录就直接返回 null。
然后就是调用方法获取 sessionId 列表,然后遍历列表,调用方法构造 MessageSession 对象,最后添加进 会话列表中,然后返回。
写完后端之后,启动服务,来测试一下。
先登录,再进行接口测试。
没啥问题,接下来写前端的代码。
前端
还是找到 client.js ,定义一个方法,然后调用它。
根据约定的接口来发起 ajax 请求,首先进行原列表的清空,防止多次刷新叠加在一起。
然后判断后端返回结果是否有效,无效就直接返回。
如果有效,就构造 html,然后赋值,顺便把 sessionId 也存到 session 中。
然后启动服务,测试看看效果。
先登录:
能正常显示,说明没什么大问题。
获取历史消息功能
还是先约定接口,前端可以把之前存的 sessionId 传给后端,然后后端返回消息列表。
后端
先写 mapper ,新建一个 MessageMapper 接口,加上 @Mapper 注解。
然后写一个根据 sessionId,查询 message 表里的历史消息的方法。
写到这发现还没有 Message 类,那就得去 model 包里新建个 Message 类。
根据前端的展示结果可以知道,类里必须包含 fromId,fromName,sessionId,content 这四个属性,跟 message 表相比,差了个 fromName,所以我们可以照抄 message 表里面的字段,再多添加一个 fromName 属性即可。
然后我们可以继续写 MessageMapper 里的方法了。
因为多了个 fromName 属性,所以得跟 user 表进行联合查询。
但是这里还得注意一个细节,那就是如果历史消息太多,达到 999+。
一口气展示出来的话,就会非常卡,所以这里我们可以展示最近的 100 条消息。
写出来就是这样子的:
写完 mapper 之后,去写 service。
在 service 底下新建一个 MessageService,加上对应的 @Service,再注入 MessageMapper。
然后调用对应的 mapper 方法即可。
写完 service 之后就可以去写 controller 的代码了。
还是先在 controller 底下新建一个 MessageController 类,并加上对应的注解,并注入依赖。
根据上面约定的接口来写方法。
还是先进行参数校验,如果参数不合法就直接返回 null。
然后就是调用 MessageService 获取历史消息,因为我们希望是按时间顺序排列,所以返回前得先逆置一下顺序表。
写完后端后,启动服务来测试一下。
没什么问题,可以来实现前端代码了。
前端
从 clickSession 开始写起。
点击这个会话,就需要将这个会话高亮,然后获取这个会话的历史消息。
就可以分别写两个方法 activeSession 和 getHistoryMessage ,完成对应的功能。
activeSession 比较好实现,就是遍历所有的 li 标签,看看哪一个标签和当前标签一样。
如果一样,就设置被选中状态,如果不一样,就设置为空。
然后来实现 getHistoryMessage 方法。
首先清空右边显示消息的区域,然后还需要重新设置会话名字,直接从左边的会话展示区拿即可。
然后根据之前的约定,发送 ajax 请求,然后进行进行添加消息到页面上即可。
但是这里还要注意,要自动将消息的滚动条拖到最下面才行。
写出来就是这样子。
然后再分别实现 addMessage 和 scrollBottom 方法。
先来实现 addMessage 方法。
就是构造标签,然后判断消息是自己发的还是别人发的,对消息进行调整,最后将消息添加到页面上即可。
然后再来实现 scrollBottom 方法,滚动消息内容的高度与消息展示区域的高度之差即可。
这部分的前端就写完了,可以启动服务测试下看看。
先登录进聊天主页面后,点击会话查看历史消息:
没什么问题就可以进行下个功能的开发了。
获取好友列表功能
还是先约定接口:
约定完了就可以去写后端代码啦。
后端
先写 mapper,创建一个 FriendMapper 接口,加上 @Mapper。
根据 userId,从 friend 表和 user 表中查询对应的好友信息。
写一个联合查询就行,连接条件是 friendId 和 userId。
写出来就是这样子的。
写完 mapper 后,可以来写 service。
在 service 底下创建一个 FriendService,加上注解并注入依赖,然后调用下 mapper 的方法即可。
然后再来写 controller。
在 controller 底下创建一个 FriendController ,并加上注解并注入依赖。
根据之前约定的接口来写方法。
还是先进行登录的校验,如果没登录,那就返回 null。
如果登录了,那就调用 FriendService 的方法,然后返回即可。
后端写完了,就可以启动服务来测试下。
先登录,然后再测试接口。
没啥问题,可以来写前端代码了。
前端
首先得实现标签页的切换。
监视两个按钮的点击情况,如果一个被点击,那么被点的那个就设置成高亮,另一个就取消高亮,并且将对应的列表隐藏,而被点击的那个列表就展示出来。
然后再来写获取好友列表的方法。
直接定义一个方法,发送 ajax 请求,然后清空之前所获取的好友列表(防止叠加),然后构造标签,添加到页面上。
写完前端之后,可以启动服务看看效果。
登录之后,进入主页面,点击好友列表,看看是否高亮。
没啥问题,可以去写下一个功能了。
创建新聊天功能
还是先约定接口:
需要告诉后端,会话里面的用户有谁。
后端
先写 mapper,直接再 MessageSessionMapper 里面写,就是直接创建一个消息会话,然后返回会话ID ,除此之外,还需要在 message_session_user 中插入对应的记录。
所以得写两条 sql 语句。
写完 mapper 之后,就可以去写 service,直接调用 mapper 里面的方法就行。
写完 service 之后就可以写 controller 了,根据之前约定的接口来写方法。
还是先进行参数校验以及登录校验,如果参数不合法或者用户没登录,那就直接返回 null。
如果校验通过,那就调用创建消息会话的方法,获取到 sessionId ,然后调用刚刚写的另一个方法把记录存到消息会话用户表里。因为这两条 sql 必须一起成功(原子性)才成功,所以得加个事务的注解,最后再返回 sessionId 即可。
写完之后,启动服务来测试下。
先登录再来测试接口:
没什么问题,就可以来写前端了。
前端
继续来完成刚刚前端写的 clickFriend 方法。
首先得判断下是否以及存在同样的会话了,如果以及存在对话,那就将对应对话设置为高亮并顶置该对话,然后获取该对话的历史消息。如果不存在,那就得创建一个会话,然后将这个会话顶置并设置高亮,最后跳转到消息会话列表。
然后再来写 createSession 方法。
根据之前约定的接口,调用 ajax 请求就行。
写完前端后,启动服务看看效果。
登录之后:
然后点击 zhangsan:
没什么问题,然后从数据库里面删掉和 wangwu 的会话。
然后刷新下页面。
点击好友列表的 wangwu:
没什么问题,就可以去写下一个功能了。
实时发送消息功能
要实现实时发送消息的话,我们就得用一个新的协议:WebSocket 协议。
WebSocket 是从 HTML5 开始⽀持的⼀种网页端和服务端保持长连接的消息推送机制。
那么什么是消息推送机制呢?
一般主动发起请求的叫做客户端,被动接受请求的叫做服务器。
如果让 HTTP 来实现消息推送,那将会非常麻烦,所以我们可以使用 WebSocket 协议。
要使用 WebSocket 协议,我们就得先了解 WebSocket 协议。
可以在浏览器上搜索 websocket rfc 6455
出来的第一个就是,点进去,发现全是英文,这时候不要慌,往下滑能看到目录。
打开有道在线翻译,先将一段目录复制到在线翻译中。
我们先来看报文格式,返回 rfc ,点 5.2 的链接跳转到基础数据帧协议这里。
可以看到有这样的一张表。
往下滑能看到解释,可以把解释复制到有道翻译里面一个个去看,就能知道:
了解玩数据报后,我们来看看 WebSocket 的握手过程。
最初 WebSocket 也是从 HTTP 升级而来的。
101 表示协议切换,这样就完成了 WebSocket 的握手操作了,后续客户端和服务器就会使用 WebSocket 进行操作了,HTTP 与 WebSocket 的关系可以用下面这个例子来表示:
有一天,你喜欢的人突然问你要你的室友的微信,你兴奋的把室友的微信给了你喜欢的人,那么你喜欢的人就和你的室友聊起来了,就没你什么事了。
而在这之中,你的作用就是:工具人。
HTTP 同理,也是工具人,所以 WebSocket 和 HTTP 是并列关系。
学习完 WebSocket 之后,我们就可以写代码了。
还是先约定前后端交互接口,websocket 是使用对象进行传输消息的。
约定完了,我们就可以来写代码了。
后端
先创建一个 config 包,在里面创建一个 WebSocketHandler 类,用来处理 websocket 发送的请求
加上对应注解,让 Spring 管理这个对象。
让这个类继承 TextWebSocketHandler ,然后重写(快捷键 ctrl + o )里面的四个方法。
然后点 OK。
然后将这个类注册到 Spring 中,并配置对应的路径。
在 config 包下创建一个 WebSocketConfig 类,实现 WebSocketConfigurer 类,然后重写里面的方法。
所以我们得创建一个新的类,用来存放它们的映射关系。
新建一个 component 包,在包底下新建个 OnlineUserManager 类,加上@Component 注解。
我们可以用哈希表来存储映射关系,当用户一上线,那就存映射,下线就删除映射,然后再写个根据 userId 取 session 的方法。
写出来就是这样子的:
// 记录 userId 与 WebSocketSession 的映射
@Slf4j
@Component
public class OnlineUserManager {
private Map<Integer, WebSocketSession> sessions = new HashMap<>();
// 用户上线方法
public void online(Integer userId, WebSocketSession webSocketSession) {
// 参数校验
if (userId == null || userId < 1 || webSocketSession == null) return;
// 先看看用户是否已经在线,如果已经在线,那就啥都不干(防止多开)
WebSocketSession session1 = sessions.get(userId);
if (session1 != null) return;
sessions.put(userId, webSocketSession);
log.info("用户" + userId + "上线");
}
// 用户下线方法
public void offline(Integer userId, WebSocketSession session) {
if (userId == null || userId < 1) {
return;
}
WebSocketSession existSession = sessions.get(userId);
if (session == existSession) {
// 如果这俩 session 是同一个,才真正进行下线操作,否则啥都不干(从多开角度考虑)
sessions.remove(userId);
log.info("[" + userId + "] 用户下线!");
return;
}
}
// 根据 userId 获取 WebSocketSession
public WebSocketSession getWebSocketSessionById(Integer userId) {
if (userId == null || userId < 1) {
return null;
}
return sessions.get(userId);
}
}
同时,我们也需要回到 WebSocketConfig 类,注入 OnlineUserManager 的依赖,然后实现用户上线和下线的逻辑。
还是先校验用户是否登录,从 session 中获取 user 对象。
校验成功,就存储映射。
用户下线也是同理,先判断是否登录,然后再删除映射。
写完准备工作之后,就可以写处理发送消息的业务代码了。
因为前端传过来的是 JSON 格式的数据,所以我们可以注入 ObjectMapper 这个依赖,将传过来的 JSON 数据转化为对象,所以我们得创建两个类,MessageRequest 和 MessageResponse。
对象的属性可以直接照抄前面的约定。
然后就可以开始重写 handleTextMessage 方法了。
还是先判断用户是否登录,如果校验成功,那就将前端传来的数据转化为对象,然后创建一个方法来处理。
首先定义 transferMessage 方法,最后要发送的也是 JSON 数据,所以我们可以先创建一个 MessageResponse 对象,然后设置里面的属性,然后转成 json 再发送即可。
因为是要发送给这个会话里的所有人(消息发送者也得发一份,方便前端展示),
所以我们可以调用之前写过的根据 userId 和 sessionId 获取好友列表的方法,那就需要注入 MessageSessionService 的依赖了。然后调用方法获取好友列表,并将用户信息也加入进好友列表,然后遍历好友列表,然后根据 friendId 获取 session,然后调用 session.send 发送 json 即可,最后还需要将发送的消息记录给保存到数据库中。
所以我们需要在 MessageMapper 中写一个新增消息的方法。
写完 mapper 之后,去写 service,service直接调用一下刚刚写完的方法即可。
然后将 MessageService 的依赖注入,调用刚刚写好的方法。
private void transferMessage(User user, MessageRequest messageRequest) throws IOException {
// 1. 先构造一个 MessageResponse 对象
MessageResponse messageResponse = new MessageResponse(user.getUserId(), user.getUserName(), messageRequest.getSessionId(), messageRequest.getContent());
String respJson = objectMapper.writeValueAsString(messageResponse);
// 2. 根据 sessionId,获取好友列表
List<Friend> friends = messageSessionService.getFriendsBySessionId(messageResponse.getSessionId(), user.getUserId());
// 同时给自己也要发一份消息,方便前端展示
friends.add(new Friend(user.getUserName(), user.getUserId()));
// 3. 根据 userId 获取 WebSocketSession 对象,进行消息转发
for (Friend friend : friends) {
WebSocketSession session = onlineUserManager.getWebSocketSessionById(friend.getFriendId());
if (session == null) {
// 用户不在线
continue;
}
session.sendMessage(new TextMessage(respJson));
}
// 4. 保存消息记录
Integer result = messageService.addMessage(new Message(user.getUserId(), user.getUserName(), messageResponse.getSessionId(), messageResponse.getContent()));
if (result == null || result < 1) {
log.error("增加消息到数据库失败");
}
}
这样一来,后端就写完了,然后就可以去写前端代码了。
前端
可以根据上面的提示来写前端代码。
可以监视发送按钮按钮的点击情况,判断消息内容是否为空,以及是否选中某个对话,然后获取 sessionId 以及消息内容,构造出 json 对象,然后使用 send 发送请求。
然后后端返回响应,我们就需要使用 websocket,可以根据下面的提示来写前端代码。
首先创建一个 WebSocket 的对象,被初始化 url 。
然后设置回调函数,在 onmessage 的方法里写接收消息的逻辑。
先进行 type 判断,如果 type 为 message,则进行消息的处理。
然后再来写 handleMessage 函数。
把收到的消息展示在页面上。
function handleMessage(resp) {
// 把客户端收到的消息, 给展示出来.
// 展示到对应的会话预览区域, 以及右侧消息列表中.
// 1. 根据响应中的 sessionId 获取到当前会话对应的 li 标签.
// 如果 li 标签不存在, 则创建一个新的
let curSessionLi = findSessionLi(resp.sessionId);
if (curSessionLi == null) {
// 就需要创建出一个新的 li 标签, 表示新会话.
curSessionLi = document.createElement('li');
curSessionLi.setAttribute('message-session-id', resp.sessionId);
// 此处 p 标签内部应该放消息的预览内容. 一会后面统一完成, 这里先置空
curSessionLi.innerHTML = '<h3>' + resp.fromName + '</h3>'
+ '<p></p>';
// 给这个 li 标签也加上点击事件的处理
curSessionLi.onclick = function () {
clickSession(curSessionLi);
}
}
// 2. 把新的消息, 显示到会话的预览区域 (li 标签里的 p 标签中)
// 如果消息太长, 就需要进行截断.
let p = curSessionLi.querySelector('p');
p.innerHTML = resp.content;
if (p.innerHTML.length > 10) {
p.innerHTML = p.innerHTML.substring(0, 10) + '...';
}
// 3. 把收到消息的会话, 给放到会话列表最上面.
let sessionListUL = document.querySelector('#session-list');
sessionListUL.insertBefore(curSessionLi, sessionListUL.children[0]);
// 4. 如果当前收到消息的会话处于被选中状态, 则把当前的消息给放到右侧消息列表中.
// 新增消息的同时, 注意调整滚动条的位置, 保证新消息虽然在底部, 但是能够被用户直接看到.
if (curSessionLi.className == 'selected') {
// 把消息列表添加一个新消息.
let messageShowDiv = document.querySelector('.right .message-show');
addMessage(messageShowDiv, resp);
scrollBottom(messageShowDiv);
}
// 其他操作, 还可以在会话窗口上给个提示 (红色的数字, 有几条消息未读), 还可以播放个提示音.
// 这些操作都是纯前端的. 实现也不难, 不是咱们的重点工作. 暂时不做了.
}
function findSessionLi(targetSessionId) {
// 获取到所有的会话列表中的 li 标签
let sessionLis = document.querySelectorAll('#session-list li');
for (let li of sessionLis) {
let sessionId = li.getAttribute('message-session-id');
if (sessionId == targetSessionId) {
return li;
}
}
// 啥时候会触发这个操作, 就比如如果当前新的用户直接给当前用户发送消息, 此时没存在现成的 li 标签
return null;
}
写完前端后可以启动服务测试看看效果。
可以实时传送消息,再来看看离线的结果。
wangwu 在 mika 离线的时候给 mika 发消息。
mika 上线也能收到消息。
没什么问题,接下来就是可以实现最后一个添加好友的大模块了。
查询指定用户功能
大概就是图上所展示的样子。
还是先约定接口:
约定完毕,就可以去写后端代码了。
后端
先写 mapper,在 UserMapper 底下新增一个方法。
根据 userName,模糊查询 user 表里的用户即可。
写完 UserMapper 后,就去写 service,直接调用刚刚写的方法就行。
写完 service,就可以去写 controller 了。
先进行参数校验,然后调用 service,最后返回结果就行。
写完后端可以启动服务测试下。
没啥问题,可以去写前端了。
前端
写一个方法,监视搜索按钮的点击情况,获取到搜索框的输入内容,进行参数校验,然后发送 ajax 请求,再将返回的响应展示到页面上,但是这里要注意一点,用户搜索结果不能展示用户自己,毕竟添加好友哪有自己加自己的。
function searchFriends() {
let sendButton = document.querySelector('.search button');
sendButton.onclick = function () {
var targetName = $(".search #userNmae").val();
if (targetName == null || targetName == '') {
console.log("targetName == null");
return;
}
console.log("targetName: " + targetName);
$.ajax({
type: "get",
url: "/user/getUsersByName",
data: {
userName: targetName
},
success: function (result) {
console.log(result);
if (result == null) {
alert("没有你要查找的这号人物");
return;
}
// 先清空右侧列表中的已有内容
var titleDiv = document.querySelector('.right .title');
titleDiv.innerHTML = '';
var messageShowDiv = document.querySelector('.right .message-show');
messageShowDiv.innerHTML = '';
var selfUsername = document.querySelector('.left .user').innerHTML;
console.log("selfUsername: " + selfUsername);
titleDiv.innerHTML = "好友搜索结果";
var finalHtml = '';
for (var user of result) {
if (user.userName != selfUsername) {
// 排除自己
finalHtml += '<div class="message">';
finalHtml += '<tr>';
finalHtml += '<span>' + user.userName + '</span>';
finalHtml += '<th> <input id=reason' + user.userId + ' type="text" placeholder="添加理由"></th>';
finalHtml += '<td> <button class="btn btn-primary" onclick="addFriend(\'' + user.userId + '\')"> 添加好友 </button> </td>';
finalHtml += "</tr>"
finalHtml += "</div>"
}
}
messageShowDiv.innerHTML = finalHtml;
console.log(messageShowDiv);
}
});
}
}
searchFriends();
前端也写完了,启动服务,测试一下看看效果。
没啥问题,就可以去写下一个功能了。
实时发送好友申请功能
首先设计数据库,得设计一个表,来记录好友申请。
要实时的话,就得用 WebSocket。
约定前后端交互接口,使用对象来传输数据。
约定好对象之后,就可以去写后端代码了。
后端
首先写创建数据库,按照之前设计的来写 sql。
写出来就是这样的,然后在把刚刚写好的代码粘贴到数据库里。
点击运行,然后刷新一下表。
没什么问题就可以继续写代码了
还是先按照约定的接口来新建请求和响应的对象。
我们可以在 model 包底下新建两个类:AddFriendRequest 和 AddFriendResponse。
并加上对应的注解,然后按照刚刚约定来写属性。
写完之后就可以去 WebSocketHandler 里补充 handleTextMessage 的逻辑的。
但是在这里面,还需要注意一个特殊的小问题。
所以我们得在 MessageRequest 里补充新的属性。
然后我们就可以继续写代码了。
我们根据 type 是否为 ‘addFriendRequire’ 来判断是否是发送好友申请的请求,如果是,则将传过来的 json 转化成 AddFriendRequest 对象,新开一个方法来处理。
创建 sendAddFriendRequire ,在这个方法里写要实时发送好友申请的功能。
还是先进行参数的校验,参数校验完毕后,要根据 friendId 获取到对应的 session,然后判断 session 是否为空,为空就说明用户不在线,就直接将好友申请保存在数据库中。如果 session 不为空,那么就构造响应对象,将其转化为 json 格式发送,然后再将好友申请保存到数据库中。
所以根据上面的思路,我们还需要新建一个对象,对象的属性对应数据库中的 add_friend 字段。
在 model 包底下新建个 AddFriend 类,属性照着数据库抄就行。
然后按照上面的思路来写方法,还要注入 FriendService 的依赖。
写完之后,我们去 FriendMapper 新增对应的方法,写一个新增记录的 sql 就行。
写出来就是这样的:
然后写 service,在 FriendService 调用一下刚刚写的方法就行。
写到这里我还发现了一个小小的问题,如果用户之前以及发送了好友申请,现在又重新发送一遍,或者用户和目标好友已经是好友关系了,那还发送好友申请,显然这是不科学的,所以还得新增两个方法:1. 判断用户与目标好友是否是好友关系 2. 查询数据库中是否存在相同的好友申请。
回到 FriendMapper,写根据 userId 和 friendId ,查询 friend 表记录 和 根据 userId 和 friendId,查询 add_friend 表。
然后写 service,调用一下方法即可。
然后返回 sendAddFriendRequire 方法,在参数校验完毕后调用这两个方法。
private void sendAddFriendRequire(User user, AddFriendRequest req) throws IOException {
if (user == null || req == null) return;
// 先看看数据库中是否已经是好友关系了,如果是,那就不用添加了
Friend friendShip = friendService.isFriendShip(user.getUserId(), req.getFriendId());
if (friendShip != null && friendShip.getFriendId() > 0) {
return;
}
// 再看看好友申请是否已经在数据库中存在
AddFriend sameAddFriend = friendService.FindSameAddFriendRequired(user.getUserId(), req.getFriendId());
if (sameAddFriend != null) return;
// 1. 先构造一个响应
AddFriendResponse resp = new AddFriendResponse(req.getAddReason(), req.getFriendId(), user.getUserName(), user.getUserId());
// 2. 根据 friendId 获取 session
WebSocketSession session = onlineUserManager.getWebSocketSessionById(req.getFriendId());
// 构建好友申请对象
AddFriend addFriend = new AddFriend();
addFriend.setFromId(user.getUserId());
addFriend.setTargetId(req.getFriendId());
addFriend.setAddReason(resp.getAddReason());
if (session == null) {
// 用户不在线,则不发送,将好友申请保存到数据库中
friendService.addFriendRequired(addFriend);
return;
}
// 3. 将响应转化成 json 格式,然后发送
String respJson = objectMapper.writeValueAsString(resp);
session.sendMessage(new TextMessage(respJson));
// 4. 将好友申请保存到数据库中
friendService.addFriendRequired(addFriend);
}
写完后端来写前端代码。
前端
直接写 addFriend 函数,根据上面的约定来发送请求。
先获取参数并进行参数判定,然后构造 json 对象,然后发送请求。
然后再来写接收响应的情况。
找到 websocket.onmessage 的回调函数,然后判断响应数据的 type 是否为 ‘addFriendRequire’,如果是,则新增一个方法来处理好友请求的实时展示。
写出来就是这样的:
function handleAddFriendRequire(addRequire) {
console.log("收到申请好友请求");
console.log(JSON.stringify(addRequire));
// 1. 清空之前的会话列表
let sessionListUL = document.querySelector('#session-list');
if (addRequire == null) return;
// 2. 实时发送一条好友申请 针对结果来构造页面
// 针对 addReason 的长度进行截断处理
if (addRequire.addReason != null && addRequire.addReason.length > 10) {
addRequire.addReason = addRequire.addReason.substring(0, 10) + '...';
}
let li = document.createElement('li');
// 把会话 id 保存到 li 标签的自定义属性中.
li.innerHTML = '<h3>' + addRequire.fromName + '</h3>'
+ '<p>' + addRequire.addReason + '</p>'
+ '<td> <button class="btn btn-primary" onclick="acceptAddFriendRequire(\'' + addRequire.fromId + '\')"> 接受 </button> </td>'
+ '<td> <button class="btn btn-primary" onclick="rejectAddFriendRequire(\'' + addRequire.fromId + '\')"> 拒绝 </button> </td>';
sessionListUL.appendChild(li);
console.log(sessionListUL);
}
前端写完后启动服务测试一下看看效果。
点击添加好友之后确实能实时收到好友申请。
又试了另外一个。
没啥问题,可以去写下一个功能啦。
获取好友申请功能
还是先约定接口:
约定完了就可以去写后端代码啦。
后端
因为前端需要将好友申请展示到页面上,所以我们需要申请者名字以及申请原因。
先写 mapper,在 FriendMapper 底下写一个根据 userId ,查询 add_friend 和 user 的联合查询即可。
因为需要申请者名字,所以得在 AddFriend 类新增一个 fromName 属性。
然后再来写联合查询,
@Select("select af.*, u.user_name as fromName from add_friend af, user u where af.target_id = #{targetId} and u.user_id = af.from_id")
List<AddFriend> getAddRequire(Integer targetId);
然后再写 service,调用一下刚刚写的方法即可。
然后再来写 controller,直接按照刚刚的约定写就行。
还是先进行登录校验,校验通过再来调用方法,最后返回即可。
后端写完了启动服务来测试一下。
先登录,然后再测试接口。
没啥问题,可以去写前端代码了。
前端
因为这个方法是一进入主页面就调用的,所以我们可以把这个方法写的靠前面一点。
按照前面的约定来发送 ajax 请求,然后校验返回的响应参数,最后将参数展示到列表上即可。
// 通过这个函数来达成用户一上线就能看到好友申请的效果(离线用户)
function getAddFriendRequire() {
console.log("[getAddFriendRequire]");
$.ajax({
type: "get",
url: "friend/getAddRequire",
success: function (result) {
if (result != null) {
// TODO 将获取的好友申请展示到页面上
let sessionListUL = document.querySelector('#session-list');
for (let addRequire of result) {
// 针对 addReason 的长度进行截断处理
if (addRequire.addReason.length > 10) {
addRequire.addReason = addRequire.addReason.substring(0, 10) + '...';
}
let li = document.createElement('li');
li.style = 'height: 100px';
// 把会话 id 保存到 li 标签的自定义属性中.
li.innerHTML = '<h3>' + addRequire.fromName + '</h3>'
+ '<p>' + addRequire.addReason + '</p>'
+ '<td> <button class="btn btn-primary" onclick="acceptAddFriendRequire(\'' + addRequire.fromId + '\')"> 接受 </button> </td>'
+ '<td> <button class="btn btn-primary" onclick="rejectAddFriendRequire(\'' + addRequire.fromId + '\')"> 拒绝 </button> </td>';
sessionListUL.appendChild(li);
}
console.log(sessionListUL);
}
}
});
}
getAddFriendRequire();
写完前端之后,就可以启动服务测试下看看啦。
刚刚测试的时候发现了一些问题,就是明明我这个好友申请已经创建好了,但是在页面展示的时候却没展示到页面上,后端的数据也是正常返回的,但是就是在当用户有对话列表的时候,好友申请展示不出来,但是只有好友申请的列表却能正常展示(也就是上面那种情况是正常的)。所以我怀疑是会话列表展示的前端除了问题。
所以我直接把这段代码复制,然后注释掉这段代码,把代码粘贴到我的获取好友列表里。
这样,页面就能正常展示了
没啥问题,就可以去写下一个功能啦。
实时接受好友申请功能
因为是要实现实时接受好友申请功能,所以还是使用 websocket 来完成。
使用 json 来传输数据。
先约定接口:
约定完毕就可以去写后端代码啦。
后端
还是回到 WebSocketHandler,补充 handleTextMessage 的逻辑,首先在 AddFriendRequest 补充新的属性,
然后判断 type 如果是 acceptAddFriendRequire ,就写一个新的方法来处理实时接受好友申请。
然后实现 acceptAddFriendRequire 方法即可。
还是先进行参数校验,然后构建响应,这里我就直接用之前写的 AddFriendResponse,给它补上新的属性。
构造完响应之后,就可以来将好友信息存进数据库,并将之前的好友申请记录删掉了。这个操作是原子性的,所以得加上事务注解。
然后根据 fromId 来获取 session,如果 session 为空,说明申请发送者不在线,直接返回,如果不为空,那么就将刚刚的 java 对象转换成 json 字符串来发送。
然后去 FriendMapper 实现操作即可。
然后去实现 service,调用刚刚实现的方法即可。
写完后端后,就可以去写前端了。
前端
直接实现 acceptAddFriendRequire 方法即可,根据约定构造对象,然后使用 websocket 发送即可。
// 接受好友申请
function acceptAddFriendRequire(fromId) {
// 记录 friendName
var selfUsername = document.querySelector('.left .user').innerHTML;
console.log("[acceptAddFriendRequire] fromId: " + fromId);
console.log("[acceptAddFriendRequire] websocket...");
alert("添加成功");
// 实时告诉对方申请好友成功
let req = {
type: 'acceptAddFriendRequire',
fromId: fromId
};
req = JSON.stringify(req);
console.log("接受好友申请");
console.log("[websocket] send: " + req);
// d) 通过 websocket 发送消息
websocket.send(req);
location.href = "client.html";
}
然后去实现 websocket.onmessage 的回调函数。
判断 type 是否为 acceptAddFriendRequire,如果是,就写个方法提示 xxx 接受了你的好友申请!即可。
写完前端之后可以启动服务测试下看看效果。
没啥问题,可以去写下一个功能啦。
实时拒绝好友申请功能
这个功能的逻辑和刚刚写的实时接受好友申请逻辑差不多,我们改一下 type ,直接拿过来用
还是先约定接口:
因为是拒绝好友申请,那直接把好友申请记录从数据库里删除即可。
后端
直接抄一下刚刚的代码,然后再改一改。
rejectAddFriendRequire 这个方法也是直接抄刚刚写过的,再改一改即可,把添加好友关系的删了就行。
写完后端,就可以去写前端了。
前端
可以根据约定来发送请求,也可以直接抄刚刚前端写的代码,然后把 type 改一下就行。
写完前端后可以启动服务测试下看看。
好耶!没问题,终于把这个项目给写完了,然后就可以去部署项目啦!!!
Linux 部署项目
配置文件的修改
觉得要手动改配置文件很麻烦,所以就利用 maven 来帮我们打包时打正确的配置文件。
首先将 application.properties 复制两份出来,然后改名字。
改成这种形式的 application-xxxx.yml
将 dev 后缀的配置文件作为开发环境的配置文件,而 prod 为部署时发布的配置文件。
然后就可以将 application.yml 里面的内容全删掉了。
然后点开 pom.xml 文件。
将下面这段文件配置粘贴到 pom 文件中。
<profiles>
<profile>
<id>dev</id>
<properties>
<!--自定义配置-->
<profile.name>dev</profile.name>
</properties>
</profile>
<profile>
<id>prod</id>
<properties>
<!--自定义配置-->
<profile.name>prod</profile.name>
</properties>
</profile>
</profiles>
然后刷新一下,就是点右上角的 m。
此时再点开 maven,就会发现有个新的选项:
但是还有一步,就是原来的配置文件也要改一下。
在原来的配置文件加上:
spring:
profiles:
active: @profile.name@
然后把你的 prod 改一改,包括你的数据库密码等等。
然后就可以打包了。
把包的路径复制粘贴到文件路径中。
这个就是你刚才打的包,然后点开 Xshell,连上你的云服务器。
数据库的创建
打开你的 xshell ,连上云服务器后,输入 :
mysql -uroot -p
输入正确的密码后,进入你的数据库。
把你的 idea 上的 那个 sql 文件的内容全部复制,然后粘贴到云服务器的数据库里。
粘完之后看看对应的表,数据是否完成,有无错漏。
没什么问题就可以输入 exit; 退出数据库。
然后找个地方,把你刚刚打好的包拖动到 Xshell 中。
直接把包拖过去。
存在相同名字的 jar 包,说明已经传输完毕。
接下来就用命令启动服务即可。
输入 :
nohup 命令 &
输入这个:
会弹出这个。
然后新开一个窗口,输入 :
ps -aux | grep java
看到上面这张图,也就是存在你的包的名字,那就是部署成功了。
接下来就是查看你云服务器的 ip。
然后用你云服务器的 ip 去访问即可。
最后再测测功能是否正常即可。
部分源码展示
controller
UserController
@Slf4j
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;
@RequestMapping("/login")
public User login(String userName, String password, HttpServletRequest request) {
if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {
log.error("[login] 用户名或密码为空");
return null;
}
// 用户登录成功,就存 session
User user = userService.selectUserByName(userName);
if (user == null || user.getUserId() < 1) {
log.error("用户不存在");
return null;
}
if (password.equals(user.getPassword())) {
user.setPassword("");
request.getSession(true).setAttribute(Constant.USER_SESSION, user);
return user;
}
return null;
}
@RequestMapping("/register")
public User register(String userName, String password) {
if (!StringUtils.hasLength(userName) || !StringUtils.hasLength(password)) {
log.error("[registry] 用户名或密码为空");
return null;
}
User user = null;
try {
user = new User(userName, password);
Integer result = userService.insertUser(user);
log.info("register, result: " + result);
user.setPassword("");
if (result > 0) return user;
} catch (Exception e) {
log.error("e:{}", e);
return null;
}
return null;
}
@RequestMapping("/getUserInfo")
public User getUserInfo(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
User user = (User) session.getAttribute(Constant.USER_SESSION);
if (user == null) return null;
user.setPassword("");
return user;
}
@RequestMapping("/getUsersByName")
public List<User> getUsersByName(String userName) {
if (!StringUtils.hasLength(userName)) {
return null;
}
List<User> users = userService.getUsersByName(userName);
if (users == null || users.size() == 0) return null;
return users;
}
}
FriendController
@Slf4j
@RestController
@RequestMapping("/friend")
public class FriendController {
@Autowired
private FriendService friendService;
@RequestMapping("/getFriendList")
public List<Friend> getFriendList(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
User user = (User) session.getAttribute(Constant.USER_SESSION);
if (user == null) return null;
return friendService.getFriendsByUserId(user.getUserId());
}
@RequestMapping("/getAddRequire")
public List<AddFriend> getAddRequire(HttpServletRequest request) {
// 首先判断是否登录
HttpSession session = request.getSession(false);
if (session == null) {
log.error("用户未登录!");
return null;
}
User user = (User) session.getAttribute(Constant.USER_SESSION);
if (user == null || user.getUserId() < 1) {
log.error("用户未登录!");
return null;
}
// 根据 target_id 查找数据库
List<AddFriend> addRequires = friendService.getAddRequire(user.getUserId());
return addRequires;
}
}
MessageController
@RestController
@RequestMapping("/message")
public class MessageController {
@Autowired
private MessageService messageService;
@RequestMapping("/getMessageList")
public List<Message> getMessageList(Integer sessionId) {
if (sessionId == null || sessionId < 1) {
return null;
}
List<Message> messages = messageService.getHistoryBySessionId(sessionId);
Collections.reverse(messages);
return messages;
}
}
MessageSessionController
@RestController
@RequestMapping("/messageSession")
public class MessageSessionController {
@Autowired
private MessageSessionService messageSessionService;
@RequestMapping("/getSessionList")
public List<MessageSession> getSessionList(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
User user = (User) session.getAttribute(Constant.USER_SESSION);
if (user == null) return null;
List<Integer> sessionIds = messageSessionService.getSessionByUserId(user.getUserId());
List<MessageSession> ret = new ArrayList<>();
for (Integer sessionId : sessionIds) {
MessageSession messageSession = new MessageSession();
messageSession.setSessionId(sessionId);
List<Friend> friends = messageSessionService.getFriendsBySessionId(sessionId, user.getUserId());
messageSession.setFriends(friends);
String lastMessage = messageSessionService.getLastMessageBySessionId(sessionId);
// 这里还得判断,如果没有最后一条消息,说明这个会话是新创建的
if (!StringUtils.hasLength(lastMessage)) {
messageSession.setLastMessage("");
} else {
messageSession.setLastMessage(lastMessage);
}
ret.add(messageSession);
}
return ret;
}
@Transactional
@RequestMapping("/createSession")
public Integer addMessageSession(Integer toUserId, @SessionAttribute(Constant.USER_SESSION) User user) {
if (toUserId == null || toUserId < 1 || user == null || user.getUserId() < 1) return null;
// 新增会话
MessageSession messageSession = new MessageSession();
Integer ret1 = messageSessionService.addMessageSession(messageSession);
if (ret1 == null || ret1 < 1 || messageSession.getSessionId() == null) return null;
// 插入会话信息
Integer ret2 = messageSessionService.addMessageSessionUser(messageSession.getSessionId(), toUserId);
Integer ret3 = messageSessionService.addMessageSessionUser(messageSession.getSessionId(), user.getUserId());
if (ret2 + ret3 < 2) return null;
return messageSession.getSessionId();
}
}
service
UserService
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
public User selectUserByName(String userName) {
return userMapper.selectUserByName(userName);
}
public Integer insertUser(User user) {
return userMapper.insertUser(user);
}
public List<User> getUsersByName(String userName) {
return userMapper.getUsersByName(userName);
}
}
FriendService
@Service
public class FriendService {
@Autowired
private FriendMapper friendMapper;
public List<Friend> getFriendsByUserId(Integer userId) {
return friendMapper.getFriendsByUserId(userId);
}
public Integer addFriendRequired(AddFriend addFriend) {
return friendMapper.addFriendRequired(addFriend);
}
public Friend isFriendShip(Integer userId, Integer friendId) {
return friendMapper.isFriendShip(userId, friendId);
}
public AddFriend FindSameAddFriendRequired(Integer fromId, Integer targetId) {
return friendMapper.FindSameAddFriendRequired(fromId, targetId);
}
public List<AddFriend> getAddRequire(Integer targetId) {
return friendMapper.getAddRequire(targetId);
}
public Integer insertFriend(Integer userId, Integer friendId) {
return friendMapper.insertFriend(userId, friendId);
}
public Integer deleteAddFriendRequire(Integer fromId, Integer targetId) {
return friendMapper.deleteAddFriendRequire(fromId, targetId);
}
}
MessageService
@Service
public class MessageService {
@Autowired
private MessageMapper messageMapper;
public List<Message> getHistoryBySessionId(Integer sessionId) {
return messageMapper.getHistoryBySessionId(sessionId);
}
public Integer addMessage(Message message) {
return messageMapper.addMessage(message);
}
}
MessageSessionService
@Service
public class MessageSessionService {
@Autowired
private MessageSessionMapper messageSessionMapper;
public List<Integer> getSessionByUserId(Integer userId) {
return messageSessionMapper.getSessionByUserId(userId);
}
public List<Friend> getFriendsBySessionId(Integer sessionId, Integer userId) {
return messageSessionMapper.getFriendsBySessionId(sessionId, userId);
}
public String getLastMessageBySessionId(Integer sessionId) {
return messageSessionMapper.getLastMessageBySessionId(sessionId);
}
public Integer addMessageSession(MessageSession messageSession) {
return messageSessionMapper.addMessageSession(messageSession);
}
public Integer addMessageSessionUser(Integer sessionId, Integer userId) {
return messageSessionMapper.addMessageSessionUser(sessionId, userId);
}
}
mapper
UserMapper
@Mapper
public interface UserMapper {
// 根据 userName,查询数据库信息
@Select("select * from user where user_name = #{userName}")
User selectUserByName(String userName);
// 新增 user 用户
@Options(useGeneratedKeys = true, keyProperty = "userId")
@Insert("insert into user values(null,#{userName},#{password})")
Integer insertUser(User user);
@Select("select * from user where user_name like concat('%',#{userName},'%')")
List<User> getUsersByName(String userName);
}
FriendMapper
@Mapper
public interface FriendMapper {
@Select("select f.friend_id, u.user_name as friendName from friend f, user u" +
" where f.user_id = #{userId} and u.user_id = f.friend_id")
List<Friend> getFriendsByUserId(Integer userId);
@Insert("insert into add_friend(from_id,target_id,add_reason) values(#{fromId},#{targetId},#{addReason})")
Integer addFriendRequired(AddFriend addFriend);
@Select("select friend_id from friend where user_id = #{userId} and friend_id = #{friendId}")
Friend isFriendShip(Integer userId, Integer friendId);
@Select("select * from add_friend where from_id = #{fromId} and target_id = #{targetId}")
AddFriend FindSameAddFriendRequired(Integer fromId, Integer targetId);
@Select("select af.*, u.user_name as fromName from add_friend af, user u where af.target_id = #{targetId} and u.user_id = af.from_id")
List<AddFriend> getAddRequire(Integer targetId);
@Insert("insert into friend values(#{userId}, #{friendId})")
Integer insertFriend(Integer userId, Integer friendId);
@Delete("delete from add_friend where from_id = #{fromId} and target_id = #{targetId}")
Integer deleteAddFriendRequire(Integer fromId, Integer targetId);
}
MessageMapper
@Mapper
public interface MessageMapper {
// 根据 sessionId,获取历史消息
@Select("select m.*, u.user_name as fromName from message m, user u" +
" where m.session_id = #{sessionId} and u.user_id = m.from_id order by m.post_time desc limit 100")
List<Message> getHistoryBySessionId(Integer sessionId);
@Insert("insert into message(from_id,session_id,content) values(#{fromId},#{sessionId},#{content})")
Integer addMessage(Message message);
}
MessageSessionMapper
@Mapper
public interface MessageSessionMapper {
// 根据 userId,查找用户会话列表
@Select("select msu.session_id from message_session_user msu, message_session ms" +
" where msu.user_id = #{userId} and ms.session_id = msu.session_id order by ms.last_time desc")
List<Integer> getSessionByUserId(Integer userId);
// 根据 sessionId,查找朋友列表
@Select("select" +
" msu.user_id as friendId," +
" u.user_name as friendName" +
" from message_session_user msu, user u" +
" where msu.session_id = #{sessionId}" +
" and msu.user_id != #{userId}" +
" and u.user_id = msu.user_id")
List<Friend> getFriendsBySessionId(Integer sessionId, Integer userId);
// 根据 sessionId,查找最后一条消息
@Select("select content from message where session_id = #{sessionId} order by post_time desc limit 1")
String getLastMessageBySessionId(Integer sessionId);
@Options(useGeneratedKeys = true, keyProperty = "sessionId")
@Insert("insert into message_session values(null, now())")
Integer addMessageSession(MessageSession messageSession);
@Insert("insert into message_session_user(session_id, user_id) values(#{sessionId}, #{userId})")
Integer addMessageSessionUser(Integer sessionId, Integer userId);
}
config
WebSocketHandler
@Slf4j
@Component
public class WebSocketHandler extends TextWebSocketHandler {
@Autowired
private OnlineUserManager onlineUserManager;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private MessageSessionService messageSessionService;
@Autowired
private MessageService messageService;
@Autowired
private FriendService friendService;
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// WebSocketSession 是在 WebSocket 连接中对应的会话
// 这个方法会在 WebSocket 成功建立连接之后,自动调用
// 判断用户是否登录
Map<String, Object> attributes = session.getAttributes();
User user = (User) attributes.get(Constant.USER_SESSION);
if (user == null) return;
// 用户上线,存映射
onlineUserManager.online(user.getUserId(), session);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
log.info("WebSocketHandler 收到消息!" + message.toString());
// 判断用户是否登录
Map<String, Object> attributes = session.getAttributes();
User user = (User) attributes.get(Constant.USER_SESSION);
if (user == null) return;
// TextMessage 就是 收到的消息的值
// 这个方法会在 WebSocket 接收到消息时,自动调用
String payload = message.getPayload();
// 将获取的 payload 转化成 Java 对象
MessageRequest messageRequest = objectMapper.readValue(payload, MessageRequest.class);
if (Constant.MESSAGE.equals(messageRequest.getType())) {
// 处理消息的转发以及保存
transferMessage(user, messageRequest);
} else if (Constant.ADD_FRIEND_REQUIRE.equals(messageRequest.getType())) {
AddFriendRequest req = objectMapper.readValue(message.getPayload(), AddFriendRequest.class);
// 处理好友申请的发送以及保存
sendAddFriendRequire(user, req);
} else if (Constant.ACCEPT_ADD_FRIEND_REQUIRE.equals(messageRequest.getType())) {
AddFriendRequest req = objectMapper.readValue(message.getPayload(), AddFriendRequest.class);
req.setType(Constant.ACCEPT_ADD_FRIEND_REQUIRE);
// 处理接受好友申请
acceptAddFriendRequire(user, req);
} else if (Constant.REJECT_ADD_FRIEND_REQUIRE.equals(messageRequest.getType())) {
AddFriendRequest req = objectMapper.readValue(message.getPayload(), AddFriendRequest.class);
req.setType(Constant.REJECT_ADD_FRIEND_REQUIRE);
// 处理拒绝好友申请
rejectAddFriendRequire(user, req);
} else {
log.error("[WebSocketHandler] type 类型错误!" + messageRequest.getType());
}
}
private void rejectAddFriendRequire(User user, AddFriendRequest req) throws IOException {
if (user == null || req == null) return;
// 删除好友申请记录
Integer ret3 = friendService.deleteAddFriendRequire(req.getFromId(), user.getUserId());
// 1. 先构造一个响应
AddFriendResponse resp = new AddFriendResponse(req.getType(), req.getFromId(), user.getUserName());
// 2. 根据 fromId 获取 session
WebSocketSession session = onlineUserManager.getWebSocketSessionById(resp.getFromId());
if (session == null) {
// 用户不在线
return;
}
// 3. 发送 respJson
String respJson = objectMapper.writeValueAsString(resp);
session.sendMessage(new TextMessage(respJson));
}
@Transactional
private void acceptAddFriendRequire(User user, AddFriendRequest req) throws IOException {
if (user == null || req == null) return;
// 1. 先构造一个响应
AddFriendResponse resp = new AddFriendResponse(req.getType(), req.getFromId(), user.getUserName());
// 添加好友关系
Integer ret1 = friendService.insertFriend(user.getUserId(), req.getFromId());
Integer ret2 = friendService.insertFriend(req.getFromId(), user.getUserId());
// 删除好友申请记录
Integer ret3 = friendService.deleteAddFriendRequire(req.getFromId(), user.getUserId());
// 2. 根据 fromId 获取 session
WebSocketSession session = onlineUserManager.getWebSocketSessionById(resp.getFromId());
if (session == null) {
// 用户不在线
return;
}
// 3. 发送 respJson
String respJson = objectMapper.writeValueAsString(resp);
session.sendMessage(new TextMessage(respJson));
}
private void sendAddFriendRequire(User user, AddFriendRequest req) throws IOException {
if (user == null || req == null) return;
// 先看看数据库中是否已经是好友关系了,如果是,那就不用添加了
Friend friendShip = friendService.isFriendShip(user.getUserId(), req.getFriendId());
if (friendShip != null && friendShip.getFriendId() > 0) {
return;
}
// 再看看好友申请是否已经在数据库中存在
AddFriend sameAddFriend = friendService.FindSameAddFriendRequired(user.getUserId(), req.getFriendId());
if (sameAddFriend != null) return;
// 1. 先构造一个响应
AddFriendResponse resp = new AddFriendResponse(req.getAddReason(), req.getFriendId(), user.getUserName(), user.getUserId());
// 2. 根据 friendId 获取 session
WebSocketSession session = onlineUserManager.getWebSocketSessionById(req.getFriendId());
// 构建好友申请对象
AddFriend addFriend = new AddFriend();
addFriend.setFromId(user.getUserId());
addFriend.setTargetId(req.getFriendId());
addFriend.setAddReason(resp.getAddReason());
if (session == null) {
// 用户不在线,则不发送,将好友申请保存到数据库中
friendService.addFriendRequired(addFriend);
return;
}
// 3. 将响应转化成 json 格式,然后发送
String respJson = objectMapper.writeValueAsString(resp);
session.sendMessage(new TextMessage(respJson));
// 4. 将好友申请保存到数据库中
friendService.addFriendRequired(addFriend);
}
private void transferMessage(User user, MessageRequest messageRequest) throws IOException {
// 1. 先构造一个 MessageResponse 对象
MessageResponse messageResponse = new MessageResponse(user.getUserId(), user.getUserName(), messageRequest.getSessionId(), messageRequest.getContent());
String respJson = objectMapper.writeValueAsString(messageResponse);
// 2. 根据 sessionId,获取好友列表
List<Friend> friends = messageSessionService.getFriendsBySessionId(messageResponse.getSessionId(), user.getUserId());
// 同时给自己也要发一份消息,方便前端展示
friends.add(new Friend(user.getUserName(), user.getUserId()));
// 3. 根据 userId 获取 WebSocketSession 对象,进行消息转发
for (Friend friend : friends) {
WebSocketSession session = onlineUserManager.getWebSocketSessionById(friend.getFriendId());
if (session == null) {
// 用户不在线
continue;
}
session.sendMessage(new TextMessage(respJson));
}
// 4. 保存消息记录
Integer result = messageService.addMessage(new Message(user.getUserId(), user.getUserName(), messageResponse.getSessionId(), messageResponse.getContent()));
if (result == null || result < 1) {
log.error("增加消息到数据库失败");
}
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
// 这个方法会在 WebSocket 连接异常之后,自动调用
// 判断用户是否登录
Map<String, Object> attributes = session.getAttributes();
User user = (User) attributes.get(Constant.USER_SESSION);
if (user == null) return;
// 用户下线,删除映射
onlineUserManager.offline(user.getUserId(), session);
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
// 这个方法会在 WebSocket 连接正常关闭后,自动调用
// 判断用户是否登录
Map<String, Object> attributes = session.getAttributes();
User user = (User) attributes.get(Constant.USER_SESSION);
if (user == null) return;
// 用户下线,删除映射
onlineUserManager.offline(user.getUserId(), session);
}
}
WebSocketConfig
@Configuration
@EnableWebSocket // 用来启动 WebSocket 的
public class WebSocketConfig implements WebSocketConfigurer {
@Autowired
private WebSocketHandler webSocketHandler;
// 配置 WebSocketHandler 的路由, 注册到具体的路径上
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// 当浏览器 WebSocket 的请求路径是 /WebSocketMessage 时,就会调用 WebSocketHandler 里面的方法
registry.addHandler(webSocketHandler, "/WebSocketMessage")
// 通过注册这个特定的 HttpSession 拦截器,就可以把用户给 Httpsession 中添加的 Attribute 键值对
// 往我们的 WebSocketSession 里也添加一份
.addInterceptors(new HttpSessionHandshakeInterceptor());
}
}
component
OnlineUserManager
// 记录 userId 与 WebSocketSession 的映射
@Slf4j
@Component
public class OnlineUserManager {
private Map<Integer, WebSocketSession> sessions = new HashMap<>();
// 用户上线方法
public void online(Integer userId, WebSocketSession webSocketSession) {
// 参数校验
if (userId == null || userId < 1 || webSocketSession == null) return;
// 先看看用户是否已经在线,如果已经在线,那就啥都不干(防止多开)
WebSocketSession session1 = sessions.get(userId);
if (session1 != null) return;
sessions.put(userId, webSocketSession);
log.info("用户" + userId + "上线");
}
// 用户下线方法
public void offline(Integer userId, WebSocketSession session) {
if (userId == null || userId < 1) {
return;
}
WebSocketSession existSession = sessions.get(userId);
if (session == existSession) {
// 如果这俩 session 是同一个,才真正进行下线操作,否则啥都不干(从多开角度考虑)
sessions.remove(userId);
log.info("[" + userId + "] 用户下线!");
return;
}
}
// 根据 userId 获取 WebSocketSession
public WebSocketSession getWebSocketSessionById(Integer userId) {
if (userId == null || userId < 1) {
return null;
}
return sessions.get(userId);
}
}
constant
Constant
public class Constant {
public static final String USER_SESSION = "user_session";
// 这个是 websocket 用来处理 实时发送消息 的请求
public static final String MESSAGE = "message";
// 这个是 websocket 用来处理 实时发送好友申请 的请求
public static final String ADD_FRIEND_REQUIRE = "addFriendRequire";
// 这个是 websocket 用来处理 实时接受好友申请 的请求
public static final String ACCEPT_ADD_FRIEND_REQUIRE = "acceptAddFriendRequire";
// 这个是 websocket 用来处理 实时拒绝好友申请 的请求
public static final String REJECT_ADD_FRIEND_REQUIRE = "rejectAddFriendRequire";
}
model
AddFriend
@Data
public class AddFriend {
private Integer id;
private Integer fromId;
private Integer targetId;
private String addReason;
private Date createTime;
private String fromName;
}
AddFriendRequest
@Data
@NoArgsConstructor
public class AddFriendRequest {
private String type = Constant.ADD_FRIEND_REQUIRE;
private String addReason;
private Integer friendId;
// 用来处理接受/拒绝好友申请
private Integer fromId;
}
AddFriendResponse
@Data
@NoArgsConstructor
public class AddFriendResponse {
private String type = Constant.ADD_FRIEND_REQUIRE;
private String addReason;
private Integer friendId;
private String fromName;
private Integer fromId;
public AddFriendResponse(String addReason, Integer friendId, String fromName, Integer fromId) {
this.addReason = addReason;
this.friendId = friendId;
this.fromName = fromName;
this.fromId = fromId;
}
// 用来处理接受/拒绝好友申请
private String friendName;
public AddFriendResponse(String type, Integer fromId, String friendName) {
this.type = type;
this.fromId = fromId;
this.friendName = friendName;
}
}
Friend
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Friend {
private String friendName;
private Integer friendId;
}
Message
@Data
@NoArgsConstructor
public class Message {
private Integer messageId;
private Integer fromId;
private String fromName;
private Integer sessionId;
private String content;
private Date postTime;
public Message(Integer fromId, String fromName, Integer sessionId, String content) {
this.fromId = fromId;
this.fromName = fromName;
this.sessionId = sessionId;
this.content = content;
}
}
MessageRequest
@Data
public class MessageRequest {
private String type = "message";
private Integer sessionId;
private String content;
// 用来处理 添加好友请求 的属性
private String addReason;
private Integer friendId;
// 用来处理 接受好友请求
private Integer fromId;
private String friendName;
}
MessageResponse
@Data
@NoArgsConstructor
public class MessageResponse {
// 可以想想前端页面需要展示什么
private String type = "message";
private Integer fromId;
private String fromName;
private Integer sessionId;
private String content;
public MessageResponse(Integer fromId, String fromName, Integer sessionId, String content) {
this.fromId = fromId;
this.fromName = fromName;
this.sessionId = sessionId;
this.content = content;
}
}
MessageSession
@Data
public class MessageSession {
private Integer sessionId;
private List<Friend> friends;
private String lastMessage;
}
User
@Data
@NoArgsConstructor
public class User {
private Integer userId;
private String userName;
private String password;
public User(String userName, String password) {
this.userName = userName;
this.password = password;
}
}
db.sql
-- 创建用户表
create database if not exists chat_room_review charset utf8;
use chat_room_review;
drop table if exists user;
create table user(
user_id int primary key auto_increment,
user_name varchar(20) unique,
password varchar(50)
);
insert into user(user_name,password) values("mika","123");
insert into user(user_name,password) values("zhangsan","123");
insert into user(user_name,password) values("lisi","123");
insert into user(user_name,password) values("wangwu","123");
-- 创建好友表
drop table if exists friend;
create table friend (
user_id int,
friend_id 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);
-- 创建会话表
drop table if exists message_session;
create table message_session(
session_id int primary key auto_increment,
last_time datetime DEFAULT now()
);
insert into message_session values(1, '2000-05-01 00:00:00');
insert into message_session values(2, '2000-06-01 00:00:00');
-- 创建会话和用户的关联表
drop table if exists message_session_user;
create table message_session_user(
session_id int ,
user_id int
);
-- 1 号会话里有张三和 mika
insert into message_session_user values(1, 1), (1, 2);
-- 2 号会话里有李四和 mika
insert into message_session_user values(2, 1), (2, 3);
-- 创建消息表
drop table if exists message;
create table message(
message_id int primary key auto_increment,
from_id int not null, -- 消息是哪个用户发的
session_id int not null, -- 消息发给哪个会话
content varchar(5000), -- 消息的内容
post_time datetime default now() -- 消息的发送时间
);
-- mika 和 zhangsan 的对话
insert into message values(1,1,1,"星露谷联机?","2024-03-21 17:00:00");
insert into message values(2,2,1,"来来来!","2024-03-21 17:00:03");
insert into message values(3,1,1,"正好 1.6 更新了!!!","2024-03-21 17:00:07");
insert into message values(4,1,1,"我还没开始玩 1.6 呢","2024-03-21 17:00:12");
insert into message values(5,2,1,"行,那接电话,开麦玩","2024-03-21 17:00:16");
insert into message values(6,1,1,"OK","2024-03-21 17:00:20");
-- mika 和 lisi 的对话
insert into message values(7,1,2,"葬送的芙莉莲完结了,快去看!!!","2024-03-23 18:01:00");
insert into message values(8,3,2,"最近在忙,要学习没空","2024-03-23 18:02:00");
insert into message values(9,1,2,"那就吃饭的时候看呗","2024-03-23 18:02:15");
insert into message values(10,3,2,"忙,没空","2024-03-23 18:03:15");
-- add_friend 表用来存放申请好友记录
drop table if exists add_friend;
create table add_friend(
id int primary key auto_increment,
from_id int , -- 谁主动申请好友
target_id int , -- 好友关系发给谁
add_reason varchar(30), -- 申请理由
create_time datetime default now()
);
完整源码
链接1:Gitee源码
链接2:Github源码