官网介绍
🌐 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如下:
# Server
server.port=3333
# Thymeleaf
spring.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;
@Override
public <T> T getEndpointInstance(Class<T> clazz) throws InstantiationException {
return context.getBean(clazz);
}
@Override
public 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
*/
@Configuration
public class WebSocketConfigure {
/**
* 扫描并注册所有携带@ServerEndpoint注解的实例
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
@Bean
public 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);
@Resource
private ObjectMapper objectMapper;
/**
* 在线人数
*/
public static AtomicLong onlineCount = new AtomicLong();
/**
* 在线用户
* key:username,value:session
*/
public static Map<String, Session> sessionStorage = new ConcurrentHashMap<>();
/**
* 连接建立成功调用
*/
@OnOpen
public 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());
}
/**
* 连接关闭调用
*/
@OnClose
public 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());
}
/**
* 收到客户端消息调用
*/
@OnMessage
public void onMessage(Session session, String message) {
// json -> hashmap
try {
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());
}
}
/**
* 发生错误调用
*/
@OnError
public 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
@SpringBootApplication
public 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") {
//取出username
var 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>
实现效果
私聊
群聊