目录
项目介绍
项目功能简介
项目创建
用户管理模块
1.数据库设计及代码实现
2.前后端交互接口的设计
3.服务器代码开发
好友管理模块
数据库设计
好友表设计的两个重要问题
设计前后端交互接口
服务器代码
会话管理模块
会话的数据库设计
获取会话信息
约定前后端接口
客户端代码
服务器代码
创建会话
约定前后端交互接口
客户端代码
服务器代码
消息的传输模块
获取指定会话的最后一条消息
服务器代码
获取指定会话的历史消息
约定前后端交互接口
服务器代码
客户端代码
消息的发送和接收
更好的方案WebSocket
WebSocket的报文格式
websocket握手过程
基于websocket编写代码
基于spring的websocket api,实现简单的hello world
服务器代码
客户端代码
使用websocket实现消息的接收和转发
约定消息传输的前后端交互接口
实现客户端发送消息
客户端接收消息
服务器的接收和转发消息
添加好友功能
搜索功能
添加好友请求
收到好友请求之后,决定是接受还是拒绝
接受和拒绝好友
项目介绍
仿照网页微信实现用户之间的聊天通信.
项目功能简介
用户管理模块
注册:实现一个注册页面,输入用户名密码,进行用户注册.
登录:实现一个登录页面,输入用户名密码,进行用户登录.
主界面模块
个人信息模块:在左上角显示当前用户的信息(用户名)
会话列表模块:左侧罗列出当前的用户有哪些会话.
好友列表模块:左侧罗列出当前所有的好友信息.
消息区域模块:右侧显示消息区域,最上面显示会话名称,中间是消息列表,下方显示一个消息输入框,可以输入消息,并发送.
消息传输模块:能够真正的进行消息的发送和接收.
添加好友模块:左上角搜索框,搜索用户名,进行添加好友模块.
项目创建
用户管理模块
实现用户登录和用户注册两个功能.
注册操作就是给用户表插入新的记录,登录操作就是从用户表中进行查询.
1.数据库设计及代码实现
首先创建一个用户表,包含userId,username,password三个字段.
编写数据库实现代码
创建实体类,User.
编写mapper接口
编写sml,借助MyBatis自动生成数据库操作的实现.
这里的名字要符合配置文件中规定的名字.
在resources目录下创建mapper子目录,xml文件要以Mapper.xml为后缀.
2.前后端交互接口的设计
此处需要两个接口,一个是注册接口,一个是登录接口.
我们要提前规定好前端后端交互的具体细节.
3.服务器代码开发
4.客户端代码
客户端界面的设计在完整代码中,此处不过多介绍.
我们在前端使用ajax的方式向后端发起请求.
如果拿个对象,传给ajax方法的data属性,ajax就会默认按照form表单的格式来构造数据.
我们可以通过抓包来查看此处登录交互的过程.
在主界面获取当前登录用户
我们要在客户端主界面的左上角显示出当前登录用户的用户名.
在前端发送一个ajax的get请求,在后端实现一个路由为userInfo的接口.
在后端里,我们直接从此次请求的session里获取到当前的登录用户.因为我们在登录的时候,已经保存了当前的用户的session.
好友管理模块
数据库设计
好友表里应该包含两个实体,一个是用户,一个是好友.
实体之间的关系应该是多对多的关系:一个用户可以有多个好友,一个好友也可以被多个用户添加.
此处需要有一个关联表,通过关联表把另外两个表联系到一起.其实这里的两个表(用户表,好友表)是一个表,都是用户表.更准确的说是把用户表的两条记录联系到一起.
friend表作为关联表,有userId和friendId两个字段.
聊天软件里好友关系,都属于是强好友关系.A是B的好友,那么B也是A的好友.
好友表设计的两个重要问题
1.如果用户很多,每个用户的好友又有很多很多,此时这个好友表就会非常大,查询速度就会变得很慢很慢,那么此时应该怎么办?
处理方法就是分库分表.
一种典型的思路就是userId进行切分.比如针对userId来计算一个hashCode,然会在针对hashCode进行切分.假设分成100张表(friend0-friend99),此时hashCode%100,结果是几,就把这个记录放到哪个表里.我们这样做,同一个userId的记录是一定在同一张表里的.
后续比如要查询某个用户的好友都有哪些,还是按照同样的方法来查.根据userId来计算hashCode,算出hashCode之后,hashCode%100,到对应的表里去查询.
2.在分库分表的背景下,我们是希望每个表的大小是相对均匀的,这样查询速度也没有太大的差异.
但是在用户中,可能存在一些用户,好友非常的多,这些大用户的存在就可能会导致表的大小不均衡了.
面对此种情况,我们要进行特殊处理.毕竟大用户还是少数的.所以我们可以针对这些大用户单独分表,这个表里只存大用户对应的好友关系,让大用户和普通用户的表分离开.这种处理方法,我们也叫做冷热数据分环处理.
但是在这里上述问题,我们暂时不考虑,我们没有那么的假数据.
设计前后端交互接口
请求
GET/friendList
响应
HTTP/1.1 200 OK
Content-Type:application/json
[{friendId:2 friendName:'李四'},{friendId:3 friendName:'王五'}]
body部分我们用一个json数组接收.
前端我们使用ajax发送get请求
此函数的第一件事就是要把好友列表里的原有内容给清空掉.
这样做有两个目的:第一就是把原有我们写死的内容给删除掉;第二就是假如页面触发了多次getFriendList()函数,如果不先清空原有的内容,就会把第二次查询的内容追加到好友列表中.
关于网络传输的注意点
网络上交互的数据都是字符串(二进制的字节流),换而言之就是网络传输中就没有对象的概念.
服务器返回响应的时候,需要先把返回的对象,通过json库,转成json格式的"字符串",然后才能进行网络传输.
浏览器收到的也是"字符串",正常来说,浏览器收到响应body中的字符串(json格式),需要先使用JSON.parse把字符串转换未js对象的数组.但是对于响应中Content-Type为application/json这种情况,不需要我们在代码里进行手动转换了,这个工作由jquery的ajax自动完成了,所以我们代码里的回调函数的参数body已经是JSON.parse转换之后的了,得到的已经是js对象数组了.
服务器代码
先实现一个friend的实体类.
实现FriendMapper,根据userId查询好友.
此处查询使用的是子查询.先根据传递的userId在friend表里查询其对应的好友id是多少,在根据查到的好友id在user表里查询其对应的用户是谁.
会话管理模块
会话管理包括获取会话信息和新增会话两个部分.
此处谈到的会话(session)特指在聊天过程中,产生的业务上的会话.
每次用户发起一个聊天,就相当于创建了一个会话,这个会话里就包括了"人"和"消息".
会话管理要想持久化存储,就必须在数据库中进行保存.
会话的数据库设计
设计的实体有3个:会话,用户和消息.
首先要创建一个会话表,包含sessionId和lastTime(上次访问事件,方便后续我们通过时间针对会话表进行排序)两个字段.
在这里会话和用户是多对多的关系.所以要创建一个会话和用户的关联表.
message_session_user表里就包含sessionId和userId两个字段.
会话和消息之间的关系是一对多的的关系.一个会话里包含多条消息,一条消息只能从属于一个会话.
所以我们还要创建一个消息表.
message表包含messageId,fromId(消息是谁发送的),sessionId(消息从属于哪个会话),content(消息的正文部分),postTime(消息的发送时间).
在会话管理模块,我们先实现关于获取会话信息和新增会话两个功能,关于消息的功能后续实现.
获取会话信息
约定前后端接口
请求:GET/sessionList
响应:
HTTP/1.1 200 OK
Content-Type:application/json
[
{
sessionId:1,
friends:[{friendName:'lisi',friendId:2}],
lastMessage:"hello"
},
{
sessionId:1,
friends:[{friendName:'wangwu',friendId:3}],
lastMessage:"hi"
}
]
//响应的body是一个json数组
返回出当前用户的所有会话,同时按照这些会话的最后访问时间进行降序排序.针对每个会话,都要获取到当前的会话是和哪些用户产生的,另外还要获取到这个会话里最后一条消息是什么,以便显示到界面上.
客户端代码
服务器代码
获取会话信息,首先我们要根据userId查询出当前用户都存在哪些会话.
在通过查询到的sessionId查询出每个会话包含的用户(刨除自己).
在这里我们使用一个子查询来实现.其实根据userId查询sessionId用不到message_session表,但是由于我们希望返回的会话是按照时间的降序排序的,这一点很容易理解,因为界面上会话的排序肯定是时间降序排序的.由于关于会话时间的字段实在message_session里的,所以我们要进行一个子查询.(联合查询也可以实现).
此sql用来根据sessionId查询会话里存在的好友都有谁.
在这里要注意的是,虽说是好友,但也是用户,所以直接在user表里查询.要给列名取别名对应friend实体类.
创建会话
当用户点击了好友列表的某个好友的时候,此时就会触发创建会话的操作.
点击一个好友触发的操作包含两种情况:
1.如果会话不存在,就创建会话:
1)需要在客户端上创建出一个对应li标签,放到会话列表中,并且将此标签置顶且处于高亮状态,同时还要从好友列表切换到会话列表标签页.
2)还要给服务器发送一个创建会话的请求,让服务器将此新创建的会话的信息保存在数据库中.
2.如果会话已经存在,则把之前的会话找到:
1)把标签页切换到会话列表标签页,找到指定的会话,将此会话置顶并使其高亮.
2)给服务器发送请求,获取到该会话的历史消息,并且显示到右侧消息区域.(此功能在后续实现消息功能的时候实现)
约定前后端交互接口
请求:POST/session?toUserId=2
POST请求的参数,不仅仅可以放到body中,还可以放到查询字符串中.
响应:
HTTP/1.1 200 OK
Content-Type:application/json
{sessionId : 1}
响应的body中返回一个sessionId,将得到的sessionId保存到li标签的属性中.
客户端代码
在之前写的getFriendList函数中,给每个好友标签都添加一个点击事件.
这个客户端代码里,并不只是单纯的界面操作了,也不是单纯的前后端交互,而是包含了业务逻辑.
服务器代码
创建会话的服务器方面,涉及到三个操作:
1.先在messag_session表里新增一条记录,表示新建了一个会话记录.同时获取到新会话的自增主键.
2.给message_session_user表中插入记录.张三向李四创建新会话:5,1
3.给message_session_user表中插入记录.张三向李四创建新会话5,2
在这里使用spring提供的@Transactional标签,开启事务.
因为这三个操作都是连续的,其中任何的一个出了异常,整个的操作都不能执行.
消息的传输模块
前面在会话功能那里,我们已经设计好了message表应该包含哪些字段.
message表包含messageId,fromId(消息是谁发送的),sessionId(消息从属于哪个会话),content(消息的正文部分),postTime(消息的发送时间).
接下来我们来实现会话功能里的一个遗留的问题:获取指定会话的最后一条消息.
前面我们在实现会话功能的时候,服务器返回的消息是写死的,在这里,我们要在数据库里查询当前会话的最后一条消息.
获取指定会话的最后一条消息
服务器代码
按照事件的降序,把最新的消息显示到界面上.
把之前的代码修改掉.
获取指定会话的历史消息
这个操作肯定是要和服务器进行交互的,毕竟消息是存储在数据库中的,就需要前端先访问服务器,再让服务器来查询数据库.
约定前后端交互接口
服务器代码
我们规定的响应里除了message表的字段之外,还要包括发送消息的人的名字,所以要和user表进行一个联合查询.需要名字因为前端的消息区域里消息气泡上要带名字.
获取最近的一百条消息,所以用降序排序.
客户端代码
当消息足够多的时候,我们要将右侧消息区域滚动到最下方,所以还要做一个滚动条的设置.
消息的发送和接收
这是整个项目最核心的部分,但是这个部分的编写,需要依赖我们之前实现各大模块.
这里要明确一点,发送和接收消息,是需要"实时进行传输"的.
比如张三发了一条信息,李四是能立即收到消息的.
张三和李四之间能不能不通过服务器直接进行通信?
不能!因为NAT动态地址转换机制,不在同一局域网的两个内网的设备无法直接进行通信.
张三和李四都是普通的客户端,它们的设备大概率是没有外网IP的,只有内网IP.如果没有外网IP则不能直接被访问到.而我们的服务器是带有外网IP的,张三和李四都能够访问到服务器,因此就可以让服务器进行消息的中转.
使用服务器中转的另一个原因就是更容易在服务器记录历史消息,随时方便用户获取历史消息.
服务器如何主动推送消息给客户端?
张三发给服务器,张三是客户端,聊天程序是服务器,客户端主动发送消息给服务器,这很正常.(本来客户端就是主动发起请求的一方).但是服务器把消息转发给李四,李四也是客户端,服务器主动发响应给客户端,这对http来说是不太容易实现的.
这一点也可以理解,因为Http早期设计出来就是用户在静态网页上看报纸,用户需要哪个,就给服务器发送对应的请求.
那么采用轮询的方式可行吗?
用HTTP模拟实现消息推送的效果.就是让李四每隔一定的时间就给服务器发送请求,看看是否有自己的消息,如果有,就获取消息,如果没有,就继续等待到下次询问周期的到来.
轮询存在两个问题:
1.消耗更多的系统资源.
接收方在等待过程中,需要频繁的给服务器发起请求,然而这些请求里,大部分都是"空转"的.
2.获取消息不够及时.
需要等到下个请求周期才能拿到数据.
如果提高轮询的频率,此时获取消息就更及时了,但是消耗的系统资源也就更多了.
如果降低轮询的频率,此时消耗的系统资源相对较少了,但是同时获取消息就更不及时了.
更好的方案WebSocket
WebSocket是解决消息推送问题更好的方案.
WebSocket是一个应用层协议,和HTTP的地位是对等的,都是基于传输层的TCP实现的一个广泛使用的应用层协议.
WebSocket的报文格式
FIN表示是否要关闭WebSocket .
RSV是保留位,在这里有三个保留位.
opcode操作码,描述了当前的websocket数据帧具有什么作用.取值为0x1表示是个文本数据;取值为0x2表示是二进制数据.websocket协议既可以传输二进制数据,也可以传输文本数据.
MASK是否开启掩码操作.
payload length表示载荷的长度.payload就是载荷,也就是数据报上携带的具体数据.7个bit表示的范围,单位是字节,那是不是意味着一个websocket最多只能保存127字节?
其实不然,我们可以看到后面还有额外的载荷长度的表示,payload length有三种模式:
1)7bit,此时能表示的范围比较小
2)16bit,能表示的范围更大了
3)64bit,能表示的范围就更大了
最初的7bit的payload length<126,此时模式1生效;如果7bit的值为126,此时是模式2,16个bit位生效;如果7bit的值为127,此时是模式3,64个bit都生效了.
payload data是真正要传输的数据.
websocket握手过程
浏览器借助HTTP,向服务器发送请求,这个请求里会带有特殊的header.
Connection: upgrade(升级)和Upgrade:websocket.
如果服务器同意协议升级为websocket,会在响应中返回101的HTTP状态码,表示协议转换,同时也会在header中带有Connection: upgrade(升级)和Upgrade:websocket.
接下来,浏览器和服务器之间就相当于建立好了websocket的连接了,接下来就可以使用websocket进行数据传输了.
基于websocket编写代码
在java中有两种形式来使用websocket:
1.直接使用tomcat提供的原生websocket api.
2.使用spring提供的websocket api.
基于spring的websocket api,实现简单的hello world
服务器代码
1.先创建一个类,作为WebSocketHandler,来处理websocket中的各个通信流程.
此类要继承自TextWebSocketHandler(此类是Spring内置的).
给这个类加上@Component注解,注册到spring中去,确保程序启动,就能加载此类.
此处继承父类,主要是为了重写父类中的方法.
在这里主要重写这四个方法.
对于方法中形参的理解
WebSocketSession是websocket连接中对应的会话,此会话中就持有了此次websocket的通信连接,记录了通信双发都是谁.
TextMessage就是收到的具体消息.
Throwable exception记录了异常信息.
CloseStatus status记录了连接关闭时的状态.
2.将上述类的实例,注册到spring里面,并配置路由.
客户端代码
websocket的客户端和服务器都是分成四个阶段来进行处理:
连接建立成功后,收到消息后,连接关闭后,连接异常后.
启动程序,建立连接
抓包来看一下升级协议的交互过程
使用websocket实现消息的接收和转发
约定消息传输的前后端交互接口
此处已经不是http了,所以直接使用json格式来作为payload表示传输的内容.
请求
响应
实现客户端发送消息
要想发送消息,我们就要针对右侧消息的输入框和发送按钮进行处理.
此时构造完请求之后,不能直接通过websocket进行发送.
此时的req对象在js里的类型是object而 websocket.send()的参数必须是一个字符串,所以此处需要手动的将json对象转成json格式的字符串.
req = JSON.stringify(req);
JSON.stringfy是js自带的方法,功能就是把js对象转为json格式的字符串,类似于jackson的objectMapper.writeValueAsString().
JSON.parse,也是js自带的方法,功能是把json格式的字符串转为json对象.
类似于jackson的objectMapper.readValue().
客户端接收消息
服务器的接收和转发消息
实现服务器转发逻辑的时候,需要能够维护一个重要的映射关系:userId->WebSocketSession.
每个和服务器连接好的客户端,都会在服务器这边有一个对应的webSocketSession对象,服务器想要给谁发消息,就得通过哪个对象来send.
张三给服务器发送消息,服务器要将消息发送给李四,就必须获取到李四对应的websocketSession.
所以我们使用一个hashMap来记录,key是userId,value是WebSocketSession.
在客户端建立连接的时候,在afterConnectionEstablished方法中,将映射关系插入到哈希表中,意味着用户上线.
在客户端断开连接的时候,在handleTransportError和afterConnectionClosed方法中将映射关系从哈希表中删除,意味着用户下线.
此时已经可以确认,键值对中的value已经准备就绪了,在方法的参数中带有,那么key,userId应该怎么获取呢?
由于当前进行的不是http通信,所以不能直接获取到HttpSession中我们已经保存的用户信息.
既然信息在HttpSession里,那么在当前的websocket代码里,怎么获取到HTTP Session呢?
加入拦截器,我们只需要在最初注册WebSocketHandler的时候,在指定一个特殊的拦截器即可.
通过这样的手段,就能将用户在登录的时候,在HttpSession中存的user->User对象的键值对拷贝到websocketSession中来.
创建一个类,来记录映射关系
在WebSocketController这个handler类里实现具体的用户上线下线和消息转发和接收逻辑.
添加好友功能
搜索功能
在搜索框内输入一个用户名,点击搜索按钮,此时就会给服务器发起一个ajax请求,服务器就会根据用户名进行匹配,把名字符合的结果都显示到界面上.
先将搜索按钮进行初始化,点击搜索按钮,会向服务器发起请求,来获取搜索结果.
在这里使用一个模糊查询,注意在mybatis中,模糊查询要使用concat函数,用来进行字符串的拼接.
添加好友请求
获取到好友结果后,将结果显示到右侧消息区域.
输入理由之后,点击添加好友按钮,向服务器发起一个好友请求.
服务器收到之后,现针对此好友请求进行判定.
如果当前要添加的好友已经存在,就直接返回;如果已经向该用户发起过好友请求了,也直接返回.
如果判定通过,将此次的好友请求插入到数据库表中.
在数据库中,我们使用friend_request表来记录好友请求.
收到好友请求之后,决定是接受还是拒绝
收到请求可能是用户在线收到的,也可能是离线后下次上线收到的.
如果该用户是在线的,就直接通过websocket进行好友请求的实时传输.
如果用户不在线,那么在下次上线的时候,获取历史的好友请求.
接受和拒绝好友
在左侧会话列表里构造出一个好友请求,其中包含接受和拒绝按钮.
点击接受,就发起ajax请求,服务器收到之后,就把对应的好友关系加入到数据库friend表里,同时把friend_request表里对应的记录删除掉.
点击拒绝,发起ajax请求给服务器,只是把friend_request表里对应的好友请求删除掉,不修改好友关系表.