去年獨立負責開發了一個小程序拼單的功能,要求兩個及兩個以上的設備同時在線點單時,單個設備加入購物車的商品要實時顯示在所有設備的購物車上面,最后再由拼單發起人進行結算和支付。當時小程序額外還加了一個拼單發起人可以向參與人發起群收款功能,這個功能以后再介紹。
剛寫代碼的時候用PHP集成Swoole寫過視頻直播的聊天功能,所以當時看到拼單購物車共享功能就想到實時性要求肯定很高,腦袋里就浮現出了Websocket,但技術選型因為某些原因被否決了,最后我只能采用每秒輪詢購物車版本號的方式來實現這個功能了。但是在面對實時性要求很高的功能,我堅信Websocket依然是很好的選擇。
這篇文章就將Websocket集成到SpringBoot中,簡單實現聊天房間在線用戶和消息列表。
首先在SpringBoot的pom.xml文件里面加入Websocket整合包:
<dependencies>
...
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
...
</dependencies>
然后創建一個配置文件,注入ServerEndpointExporter,簡單來說就是讓SpringBoot識別Websocket的注解
package com.demo.www.config.websocket; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.server.standard.ServerEndpointExporter; /** * WebSocket服務配置 * @author AnYuan */ @Configuration public class WebsocketConfig { /** * 注入一個ServerEndpointExporter * 該Bean會自動注冊使用@ServerEndpoint注解申明的websocket endpoint */ @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } }
再做一個前置事情,這邊構建一個統一的消息模版類,用來統一接受和發送消息的數據字段和類型:
package com.demo.www.config.websocket; import lombok.Data; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.List; /** * 消息模版 * @author AnYuan */ @Data public class WebsocketMsgDTO { /** * 發送消息用戶 */ private String uid; /** * 接收消息用戶 */ private String toUId; /** * 消息內容 */ private String content; /** * 消息時間 */ private String dateTime; /** * 用戶列表 */ private List<String> onlineUser; /** * 統一消息模版 * @param uid 發送消息用戶 * @param content 消息內容 * @param onlineUser 在線用戶列表 */ public WebsocketMsgDTO(String uid, String content, List<String> onlineUser) { this.uid = uid; this.content = content; this.onlineUser = onlineUser; this.dateTime = localDateTimeToString(); } /** * 獲取當前時間 * @return String 12:00:00 */ private String localDateTimeToString() { DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss"); return dateTimeFormatter.format( LocalDateTime.now()); } }
最后上服務端邏輯代碼:@ServerEndpoint(value="") 這個是Websocket服務url前綴,{uid}類似於ResutFul風格的參數
package com.demo.www.config.websocket;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* WebSocketServer服務
* @author AnYuan
*/
@ServerEndpoint(value = "/webSocket/{uid}")
@Component
@Slf4j
public class WebSocketServer {
/**
* 機器人發言名稱
*/
private static final String SPOKESMAN_ADMIN = "機器人";
/**
* concurrent包的線程安全Set
* 用來存放每個客戶端對應的Session對象
*/
private static final ConcurrentHashMap<String, Session> SESSION_POOLS = new ConcurrentHashMap<>();
/**
* 靜態變量,用來記錄當前在線連接數。
* 應該把它設計成線程安全的。
*/
private static final AtomicInteger ONLINE_NUM = new AtomicInteger();
/**
* 獲取在線用戶列表
* @return List<String>
*/
private List<String> getOnlineUsers() {
return new ArrayList<>(SESSION_POOLS.keySet());
}
/**
* 用戶建立連接成功調用
* @param session 用戶集合
* @param uid 用戶標志
*/
@OnOpen
public void onOpen(Session session, @PathParam(value = "uid") String uid) {
// 將加入連接的用戶加入SESSION_POOLS集合
SESSION_POOLS.put(uid, session);
// 在線用戶+1
ONLINE_NUM.incrementAndGet();
sendToAll(new WebsocketMsgDTO(SPOKESMAN_ADMIN, uid + " 加入連接!", getOnlineUsers()));
}
/**
* 用戶關閉連接時調用
* @param uid 用戶標志
*/
@OnClose
public void onClose(@PathParam(value = "uid") String uid) {
// 將加入連接的用戶移除SESSION_POOLS集合
SESSION_POOLS.remove(uid);
// 在線用戶-1
ONLINE_NUM.decrementAndGet();
sendToAll(new WebsocketMsgDTO(SPOKESMAN_ADMIN, uid + " 斷開連接!", getOnlineUsers()));
}
/**
* 服務端收到客戶端信息
* @param message 客戶端發來的string
* @param uid uid 用戶標志
*/
@OnMessage
public void onMessage(String message, @PathParam(value = "uid") String uid) {
log.info("Client:[{}], Message: [{}]", uid, message);
// 接收並解析前端消息並加上時間,最后根據是否有接收用戶,區別發送所有用戶還是單個用戶
WebsocketMsgDTO msgDTO = JSONObject.parseObject(message, WebsocketMsgDTO.class);
msgDTO.setDateTime(localDateTimeToString());
// 如果有接收用戶就發送單個用戶
if (Strings.isNotBlank(msgDTO.getToUId())) {
sendMsgByUid(msgDTO);
return;
}
// 否則發送所有人
sendToAll(msgDTO);
}
/**
* 給所有人發送消息
* @param msgDTO msgDTO
*/
private void sendToAll(WebsocketMsgDTO msgDTO) {
//構建json消息體
String content = JSONObject.toJSONString(msgDTO);
// 遍歷發送所有在線用戶
SESSION_POOLS.forEach((k, session) -> sendMessage(session, content));
}
/**
* 給指定用戶發送信息
*/
private void sendMsgByUid(WebsocketMsgDTO msgDTO) {
sendMessage(SESSION_POOLS.get(msgDTO.getToUId()), JSONObject.toJSONString(msgDTO));
}
/**
* 發送消息方法
* @param session 用戶
* @param content 消息
*/
private void sendMessage(Session session, String content){
try {
if (Objects.nonNull(session)) {
// 使用Synchronized鎖防止多次發送消息
synchronized (session) {
// 發送消息
session.getBasicRemote().sendText(content);
}
}
} catch (IOException ioException) {
log.info("發送消息失敗:{}", ioException.getMessage());
ioException.printStackTrace();
}
}
/**
* 獲取當前時間
* @return String 12:00:00
*/
private String localDateTimeToString() {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss");
return dateTimeFormatter.format( LocalDateTime.now());
}
}
以上啟動后,就開啟了一個WebSocket服務了,只需要前端接入就可以了。這里提一下,[獲取當前時間]的方法兩個地方用到了,建議用一個時間工具類將其提取出來,這里為了減少文件就寫了兩次。
那接下來我們簡單寫一下前端樣式和Js代碼,前端樣式是我在網上下載后進行修改的,重點是Js部分, 下面將創建一個Admin用戶,一個user用戶,同時連接這個WebSocket服務,實現展現在線用戶和通告列表的功能。
第一個文件:admin.html
<!DOCTYPE html>
<html>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<head>
<title>Admin Hello WebSocket</title>
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.4.1/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.2.1/jquery.js"></script>
<script src="app.js"></script>
<style>
body {
background-color: #f5f5f5;
}
#main-content {
max-width: 940px;
padding: 2em 3em;
margin: 0 auto 20px;
background-color: #fff;
border: 1px solid #e5e5e5;
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
}
</style>
</head>
<body>
<div id="main-content" class="container">
<div class="row">
<div class="col-md-6">
<form class="form-inline">
<div class="form-group">
<input id="userId" value="Admin" hidden>
<label for="connect">建立連接通道:</label>
<button id="connect" class="btn btn-default" type="submit">Connect</button>
<button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect
</button>
</div>
</form>
</div>
<div class="col-md-6">
<form class="form-inline">
<div class="form-group">
<label>發布新公告</label>
<input type="text" id="content" class="form-control" value="" placeholder="發言框..">
</div>
<button id="send" class="btn btn-default" type="submit">發布</button>
</form>
</div>
</div>
<div class="row" style="margin-top: 30px">
<div class="col-md-12">
<table id="userlist" class="table table-striped">
<thead>
<tr>
<th>實時在線用戶列表<span id="onLineUserCount"></span></th>
</tr>
</thead>
<tbody id='online'>
</tbody>
</table>
</div>
<div class="col-md-12">
<table id="conversation" class="table table-striped">
<thead>
<tr>
<th>游戲公告內容</th>
</tr>
</thead>
<tbody id="notice">
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
第二個文件:user.html
<!DOCTYPE html>
<html>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<head>
<title>User1 Hello WebSocket</title>
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.4.1/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.2.1/jquery.js"></script>
<script src="app.js"></script>
<style>
body {
background-color: #f5f5f5;
}
#main-content {
max-width: 940px;
padding: 2em 3em;
margin: 0 auto 20px;
background-color: #fff;
border: 1px solid #e5e5e5;
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
}
</style>
</head>
<body>
<div id="main-content" class="container">
<div class="row">
<div class="col-md-6">
<form class="form-inline">
<div class="form-group">
<input id="userId" value="user1" hidden>
<label for="connect">建立連接通道:</label>
<button id="connect" class="btn btn-default" type="submit">Connect</button>
<button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect
</button>
</div>
</form>
</div>
</div>
<div class="row" style="margin-top: 30px">
<div class="col-md-12">
<table id="userlist" class="table table-striped">
<thead>
<tr>
<th>實時在線用戶列表<span id="onLineUserCount"></span></th>
</tr>
</thead>
<tbody id='online'>
</tbody>
</table>
</div>
<div class="col-md-12">
<table id="conversation" class="table table-striped">
<thead>
<tr>
<th>游戲公告內容</th>
</tr>
</thead>
<tbody id="notice">
</tbody>
</table>
</div>
</div>
</div>
</body>
</html>
最后重點的Js文件:app.js。 將其與admin.html、user.html放在同一個目錄下即可引用
var socket;
function setConnected(connected) {
$("#connect").prop("disabled", connected);
$("#disconnect").prop("disabled", !connected);
if (connected) {
$("#conversation").show();
} else {
$("#conversation").hide();
}
$("#notice").html("");
}
// WebSocket 服務操作
function openSocket() {
if (typeof (WebSocket) == "undefined") {
console.log("瀏覽器不支持WebSocket");
} else {
console.log("瀏覽器支持WebSocket");
//實現化WebSocket對象,指定要連接的服務器地址與端口 建立連接
if (socket != null) {
socket.close();
socket = null;
}
// ws 為websocket連接標識,localhost:9999 為SpringBoot的連接地址,webSocket 為后端配置的前綴, userId 則是參數
socket = new WebSocket("ws://localhost:9999/webSocket/" + $("#userId").val());
//打開事件
socket.onopen = function () {
console.log("websocket已打開");
setConnected(true)
};
//獲得消息事件
socket.onmessage = function (msg) {
const msgDto = JSON.parse(msg.data);
console.log(msg)
showContent(msgDto);
showOnlineUser(msgDto.onlineUser);
};
//關閉事件
socket.onclose = function () {
console.log("websocket已關閉");
setConnected(false)
removeOnlineUser();
};
//發生了錯誤事件
socket.onerror = function () {
setConnected(false)
console.log("websocket發生了錯誤");
}
}
}
//2、關閉連接
function disconnect() {
if (socket !== null) {
socket.close();
}
setConnected(false);
console.log("Disconnected");
}
function sendMessage() {
if (typeof (WebSocket) == "undefined") {
console.log("您的瀏覽器不支持WebSocket");
} else {
var msg = '{"uid":"' + $("#userId").val() + '", "toUId": null, "content":"' + $("#content").val() + '"}';
console.log("向服務端發送消息體:" + msg);
socket.send(msg);
}
}
// 訂閱的消息顯示在客戶端指定位置
function showContent(serverMsg) {
$("#notice").html("<tr><td>" + serverMsg.uid + ": </td> <td>" + serverMsg.content + "</td><td>" + serverMsg.dateTime + "</td></tr>" + $("#notice").html())
}
//顯示實時在線用戶
function showOnlineUser(serverMsg) {
if (null != serverMsg) {
let html = '';
for (let i = 0; i < serverMsg.length; i++) {
html += "<tr><td>" + serverMsg[i] + "</td></tr>";
}
$("#online").html(html);
$("#onLineUserCount").html(" ( " + serverMsg.length + " )");
}
}
//顯示實時在線用戶
function removeOnlineUser() {
$("#online").html("");
$("#onLineUserCount").html("");
}
$(function () {
$("form").on('submit', function (e) {
e.preventDefault();
});
$("#connect").click(function () {
openSocket();
});
$("#disconnect").click(function () {
disconnect();
});
$("#send").click(function () {
sendMessage();
});
});
打開admin.html和user.html頁面:

分別點擊Connect連接Websocket服務,然后使用admin頁面的[發布新通告]進行消息發布:

這里只實現了管理員群發消息,沒有實現一對一的聊天,可以通過修改用戶列表的樣式,增加一對一聊天的功能。在app.js里,發送消息時指定發送對象字段 [toUId] 就可以實現一對一聊天了。
到此SpringBoot集成Websocket就已經完成了,如果要實現聊天或者拼單等一些實時性要求比較高的功能,可以通過更改WebSocketServer文件的邏輯,接入業務代碼。
最后建議將WebSocket服務與業務代碼運行環境分開,使用不同的容器,最后再根據流量大小決定是否需要擴容。
