317 lines
9.4 KiB
TypeScript
317 lines
9.4 KiB
TypeScript
import { Client, IMessage, StompSubscription } from "@stomp/stompjs";
|
||
import { Storage } from "@/utils/storage";
|
||
import { ACCESS_TOKEN_KEY } from "@/constants/cache-keys";
|
||
import { ref, watch } from "vue";
|
||
|
||
export interface UseStompOptions {
|
||
/** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */
|
||
brokerURL?: string;
|
||
/** 用于鉴权的 token,不传时使用 getAccessToken() 的返回值 */
|
||
token?: string;
|
||
/** 重连延迟,单位毫秒,默认为 8000 */
|
||
reconnectDelay?: number;
|
||
/** 连接超时时间,单位毫秒,默认为 10000 */
|
||
connectionTimeout?: number;
|
||
/** 是否开启指数退避重连策略 */
|
||
useExponentialBackoff?: boolean;
|
||
/** 最大重连次数,默认为 5 */
|
||
maxReconnectAttempts?: number;
|
||
/** 最大重连延迟,单位毫秒,默认为 60000 */
|
||
maxReconnectDelay?: number;
|
||
/** 是否开启调试日志 */
|
||
debug?: boolean;
|
||
}
|
||
|
||
/**
|
||
* STOMP WebSocket连接组合式函数
|
||
* 用于管理WebSocket连接的建立、断开、重连和消息订阅
|
||
*/
|
||
export function useStomp(options: UseStompOptions = {}) {
|
||
// 默认值:brokerURL 从环境变量中获取,token 从 getAccessToken() 获取
|
||
const defaultBrokerURL = import.meta.env.VITE_APP_WS_ENDPOINT || "";
|
||
|
||
const brokerURL = ref(options.brokerURL ?? defaultBrokerURL);
|
||
// 默认配置参数
|
||
const reconnectDelay = options.reconnectDelay ?? 15000; // 默认15秒重连间隔
|
||
const connectionTimeout = options.connectionTimeout ?? 10000;
|
||
const useExponentialBackoff = options.useExponentialBackoff ?? false;
|
||
const maxReconnectAttempts = options.maxReconnectAttempts ?? 3; // 最多重连3次
|
||
const maxReconnectDelay = options.maxReconnectDelay ?? 60000;
|
||
|
||
// 连接状态标记
|
||
const isConnected = ref(false);
|
||
// 重连尝试次数
|
||
const reconnectCount = ref(0);
|
||
// 重连计时器
|
||
let reconnectTimer: any = null;
|
||
// 连接超时计时器
|
||
let connectionTimeoutTimer: any = null;
|
||
// 存储所有订阅
|
||
const subscriptions = new Map<string, StompSubscription>();
|
||
|
||
// 用于保存 STOMP 客户端的实例
|
||
let client = ref<Client | null>(null);
|
||
|
||
/**
|
||
* 初始化 STOMP 客户端
|
||
*/
|
||
const initializeClient = () => {
|
||
if (client.value) {
|
||
return;
|
||
}
|
||
|
||
// 检查WebSocket端点是否配置
|
||
if (!brokerURL.value) {
|
||
console.error("WebSocket连接失败: 未配置WebSocket端点URL");
|
||
return;
|
||
}
|
||
|
||
// 每次连接前重新获取最新令牌,不依赖之前的token值
|
||
const currentToken = Storage.get(ACCESS_TOKEN_KEY, "");
|
||
|
||
// 检查令牌是否为空,如果为空则不进行连接
|
||
if (!currentToken) {
|
||
console.error("WebSocket连接失败:授权令牌为空,请先登录");
|
||
return;
|
||
}
|
||
|
||
// 创建 STOMP 客户端
|
||
client.value = new Client({
|
||
brokerURL: brokerURL.value,
|
||
connectHeaders: {
|
||
Authorization: `Bearer ${currentToken}`,
|
||
},
|
||
debug: options.debug ? console.log : () => {},
|
||
reconnectDelay: useExponentialBackoff ? 0 : reconnectDelay, // 禁用内置重连机制
|
||
heartbeatIncoming: 4000,
|
||
heartbeatOutgoing: 4000,
|
||
});
|
||
|
||
// 设置连接监听器
|
||
client.value.onConnect = () => {
|
||
isConnected.value = true;
|
||
reconnectCount.value = 0;
|
||
clearTimeout(connectionTimeoutTimer);
|
||
console.log("WebSocket连接已建立");
|
||
};
|
||
|
||
// 设置断开连接监听器
|
||
client.value.onDisconnect = () => {
|
||
isConnected.value = false;
|
||
console.log("WebSocket连接已断开");
|
||
|
||
// 如果使用自定义指数退避重连策略,则在这里处理
|
||
if (useExponentialBackoff && reconnectCount.value < maxReconnectAttempts) {
|
||
handleReconnect();
|
||
}
|
||
};
|
||
|
||
// 设置 Web Socket 关闭监听器
|
||
client.value.onWebSocketClose = (event) => {
|
||
isConnected.value = false;
|
||
console.log(`WebSocket已关闭: ${event?.code} ${event?.reason}`);
|
||
|
||
// 如果是授权问题导致的关闭,尝试重新获取令牌
|
||
if (event?.code === 1000 || event?.code === 1006 || event?.code === 1008) {
|
||
console.log("可能是授权问题导致连接关闭,尝试重新建立连接");
|
||
|
||
// 等待一段时间后再尝试重连,避免立即重连
|
||
setTimeout(() => {
|
||
// 强制重新初始化客户端,获取最新令牌
|
||
client.value = null;
|
||
|
||
// 检查当前是否有有效令牌
|
||
const freshToken = Storage.get(ACCESS_TOKEN_KEY, "");
|
||
if (freshToken) {
|
||
initializeClient();
|
||
connect();
|
||
} else {
|
||
console.warn("没有有效令牌,暂不重连WebSocket");
|
||
}
|
||
}, 3000);
|
||
}
|
||
};
|
||
|
||
// 设置错误监听器
|
||
client.value.onStompError = (frame) => {
|
||
console.error("STOMP错误:", frame.headers, frame.body);
|
||
|
||
// 检查是否是授权错误
|
||
if (
|
||
frame.headers?.message?.includes("Unauthorized") ||
|
||
frame.body?.includes("Unauthorized") ||
|
||
frame.body?.includes("Token")
|
||
) {
|
||
console.warn("WebSocket授权错误,请检查登录状态");
|
||
}
|
||
};
|
||
};
|
||
|
||
/**
|
||
* 处理重连逻辑
|
||
*/
|
||
const handleReconnect = () => {
|
||
if (reconnectCount.value >= maxReconnectAttempts) {
|
||
console.error(`已达到最大重连次数(${maxReconnectAttempts}),停止重连`);
|
||
return;
|
||
}
|
||
|
||
reconnectCount.value++;
|
||
console.log(`尝试重连(${reconnectCount.value}/${maxReconnectAttempts})...`);
|
||
|
||
// 使用指数退避策略增加重连间隔
|
||
const delay = useExponentialBackoff
|
||
? Math.min(reconnectDelay * Math.pow(2, reconnectCount.value - 1), maxReconnectDelay)
|
||
: reconnectDelay;
|
||
|
||
// 清除之前的计时器
|
||
if (reconnectTimer) {
|
||
clearTimeout(reconnectTimer);
|
||
}
|
||
|
||
// 设置重连计时器
|
||
reconnectTimer = setTimeout(() => {
|
||
if (!isConnected.value && client.value) {
|
||
client.value.activate();
|
||
}
|
||
}, delay);
|
||
};
|
||
|
||
// 监听 brokerURL 的变化,若地址改变则重新初始化
|
||
watch(brokerURL, (newURL, oldURL) => {
|
||
if (newURL !== oldURL) {
|
||
console.log(`brokerURL changed from ${oldURL} to ${newURL}`);
|
||
// 断开当前连接,重新激活客户端
|
||
if (client.value && client.value.connected) {
|
||
client.value.deactivate();
|
||
}
|
||
brokerURL.value = newURL;
|
||
initializeClient(); // 重新初始化客户端
|
||
}
|
||
});
|
||
|
||
// 初始化客户端
|
||
initializeClient();
|
||
|
||
/**
|
||
* 激活连接(如果已经连接或正在激活则直接返回)
|
||
*/
|
||
const connect = () => {
|
||
// 检查是否有配置WebSocket端点
|
||
if (!brokerURL.value) {
|
||
console.error("WebSocket连接失败: 未配置WebSocket端点URL");
|
||
return;
|
||
}
|
||
|
||
if (!client.value) {
|
||
initializeClient();
|
||
}
|
||
|
||
if (!client.value) {
|
||
console.error("STOMP客户端初始化失败");
|
||
return;
|
||
}
|
||
|
||
// 避免重复连接:检查是否已连接或正在连接
|
||
if (client.value.connected) {
|
||
console.log("WebSocket已经连接,跳过重复连接");
|
||
return;
|
||
}
|
||
|
||
if (client.value.active) {
|
||
console.log("WebSocket连接正在进行中,跳过重复连接请求");
|
||
return;
|
||
}
|
||
|
||
// 设置连接超时
|
||
clearTimeout(connectionTimeoutTimer);
|
||
connectionTimeoutTimer = setTimeout(() => {
|
||
if (!isConnected.value) {
|
||
console.warn("WebSocket连接超时");
|
||
if (useExponentialBackoff) {
|
||
handleReconnect();
|
||
}
|
||
}
|
||
}, connectionTimeout);
|
||
|
||
client.value.activate();
|
||
};
|
||
|
||
/**
|
||
* 订阅指定主题
|
||
* @param destination 目标主题地址
|
||
* @param callback 接收到消息时的回调函数
|
||
* @returns 返回订阅 id,用于后续取消订阅
|
||
*/
|
||
const subscribe = (destination: string, callback: (_message: IMessage) => void): string => {
|
||
if (!client.value || !client.value.connected) {
|
||
console.warn(`尝试订阅 ${destination} 失败: 客户端未连接`);
|
||
return "";
|
||
}
|
||
|
||
try {
|
||
const subscription = client.value.subscribe(destination, callback);
|
||
const subscriptionId = subscription.id;
|
||
subscriptions.set(subscriptionId, subscription);
|
||
console.log(`订阅成功: ${destination}, ID: ${subscriptionId}`);
|
||
return subscriptionId;
|
||
} catch (error) {
|
||
console.error(`订阅 ${destination} 失败:`, error);
|
||
return "";
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 取消订阅
|
||
* @param subscriptionId 订阅 id
|
||
*/
|
||
const unsubscribe = (subscriptionId: string) => {
|
||
const subscription = subscriptions.get(subscriptionId);
|
||
if (subscription) {
|
||
subscription.unsubscribe();
|
||
subscriptions.delete(subscriptionId);
|
||
console.log(`已取消订阅: ${subscriptionId}`);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 断开WebSocket连接
|
||
*/
|
||
const disconnect = () => {
|
||
if (client.value && client.value.connected) {
|
||
// 清除所有订阅
|
||
for (const [id, subscription] of subscriptions.entries()) {
|
||
subscription.unsubscribe();
|
||
subscriptions.delete(id);
|
||
}
|
||
|
||
// 断开连接
|
||
client.value.deactivate();
|
||
console.log("WebSocket连接已断开");
|
||
}
|
||
|
||
// 清除重连计时器
|
||
if (reconnectTimer) {
|
||
clearTimeout(reconnectTimer);
|
||
reconnectTimer = null;
|
||
}
|
||
|
||
// 清除连接超时计时器
|
||
if (connectionTimeoutTimer) {
|
||
clearTimeout(connectionTimeoutTimer);
|
||
connectionTimeoutTimer = null;
|
||
}
|
||
|
||
isConnected.value = false;
|
||
reconnectCount.value = 0;
|
||
};
|
||
|
||
return {
|
||
isConnected,
|
||
connect,
|
||
subscribe,
|
||
unsubscribe,
|
||
disconnect,
|
||
};
|
||
}
|