目錄
什么是 WebSocket?
為什么需要 WebSocket?
HTTP 的局限性
WebSocket 的優勢
總結:HTTP 和?WebSocket 的區別
WebSocket 的劣勢
WebSocket 常見應用場景
WebSocket 握手過程
WebSocket 事件處理和生命周期
WebSocket 心跳機制
配置接入文心一言
WebSocket 實現
什么是 WebSocket?
WebSocket是一種在單個TCP連接上進行全雙工通信的協議。WebSocket使得客戶端和服務器之間的數據交換變得更加簡單,允許服務端主動向客戶端推送數據。在WebSocket API中,瀏覽器和服務器只需要完成一次握手,兩者之間就直接可以創建持久性的連接,并進行雙向數據傳輸。
為什么需要 WebSocket?
HTTP 的局限性
HTTP 協議是基于請求-響應模式的,它的設計適合于靜態頁面的交互,但對于實時通信卻有以下局限:
-
單向通信:
HTTP 是單向通信協議,客戶端必須發起請求,服務器才能響應,服務器無法主動向客戶端推送消息。 -
高延遲:
為了獲取最新數據,客戶端需要不斷輪詢,即周期性發送請求檢查是否有新消息。這會導致明顯的延遲,無法滿足實時需求。 -
資源浪費:
- 在長輪詢中,服務器需要保持連接直到有新數據可發送,這會消耗服務器資源。
- 即使使用普通輪詢,大量的請求仍會消耗帶寬和服務器處理能力。
WebSocket 的優勢
1. 全雙工通信
- WebSocket 支持 雙向通信,客戶端和服務器可以在單個連接上同時發送和接收消息。
- 與 HTTP 的單向請求-響應模式相比,WebSocket 提供了更高效的通信機制,特別適合需要頻繁數據交換的場景。
2. 長連接
- WebSocket 建立連接后,保持連接持續打開,直到客戶端或服務器主動關閉。無需像 HTTP 那樣頻繁建立和斷開連接。
- 長連接的特性降低了連接建立和關閉的開銷,提高了性能。
3. 低延遲
- 一旦連接建立,WebSocket 消息傳輸延遲極低。
- 服務器可以 主動推送數據 到客戶端,無需等待客戶端的請求。
4. 較少的網絡開銷
4.1 減少握手和頭部信息
- WebSocket 的握手過程發生在連接建立時,之后的數據幀頭部信息非常小。
- HTTP 的每個請求都有完整的頭部,而 WebSocket 只需一次握手,數據傳輸更加高效。
4.2 減少帶寬消耗
- 相較于 HTTP 輪詢或長輪詢,WebSocket 顯著減少了帶寬消耗,數據流量更小,網絡利用率更高。
5. 實現實時功能
WebSocket 為許多實時應用提供了天然支持,避免了傳統 HTTP 無法滿足的延遲問題。
常見功能包括:
- 實時消息推送。
- 實時互動(如在線聊天)。
- 數據同步。
6. 支持二進制數據
- WebSocket 不僅可以傳輸文本數據,還支持傳輸二進制數據,這使得 WebSocket 能夠高效處理圖像、音頻、視頻等多媒體數據。
7. 跨平臺支持
- WebSocket 是一種標準協議,被廣泛支持于各種語言、框架和平臺(如 JavaScript、Python、Java 等)。
- 不論是前端瀏覽器還是后端服務器,都可以輕松實現 WebSocket 功能。
8. 安全性
- WebSocket 支持加密協議 WSS(WebSocket Secure),通過 TLS/SSL 保障數據傳輸的安全性。
- 可以結合身份驗證(如 JWT)或 IP 限制等機制,防止連接濫用。
總結:HTTP 和?WebSocket 的區別
特點 | WebSocket | HTTP |
---|---|---|
通信方式 | 雙向通信 | 單向請求/響應模式 |
連接類型 | 長連接,連接保持打開 | 默認短連接,每次請求需新建連接 |
實時性 | 高,低延遲 | 中等,輪詢或長輪詢增加延遲 |
效率 | 數據傳輸輕量,性能高 | 每次請求頭部信息冗余,開銷大 |
適用場景 | 實時推送、聊天、游戲、物聯網等 | 靜態內容加載、API 調用 |
WebSocket 的劣勢
1. 復雜性較高
- 協議實現復雜:與傳統的 HTTP 模型相比,WebSocket 需要額外的握手過程,并且要求服務器支持 WebSocket 協議。
- 開發難度增加:需要實現雙向通信的邏輯,并處理連接生命周期、斷線重連等問題。
2. 消耗資源
- 連接資源占用:WebSocket 需要長期占用服務器的連接資源,特別是在高并發場景中,服務器需維護大量的長連接,可能導致資源消耗增加。
- 例如:服務器需要為每個 WebSocket 連接維護狀態,而 HTTP 是無狀態的。
- 客戶端性能開銷:在移動設備或低性能設備上,長時間保持 WebSocket 連接可能會增加電量和網絡資源的消耗。
3. 安全性問題
- 身份認證不足:WebSocket 本身沒有內置的身份認證機制,需要額外實現安全驗證(如使用 JWT 或 API Key)。
- 更易被濫用:
- DDoS 攻擊:攻擊者可能通過建立大量 WebSocket 連接,耗盡服務器資源。
- 劫持風險:如果使用未加密的 WebSocket(ws://),數據可能在傳輸過程中被劫持或篡改。
- 跨站風險:可能受到跨站 WebSocket 劫持攻擊。
4.協議的復雜性和兼容性
- 協議版本問題:雖然 WebSocket 是標準化協議,但與特定技術棧或庫的版本不兼容可能導致問題(如舊版 WebSocket 客戶端和新版服務器之間的兼容性問題)。
WebSocket 常見應用場景
- 實時聊天:WebSocket能夠提供雙向、實時的通信機制,使得實時聊天應用能夠快速、高效地發送和接收消息,實現即時通信。
- 實時協作:用于實時協作工具,如協同編輯文檔、白板繪畫、團隊任務管理等,團隊成員可以實時地在同一頁面上進行互動和實時更新。
- 實時數據推送:用于實時數據推送場景,如股票行情、新聞快訊、實時天氣信息等,服務器可以實時將數據推送給客戶端,確保數據的及時性和準確性。
- 多人在線游戲:實時的雙向通信機制,適用于多人在線游戲應用,使得游戲服務器能夠實時地將游戲狀態和玩家行為傳輸給客戶端,實現游戲的實時互動。
- 在線客服:WebSocket可以用于在線客服和客戶支持系統,實現實時的客戶溝通和問題解決,提供更好的用戶體驗,減少等待時間。
WebSocket 握手過程
WebSocket 握手過程是客戶端和服務器之間建立 WebSocket 連接的關鍵步驟。它的過程如下:
1.客戶端發起握手請求
客戶端首先通過 HTTP 協議向服務器發起 WebSocket 握手請求。這個請求的特點是包含一些特殊的頭部字段,要求將連接從 HTTP 協議升級到 WebSocket 協議。
請求頭包括:
- Upgrade:
websocket
(表示客戶端希望升級協議為 WebSocket)。 - Connection:
Upgrade
(表明客戶端希望升級連接)。 - Sec-WebSocket-Key: 一個隨機生成的 Base64 編碼的字符串,用于確保 WebSocket 協議升級的安全性。
- Sec-WebSocket-Version: 這個字段表示客戶端支持的 WebSocket 協議版本(通常為
13
)。 - Origin: (可選)指示請求來自的源,服務器可以使用此字段來判斷是否允許建立 WebSocket 連接,防止跨站點攻擊。
2.服務器響應握手請求
當服務器收到客戶端的握手請求后,如果它支持 WebSocket 協議,并同意升級連接,則會返回一個 HTTP 101 狀態碼(切換協議)。響應頭包含以下字段:
- HTTP/1.1 101 Switching Protocols: 表示服務器同意協議切換。
- Upgrade:
websocket
(表示協議切換為 WebSocket)。 - Connection:
Upgrade
(表示連接已經升級)。 - Sec-WebSocket-Accept: 服務器將
Sec-WebSocket-Key
的值與一個固定的 GUID (258EAFA5-E914-47DA-95CA-C5AB0DC85B11
) 拼接,并通過 SHA-1 加密后再進行 Base64 編碼,生成這個字段的值。這個過程用于確保握手的安全性,防止惡意請求偽造 WebSocket 協議。
3.WebSocket 連接建立
一旦服務器成功回應了客戶端的請求并發送了 Sec-WebSocket-Accept
字段,客戶端就可以確認協議切換成功,WebSocket 連接正式建立。這時,客戶端和服務器之間的通信將切換到 WebSocket 協議,而不再依賴 HTTP。
4.數據交換
連接建立后,客戶端和服務器就可以通過 WebSocket 連接進行雙向通信,雙方可以隨時發送消息,而不需要重新建立連接。
5.連接關閉
連接可以通過以下方式關閉:
- 客戶端或服務器發送關閉幀:在傳輸完消息后,任一方都可以發起關閉連接的請求,另一方確認后關閉連接。
- 協議約定:WebSocket 協議定義了一個關閉幀,包含一個狀態碼,表示關閉連接的原因。
WebSocket 握手的核心在于通過 HTTP 協議的升級請求和響應,成功切換到 WebSocket 協議,然后客戶端和服務器通過持久化的雙向連接進行高效的數據交換。
WebSocket 事件處理和生命周期
1.onopen:連接成功時觸發
onopen
事件在 WebSocket 連接成功建立時觸發。這通常意味著客戶端與服務器之間的 WebSocket 連接已經完成,雙方可以開始交換消息。
作用:在連接建立時執行一些初始化操作(如發送第一個消息,記錄日志等)。
let ws = new WebSocket('ws://example.com/socket');
ws.onopen = function(event) {console.log('Connection established');ws.send('Hello Server');
};
2.onmessage:接收到消息時觸發
onmessage
事件在 WebSocket 連接收到來自服務器的消息時觸發。事件的參數包含接收到的消息,可以是文本或二進制數據。
作用:用于處理服務器發來的數據。event.data
包含服務器傳來的消息內容。
let ws = new WebSocket('ws://example.com/socket');
ws.onmessage = function(event) {console.log('Received message:', event.data);
};
3.onclose:連接關閉時觸發
onclose
事件在 WebSocket 連接關閉時觸發。關閉可以是由客戶端或服務器主動發起的。事件參數通常包括關閉的代碼和原因。
作用:用于執行清理操作,如更新 UI 狀態或重新連接等。
let ws = new WebSocket('ws://example.com/socket');
ws.onclose = function(event) {if (event.wasClean) {console.log('Closed cleanly');} else {console.log('Closed with error');}console.log('Close code:', event.code);
};
4.onerror:發生錯誤時觸發
onerror
事件在 WebSocket 連接出現錯誤時觸發。這個事件通常是在網絡錯誤、協議錯誤等情況下發生,客戶端可以捕獲這個事件并進行相應的錯誤處理。
作用:用于捕獲并處理 WebSocket 的錯誤,可能包括連接失敗、數據傳輸失敗等。
let ws = new WebSocket('ws://example.com/socket');
ws.onerror = function(event) {console.error('WebSocket error:', event);
};
WebSocket 心跳機制
概述
WebSocket 的心跳機制用于確保客戶端和服務器之間的連接穩定,并檢測連接是否斷開。由于 WebSocket 是基于持久連接的協議,如果一方長時間不活動,可能會被防火墻、代理或其他網絡設備視為非活躍連接而斷開。心跳機制通過定期發送消息來保持連接活躍。
原理
- 發送心跳消息:客戶端或服務器定期發送特殊的“心跳包”消息,表明連接仍然正常。
- 接收心跳響應:另一方接收到心跳包后,應返回一個確認消息(可以是固定格式,也可以是直接回復心跳包)。
- 斷開檢測:如果在一定時間內未收到預期的心跳響應,說明連接可能已斷開,此時可以嘗試重新連接。
實現
客戶端通常通過 setInterval
定期發送心跳包,并監聽服務器的響應。
服務器接收到心跳包后,應返回一個固定的心跳響應(如 "pong"
)。
應用
心跳機制廣泛應用于以下場景:
- 即時通訊(IM)應用:保持用戶在線狀態。
- 在線游戲:保持游戲連接,防止掉線。
- 實時數據更新:如股票、天氣等實時推送數據。
- 長時間后臺連接:如 IoT 設備的數據上傳。
配置接入文心一言
1.在百度智能云開放平臺中注冊成為開發者
百度智能云 API開放平臺-API服務商-冪簡集成
2.進入百度智能云官網進行登錄,點擊立即體驗
3.進入千帆ModelBuilder,點擊左側的應用接入并且點擊創建應用
4.在頁面上的應用名稱輸入自己想要的應用名稱和應用描述
5.獲取對應的API Key 和 Secret Key
6.配置文心一言ERNIE4.0 API并調用,選擇一個想要使用的模型
7.添加依賴
<dependency><groupId>com.squareup.okhttp3</groupId><artifactId>okhttp</artifactId><version>4.9.3</version>
</dependency>
8.根據示例代碼進行測試
package com.qcby.byspringbootdemo;import okhttp3.*;
import org.json.JSONException;
import org.json.JSONObject;import java.io.*;
import java.util.concurrent.TimeUnit;class Sample {// 密鑰public static final String API_KEY = "";public static final String SECRET_KEY = "";// OkHttpClient 配置,設置連接超時和讀取超時static final OkHttpClient HTTP_CLIENT = new OkHttpClient().newBuilder().connectTimeout(60, TimeUnit.SECONDS) // 設置連接超時為60秒.readTimeout(60, TimeUnit.SECONDS) // 設置讀取超時為60秒.build();public static void main(String[] args) throws IOException, JSONException {// 定義請求的媒體類型MediaType mediaType = MediaType.parse("application/json");// 構建請求體,消息內容包含了用戶請求RequestBody body = RequestBody.create(mediaType, "{\"messages\":["+ "{\"role\":\"user\",\"content\":\"北京的天氣是什么\"}" // 用戶輸入的消息內容+ "],"+ "\"temperature\":0.95," // 設置溫度參數,控制模型的輸出多樣性+ "\"top_p\":0.8," // 設置 top_p 參數,控制模型輸出的多樣性+ "\"penalty_score\":1," // 設置懲罰得分參數,影響模型對重復內容的懲罰+ "\"enable_system_memory\":false," // 禁用系統內存+ "\"disable_search\":false," // 禁用搜索功能+ "\"enable_citation\":false}"); // 禁用引用功能// 構建 HTTP 請求Request request = new Request.Builder().url("https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/completions_pro?access_token=" + getAccessToken()).method("POST", body) // 設置請求方法為 POST.addHeader("Content-Type", "application/json") // 設置請求頭,表示發送的內容是 JSON 格式.build();// 發送請求并獲取響應try (Response response = HTTP_CLIENT.newCall(request).execute()) {// 輸出響應內容,打印接口返回的數據System.out.println(response.body().string());} catch (IOException e) {// 捕獲 IO 異常(如網絡錯誤、超時等),并打印異常信息e.printStackTrace();}}/*** 從用戶的 API 密鑰(AK、SK)生成鑒權簽名(Access Token)** @return 鑒權簽名(Access Token)* @throws IOException 如果發生 I/O 異常* @throws JSONException 如果發生 JSON 解析異常*/static String getAccessToken() throws IOException, JSONException {// 設置請求體的媒體類型為 x-www-form-urlencodedMediaType mediaType = MediaType.parse("application/x-www-form-urlencoded");// 創建請求體,包含 API 的 client_id 和 client_secretRequestBody body = RequestBody.create(mediaType, "grant_type=client_credentials&client_id=" + API_KEY+ "&client_secret=" + SECRET_KEY);// 構建請求,使用 POST 方法獲取 Access TokenRequest request = new Request.Builder().url("https://aip.baidubce.com/oauth/2.0/token") // 請求 URL,獲取 Access Token.method("POST", body) // 使用 POST 方法發送請求.addHeader("Content-Type", "application/x-www-form-urlencoded") // 請求頭.build();// 發送請求并獲取響應try (Response response = HTTP_CLIENT.newCall(request).execute()) {// 從響應中解析出 Access Tokenreturn new JSONObject(response.body().string()).getString("access_token");}}
}
WebSocket 實現
1.WebSocketConfig?
配置一個 Spring Boot 應用中的 WebSocket 支持,通過使用 ServerEndpointExporter
來實現 WebSocket 的啟用
package com.qcby.byspringbootdemo.config;import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;@Configuration
public class WebSocketConfig {@Beanpublic ServerEndpointExporter serverEndpointExporter() {return new ServerEndpointExporter();}
}
2.WebSocketServer?
使用 @ServerEndpoint
注解聲明了 WebSocket 端點,指定了路徑 /api/websocket/{sid}
,其中 {sid}
是一個動態路徑參數,代表每個連接的唯一標識。
WebSocket 事件處理:
@OnOpen
: 連接建立時調用的方法。每當一個新的 WebSocket 連接建立時,執行此方法,并將當前連接的sid
和session
保存到webSocketSet
中,同時增加在線人數。- 發送一個 JSON 格式的 "conn_success" 消息給客戶端,表示連接成功。
@OnClose
: 連接關閉時調用的方法。每當一個 WebSocket 連接關閉時,執行此方法,并從webSocketSet
中移除該連接,同時減少在線人數。@OnMessage
: 收到客戶端消息時調用的方法。該方法解析接收到的消息并根據目標用戶的sid
將消息發送給目標客戶端。如果目標用戶是管理員且管理員不在線,系統會通過WenXinYiYanUtil
獲取自動回復,進行自動響應。@OnError
: 發生錯誤時調用的方法,日志記錄錯誤信息。
消息發送:
sendMessage
: 該方法用于向客戶端發送消息,利用session.getBasicRemote().sendText()
實現。sendInfo
: 該方法用于群發消息,向所有連接的客戶端發送自定義的消息。可以根據傳入的sid
進行定向推送。
package com.qcby.byspringbootdemo.server;import com.alibaba.fastjson2.JSONException;
import com.alibaba.fastjson2.JSONObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.qcby.byspringbootdemo.util.WenXinYiYanUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;
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.CopyOnWriteArraySet;/*** WebSocket 服務端*/
@Component
@Slf4j
@Service
@ServerEndpoint("/api/websocket/{sid}")
public class WebSocketServer {//當前在線連接數private static int onlineCount = 0;//存放每個客戶端對應的 WebSocketServer 對象private static final CopyOnWriteArraySet<WebSocketServer> webSocketSet = new CopyOnWriteArraySet<>();//用戶信息private Session session;//當前用戶的 sidprivate String sid = "";//JSON解析工具private static final ObjectMapper objectMapper = new ObjectMapper();/*** 連接建立成功調用的方法*/@OnOpenpublic void onOpen(Session session, @PathParam("sid") String sid) {this.session = session;this.sid = sid;webSocketSet.add(this); //加入集合addOnlineCount(); //在線數加1try {// 發送 JSON 格式的消息String successMessage = "{\"message\": \"conn_success\"}";sendMessage(successMessage);log.info("有新窗口開始監聽: " + sid + ", 當前在線人數為: " + getOnlineCount());} catch (IOException e) {log.error("WebSocket IO Exception", e);}}/*** 連接關閉調用的方法*/@OnClosepublic void onClose() {webSocketSet.remove(this); //從集合中刪除subOnlineCount(); //在線數減1log.info("釋放的 sid 為:" + sid);log.info("有一連接關閉!當前在線人數為 " + getOnlineCount());}/*** 收到客戶端消息后調用的方法*/@OnMessagepublic void onMessage(String message, Session session) {log.info("收到來自窗口 " + sid + " 的信息: " + message);String targetSid;String msgContent;try {//解析接收到的 JSON 消息Map<String, String> messageMap = objectMapper.readValue(message, Map.class);targetSid = messageMap.get("targetSid");msgContent = messageMap.get("message");} catch (IOException e) {log.error("消息解析失敗", e);return;}//判斷目標用戶是否為管理員且管理員不在線boolean isTargetAdmin = isAdmin(targetSid);if (isTargetAdmin && !isAdminOnline()) {log.info("管理員不在線,調用文心一言進行自動回復");String wenxinResponse = getWenxinResponse(msgContent);if (wenxinResponse != null) {log.info("文心一言返回的回復: " + wenxinResponse);Map<String, String> responseMap = new HashMap<>();responseMap.put("sourceSid", sid);responseMap.put("message", wenxinResponse);String jsonResponse;try {//將回復消息轉換為 JSON 格式jsonResponse = objectMapper.writeValueAsString(responseMap);} catch (IOException e) {log.error("JSON 序列化失敗", e);return;}//發送自動回復消息給發送方try {sendMessage(jsonResponse);log.info("發送自動回復消息: " + jsonResponse);} catch (IOException e) {log.error("消息發送失敗", e);}return;}}//如果管理員在線或者不是管理員,按照正常邏輯發送消息Map<String, String> responseMap = new HashMap<>();responseMap.put("sourceSid", sid);responseMap.put("message", msgContent);String jsonResponse;try {//將消息轉換為 JSON 格式jsonResponse = objectMapper.writeValueAsString(responseMap);} catch (IOException e) {log.error("JSON 序列化失敗", e);return;}//將消息發送給目標 sidfor (WebSocketServer item : webSocketSet) {try {if (targetSid.equals(item.sid)) {item.sendMessage(jsonResponse);break;}} catch (IOException e) {log.error("消息發送失敗", e);}}}/*** 判斷是否是管理員*/private boolean isAdmin(String sid) {return "admin".equals(sid);}/*** 發生錯誤時調用的方法*/@OnErrorpublic void onError(Session session, Throwable error) {log.error("發生錯誤", error);}/*** 實現服務器主動推送*/public void sendMessage(String message) throws IOException {this.session.getBasicRemote().sendText(message);}/*** 群發自定義消息*/public static void sendInfo(String message, @PathParam("sid") String sid) throws IOException {log.info("推送消息到窗口 " + sid + ",推送內容: " + message);for (WebSocketServer item : webSocketSet) {try {if (sid == null) {item.sendMessage(message); //推送給所有人} else if (item.sid.equals(sid)) {item.sendMessage(message); //推送給指定 sid}} catch (IOException e) {log.error("推送消息失敗", e);}}}public static synchronized int getOnlineCount() {return onlineCount;}public static synchronized void addOnlineCount() {WebSocketServer.onlineCount++;}public static synchronized void subOnlineCount() {WebSocketServer.onlineCount--;}public static CopyOnWriteArraySet<WebSocketServer> getWebSocketSet() {return webSocketSet;}public String getSid() {return this.sid;}private boolean isAdminOnline() {for (WebSocketServer item : webSocketSet) {if (isAdmin(item.sid)) {log.info("管理員已在線: " + item.sid);return true;}}log.info("管理員不在線");return false;}private String getWenxinResponse(String query) {try {//調用WenXinYiYanUtil類的靜態方法獲取回復String wenxinReplyJson = WenXinYiYanUtil.getWenxinReply(query);//將文心一言回復的JSON字符串解析為JSONObjectJSONObject wenxinReplyObj = JSONObject.parseObject(wenxinReplyJson);//提取出要展示給用戶的回復內容String result = wenxinReplyObj.getString("result");return result;} catch (IOException | JSONException e) {log.error("調用文心一言失敗", e);return null;}}}
3.ChatController?
@GetMapping("/online-users")
:該方法處理 GET 請求,返回當前在線的用戶列表,排除管理員。該方法遍歷WebSocketServer
中的所有 WebSocket 連接,檢查每個連接的sid
(即用戶標識符),如果不是管理員(sid
不是"admin"
),就將該用戶的sid
加入返回的列表sidList
。
@PostMapping("/send"):
該方法處理 POST 請求,用于管理員發送消息給指定用戶。該方法接收兩個請求參數,sid
和message
。sid
是目標用戶的sid
(即唯一標識符),message
是要發送的消息內容。然后,它調用WebSocketServer.sendInfo()
方法,將消息推送給目標用戶
package com.qcby.byspringbootdemo.controller;import com.qcby.byspringbootdemo.server.WebSocketServer;
import org.springframework.web.bind.annotation.*;import java.io.IOException;
import java.util.ArrayList;
import java.util.List;@RestController
@RequestMapping("/api/chat")
public class ChatController {/*** 獲取在線用戶列表,不包含管理員*/@GetMapping("/online-users")public List<String> getOnlineUsers() {List<String> sidList = new ArrayList<>();for (WebSocketServer server : WebSocketServer.getWebSocketSet()) {//排除管理員if (!server.getSid().equals("admin")) {sidList.add(server.getSid());}}return sidList;}/*** 管理員發送消息給指定用戶*/@PostMapping("/send")public void sendMessageToUser(@RequestParam String sid, @RequestParam String message) throws IOException {WebSocketServer.sendInfo(message, sid);}}
4.WenXinYiYanUtil?
通過使用 OkHttp
客戶端與百度的文心一言(Wenxin)API 進行交互,獲取基于用戶查詢的回復
package com.qcby.byspringbootdemo.util;import okhttp3.*;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.util.concurrent.TimeUnit;public class WenXinYiYanUtil {//密鑰public static final String API_KEY = "";public static final String SECRET_KEY = "";//OkHttpClient 配置,設置連接超時和讀取超時static final OkHttpClient HTTP_CLIENT = new OkHttpClient().newBuilder().connectTimeout(60, TimeUnit.SECONDS) //設置連接超時為60秒.readTimeout(60, TimeUnit.SECONDS) //設置讀取超時為60秒.build();public static String getWenxinReply(String query) throws IOException, JSONException {//定義請求的媒體類型MediaType mediaType = MediaType.parse("application/json");//構建請求體,消息內容包含了從前端傳來的用戶請求RequestBody body = RequestBody.create(mediaType, "{\"messages\":["+ "{\"role\":\"user\",\"content\":\"" + query + "\"}"+ "],"+ "\"temperature\":0.95,"+ "\"top_p\":0.8,"+ "\"penalty_score\":1,"+ "\"enable_system_memory\":false,"+ "\"disable_search\":false,"+ "\"enable_citation\":false}");//構建 HTTP 請求Request request = new Request.Builder().url("https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop/chat/completions_pro?access_token=" + getAccessToken()).method("POST", body).addHeader("Content-Type", "application/json").build();System.out.println("調用文心一言,查詢內容: " + query);//請求代碼try (Response response = HTTP_CLIENT.newCall(request).execute()) {String responseBody = response.body().string();System.out.println("文心一言返回的內容: " + responseBody);return responseBody;}}/*** 從用戶的 API 密鑰(AK、SK)生成鑒權簽名(Access Token)* @return 鑒權簽名(Access Token)* @throws IOException 如果發生 I/O 異常* @throws JSONException 如果發生 JSON 解析異常*/static String getAccessToken() throws IOException, JSONException {//設置請求體的媒體類型MediaType mediaType = MediaType.parse("application/x-www-form-urlencoded");//創建請求體,包含 API 的 client_id 和 client_secretRequestBody body = RequestBody.create(mediaType, "grant_type=client_credentials&client_id=" + API_KEY+ "&client_secret=" + SECRET_KEY);//構建請求,使用 POST 方法獲取 Access TokenRequest request = new Request.Builder().url("https://aip.baidubce.com/oauth/2.0/token") // 請求 URL,獲取 Access Token.method("POST", body).addHeader("Content-Type", "application/x-www-form-urlencoded").build();//發送請求并獲取響應try (Response response = HTTP_CLIENT.newCall(request).execute()) {//從響應中解析出 Access Tokenreturn new JSONObject(response.body().string()).getString("access_token");}}}
?5.用戶端
- WebSocket 連接:
connectWebSocket()
:初始化一個 WebSocket 連接,連接的 URL 是ws://localhost:8080/api/websocket/{sid}
,其中{sid}
是一個隨機生成的用戶 ID。onopen
:WebSocket 連接成功時,會在控制臺打印日志,并在聊天窗口顯示連接成功的提示信息。onmessage
:接收到來自 WebSocket 服務器的消息時,解析消息并根據來源和目標 SID 顯示不同的消息。管理員的消息會顯示在左側,用戶的消息顯示在右側。- 錯誤處理:處理 WebSocket 錯誤和連接關閉的情況。
- 發送消息:
sendMessage()
:當用戶點擊 "Send" 按鈕時,發送輸入框中的消息到 WebSocket 服務器,并將該消息顯示在聊天窗口的右側(代表用戶)。
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>User - Chat Window</title><style>* {margin: 0;padding: 0;box-sizing: border-box;}body {font-family: Arial, sans-serif;background-color: #f0f4f8;display: flex;justify-content: center;align-items: center;min-height: 100vh;}#chatBox {position: fixed;bottom: 10px;right: 10px;width: 400px;height: 500px;background-color: #ffffff;border-radius: 8px;padding: 20px;box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);background: linear-gradient(to top right, #f9f9f9, #e9eff7);display: flex;flex-direction: column;max-height: 80vh;}#chatBox h3 {font-size: 20px;margin-bottom: 15px;color: #333;text-align: center;}#messages {flex: 1;border: 1px solid #ddd;padding: 15px;overflow-y: auto;background-color: #f9f9f9;border-radius: 8px;margin-bottom: 15px;font-size: 14px;color: #333;line-height: 1.5;box-shadow: inset 0 0 8px rgba(0, 0, 0, 0.1);}.message {padding: 10px;margin: 5px 0;border-radius: 8px;max-width: 80%;word-wrap: break-word;}.message-right {background-color: #dcf8c6;text-align: right;margin-left: auto;}.message-left {background-color: #f1f0f0;text-align: left;margin-right: auto;}#inputWrapper {display: flex;width: 100%;}#messageInput {width: calc(100% - 80px);padding: 12px;border-radius: 25px;border: 1px solid #ccc;margin-right: 10px;font-size: 16px;transition: border-color 0.3s ease;}#messageInput:focus {border-color: #007bff;outline: none;}button {padding: 12px 20px;border-radius: 25px;border: 1px solid #007bff;background-color: #007bff;color: white;cursor: pointer;font-size: 16px;transition: background-color 0.3s ease;width: 60px;display: inline-flex;align-items: center;justify-content: center;}button:hover {background-color: #0056b3;}@media (max-width: 768px) {#chatBox {width: 100%;bottom: 20px;padding: 15px;}#messageInput {width: calc(100% - 100px);}button {width: 70px;padding: 10px;}}</style><script>let websocket;const sid = Math.random().toString(36).substring(2, 15);const isAdmin = false;function connectWebSocket() {websocket = new WebSocket("ws://localhost:8080/api/websocket/" + sid);websocket.onopen = () => {console.log("Connection successful, User ID: " + sid);document.getElementById("messages").innerHTML += `<div class="message-left">Connection successful, Your ID is: ${sid}</div>`;};websocket.onmessage = (event) => {try {let data = JSON.parse(event.data);if (data.message === "conn_success") {console.log("Connection successful, Your ID is: " + sid);document.getElementById("messages").innerHTML += `<div class="message-left">Connection successful, Your ID is: ${sid}</div>`;scrollToBottom();return;}const { sourceSid, message, targetSid } = data;if (targetSid === sid || sourceSid === 'admin') {let newMessage = document.createElement("div");newMessage.classList.add(sourceSid === 'admin' ? 'message-left' : 'message-right');newMessage.textContent = message;setTimeout(() => {document.getElementById("messages").appendChild(newMessage);scrollToBottom();}, 0);} else {let newMessage = document.createElement("div");newMessage.classList.add('message-left');newMessage.textContent = `${message}`;document.getElementById("messages").appendChild(newMessage);scrollToBottom();}} catch (e) {console.error("Failed to parse message", e);}};websocket.onclose = () => {console.log("Connection closed");};websocket.onerror = (error) => {console.error("WebSocket error", error);};}function sendMessage() {const message = document.getElementById("messageInput").value;const targetSid = "admin";if (message.trim() !== "") {websocket.send(JSON.stringify({ targetSid, message }));document.getElementById("messages").innerHTML += `<div class="message-right">${message}</div>`;document.getElementById("messageInput").value = '';scrollToBottom();}}function scrollToBottom() {const messagesDiv = document.getElementById("messages");messagesDiv.scrollTop = messagesDiv.scrollHeight;}connectWebSocket();</script>
</head>
<body>
<div id="chatBox"><h3>User Chat Window</h3><div id="messages"></div><div id="inputWrapper"><input id="messageInput" type="text" placeholder="Enter message"><button onclick="sendMessage()">Send</button></div>
</div>
</body>
</html>
6.管理員端
-
WebSocket 連接:
- 使用
new WebSocket()
創建一個連接,目標地址為ws://localhost:8080/api/websocket/admin
,這里的admin
是管理員的 ID。 onopen
事件:當 WebSocket 連接成功時,控制臺打印 "連接成功" 信息。onmessage
事件:每當接收到消息時,如果是 JSON 格式的數據,則解析消息并更新聊天記錄;如果是純文本消息,則通過handleTextMessage
函數處理。onclose
事件:連接關閉時的處理。onerror
事件:處理 WebSocket 錯誤。
- 使用
-
聊天記錄管理:
chatHistory
:一個對象,用來存儲每個用戶的聊天記錄。- 當管理員接收到消息時,根據
sourceSid
將消息保存到對應用戶的聊天記錄中,并根據當前選擇的聊天用戶來更新聊天窗口。
-
消息顯示:
displayMessages()
:顯示當前用戶與選擇的聊天對象的所有聊天記錄,自動滾動到最新消息。scrollToBottom()
:滾動聊天窗口到最底部,確保用戶始終看到最新的消息。
-
消息發送:
sendMessage()
:用戶輸入消息后,點擊 "Send" 按鈕發送消息。如果沒有選擇聊天對象,系統會提示用戶選擇一個聊天對象。
-
在線用戶管理:
getOnlineUsers()
:向后端請求在線用戶列表,更新#onlineUsers
列表。selectUser()
:當用戶點擊某個在線用戶時,該用戶被選中,并開始與該用戶進行聊天。notifyUnreadMessage()
:如果接收到非當前用戶的消息,則標記該用戶為有未讀消息。clearUnreadMessage()
:當與某個用戶進行聊天時,清除該用戶的未讀消息標記。
-
本地存儲:
localStorage
用于保存當前選中的聊天用戶,以便在頁面重新加載后恢復用戶的選擇。
-
恢復選中的用戶:
restoreSelectedUser()
:在頁面加載時,恢復之前選中的聊天用戶,顯示其聊天記錄,并高亮顯示。
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Admin - Chat Window</title><style>* {margin: 0;padding: 0;box-sizing: border-box;}body {font-family: Arial, sans-serif;display: flex;height: 100vh;margin: 0;background-color: #f4f7fc;color: #333;}/* 左側在線用戶列表 */#onlineUsersContainer {width: 250px;padding: 20px;background-color: #fff;border-right: 1px solid #ddd;box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);overflow-y: auto;}#onlineUsers {list-style-type: none;padding: 0;margin-top: 20px;}#onlineUsers li {padding: 10px;cursor: pointer;border-radius: 5px;transition: background-color 0.3s ease;}#onlineUsers li:hover {background-color: #e9f1fe;}#onlineUsers li.selected {background-color: #d0e7fe;}/* 右側聊天窗口 */#chatBox {flex: 1;display: flex;flex-direction: column;padding: 20px;background-color: #fff;}#messages {border: 1px solid #ddd;height: 500px;overflow-y: scroll;margin-bottom: 20px;padding: 15px;background-color: #f9f9f9;border-radius: 10px;box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.1);}.message {padding: 10px;margin: 8px 0;border-radius: 10px;max-width: 80%;line-height: 1.6;word-wrap: break-word;}.message-right {background-color: #dcf8c6;text-align: right;margin-left: auto;}.message-left {background-color: #f1f0f0;text-align: left;margin-right: auto;}#messageInput {width: 80%;padding: 12px;border-radius: 25px;border: 1px solid #ccc;margin-right: 10px;font-size: 16px;transition: border-color 0.3s ease;}#messageInput:focus {border-color: #007bff;outline: none;}button {padding: 12px 20px;border-radius: 25px;border: 1px solid #007bff;background-color: #007bff;color: white;cursor: pointer;font-size: 16px;transition: background-color 0.3s ease;}button:hover {background-color: #0056b3;}h3 {font-size: 18px;color: #333;margin-bottom: 20px;}#onlineUsers li.unread {font-weight: bold;color: red;}@media (max-width: 768px) {#onlineUsersContainer {width: 100%;padding: 15px;}#chatBox {padding: 15px;}#messageInput {width: calc(100% - 100px);}button {width: 80px;}}</style>
</head>
<body>
<div id="onlineUsersContainer"><h3>Online Users</h3><ul id="onlineUsers"></ul>
</div>
<div id="chatBox"><h3>Chat Window</h3><div id="messages"></div><div style="display: flex;"><input id="messageInput" type="text" placeholder="Enter message"><button onclick="sendMessage()">Send</button></div>
</div><script>let websocket;const sid = "admin";let currentUserSid = null; // 當前選擇聊天的用戶SIDlet chatHistory = {}; // 用來存儲每個用戶的聊天記錄// 頁面加載時初始化window.onload = () => {connectWebSocket();getOnlineUsers(); // 刷新在線用戶列表restoreSelectedUser(); // 恢復選中的用戶};function connectWebSocket() {websocket = new WebSocket("ws://localhost:8080/api/websocket/" + sid);websocket.onopen = () => {console.log("連接成功,管理員ID:" + sid);};websocket.onmessage = (event) => {try {let data;if (event.data.startsWith("{") && event.data.endsWith("}")) {data = JSON.parse(event.data); // 如果是有效的JSON格式,解析它} else {// 如果不是有效的JSON格式(比如 "conn_success" 這樣的文本消息),處理它console.log("收到非JSON消息:", event.data);handleTextMessage(event.data); // 處理文本消息return;}const { sourceSid, message } = data;if (sourceSid) {// 如果聊天記錄中沒有該用戶,初始化if (!chatHistory[sourceSid]) {chatHistory[sourceSid] = [];}// 存儲收到的消息chatHistory[sourceSid].push({ sender: 'left', message });// 如果是當前聊天用戶的消息,更新聊天窗口if (sourceSid === currentUserSid) {displayMessages();} else {// 否則標記該用戶為有未讀消息notifyUnreadMessage(sourceSid);}}} catch (e) {console.error("消息解析失敗", e);}};websocket.onclose = () => {console.log("連接關閉");};websocket.onerror = (error) => {console.error("WebSocket錯誤", error);};}function notifyUnreadMessage(userSid) {const userListItems = document.querySelectorAll("#onlineUsers li");userListItems.forEach(item => {if (item.textContent === userSid) {item.classList.add("unread"); // 添加未讀消息樣式}});}// 處理文本消息function handleTextMessage(text) {// 處理接收到的純文本消息const { sourceSid } = JSON.parse(event.data); // 假設event.data中依然包含sourceSidif (sourceSid) {chatHistory[sourceSid].push({ sender: 'left', message: text });if (sourceSid === currentUserSid) {displayMessages();} else {notifyUnreadMessage(sourceSid);}}}// 清除未讀消息的標記function clearUnreadMessage(userSid) {const userListItems = document.querySelectorAll("#onlineUsers li");userListItems.forEach(item => {if (item.textContent === userSid) {item.classList.remove("unread");}});}function sendMessage() {const message = document.getElementById("messageInput").value;if (!currentUserSid) {alert("請選擇一個用戶進行聊天!");return;}if (message.trim() !== "") {websocket.send(JSON.stringify({ targetSid: currentUserSid, message }));chatHistory[currentUserSid] = chatHistory[currentUserSid] || [];chatHistory[currentUserSid].push({ sender: 'right', message });document.getElementById("messageInput").value = '';displayMessages();}}// 顯示當前用戶的聊天記錄function displayMessages() {const messagesDiv = document.getElementById("messages");messagesDiv.innerHTML = "";if (currentUserSid && chatHistory[currentUserSid]) {chatHistory[currentUserSid].forEach(msg => {const messageDiv = document.createElement("div");messageDiv.classList.add("message", msg.sender === 'right' ? "message-right" : "message-left");messageDiv.textContent = msg.message; // 顯示解析后的消息內容messagesDiv.appendChild(messageDiv);});}scrollToBottom();}function scrollToBottom() {const messagesDiv = document.getElementById("messages");messagesDiv.scrollTop = messagesDiv.scrollHeight;}// 獲取在線用戶列表(不包括管理員)function getOnlineUsers() {fetch("/api/chat/online-users").then(response => response.json()).then(users => {const userList = document.getElementById("onlineUsers");userList.innerHTML = ""; // 清空當前列表users.forEach(user => {if (user !== "admin") {const li = document.createElement("li");li.textContent = user;li.onclick = () => selectUser(user, li);userList.appendChild(li);}});});}// 選擇一個用戶進行聊天function selectUser(user, liElement) {// 清除所有選中狀態const userListItems = document.querySelectorAll("#onlineUsers li");userListItems.forEach(item => item.classList.remove("selected"));// 高亮顯示當前選擇的用戶liElement.classList.add("selected");if (currentUserSid !== user) {currentUserSid = user;// 清除未讀消息通知clearUnreadMessage(user);// 顯示與該用戶的聊天記錄displayMessages();// 保存當前選中的用戶localStorage.setItem('selectedUserSid', user);}scrollToBottom();}// 從localStorage恢復選中的用戶function restoreSelectedUser() {const savedUserSid = localStorage.getItem('selectedUserSid');if (savedUserSid) {currentUserSid = savedUserSid;const userListItems = document.querySelectorAll("#onlineUsers li");userListItems.forEach(item => {if (item.textContent === savedUserSid) {item.classList.add("selected");}});displayMessages();}}
</script>
</body>
</html>