官网介绍
🌐 http://www.websocket.org
The HTML5 WebSockets specification defines an API that enables web pages to use the WebSockets protocol for two-way communication with a remote host. It introduces the WebSocket interface and defines a full-duplex communication channel that operates through a single socket over the Web. HTML5 WebSockets provide an enormous reduction in unnecessary network traffic and latency compared to the unscalable polling and long-polling solutions that were used to simulate a full-duplex connection by maintaining two connections.
HTML5 WebSockets account for network hazards such as proxies and firewalls, making streaming possible over any connection, and with the ability to support upstream and downstream communications over a single connection, HTML5 WebSockets-based applications place less burden on servers, allowing existing machines to support more concurrent connections. The following figure shows a basic WebSocket-based architecture in which browsers use a WebSocket connection for full-duplex, direct communication with remote hosts.
💡 维基百科
WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为RFC 6455,并由RFC 7936补充规范。WebScoket API也被W3C定为标准。
WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebScoket API中,浏览器和服务器只需要完成一次握手,两者之前就直接创建持久性的连接,并进行双向数据传输。
项目准备
使用maven构建项目:
<properties><maven.compiler.source>1.8</maven.compiler.source><maven.compiler.target>1.8</maven.compiler.target><spring.boot.version>2.2.2.RELEASE</spring.boot.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId><version>${spring.boot.version}</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId><version>${spring.boot.version}</version></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
application.properties如下:
# Serverserver.port=3333# Thymeleafspring.thymeleaf.prefix=classpath:/view/
后端编码
CustomEndpointConfigure
import org.springframework.beans.BeansException;import org.springframework.beans.factory.BeanFactory;import org.springframework.context.ApplicationContext;import org.springframework.context.ApplicationContextAware;import javax.websocket.server.ServerEndpointConfig;/*** @author KHighness* @since 2021-04-05*/public class CustomEndpointConfigure extends ServerEndpointConfig.Configurator implements ApplicationContextAware {private static volatile BeanFactory context;@Overridepublic <T> T getEndpointInstance(Class<T> clazz) throws InstantiationException {return context.getBean(clazz);}@Overridepublic void setApplicationContext(ApplicationContext applicationContext) throws BeansException {CustomEndpointConfigure.context = applicationContext;}}
WebSocketConfigure
import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.socket.server.standard.ServerEndpointExporter;/*** @author KHighness* @since 2021-04-05*/@Configurationpublic class WebSocketConfigure {/*** 扫描并注册所有携带@ServerEndpoint注解的实例*/@Beanpublic ServerEndpointExporter serverEndpointExporter() {return new ServerEndpointExporter();}@Beanpublic CustomEndpointConfigure customEndpointConfigure() {return new CustomEndpointConfigure();}}
CommonController
import org.springframework.web.bind.annotation.PathVariable;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import org.springframework.web.servlet.ModelAndView;import top.parak.websocket.WebSocketServer;import javax.websocket.Session;import java.util.ArrayList;import java.util.List;import java.util.Map;/*** @author KHighness* @since 2021-04-05*/@RestController@RequestMapping("/websocket")public class CommonController {/*** 登录*/@RequestMapping("/login/{username}")public ModelAndView login(@PathVariable("username") String username) {return new ModelAndView("socketChart.html", "username", username);}/*** 登出*/@RequestMapping("/logout/{username}")public String logout(@PathVariable("username") String username) {return username + "退出成功";}/*** 获取在线用户*/@RequestMapping("/getOnlineList")public List<String> getOnlineList(String username) {List<String> list = new ArrayList<>();for (Map.Entry<String, Session> entry : WebSocketServer.sessionStorage.entrySet()) {if (!entry.getKey().equals(username)) {list.add(entry.getKey());}}return list;}}
WebSockerServer
import com.fasterxml.jackson.core.JsonProcessingException;import com.fasterxml.jackson.databind.ObjectMapper;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import top.parak.config.CustomEndpointConfigure;import javax.annotation.Resource;import javax.websocket.*;import javax.websocket.server.PathParam;import javax.websocket.server.ServerEndpoint;import java.io.IOException;import java.util.HashMap;import java.util.Map;import java.util.concurrent.ConcurrentHashMap;import java.util.concurrent.atomic.AtomicLong;/*** @author KHighness* @since 2021-04-05*/@RestController@RequestMapping("/websocket")@ServerEndpoint(value = "/websocket/{username}", configurator = CustomEndpointConfigure.class)public class WebSocketServer {private Logger logger = LoggerFactory.getLogger(WebSocketServer.class);@Resourceprivate ObjectMapper objectMapper;/*** 在线人数*/public static AtomicLong onlineCount = new AtomicLong();/*** 在线用户* key:username,value:session*/public static Map<String, Session> sessionStorage = new ConcurrentHashMap<>();/*** 连接建立成功调用*/@OnOpenpublic void onOpen(Session session, @PathParam("username") String username) {// 用户上线sessionStorage.put(username, session);// 数量增加WebSocketServer.onlineCount.incrementAndGet();// 群发消息try {// 构建消息Map<String, Object> map = new HashMap<>();map.put("type", "onlineCount");map.put("onlineCount", onlineCount.get());map.put("username", username);sendMessage(session, objectMapper.writeValueAsString(map));} catch (JsonProcessingException e) {logger.error(e.getMessage());}logger.info("用户{}上线,SESSION_ID = {}", username, session.getId());}/*** 连接关闭调用*/@OnClosepublic void onClose(Session session) {String username = "";for (Map.Entry<String, Session> entry : sessionStorage.entrySet()) {if (entry.getValue() == session) {username = entry.getKey();// 移除用户sessionStorage.remove(username);// 数量减少onlineCount.decrementAndGet();break;}}logger.info("用户{}下线,SESSION_ID = {}", username, session.getId());}/*** 收到客户端消息调用*/@OnMessagepublic void onMessage(Session session, String message) {// json -> hashmaptry {HashMap hashMap = objectMapper.readValue(message, HashMap.class);Map srcUser = (Map) hashMap.get("srcUser");Map tarUser = (Map) hashMap.get("tarUser");// 如果点击自己,则为群聊if (srcUser.get("username").equals(tarUser.get("username"))) {groupChat(session, hashMap);} else { // 私聊privateChat(session, tarUser, hashMap);}} catch (JsonProcessingException e) {logger.error(e.getMessage());}}/*** 发生错误调用*/@OnErrorpublic void onError(Session session, Throwable error) {logger.error(error.getMessage());}/*** 群发*/private void sendMessage(Session session, String message) {for (Map.Entry<String, Session> entry : sessionStorage.entrySet()) {try {if (entry.getValue() != session) {entry.getValue().getBasicRemote().sendText(message);}} catch (IOException e) {logger.error(e.getMessage());}}}/*** 私聊*/private void privateChat(Session session, Map target, HashMap message) {Session targetSession = sessionStorage.get(target.get("username"));if (targetSession == null) { // 目标用户不在线,向自己发送<对方不在线>try {// 构建消息Map<String, Object> map = new HashMap<>();map.put("type", "0");map.put("message", "对方不在线");session.getBasicRemote().sendText(objectMapper.writeValueAsString(map));} catch (IOException e) {logger.error(e.getMessage());}} else {try {message.put("type", "1");targetSession.getBasicRemote().sendText(new ObjectMapper().writeValueAsString(message));} catch (IOException e) {logger.error(e.getMessage());}}}/*** 群聊*/private void groupChat(Session session, HashMap hashMap) {for (Map.Entry<String, Session> entry : sessionStorage.entrySet()) {if (entry.getValue() != session) {try {hashMap.put("type", "2");entry.getValue().getBasicRemote().sendText(new ObjectMapper().writeValueAsString(hashMap));} catch (IOException e) {logger.error(e.getMessage());}}}}}
KHighnessApplication
import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.scheduling.annotation.EnableAsync;/*** @author KHighness* @since 2021-04-05*/@EnableAsync@SpringBootApplicationpublic class KHighnessApplication {public static void main(String[] args) {SpringApplication.run(KHighnessApplication.class, args);}}
前端编码
socketChart.css
body{background-color: #efebdc;}#hz-main{width: 700px;height: 500px;background-color: red;margin: 0 auto;}#hz-message{width: 500px;height: 500px;float: left;background-color: #B5B5B5;}#hz-message-body{width: 460px;height: 340px;background-color: #E0C4DA;padding: 10px 20px;overflow:auto;}#hz-message-input{width: 500px;height: 99px;background-color: white;overflow:auto;}#hz-group{width: 200px;height: 500px;background-color: rosybrown;float: right;}.hz-message-list{min-height: 30px;margin: 10px 0;}.hz-message-list-text{padding: 7px 13px;border-radius: 15px;width: auto;max-width: 85%;display: inline-block;}.hz-message-list-username{margin: 0;}.hz-group-body{overflow:auto;}.hz-group-list{padding: 10px;}.left{float: left;color: #595a5a;background-color: #ebebeb;}.right{float: right;color: #f7f8f8;background-color: #919292;}.hz-badge{width: 20px;height: 20px;background-color: #FF5722;border-radius: 50%;float: right;color: white;text-align: center;line-height: 20px;font-weight: bold;opacity: 0;}
socketChart.js
//消息对象数组var msgObjArr = new Array();var websocket = null;//判断当前浏览器是否支持WebSocket, springboot是项目名if ('WebSocket' in window) {websocket = new WebSocket("ws://localhost:3333/websocket/"+username);} else {console.error("不支持WebSocket");}//连接发生错误的回调方法websocket.onerror = function (e) {console.error("WebSocket连接发生错误");};//连接成功建立的回调方法websocket.onopen = function () {//获取所有在线用户$.ajax({type: 'post',url: ctx + "/websocket/getOnlineList",contentType: 'application/json;charset=utf-8',dataType: 'json',data: {username:username},success: function (data) {if (data.length) {//列表for (var i = 0; i < data.length; i++) {var userName = data[i];$("#hz-group-body").append("<div class=\"hz-group-list\"><span class='hz-group-list-username'>" + userName + "</span><span id=\"" + userName + "-status\">[在线]</span><div id=\"hz-badge-" + userName + "\" class='hz-badge'>0</div></div>");}//在线人数$("#onlineCount").text(data.length);}},error: function (xhr, status, error) {console.log("ajax错误!");}});}//接收到消息的回调方法websocket.onmessage = function (event) {var messageJson = eval("(" + event.data + ")");//普通消息(私聊)if (messageJson.type == "1") {//来源用户var srcUser = messageJson.srcUser;//目标用户var tarUser = messageJson.tarUser;//消息var message = messageJson.message;//最加聊天数据setMessageInnerHTML(srcUser.username,srcUser.username, message);}//普通消息(群聊)if (messageJson.type == "2"){//来源用户var srcUser = messageJson.srcUser;//目标用户var tarUser = messageJson.tarUser;//消息var message = messageJson.message;//最加聊天数据setMessageInnerHTML(username,tarUser.username, message);}//对方不在线if (messageJson.type == "0"){//消息var message = messageJson.message;$("#hz-message-body").append("<div class=\"hz-message-list\" style='text-align: center;'>" +"<div class=\"hz-message-list-text\">" +"<span>" + message + "</span>" +"</div>" +"</div>");}//在线人数if (messageJson.type == "onlineCount") {//取出usernamevar onlineCount = messageJson.onlineCount;var userName = messageJson.username;var oldOnlineCount = $("#onlineCount").text();//新旧在线人数对比if (oldOnlineCount < onlineCount) {if($("#" + userName + "-status").length > 0){$("#" + userName + "-status").text("[在线]");}else{$("#hz-group-body").append("<div class=\"hz-group-list\"><span class='hz-group-list-username'>" + userName + "</span><span id=\"" + userName + "-status\">[在线]</span><div id=\"hz-badge-" + userName + "\" class='hz-badge'>0</div></div>");}} else {//有人下线$("#" + userName + "-status").text("[离线]");}$("#onlineCount").text(onlineCount);}}//连接关闭的回调方法websocket.onclose = function () {//alert("WebSocket连接关闭");}//将消息显示在对应聊天窗口 对于接收消息来说这里的toUserName就是来源用户,对于发送来说则相反function setMessageInnerHTML(srcUserName,msgUserName, message) {//判断var childrens = $("#hz-group-body").children(".hz-group-list");var isExist = false;for (var i = 0; i < childrens.length; i++) {var text = $(childrens[i]).find(".hz-group-list-username").text();if (text == srcUserName) {isExist = true;break;}}if (!isExist) {//追加聊天对象msgObjArr.push({toUserName: srcUserName,message: [{username: msgUserName, message: message, date: NowTime()}]//封装数据});$("#hz-group-body").append("<div class=\"hz-group-list\"><span class='hz-group-list-username'>" + srcUserName + "</span><span id=\"" + srcUserName + "-status\">[在线]</span><div id=\"hz-badge-" + srcUserName + "\" class='hz-badge'>0</div></div>");} else {//取出对象var isExist = false;for (var i = 0; i < msgObjArr.length; i++) {var obj = msgObjArr[i];if (obj.toUserName == srcUserName) {//保存最新数据obj.message.push({username: msgUserName, message: message, date: NowTime()});isExist = true;break;}}if (!isExist) {//追加聊天对象msgObjArr.push({toUserName: srcUserName,message: [{username: msgUserName, message: message, date: NowTime()}]//封装数据});}}// 对于接收消息来说这里的toUserName就是来源用户,对于发送来说则相反var username = $("#toUserName").text();//刚好打开的是对应的聊天页面if (srcUserName == username) {$("#hz-message-body").append("<div class=\"hz-message-list\">" +"<p class='hz-message-list-username'>"+msgUserName+":</p>" +"<div class=\"hz-message-list-text left\">" +"<span>" + message + "</span>" +"</div>" +"<div style=\" clear: both; \"></div>" +"</div>");} else {//小圆点++var conut = $("#hz-badge-" + srcUserName).text();$("#hz-badge-" + srcUserName).text(parseInt(conut) + 1);$("#hz-badge-" + srcUserName).css("opacity", "1");}}//发送消息function send() {//消息var message = $("#hz-message-input").html();//目标用户名var tarUserName = $("#toUserName").text();//登录用户名var srcUserName = $("#talks").text();websocket.send(JSON.stringify({"type": "1","tarUser": {"username": tarUserName},"srcUser": {"username": srcUserName},"message": message}));$("#hz-message-body").append("<div class=\"hz-message-list\">" +"<div class=\"hz-message-list-text right\">" +"<span>" + message + "</span>" +"</div>" +"</div>");$("#hz-message-input").html("");//取出对象if (msgObjArr.length > 0) {var isExist = false;for (var i = 0; i < msgObjArr.length; i++) {var obj = msgObjArr[i];if (obj.toUserName == tarUserName) {//保存最新数据obj.message.push({username: srcUserName, message: message, date: NowTime()});isExist = true;break;}}if (!isExist) {//追加聊天对象msgObjArr.push({toUserName: tarUserName,message: [{username: srcUserName, message: message, date: NowTime()}]//封装数据[{username:huanzi,message:"你好,我是欢子!",date:2018-04-29 22:48:00}]});}} else {//追加聊天对象msgObjArr.push({toUserName: tarUserName,message: [{username: srcUserName, message: message, date: NowTime()}]//封装数据[{username:huanzi,message:"你好,我是欢子!",date:2018-04-29 22:48:00}]});}}//监听点击用户$("body").on("click", ".hz-group-list", function () {$(".hz-group-list").css("background-color", "");$(this).css("background-color", "whitesmoke");$("#toUserName").text($(this).find(".hz-group-list-username").text());//清空旧数据,从对象中取出并追加$("#hz-message-body").empty();$("#hz-badge-" + $("#toUserName").text()).text("0");$("#hz-badge-" + $("#toUserName").text()).css("opacity", "0");if (msgObjArr.length > 0) {for (var i = 0; i < msgObjArr.length; i++) {var obj = msgObjArr[i];if (obj.toUserName == $("#toUserName").text()) {//追加数据var messageArr = obj.message;if (messageArr.length > 0) {for (var j = 0; j < messageArr.length; j++) {var msgObj = messageArr[j];var leftOrRight = "right";var message = msgObj.message;var msgUserName = msgObj.username;var toUserName = $("#toUserName").text();//当聊天窗口与msgUserName的人相同,文字在左边(对方/其他人),否则在右边(自己)if (msgUserName == toUserName) {leftOrRight = "left";}//但是如果点击的是自己,群聊的逻辑就不太一样了if (username == toUserName && msgUserName != toUserName) {leftOrRight = "left";}if (username == toUserName && msgUserName == toUserName) {leftOrRight = "right";}var magUserName = leftOrRight == "left" ? "<p class='hz-message-list-username'>"+msgUserName+":</p>" : "";$("#hz-message-body").append("<div class=\"hz-message-list\">" +magUserName+"<div class=\"hz-message-list-text " + leftOrRight + "\">" +"<span>" + message + "</span>" +"</div>" +"<div style=\" clear: both; \"></div>" +"</div>");}}break;}}}});//获取当前时间function NowTime() {var time = new Date();var year = time.getFullYear();//获取年var month = time.getMonth() + 1;//或者月var day = time.getDate();//或者天var hour = time.getHours();//获取小时var minu = time.getMinutes();//获取分钟var second = time.getSeconds();//或者秒var data = year + "-";if (month < 10) {data += "0";}data += month + "-";if (day < 10) {data += "0"}data += day + " ";if (hour < 10) {data += "0"}data += hour + ":";if (minu < 10) {data += "0"}data += minu + ":";if (second < 10) {data += "0"}data += second;return data;}
socketChart.html
<!DOCTYPE><!--解决idea thymeleaf 表达式模板报红波浪线--><!--suppress ALL --><html xmlns:th="http://www.thymeleaf.org"><head><title>聊天页面</title><!-- jquery在线版本 --><script src="http://libs.baidu.com/jquery/2.1.4/jquery.min.js"></script><!--引入样式--><link th:href="@{/css/socketChart.css}" rel="stylesheet" type="text/css"/></head><body><div id="hz-main"><div id="hz-message"><!-- 头部 -->正在与<span id="toUserName"></span>聊天<hr style="margin: 0px;"/><!-- 主体 --><div id="hz-message-body"></div><!-- 功能条 --><div id=""><button>表情</button><button>图片</button><button id="videoBut">视频</button><button onclick="send()" style="float: right;">发送</button></div><!-- 输入框 --><div contenteditable="true" id="hz-message-input"></div></div><div id="hz-group">登录用户:<span id="talks" th:text="${username}">请登录</span><br/>在线人数:<span id="onlineCount">0</span><!-- 主体 --><div id="hz-group-body"></div></div></div></body><script type="text/javascript" th:inline="javascript">//项目路径ctx = [[${#request.getContextPath()}]];//登录名var username = /*[[${username}]]*/'';</script><script th:src="@{/js/socketChart.js}"></script></html>
实现效果
私聊
群聊
