目录
基于spring的websocket api,实现简单的hello world
仿照网页微信实现用户之间的聊天通信.
完整版代码码云连接:Cc/gitDemo - Gitee.com
用户管理模块
注册:实现一个注册页面,输入用户名密码,进行用户注册.
登录:实现一个登录页面,输入用户名密码,进行用户登录.
主界面模块
个人信息模块:在左上角显示当前用户的信息(用户名)
会话列表模块:左侧罗列出当前的用户有哪些会话.
好友列表模块:左侧罗列出当前所有的好友信息.
消息区域模块:右侧显示消息区域,最上面显示会话名称,中间是消息列表,下方显示一个消息输入框,可以输入消息,并发送.
消息传输模块:能够真正的进行消息的发送和接收.
添加好友模块:左上角搜索框,搜索用户名,进行添加好友模块.
实现用户登录和用户注册两个功能.
注册操作就是给用户表插入新的记录,登录操作就是从用户表中进行查询.
首先创建一个用户表,包含userId,username,password三个字段.
编写数据库实现代码
创建实体类,User.
编写mapper接口
编写sml,借助MyBatis自动生成数据库操作的实现.
这里的名字要符合配置文件中规定的名字.
在resources目录下创建mapper子目录,xml文件要以Mapper.xml为后缀.
此处需要两个接口,一个是注册接口,一个是登录接口.
我们要提前规定好前端后端交互的具体细节.
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是一个应用层协议,和HTTP的地位是对等的,都是基于传输层的TCP实现的一个广泛使用的应用层协议.
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是真正要传输的数据.
浏览器借助HTTP,向服务器发送请求,这个请求里会带有特殊的header.
Connection: upgrade(升级)和Upgrade:websocket.
如果服务器同意协议升级为websocket,会在响应中返回101的HTTP状态码,表示协议转换,同时也会在header中带有Connection: upgrade(升级)和Upgrade:websocket.
接下来,浏览器和服务器之间就相当于建立好了websocket的连接了,接下来就可以使用websocket进行数据传输了.
在java中有两种形式来使用websocket:
1.直接使用tomcat提供的原生websocket api.
2.使用spring提供的websocket api.
1.先创建一个类,作为WebSocketHandler,来处理websocket中的各个通信流程.
此类要继承自TextWebSocketHandler(此类是Spring内置的).
给这个类加上@Component注解,注册到spring中去,确保程序启动,就能加载此类.
此处继承父类,主要是为了重写父类中的方法.
在这里主要重写这四个方法.
对于方法中形参的理解
WebSocketSession是websocket连接中对应的会话,此会话中就持有了此次websocket的通信连接,记录了通信双发都是谁.
TextMessage就是收到的具体消息.
Throwable exception记录了异常信息.
CloseStatus status记录了连接关闭时的状态.
2.将上述类的实例,注册到spring里面,并配置路由.
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表里对应的好友请求删除掉,不修改好友关系表.