前几天写了一篇《SpringBoot快速入门》一文,然后周末趁着有时间,在这个Springboot框架基础上整合了WebSocket技术写了一个网页版聊天功能。
如果小伙伴找不到那套框架了,可以看下之前的文章找到Springboot快速入门一文
往期推荐
通过该文章可以了解服务端与客户端之间的通信机制,以及了解相关的Http协议等技术内容。
话不多说,先来看看运行的过程:
页面写的十分简单,后续也会陆续将其优化和完善。
正文
一、HTTP相关知识
HTTP协议
http是一个简单的请求-响应协议,它通常运行在TCP之上。它指定了客户端可能发送给服务器什么样的消息以及得到什么样的响应。请求和响应消息的头以ASCII码形式给出;而消息内容则具有一个类似MIME的格式。这个简单模型是早期Web成功的有功之臣,因为它使开发和部署非常地直截了当
http 为短连接:客户端发送请求都需要服务器端回送响应。请求结束后,主动释放链接,因此为短连接。通常的做法是,不需要任何数据,也要保持每隔一段时间向服务器发送"保持连接"的请求。这样可以保证客户端在服务器端是"上线"状态。
HTTP连接使用的是"请求-响应"方式,不仅在请求时建立连接,而且客户端向服务器端请求后,服务器才返回数据。
二、Socket相关知识
1. 要想明白 Socket,必须要理解 TCP 连接。
① TCP 三次握手:握手过程中并不传输数据,在握手后服务器与客户端才开始传输数据,理想状态下,TCP 连接一旦建立,在通讯双方中的任何一方主动断开连接之前 TCP 连接会一直保持下去。
② Socket 是对 TCP/IP 协议的封装,Socket 只是个接口不是协议,通过 Socket 我们才能使用 TCP/IP 协议,除了 TCP,也可以使用 UDP 协议来传递数据。
③ 创建 Socket 连接的时候,可以指定传输层协议,可以是 TCP 或者 UDP,当用 TCP 连接,该Socket就是个TCP连接,反之。
2. Socket 原理
Socket 连接,至少需要一对套接字,分为 clientSocket,serverSocket 连接分为3个步骤:
(1) 服务器监听:服务器并不定位具体客户端的套接字,而是时刻处于监听状态;
(2) 客户端请求:客户端的套接字要描述它要连接的服务器的套接字,提供地址和端口号,然后向服务器套接字提出连接请求;
(3) 连接确认:当服务器套接字收到客户端套接字发来的请求后,就响应客户端套接字的请求,并建立一个新的线程,把服务器端的套接字的描述发给客户端。一旦客户端确认了此描述,就正式建立连接。而服务器套接字继续处于监听状态,继续接收其他客户端套接字的连接请求。
Socket为长连接:通常情况下Socket 连接就是 TCP 连接,因此 Socket 连接一旦建立,通讯双方开始互发数据内容,直到双方断开连接。在实际应用中,由于网络节点过多,在传输过程中,会被节点断开连接,因此要通过轮询高速网络,该节点处于活跃状态。
很多情况下,都是需要服务器端向客户端主动推送数据,保持客户端与服务端的实时同步。
若双方是 Socket 连接,可以由服务器直接向客户端发送数据。
若双方是 HTTP 连接,则服务器需要等客户端发送请求后,才能将数据回传给客户端。
因此,客户端定时向服务器端发送请求,不仅可以保持在线,同时也询问服务器是否有新数据,如果有就将数据传给客户端。
要弄明白 http 和 socket 首先要熟悉网络七层:物 数 网 传 会 表 应,如图:
如图
HTTP 协议:超文本传输协议,对应于应用层,用于如何封装数据。
TCP/UDP 协议:传输控制协议,对应于传输层,主要解决数据在网络中的传输。
IP 协议:对应于网络层,同样解决数据在网络中的传输。
传输数据的时候只使用 TCP/IP 协议(传输层),如果没有应用层来识别数据内容,传输后的协议都是无用的。
应用层协议很多 FTP,HTTP,TELNET等,可以自己定义应用层协议。
web 使用 HTTP 作传输层协议,以封装 HTTP 文本信息,然后使用 TCP/IP 做传输层协议,将数据发送到网络上。
三、WebSocket相关知识
WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。
WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
在 WebSocket API 中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。
现在,很多网站为了实现推送技术,所用的技术都是 Ajax 轮询。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽等资源。
HTML5 定义的 WebSocket 协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。
四、实现源码:
1 聊天页面chat.html
前端采用bootstrap,引入了: jquery-3.3.1.min.js、bootstrap.min.css。小伙伴可自行选择:
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.springframework.org/schema/mvc"> <head> <meta charset="UTF-8"> <title>chat room websocket</title> <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}"> <script th:src="@{/js/jquery-3.3.1.min.js}"></script> </head> <body class="container" style="width: 60%"> <div class="form-group" style="width: 100%; margin-top: 10px;"> <div style="width: 100%; background-color: #800080; color: #ffffff;"> <label for="user_name" style="float: left; margin-left: 45%">你好:</label> <h5 id="user_name" th:text="${username}" style="width: 80%;"></h5> </div> </div> <div class="form-group" style="float: left; width: 100%;"> <label for="user_list" style="float: left;">选择聊天用户:</label> <select id="user_list" style="width: 15%;"></select> <span id="error_select_msg" style="color: red;"></span> </div> <div class="form-group" style="float: left; width: 100%;"> <div id="message_user" style="width: 25%; height: 450px; overflow-y: auto; position: relative; float: left;" class="form-control" readonly="readonly"> 群成员:<span id="message_user_count"></span><br/> </div> <div id="message_chat" style="font-size: 13px; width: 75%; height: 300px; overflow-y: auto; position: relative; float: left;" class="form-control" readonly="readonly"> </div> <div style="width: 75%; float: right;"> <div style="width: 100%; height: 110px;"> <textarea style="height: 100%; border-bottom: #ffffff solid 0px;" id="chat_msg" value="" class="form-control"></textarea> </div> <div style="width: 100%; float: right; border-bottom: #808080 solid 1px;"> <button style="float: right;" id="send" class="btn btn-info">发送消息</button> <button style="float: right;" id="send_all" class="btn btn-info">群发消息</button> <button style="float: right;" id="user_exit" class="btn btn-warning">退出</button> </div> </div> </div> </body> <script type="text/javascript"> $(document).ready(function() { initUserList(); let urlPrefix = 'ws://localhost:8080/net/websocket/'; let ws = null; let username = $('#user_name').text(); ws = initMsg(urlPrefix, username); // 客户端发送对某一个客户的消息到服务器 $('#send').click(function() { let userList = $("#user_list option:selected").val(); if (!userList) { $("#error_select_msg").html("请选择一个用户!"); return; } let msg = $('#chat_msg').val(); if (!msg) { alert("请输入聊天内容!"); return; } msg = msg + "[" + userList + "]" + "----------" + username; if (ws) { ws.send(msg); //服务端发送的消息 $('#message_chat').append('<div style="width: 100%; float: right;"><span style="float: right;">' + username + ' </span><br/>'); $('#message_chat').append('<span style="float: right; font-size: 18px; font-weight: bolder;">' + msg.substring(0, msg.indexOf('[')) + '</span></div>'); $("#chat_msg").val(''); $("#error_select_msg").empty(); } }); // 客户端群发消息到服务器 $('#send_all').click(function() { let msg = $('#chat_msg').val(); if (!msg) { alert("请输入聊天内容!"); return; } msg = msg + "[allUsers]" + "----------" + username; if (ws) { ws.send(msg); //服务端发送的消息 $('#message_chat').append('<div style="width: 100%; float: right;"><span style="float: right;">' + username + ' 的群发消息 </span><br/>'); $('#message_chat').append('<span style="float: right; font-size: 18px; font-weight: bolder;">' + msg.replace('[allUsers]----------' + username, '') + '</span></div>'); $("#chat_msg").val(''); $("#error_select_msg").empty(); } }); // 退出聊天室 $('#user_exit').click(function() { if (ws) { ws.close(); } window.location.href = "/chat/login"; }); // 用户下拉列表点击事件 $("#user_list").on("change", function() { $("#error_select_msg").empty(); }); }); /** * 初始化用户列表 */ function initUserList() { let username = $('#user_name').text(); $.ajax({ url: "/getUserList", type: "POST", data: {username: username}, success: function(data) { let result = JSON.parse(data); let html = "<option value=''>---请选择---</option>"; for (let i = 0; i < result.length; i++) { html += "<option value='" + result[i].username + "'>" + result[i].username + "</option>"; } let userList = ""; for (let i = 0; i < result.length; i++) { userList += "<div class='select_user'>" + result[i].username + "</div>"; } $("#user_list").html(html); $("#message_user_count").text(result.length + "人"); $("#message_user").append(userList); } }); } /** * 初始化消息 * * @param urlPrefix * @param username * @returns {WebSocket} */ function initMsg(urlPrefix, username) { let url = urlPrefix + username; ws = new WebSocket(url); ws.onopen = function () { console.log("建立 websocket 连接..."); }; ws.onmessage = function(event) { //服务端发送的消息 $('#message_chat').append(event.data + '\n'); }; ws.onclose = function() { $('#message_chat').append('<div style="width: 100%; float: left;">用户[' + username + '] 已经离开聊天室!' + '</div>'); console.log("用户:[" + username + "]已关闭 websocket 连接..."); } return ws; } </script> </html>
2 pom.xml加入WebSocket依赖
<!-- 集成webSocket --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <!-- 集成json --> <dependency> <groupId>net.sf.json-lib</groupId> <artifactId>json-lib</artifactId> <version>2.2.3</version> </dependency>
3 实现WebSocket服务端
① 创建SocketEndPoint.java核心聊天页面实现类
该类为WebSocket的核心实现类,主要实现聊天连接、消息发送、退出聊天、异常处理等页面聊天的核心功能。其中:
@PathParam这个注解是将请求路径中绑定的占位符的值给取出来,作为参数条件使用。是javax.websocket.server下的一个注解。
在项目中,通过name对socket连接进行访问控制,后台后续会将name作为唯一主键,小伙伴也可以通过在url里面增加ket + name的方式进行访问控制,key作为登陆之后,服务器给用户的令牌,通过令牌和name进行权限校验(这里目前没有实现,只保证name是唯一)。
SocketEndPoint.java类实现:
package cn.cansluck.utils.net; import cn.cansluck.service.IUserService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import javax.websocket.*; import javax.websocket.server.PathParam; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.text.DateFormat; import java.util.Date; import java.util.Map; import static cn.cansluck.utils.net.SocketPool.*; import static cn.cansluck.utils.net.SocketHandler.createKey; // 注入容器 @Component // 表明这是一个websocket服务的端点 @ServerEndpoint("/net/websocket/{name}") public class SocketEndPoint { private static final Logger log = LoggerFactory.getLogger(SocketEndPoint.class); private static IUserService userService; @Autowired public void setUserService(IUserService userService){ SocketEndPoint.userService = userService; } @OnOpen public void onOpen(@PathParam("name") String name, Session session) { log.info("有新的连接:{}", session); add(createKey(name), session); for (Map.Entry<String, Session> item : sessionMap().entrySet()) { if (item.getKey().equals(name)) { SocketHandler.sendMessageAll("<div style='width: 100%; float: left;'>用户【" + name + "】已上线</div>", name); } } log.info("在线人数:{}",count()); sessionMap().keySet().forEach(item -> log.info("在线用户:" + item)); for (Map.Entry<String, Session> item : sessionMap().entrySet()) { log.info("12: {}", item.getKey()); } } @OnMessage public void onMessage(String message) { if (message.contains("[allUsers]")) { String userInfo = message.substring(message.indexOf("[allUsers]")).replace("[allUsers]----------", ""); SocketHandler.sendMessageAll( "<div style='width: 100%; float: left;'> " + userInfo + "群发消息</div><div style='width: 100%; font-size: 18px; font-weight: bolder; float: right;'>" + message.substring(0, message.indexOf("[")) + "</div>", userInfo); } else { String acceptUser = message.substring(message.indexOf("[") + 1, message.lastIndexOf("]")); String sendUser = message.substring(message.lastIndexOf("-") + 1, message.length()); Session userSession; for (Map.Entry<String, Session> item : sessionMap().entrySet()) { if (item.getKey().equals(acceptUser)) { userSession = item.getValue(); String userInfo = message.substring(0, message.indexOf("[")); SocketHandler.sendMessage(userSession, "<div style='width: 100%; float: left;'> " + sendUser + "</div><div style='width: 100%; font-size: 18px; font-weight: bolder; float: right;'>" + userInfo + "</div>"); } } } log.info("有新消息: {}", message); } @OnClose public void onClose(@PathParam("name") String name,Session session) { log.info("连接关闭: {}", session); remove(createKey(name)); log.info("在线人数:{}", count()); sessionMap().keySet().forEach(item -> log.info("在线用户:" + item)); for (Map.Entry<String, Session> item : sessionMap().entrySet()){ log.info("12: {}", item.getKey()); } Date date = new Date(); DateFormat df = DateFormat.getDateTimeInstance();//可以精确到时分秒 SocketHandler.sendMessageAll("<div style='width: 100%; float: left;'>[" + df.format(date) + "] " + name + "已离开聊天室</div>", name); } @OnError public void onError(Session session, Throwable throwable) { try { session.close(); } catch (IOException e) { log.error("退出发生异常: {}", e.getMessage()); } log.info("连接出现异常: {}", throwable.getMessage()); } }
② 创建SocketPool.java在线连接池类
package cn.cansluck.utils.net; import javax.websocket.Session; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; /** * WebSocket连接池类 * * @author Cansluck */ public class SocketPool { // 在线用户websocket连接池 private static final Map<String, Session> ONLINE_USER_SESSIONS = new ConcurrentHashMap<>(); /** * 新增一则连接 * @param key 设置主键 * @param session 设置session */ public static void add(String key, Session session) { if (!key.isEmpty() && session != null){ ONLINE_USER_SESSIONS.put(key, session); } } /** * 根据Key删除连接 * @param key 主键 */ public static void remove(String key) { if (!key.isEmpty()){ ONLINE_USER_SESSIONS.remove(key); } } /** * 获取在线人数 * @return 返回在线人数 */ public static int count(){ return ONLINE_USER_SESSIONS.size(); } /** * 获取在线session池 * @return 获取session池 */ public static Map<String, Session> sessionMap(){ return ONLINE_USER_SESSIONS; } }
③ 创建SocketHandler.java动作处理工具类
package cn.cansluck.utils.net; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.websocket.RemoteEndpoint; import javax.websocket.Session; import java.io.IOException; import static cn.cansluck.utils.net.SocketPool.sessionMap; /** * WebSocket动作类 * * @author Cansluck */ public class SocketHandler { private static final Logger log = LoggerFactory.getLogger(SocketHandler.class); /** * 根据key和用户名生成一个key值,简单实现下 * @param name 发送人 * @return 返回值 */ public static String createKey(String name){ return name; } /** * 给指定用户发送信息 * @param session session * @param msg 发送的消息 */ public static void sendMessage(Session session, String msg) { if (session == null) return; final RemoteEndpoint.Basic basic = session.getBasicRemote(); if (basic == null) return; try { basic.sendText(msg); } catch (IOException e) { log.error("消息发送异常,异常情况: {}", e.getMessage()); } } /** * 给所有的在线用户发送消息 * @param message 发送的消息 * @param username 发送人 */ public static void sendMessageAll(String message, String username) { log.info("广播:群发消息"); // 遍历map,只输出给其他客户端,不给自己重复输出 sessionMap().forEach((key, session) -> { if (!username.equals(key)) { sendMessage(session, message); } }); } }
④ 创建ChatController.java页面访问控制器类
package cn.cansluck.controller; import cn.cansluck.service.IUserService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Controller; import org.springframework.ui.ModelMap; import org.springframework.web.bind.annotation.RequestMapping; /** * 登录页 * * @author Cansluck */ @RequestMapping("/chat") @Controller public class ChatController { @Autowired private IUserService userService; /** * 登陆 * * @author Cansluck * @return 返回页面 */ @RequestMapping("/login") public String login(String username, String password, ModelMap map) { if (null == username || "".equals(username)) return "login"; boolean isLogin = userService.login(username, password); if (isLogin) { map.addAttribute("username", username); return "chat"; } return "login"; } }
⑤ 创建SocketConfig.java的websocket配置类
package cn.cansluck.utils; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.server.standard.ServerEndpointExporter; /** * WebSocket配置类 * * @author Cansluck */ @Configuration @EnableWebSocket public class SocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter(){ return new ServerEndpointExporter(); } }
以上就是一个WebSocket的简单实现,更多的场景小伙伴可以自行在这个基础上实现更多功能。后续会继续完善该聊天的功能,代码将会上传到GitHub上供下载。有兴趣的小伙伴可以一起来创作玩一下呀~后续还会将项目打包部署到我个人的腾讯云服务器上,有兴趣的可以一起来聊天呀~
GitHub项目下载地址
https://github.com/125207780/springboot-project.git
小伙伴们可以自行下载并操作,可以一起修改一起玩呀~
更多精彩敬请关注公众号
Java极客思维
微信扫一扫,关注公众号