这是整个项目最最核心的部分.
但是这个部分的编写,需要依赖"基础设施"
包括不限于前面已经实现的 主界面,用户管理,会话管理, 好友管理, 消息管理 等等....
发送消息,和接收消息,需要"实时传输
张三 发了一条消息,李四 这边立即就能接收到,
这样的机制, 基于 HTTP 实现,有点困难~~
服务器主动发消息给李四???
按照之前的讨论,张三和李四,不能直接通信(NAT)必须是张三发消息给服务器,服务器转发给 李四.(服务器有 外网 IP,张三李四都能访问到)
张三发给服务器,张三是客户端, 聊天程序是服务器客户端主动发消息给服务器,很正常.(本来客户端就是主动发起请求的一方)
服务器把消息转发给李四,李四也是客户端,聊天程序是服务器服务器要主动发响应给客户端了???这个事情是不太寻常的!!!!以往写 HTTP 系列的程序,都是第一种, 客户端发起请求了,服务器才返回响应.
客户端不发请求,服务器就不返回响应~~
第二种情况, HTTP 程序中还没遇到过~~ 这个情况称为"服务器推送数据给客户端
对于 HTTP 协议来说,第一种情况是主要的使用方式,第二种消息推送机制,其实在 HTTP 中不太容易实现
当然也可以使用 HTTP 模拟实现消息推送的效果~~
轮询轮询方式的问题
1.消耗更多的系统资源.
接收方,等待过程中,需要频繁的给服务器发起请求,这些请求里,大部分都是"空转的
2.获取到消息不够及时.
需要等到下个请求周期才能拿到数据
如果提高轮询的频率,此时获取消息就及时了,但是系统资源消耗就更多如果降低轮询的频率,此时获取消息就慢了,但是系统消耗资源减少点。此处引入了一个更好的方案,来解决上述"消息推送问题"-Websocket
1.websocket
1.1 基础
WebSocket 协议完整解析 - 知乎 (zhihu.com)
websocket rfc 6455
RFC 6455: The WebSocket Protocol (rfc-editor.org)
1.2 握手过程
当客户端想要使用 WebSocket 协议与服务端进行通信时, 首先需要确定服务端是否支持 WebSocket 协议, 因此 WebSocket 协议的第一步是进行握手, WebSocket 握手采用 HTTP Upgrade 机制, 客户端可以发送如下所示的结构发起握手 (请注意 WebSocket 握手只允许使用 HTTP GET 方法):
GET /chat HTTP/1.1 Host: server.example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Origin: http://example.com Sec-WebSocket-Protocol: chat, superchat Sec-WebSocket-Version: 13
在 HTTP Header 中设置 Upgrade 字段, 其字段值为 websocket, 并在 Connection 字段指示 Upgrade, 服务端若支持 WebSocket 协议, 并同意握手, 可以返回如下所示的结构:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= Sec-WebSocket-Protocol: chat Sec-WebSocket-Version: 13
1.3 常见错误说法
websocket是基于HTTP实现的(该说法是错误的)
websoket和HTTP是并列的关系
如何基于 websocket 编写 代码.
在 Java 中有两种形式来使用 websocket
1.直接使用 tomcat 提供的原生 websocket api
2.使用 spring 给咱们提供的 websocket api
基于 Spring 的 websocket api, 先写个简单的 hello world1.4 WebSocket的使用
万字详解,带你彻底掌握 WebSocket 用法(至尊典藏版)写的不错_websocket使用-CSDN博客
1.服务器代码
2.客户端部分
// 给这个 websocket 注册上一些回调函数. websocket.onopen = function() { // 连接建立完成后, 就会自动执行到. console.log("websocket 连接成功!"); } websocket.onclose = function() { // 连接断开后, 自动执行到. console.log("websocket 连接断开!"); } websocket.onerror = function() { // 连接异常时, 自动执行到 console.log("websocket 连接异常!"); } websocket.onmessage = function(e) { // 收到消息时, 自动执行到 console.log("websocket 收到消息! " + e.data); }
websocket 的客户端和服务器都是分成这四个阶段来进行处理,
1.连接建立成功之后
2.收到消息后3.连接关闭后
4.连接异常后/ // 操作 websocket / // 创建 websocket 实例 // let websocket = new WebSocket("ws://127.0.0.1:8080/WebSocketMessage"); let websocket = new WebSocket("ws://" + location.host + "/WebSoketMessage"); // let websocket = new WebSocket("ws://" + location.host + "/message/getMessage"); websocket.onopen = function() { console.log("websocket 连接成功!"); } websocket.onmessage = function(e) { console.log("websocket 收到消息! " + e.data); // 此时收到的 e.data 是个 json 字符串, 需要转成 js 对象 let resp = JSON.parse(e.data); if (resp.type == 'message') { // 处理消息响应 handleMessage(resp); } else { // resp 的 type 出错! console.log("resp.type 不符合要求!"); } } websocket.onclose = function() { console.log("websocket 连接关闭!"); } websocket.onerror = function() { console.log("websocket 连接异常!"); }
2.会话的区分
1.Servlet/Spring 自带的 HttpSession在登录的时候用到了
2.MessageSession 咱们自己聊天程序中创建的"会话",业务上的会话
3.WebSocketSession websocket 里面使用的会话
会话这个词,其实是"广义"概念
3.前后端交互
张三发请求,李四获得响应,张三也要有响应
【tips】
4.客户端实现发送消息操作/展示接收的消息
/
// 实现消息发送/接收逻辑
/
function initSendButton() {
// 1. 获取到发送按钮 和 消息输入框
let sendButton = document.querySelector('.right .ctrl button');
let messageInput = document.querySelector('.right .message-input');
// 2. 给发送按钮注册一个点击事件
sendButton.onclick = function() {
// a) 先针对输入框的内容做个简单判定. 比如输入框内容为空, 则啥都不干
if (!messageInput.value) {
// value 的值是 null 或者 '' 都会触发这个条件
return;
}
// b) 获取当前选中的 li 标签的 sessionId
//页面刚刚加载时,selected未被绑定
//同样也需要进行判断
let selectedLi = document.querySelector('#session-list .selected');
if (selectedLi == null) {
// 当前没有 li 标签被选中.
return;
}
let sessionId = selectedLi.getAttribute('message-session-id');
// c) 构造 json 数据
let req = {
type: 'message',
sessionId: sessionId,
content: messageInput.value
};
req = JSON.stringify(req);
console.log("[websocket] send: " + req);
// d) 通过 websocket 发送消息
websocket.send(req);
// e) 发送完成之后, 清空之前的输入框
messageInput.value = '';
}
}
initSendButton();
/
// 操作 websocket
/
// 创建 websocket 实例
// let websocket = new WebSocket("ws://127.0.0.1:8080/WebSocketMessage");
let websocket = new WebSocket("ws://" + location.host + "/WebSoketMessage");
// let websocket = new WebSocket("ws://" + location.host + "/message/getMessage");
websocket.onopen = function() {
console.log("websocket 连接成功!");
}
websocket.onmessage = function(e) {
console.log("websocket 收到消息! " + e.data);
// 此时收到的 e.data 是个 json 字符串, 需要转成 js 对象
let resp = JSON.parse(e.data);
if (resp.type == 'message') {
// 处理消息响应
handleMessage(resp);
} else {
// resp 的 type 出错!
console.log("resp.type 不符合要求!");
}
}
websocket.onclose = function() {
console.log("websocket 连接关闭!");
}
websocket.onerror = function() {
console.log("websocket 连接异常!");
}
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);
}
// 其他操作, 还可以在会话窗口上给个提示 (红色的数字, 有几条消息未读), 还可以播放个提示音.
// 这些操作都是纯前端的. 实现也不难, 不是咱们的重点工作. 暂时不做了.
}
5.服务器实现接收/转发消息
为了能够维护刚才所讨论的键值对映射关系,就需要知道当前 websocket 连接是哪个 userld 进行的.
在请求中虽然没有,但是在 HtpSession 中是有的!!最初在用户登录的时候,给 HtpSession 里存了当前的 user 对象.
//通过注册这个特定的Httpsession 拦藏器,就可以把用户给Http5ession中添加的 Attribute 键值对
//往我们的 WebSocketsession 里也添加一份package com.example.demo.component; import com.example.demo.config.Result; import com.example.demo.constant.Constant; import com.example.demo.mapper.MessageMapper; import com.example.demo.mapper.MessageSessionMapper; import com.example.demo.model.*; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.io.IOException; import java.util.List; @Component @EnableWebSocket public class WebSocketApi extends TextWebSocketHandler { @Autowired private OnlineUserManager onlineUserManager; @Autowired private MessageMapper messageMapper; @Autowired private MessageSessionMapper messageSessionMapper; //内置的 private ObjectMapper objectMapper=new ObjectMapper(); @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { System.out.println("TestApi连接成功"); User user=(User) session.getAttributes().get(Constant.USERINFO_SESSION_KEY); if(user==null){ return; } //把键值对存起来 onlineUserManager.online(user.getUserId(), session); /*if(result.getStatus()==Constant.HAVE_LOGIN){ return result; }*/ } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { System.out.println("TestApi收到消息"+message.toString()); //1.获取当前用户的信息,进行消息的转发、 User user=(User) session.getAttributes().get(Constant.USERINFO_SESSION_KEY); if(user==null){ System.out.println("未登录用户,无法进行消息转发"); return; } //2.针对请求进行解析,把json格式的字符串,转成一个Java对象 MessageRequest messageRequest=objectMapper.readValue(message.getPayload(),MessageRequest.class); if(messageRequest.getType().equals("message")){ //进行消息转发 transferMessage(user,messageRequest); }else { System.out.println("信息有误,无法进行消息转发"+message.getPayload()); } } //进行消息的转发 private void transferMessage(User user, MessageRequest messageRequest) throws IOException { // 1. 先构造一个待转发的响应对象. MessageResponse /*private Integer fromId; private String fromName; private Integer sessionId; private String content;*/ MessageResponse response=new MessageResponse(); response.setFromId(user.getUserId()); response.setFromName(user.getUsername()); response.setSessionId(messageRequest.getSessionId()); response.setContent(messageRequest.getContent()); // 把这个 java 对象转成 json 格式字符串 String respJson=objectMapper.writeValueAsString(response); System.out.println("[transferJson]"+respJson); // 2. 根据请求中的 sessionId, 获取到这个 MessageSession 里都有哪些用户. 通过查询数据库就能够知道了. List<Friend> friends= messageSessionMapper.getFriendBySessionId(messageRequest.getSessionId(), user.getUserId()); // 此处注意!!! 上述数据库查询, 会把当前发消息的用户给排除掉. 而最终转发的时候, 则需要也把发送消息的人也发一次. // 把当前用户也添加到上述 List 里面 Friend myself=new Friend(); myself.setFriendId(user.getUserId()); myself.setFriendName(user.getUsername()); friends.add(myself); // 3. 循环遍历上述的这个列表, 给列表中的每个用户都发一份响应消息 // 注意: 这里除了给查询到的好友们发, 也要给自己也发一个. 方便实现在自己的客户端上显示自己发送的消息. // 注意: 一个会话中, 可能有多个用户(群聊). 虽然客户端是没有支持群聊的(前端写起来相对麻烦), 后端无论是 API 还是 数据库 // 都是支持群聊的. 此处的转发逻辑也一样让它支持群聊. for(Friend friend:friends){ // 知道了每个用户的 userId, 进一步的查询刚才准备好的 OnlineUserManager, 就知道了对应的 WebSocketSession // 从而进行发送消息 WebSocketSession webSocketSession=onlineUserManager.getSession(friend.getFriendId()); if(webSocketSession==null){ //如果该用户未在线,则不发送 continue; } webSocketSession.sendMessage(new TextMessage(respJson)); } // 4. 转发的消息, 还需要放到数据库里. 后续用户如果下线之后, 重新上线, 还可以通过历史消息的方式拿到之前的消息. // 需要往 message 表中写入一条记录. Message message=new Message(); message.setSessionId(messageRequest.getSessionId()); message.setContent(messageRequest.getContent()); message.setFromId(user.getUserId()); //像自增主键, 还有时间这样的属性, 都可以让 SQL 在数据库中生成 messageMapper.add(message); } @Override public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception { System.out.println("TestApi出现异常"); User user=(User) session.getAttributes().get(Constant.USERINFO_SESSION_KEY); if(user==null){ return; } onlineUserManager.offline(user.getUserId(), session); } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { System.out.println("TestApi正常关闭"); User user=(User) session.getAttributes().get(Constant.USERINFO_SESSION_KEY); if(user==null){ return; } onlineUserManager.offline(user.getUserId(), session); } }
@Component public class OnlineUserManager { //为了防止多线程所出现的错误,选择ConcurrentHashMap private ConcurrentHashMap<Integer, WebSocketSession> sessions=new ConcurrentHashMap<>(); //1.用户上线,给这个哈希表中插入键值对 public void online(Integer userId,WebSocketSession webSocketSession){ if(sessions.get(userId)!=null){ //此时说明用户已经在线了,就登录失败,不会记录这个映射关系. //不记录这个映射关系,后续就收不到任何消息(毕竟,咱们此处是通过映射关系来实现消息转发的) System.out.println(userId+"已登录,请不要重复登陆"); return; } sessions.put(userId,webSocketSession); System.out.println(userId+"上线"); } //2.用户下线,针对此哈希表删除元素 public void offline(Integer userId,WebSocketSession webSocketSession){ WebSocketSession exwebSocketSession=sessions.get(userId); if(exwebSocketSession==webSocketSession){ //两个session为同一个才是真正的下线操作,否则什么也不干 sessions.remove(userId); System.out.println(userId+"下线"); } } // 3) 根据 userId 获取到 WebSocketSession public WebSocketSession getSession(int userId) { return sessions.get(userId); } }