mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-29 14:03:55 +08:00
mqtt gateway project
This commit is contained in:
@@ -0,0 +1,623 @@
|
||||
// Description: MQTT+UDP 到 WebSocket 的桥接
|
||||
// Author: terrence@tenclass.com
|
||||
// Date: 2025-03-12
|
||||
|
||||
require('dotenv').config();
|
||||
const net = require('net');
|
||||
const debugModule = require('debug');
|
||||
const debug = debugModule('mqtt-server');
|
||||
const crypto = require('crypto');
|
||||
const dgram = require('dgram');
|
||||
const Emitter = require('events');
|
||||
const WebSocket = require('ws');
|
||||
const { MQTTProtocol } = require('./mqtt-protocol');
|
||||
const { ConfigManager } = require('./utils/config-manager');
|
||||
const { validateMqttCredentials } = require('./utils/mqtt_config_v2');
|
||||
|
||||
|
||||
function setDebugEnabled(enabled) {
|
||||
if (enabled) {
|
||||
debugModule.enable('mqtt-server');
|
||||
} else {
|
||||
debugModule.disable();
|
||||
}
|
||||
}
|
||||
|
||||
const configManager = new ConfigManager('mqtt.json');
|
||||
configManager.on('configChanged', (config) => {
|
||||
setDebugEnabled(config.debug);
|
||||
});
|
||||
|
||||
setDebugEnabled(configManager.get('debug'));
|
||||
|
||||
class WebSocketBridge extends Emitter {
|
||||
constructor(connection, protocolVersion, macAddress, uuid, userData) {
|
||||
super();
|
||||
this.connection = connection;
|
||||
this.macAddress = macAddress;
|
||||
this.uuid = uuid;
|
||||
this.userData = userData;
|
||||
this.wsClient = null;
|
||||
this.protocolVersion = protocolVersion;
|
||||
this.deviceSaidGoodbye = false;
|
||||
this.initializeChatServer();
|
||||
}
|
||||
|
||||
initializeChatServer() {
|
||||
const devMacAddresss = configManager.get('development')?.mac_addresss || [];
|
||||
let chatServers;
|
||||
if (devMacAddresss.includes(this.macAddress)) {
|
||||
chatServers = configManager.get('development')?.chat_servers;
|
||||
} else {
|
||||
chatServers = configManager.get('production')?.chat_servers;
|
||||
}
|
||||
if (!chatServers) {
|
||||
throw new Error(`未找到 ${this.macAddress} 的聊天服务器`);
|
||||
}
|
||||
this.chatServer = chatServers[Math.floor(Math.random() * chatServers.length)];
|
||||
}
|
||||
|
||||
async connect(audio_params, features) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = {
|
||||
'device-id': this.macAddress,
|
||||
'protocol-version': '2',
|
||||
'authorization': `Bearer test-token`
|
||||
};
|
||||
if (this.uuid) {
|
||||
headers['client-id'] = this.uuid;
|
||||
}
|
||||
if (this.userData && this.userData.ip) {
|
||||
headers['x-forwarded-for'] = this.userData.ip;
|
||||
}
|
||||
this.wsClient = new WebSocket(this.chatServer, { headers });
|
||||
|
||||
this.wsClient.on('open', () => {
|
||||
this.sendJson({
|
||||
type: 'hello',
|
||||
version: 2,
|
||||
transport: 'websocket',
|
||||
audio_params,
|
||||
features
|
||||
});
|
||||
});
|
||||
|
||||
this.wsClient.on('message', (data, isBinary) => {
|
||||
if (isBinary) {
|
||||
const timestamp = data.readUInt32BE(8);
|
||||
const opusLength = data.readUInt32BE(12);
|
||||
const opus = data.subarray(16, 16 + opusLength);
|
||||
// 二进制数据通过UDP发送
|
||||
this.connection.sendUdpMessage(opus, timestamp);
|
||||
} else {
|
||||
// JSON数据通过MQTT发送
|
||||
const message = JSON.parse(data.toString());
|
||||
if (message.type === 'hello') {
|
||||
resolve(message);
|
||||
} else {
|
||||
this.connection.sendMqttMessage(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.wsClient.on('error', (error) => {
|
||||
console.error(`WebSocket error for device ${this.macAddress}:`, error);
|
||||
this.emit('close');
|
||||
reject(error);
|
||||
});
|
||||
|
||||
this.wsClient.on('close', () => {
|
||||
this.emit('close');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
sendJson(message) {
|
||||
if (this.wsClient && this.wsClient.readyState === WebSocket.OPEN) {
|
||||
this.wsClient.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
sendAudio(opus, timestamp) {
|
||||
if (this.wsClient && this.wsClient.readyState === WebSocket.OPEN) {
|
||||
const buffer = Buffer.alloc(16 + opus.length);
|
||||
buffer.writeUInt32BE(timestamp, 8);
|
||||
buffer.writeUInt32BE(opus.length, 12);
|
||||
buffer.set(opus, 16);
|
||||
this.wsClient.send(buffer, { binary: true });
|
||||
}
|
||||
}
|
||||
|
||||
isAlive() {
|
||||
return this.wsClient && this.wsClient.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.wsClient) {
|
||||
this.wsClient.close();
|
||||
this.wsClient = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MacAddressRegex = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/;
|
||||
|
||||
/**
|
||||
* MQTT连接类
|
||||
* 负责应用层逻辑处理
|
||||
*/
|
||||
class MQTTConnection {
|
||||
constructor(socket, connectionId, server) {
|
||||
this.server = server;
|
||||
this.connectionId = connectionId;
|
||||
this.clientId = null;
|
||||
this.username = null;
|
||||
this.password = null;
|
||||
this.bridge = null;
|
||||
this.udp = {
|
||||
remoteAddress: null,
|
||||
cookie: null,
|
||||
localSequence: 0,
|
||||
remoteSequence: 0
|
||||
};
|
||||
this.headerBuffer = Buffer.alloc(16);
|
||||
|
||||
// 创建协议处理器,并传入socket
|
||||
this.protocol = new MQTTProtocol(socket);
|
||||
|
||||
this.setupProtocolHandlers();
|
||||
}
|
||||
|
||||
setupProtocolHandlers() {
|
||||
// 设置协议事件处理
|
||||
this.protocol.on('connect', (connectData) => {
|
||||
this.handleConnect(connectData);
|
||||
});
|
||||
|
||||
this.protocol.on('publish', (publishData) => {
|
||||
this.handlePublish(publishData);
|
||||
});
|
||||
|
||||
this.protocol.on('subscribe', (subscribeData) => {
|
||||
this.handleSubscribe(subscribeData);
|
||||
});
|
||||
|
||||
this.protocol.on('disconnect', () => {
|
||||
this.handleDisconnect();
|
||||
});
|
||||
|
||||
this.protocol.on('close', () => {
|
||||
debug(`${this.clientId} 客户端断开连接`);
|
||||
this.server.removeConnection(this);
|
||||
});
|
||||
|
||||
this.protocol.on('error', (err) => {
|
||||
debug(`${this.clientId} 连接错误:`, err);
|
||||
this.close();
|
||||
});
|
||||
|
||||
this.protocol.on('protocolError', (err) => {
|
||||
debug(`${this.clientId} 协议错误:`, err);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
|
||||
handleConnect(connectData) {
|
||||
this.clientId = connectData.clientId;
|
||||
this.username = connectData.username;
|
||||
this.password = connectData.password;
|
||||
|
||||
debug('客户端连接:', {
|
||||
clientId: this.clientId,
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
protocol: connectData.protocol,
|
||||
protocolLevel: connectData.protocolLevel,
|
||||
keepAlive: connectData.keepAlive
|
||||
});
|
||||
|
||||
const parts = this.clientId.split('@@@');
|
||||
if (parts.length === 3) { // GID_test@@@mac_address@@@uuid
|
||||
try {
|
||||
const validated = validateMqttCredentials(this.clientId, this.username, this.password);
|
||||
this.groupId = validated.groupId;
|
||||
this.macAddress = validated.macAddress;
|
||||
this.uuid = validated.uuid;
|
||||
this.userData = validated.userData;
|
||||
} catch (error) {
|
||||
debug('MQTT凭据验证失败:', error.message);
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
} else if (parts.length === 2) { // GID_test@@@mac_address
|
||||
this.groupId = parts[0];
|
||||
this.macAddress = parts[1].replace(/_/g, ':');
|
||||
if (!MacAddressRegex.test(this.macAddress)) {
|
||||
debug('无效的 macAddress:', this.macAddress);
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
debug('无效的 clientId:', this.clientId);
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.replyTo = `devices/p2p/${parts[1]}`;
|
||||
|
||||
this.server.addConnection(this);
|
||||
}
|
||||
|
||||
handleSubscribe(subscribeData) {
|
||||
debug('客户端订阅主题:', {
|
||||
clientId: this.clientId,
|
||||
topic: subscribeData.topic,
|
||||
packetId: subscribeData.packetId
|
||||
});
|
||||
|
||||
// 发送 SUBACK
|
||||
this.protocol.sendSuback(subscribeData.packetId, 0);
|
||||
}
|
||||
|
||||
handleDisconnect() {
|
||||
debug('收到断开连接请求:', { clientId: this.clientId });
|
||||
// 清理连接
|
||||
this.server.removeConnection(this);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closing = true;
|
||||
if (this.bridge) {
|
||||
this.bridge.close();
|
||||
this.bridge = null;
|
||||
} else {
|
||||
this.protocol.close();
|
||||
}
|
||||
}
|
||||
|
||||
checkKeepAlive() {
|
||||
const now = Date.now();
|
||||
const keepAliveInterval = this.protocol.getKeepAliveInterval();
|
||||
|
||||
// 如果keepAliveInterval为0,表示不需要心跳检查
|
||||
if (keepAliveInterval === 0 || !this.protocol.isConnected) return;
|
||||
|
||||
const lastActivity = this.protocol.getLastActivity();
|
||||
const timeSinceLastActivity = now - lastActivity;
|
||||
|
||||
// 如果超过心跳间隔,关闭连接
|
||||
if (timeSinceLastActivity > keepAliveInterval) {
|
||||
debug('心跳超时,关闭连接:', this.clientId);
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
handlePublish(publishData) {
|
||||
debug('收到发布消息:', {
|
||||
clientId: this.clientId,
|
||||
topic: publishData.topic,
|
||||
payload: publishData.payload,
|
||||
qos: publishData.qos
|
||||
});
|
||||
|
||||
if (publishData.qos !== 0) {
|
||||
debug('不支持的 QoS 级别:', publishData.qos, '关闭连接');
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const json = JSON.parse(publishData.payload);
|
||||
if (json.type === 'hello') {
|
||||
if (json.version !== 3) {
|
||||
debug('不支持的协议版本:', json.version, '关闭连接');
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.parseHelloMessage(json).catch(error => {
|
||||
debug('处理 hello 消息失败:', error);
|
||||
this.close();
|
||||
});
|
||||
} else {
|
||||
this.parseOtherMessage(json).catch(error => {
|
||||
debug('处理其他消息失败:', error);
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
sendMqttMessage(payload) {
|
||||
debug(`发送消息到 ${this.replyTo}: ${payload}`);
|
||||
this.protocol.sendPublish(this.replyTo, payload, 0, false, false);
|
||||
}
|
||||
|
||||
sendUdpMessage(payload, timestamp) {
|
||||
if (!this.udp.remoteAddress) {
|
||||
debug(`设备 ${this.clientId} 未连接,无法发送 UDP 消息`);
|
||||
return;
|
||||
}
|
||||
this.udp.localSequence++;
|
||||
const header = this.generateUdpHeader(payload.length, timestamp, this.udp.localSequence);
|
||||
const cipher = crypto.createCipheriv(this.udp.encryption, this.udp.key, header);
|
||||
const message = Buffer.concat([header, cipher.update(payload), cipher.final()]);
|
||||
this.server.sendUdpMessage(message, this.udp.remoteAddress);
|
||||
}
|
||||
|
||||
generateUdpHeader(length, timestamp, sequence) {
|
||||
// 重用预分配的缓冲区
|
||||
this.headerBuffer.writeUInt8(1, 0);
|
||||
this.headerBuffer.writeUInt16BE(length, 2);
|
||||
this.headerBuffer.writeUInt32BE(this.connectionId, 4);
|
||||
this.headerBuffer.writeUInt32BE(timestamp, 8);
|
||||
this.headerBuffer.writeUInt32BE(sequence, 12);
|
||||
return Buffer.from(this.headerBuffer); // 返回副本以避免并发问题
|
||||
}
|
||||
|
||||
async parseHelloMessage(json) {
|
||||
this.udp = {
|
||||
...this.udp,
|
||||
key: crypto.randomBytes(16),
|
||||
nonce: this.generateUdpHeader(0, 0, 0),
|
||||
encryption: 'aes-128-ctr',
|
||||
remoteSequence: 0,
|
||||
localSequence: 0,
|
||||
startTime: Date.now()
|
||||
}
|
||||
|
||||
if (this.bridge) {
|
||||
debug(`${this.clientId} 收到重复 hello 消息,关闭之前的 bridge`);
|
||||
this.bridge.close();
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
this.bridge = new WebSocketBridge(this, json.version, this.macAddress, this.uuid, this.userData);
|
||||
this.bridge.on('close', () => {
|
||||
const seconds = (Date.now() - this.udp.startTime) / 1000;
|
||||
console.log(`通话结束: ${this.clientId} Session: ${this.udp.session_id} Duration: ${seconds}s`);
|
||||
this.sendMqttMessage(JSON.stringify({ type: 'goodbye', session_id: this.udp.session_id }));
|
||||
this.bridge = null;
|
||||
if (this.closing) {
|
||||
this.protocol.close();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
console.log(`通话开始: ${this.clientId} Protocol: ${json.version} ${this.bridge.chatServer}`);
|
||||
const helloReply = await this.bridge.connect(json.audio_params, json.features);
|
||||
this.udp.session_id = helloReply.session_id;
|
||||
this.sendMqttMessage(JSON.stringify({
|
||||
type: 'hello',
|
||||
version: json.version,
|
||||
session_id: this.udp.session_id,
|
||||
transport: 'udp',
|
||||
udp: {
|
||||
server: this.server.publicIp,
|
||||
port: this.server.udpPort,
|
||||
encryption: this.udp.encryption,
|
||||
key: this.udp.key.toString('hex'),
|
||||
nonce: this.udp.nonce.toString('hex'),
|
||||
},
|
||||
audio_params: helloReply.audio_params
|
||||
}));
|
||||
} catch (error) {
|
||||
this.sendMqttMessage(JSON.stringify({ type: 'error', message: '处理 hello 消息失败' }));
|
||||
console.error(`${this.clientId} 处理 hello 消息失败: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async parseOtherMessage(json) {
|
||||
if (!this.bridge) {
|
||||
if (json.type !== 'goodbye') {
|
||||
this.sendMqttMessage(JSON.stringify({ type: 'goodbye', session_id: json.session_id }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (json.type === 'goodbye') {
|
||||
this.bridge.close();
|
||||
this.bridge = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.bridge.sendJson(json);
|
||||
}
|
||||
|
||||
onUdpMessage(rinfo, message, payloadLength, timestamp, sequence) {
|
||||
if (!this.bridge) {
|
||||
return;
|
||||
}
|
||||
if (this.udp.remoteAddress !== rinfo) {
|
||||
this.udp.remoteAddress = rinfo;
|
||||
}
|
||||
if (sequence < this.udp.remoteSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理加密数据
|
||||
const header = message.slice(0, 16);
|
||||
const encryptedPayload = message.slice(16, 16 + payloadLength);
|
||||
const cipher = crypto.createDecipheriv(this.udp.encryption, this.udp.key, header);
|
||||
const payload = Buffer.concat([cipher.update(encryptedPayload), cipher.final()]);
|
||||
|
||||
this.bridge.sendAudio(payload, timestamp);
|
||||
this.udp.remoteSequence = sequence;
|
||||
}
|
||||
|
||||
isAlive() {
|
||||
return this.bridge && this.bridge.isAlive();
|
||||
}
|
||||
}
|
||||
|
||||
class MQTTServer {
|
||||
constructor() {
|
||||
this.mqttPort = parseInt(process.env.MQTT_PORT) || 1883;
|
||||
this.udpPort = parseInt(process.env.UDP_PORT) || this.mqttPort;
|
||||
this.publicIp = process.env.PUBLIC_IP || 'mqtt.xiaozhi.me';
|
||||
this.connections = new Map(); // clientId -> MQTTConnection
|
||||
this.keepAliveTimer = null;
|
||||
this.keepAliveCheckInterval = 1000; // 默认每1秒检查一次
|
||||
|
||||
this.headerBuffer = Buffer.alloc(16);
|
||||
}
|
||||
|
||||
generateNewConnectionId() {
|
||||
// 生成一个32位不重复的整数
|
||||
let id;
|
||||
do {
|
||||
id = Math.floor(Math.random() * 0xFFFFFFFF);
|
||||
} while (this.connections.has(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.mqttServer = net.createServer((socket) => {
|
||||
const connectionId = this.generateNewConnectionId();
|
||||
debug(`新客户端连接: ${connectionId}`);
|
||||
new MQTTConnection(socket, connectionId, this);
|
||||
});
|
||||
|
||||
this.mqttServer.listen(this.mqttPort, () => {
|
||||
console.warn(`MQTT 服务器正在监听端口 ${this.mqttPort}`);
|
||||
});
|
||||
|
||||
|
||||
this.udpServer = dgram.createSocket('udp4');
|
||||
this.udpServer.on('message', this.onUdpMessage.bind(this));
|
||||
this.udpServer.on('error', err => {
|
||||
console.error('UDP 错误', err);
|
||||
setTimeout(() => { process.exit(1); }, 1000);
|
||||
});
|
||||
this.udpServer.bind(this.udpPort, () => {
|
||||
console.warn(`UDP 服务器正在监听 ${this.publicIp}:${this.udpPort}`);
|
||||
});
|
||||
|
||||
// 启动全局心跳检查定时器
|
||||
this.setupKeepAliveTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全局心跳检查定时器
|
||||
*/
|
||||
setupKeepAliveTimer() {
|
||||
// 清除现有定时器
|
||||
this.clearKeepAliveTimer();
|
||||
this.lastConnectionCount = 0;
|
||||
this.lastActiveConnectionCount = 0;
|
||||
|
||||
// 设置新的定时器
|
||||
this.keepAliveTimer = setInterval(() => {
|
||||
// 检查所有连接的心跳状态
|
||||
for (const connection of this.connections.values()) {
|
||||
connection.checkKeepAlive();
|
||||
}
|
||||
|
||||
const activeCount = Array.from(this.connections.values()).filter(connection => connection.isAlive()).length;
|
||||
if (activeCount !== this.lastActiveConnectionCount || this.connections.size !== this.lastConnectionCount) {
|
||||
console.log(`连接数: ${this.connections.size}, 活跃数: ${activeCount}`);
|
||||
this.lastActiveConnectionCount = activeCount;
|
||||
this.lastConnectionCount = this.connections.size;
|
||||
}
|
||||
}, this.keepAliveCheckInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除心跳检查定时器
|
||||
*/
|
||||
clearKeepAliveTimer() {
|
||||
if (this.keepAliveTimer) {
|
||||
clearInterval(this.keepAliveTimer);
|
||||
this.keepAliveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
addConnection(connection) {
|
||||
// 检查是否已存在相同 clientId 的连接
|
||||
for (const [key, value] of this.connections.entries()) {
|
||||
if (value.clientId === connection.clientId) {
|
||||
debug(`${connection.clientId} 已存在连接,关闭旧连接`);
|
||||
value.close();
|
||||
}
|
||||
}
|
||||
this.connections.set(connection.connectionId, connection);
|
||||
}
|
||||
|
||||
removeConnection(connection) {
|
||||
debug(`关闭连接: ${connection.connectionId}`);
|
||||
if (this.connections.has(connection.connectionId)) {
|
||||
this.connections.delete(connection.connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
sendUdpMessage(message, remoteAddress) {
|
||||
this.udpServer.send(message, remoteAddress.port, remoteAddress.address);
|
||||
}
|
||||
|
||||
onUdpMessage(message, rinfo) {
|
||||
// message format: [type: 1u, flag: 1u, payloadLength: 2u, cookie: 4u, timestamp: 4u, sequence: 4u, payload: n]
|
||||
if (message.length < 16) {
|
||||
console.warn('收到不完整的 UDP Header', rinfo);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const type = message.readUInt8(0);
|
||||
if (type !== 1) return;
|
||||
|
||||
const payloadLength = message.readUInt16BE(2);
|
||||
if (message.length < 16 + payloadLength) return;
|
||||
|
||||
const connectionId = message.readUInt32BE(4);
|
||||
const connection = this.connections.get(connectionId);
|
||||
if (!connection) return;
|
||||
|
||||
const timestamp = message.readUInt32BE(8);
|
||||
const sequence = message.readUInt32BE(12);
|
||||
|
||||
connection.onUdpMessage(rinfo, message, payloadLength, timestamp, sequence);
|
||||
} catch (error) {
|
||||
console.error('UDP 消息处理错误:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止服务器
|
||||
*/
|
||||
async stop() {
|
||||
if (this.stopping) {
|
||||
return;
|
||||
}
|
||||
this.stopping = true;
|
||||
// 清除心跳检查定时器
|
||||
this.clearKeepAliveTimer();
|
||||
|
||||
if (this.connections.size > 0) {
|
||||
console.warn(`等待 ${this.connections.size} 个连接关闭`);
|
||||
for (const connection of this.connections.values()) {
|
||||
connection.close();
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
debug('等待连接关闭完成');
|
||||
this.connections.clear();
|
||||
}
|
||||
|
||||
if (this.udpServer) {
|
||||
this.udpServer.close();
|
||||
this.udpServer = null;
|
||||
console.warn('UDP 服务器已停止');
|
||||
}
|
||||
|
||||
// 关闭MQTT服务器
|
||||
if (this.mqttServer) {
|
||||
this.mqttServer.close();
|
||||
this.mqttServer = null;
|
||||
console.warn('MQTT 服务器已停止');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建并启动服务器
|
||||
const server = new MQTTServer();
|
||||
server.start();
|
||||
process.on('SIGINT', () => {
|
||||
console.warn('收到 SIGINT 信号,开始关闭');
|
||||
server.stop();
|
||||
});
|
||||
Reference in New Issue
Block a user