405 lines
12 KiB
JavaScript
405 lines
12 KiB
JavaScript
const { spawn, exec } = require('child_process');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
|
||
/**
|
||
* MySQL 进程管理器
|
||
* 使用进程模式启动 MySQL,无需管理员权限
|
||
*/
|
||
class MySQLServiceManager {
|
||
constructor(logWindowManager = null) {
|
||
const isDev = !process.resourcesPath;
|
||
const baseDir = isDev
|
||
? path.join(__dirname, '..')
|
||
: path.dirname(process.resourcesPath);
|
||
|
||
this.mysqlPath = path.join(baseDir, 'mysql');
|
||
this.binPath = path.join(this.mysqlPath, 'bin');
|
||
this.dataPath = path.join(this.mysqlPath, 'data');
|
||
this.configFile = path.join(this.mysqlPath, 'my.ini');
|
||
this.logWindowManager = logWindowManager;
|
||
|
||
// 进程模式:保存 MySQL 进程引用
|
||
this.mysqlProcess = null;
|
||
this.currentPort = null;
|
||
}
|
||
|
||
// 睡眠函数
|
||
sleep(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
|
||
// 日志输出辅助方法
|
||
log(type, message) {
|
||
console.log(`[MySQL] ${message}`);
|
||
if (this.logWindowManager) {
|
||
this.logWindowManager.addLog(type, `[MySQL] ${message}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 执行命令并返回Promise
|
||
*/
|
||
execCommand(command) {
|
||
return new Promise((resolve, reject) => {
|
||
exec(command, { encoding: 'utf8' }, (error, stdout, stderr) => {
|
||
if (error) {
|
||
const err = new Error(error.message || stderr || stdout || 'Command execution failed');
|
||
err.code = error.code;
|
||
err.stdout = stdout;
|
||
err.stderr = stderr;
|
||
reject(err);
|
||
} else {
|
||
resolve({ stdout, stderr });
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 读取配置文件中的端口
|
||
*/
|
||
readPortFromConfig() {
|
||
try {
|
||
if (fs.existsSync(this.configFile)) {
|
||
const content = fs.readFileSync(this.configFile, 'utf-8');
|
||
const match = content.match(/port\s*=\s*(\d+)/);
|
||
return match ? parseInt(match[1]) : 3306;
|
||
}
|
||
} catch (error) {
|
||
console.error('[MySQL] Failed to read port from config:', error);
|
||
}
|
||
return 3306;
|
||
}
|
||
|
||
/**
|
||
* 生成 my.ini 配置文件
|
||
*/
|
||
generateMyIni(port) {
|
||
const basedir = this.mysqlPath.replace(/\\/g, '/');
|
||
const datadir = this.dataPath.replace(/\\/g, '/');
|
||
|
||
const config = `[mysqld]
|
||
# 基础路径配置
|
||
basedir=${basedir}
|
||
datadir=${datadir}
|
||
|
||
# 网络配置
|
||
port=${port}
|
||
bind-address=0.0.0.0
|
||
|
||
# 字符集配置
|
||
character-set-server=utf8mb4
|
||
collation-server=utf8mb4_unicode_ci
|
||
|
||
# 性能优化
|
||
max_connections=200
|
||
innodb_buffer_pool_size=128M
|
||
innodb_log_file_size=64M
|
||
|
||
# 日志配置
|
||
log_error=${basedir}/mysql_error.log
|
||
slow_query_log=1
|
||
slow_query_log_file=${basedir}/mysql_slow.log
|
||
long_query_time=2
|
||
|
||
# 安全配置
|
||
sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
|
||
|
||
[client]
|
||
default-character-set=utf8mb4
|
||
`;
|
||
|
||
fs.writeFileSync(this.configFile, config, 'utf-8');
|
||
this.log('system', `生成配置文件,端口: ${port}`);
|
||
}
|
||
|
||
/**
|
||
* 初始化数据库
|
||
*/
|
||
async initializeDatabase() {
|
||
return new Promise((resolve, reject) => {
|
||
this.log('system', '正在初始化 MySQL 数据库...');
|
||
|
||
const mysqld = path.join(this.binPath, 'mysqld.exe');
|
||
const initProcess = spawn(mysqld, [
|
||
'--defaults-file=' + this.configFile,
|
||
'--initialize-insecure'
|
||
], {
|
||
cwd: this.mysqlPath,
|
||
stdio: 'pipe'
|
||
});
|
||
|
||
initProcess.stdout.on('data', (data) => {
|
||
this.log('system', data.toString().trim());
|
||
});
|
||
|
||
initProcess.stderr.on('data', (data) => {
|
||
this.log('system', data.toString().trim());
|
||
});
|
||
|
||
initProcess.on('close', (code) => {
|
||
if (code === 0) {
|
||
this.log('success', '数据库初始化成功');
|
||
resolve();
|
||
} else {
|
||
reject(new Error(`数据库初始化失败,退出码: ${code}`));
|
||
}
|
||
});
|
||
|
||
initProcess.on('error', (err) => {
|
||
reject(err);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 检查数据库是否已初始化
|
||
*/
|
||
isDatabaseInitialized() {
|
||
return fs.existsSync(this.dataPath) && fs.readdirSync(this.dataPath).length > 0;
|
||
}
|
||
|
||
/**
|
||
* 检查是否有残留的 mysqld 进程
|
||
*/
|
||
async checkAndKillOrphanProcess() {
|
||
try {
|
||
// 检查是否有 mysqld.exe 进程在运行
|
||
const { stdout } = await this.execCommand('tasklist /FI "IMAGENAME eq mysqld.exe" /FO CSV /NH');
|
||
|
||
if (stdout.includes('mysqld.exe')) {
|
||
this.log('warn', '检测到残留的 MySQL 进程,正在清理...');
|
||
|
||
// 尝试优雅关闭
|
||
try {
|
||
const mysqladmin = path.join(this.binPath, 'mysqladmin.exe');
|
||
await this.execCommand(`"${mysqladmin}" -u root shutdown`);
|
||
await this.sleep(2000);
|
||
this.log('success', '残留进程已优雅关闭');
|
||
} catch (e) {
|
||
// 优雅关闭失败,强制杀死
|
||
this.log('warn', '优雅关闭失败,强制终止...');
|
||
await this.execCommand('taskkill /F /IM mysqld.exe');
|
||
await this.sleep(1000);
|
||
this.log('success', '残留进程已强制终止');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// tasklist 命令失败通常意味着没有找到进程,这是正常的
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 进程模式:启动 MySQL 进程
|
||
*/
|
||
async startMySQLProcess() {
|
||
return new Promise((resolve, reject) => {
|
||
const mysqld = path.join(this.binPath, 'mysqld.exe');
|
||
|
||
this.log('system', '正在启动 MySQL 进程...');
|
||
this.log('system', `可执行文件: ${mysqld}`);
|
||
this.log('system', `配置文件: ${this.configFile}`);
|
||
|
||
// 检查文件是否存在
|
||
if (!fs.existsSync(mysqld)) {
|
||
return reject(new Error(`mysqld.exe 不存在: ${mysqld}`));
|
||
}
|
||
if (!fs.existsSync(this.configFile)) {
|
||
return reject(new Error(`配置文件不存在: ${this.configFile}`));
|
||
}
|
||
|
||
this.mysqlProcess = spawn(mysqld, [
|
||
`--defaults-file=${this.configFile}`,
|
||
'--console'
|
||
], {
|
||
cwd: this.mysqlPath,
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
windowsHide: true
|
||
});
|
||
|
||
let startupComplete = false;
|
||
let startupTimeout = null;
|
||
|
||
// 监听 stderr(MySQL 主要输出在 stderr)
|
||
this.mysqlProcess.stderr.on('data', (data) => {
|
||
const msg = data.toString().trim();
|
||
if (msg) {
|
||
// 检查启动成功标志
|
||
if (msg.includes('ready for connections') || msg.includes('port:')) {
|
||
if (!startupComplete) {
|
||
startupComplete = true;
|
||
if (startupTimeout) clearTimeout(startupTimeout);
|
||
this.log('success', 'MySQL 进程已就绪');
|
||
resolve(true);
|
||
}
|
||
}
|
||
|
||
// 输出日志(过滤一些不重要的信息)
|
||
if (!msg.includes('[Note]') || msg.includes('error') || msg.includes('ready')) {
|
||
this.log('system', `[mysqld] ${msg}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
this.mysqlProcess.stdout.on('data', (data) => {
|
||
const msg = data.toString().trim();
|
||
if (msg) {
|
||
this.log('system', `[mysqld] ${msg}`);
|
||
}
|
||
});
|
||
|
||
this.mysqlProcess.on('error', (err) => {
|
||
this.log('error', `MySQL 进程启动失败: ${err.message}`);
|
||
this.mysqlProcess = null;
|
||
reject(err);
|
||
});
|
||
|
||
this.mysqlProcess.on('exit', (code, signal) => {
|
||
this.log('system', `MySQL 进程退出,代码: ${code}, 信号: ${signal}`);
|
||
this.mysqlProcess = null;
|
||
|
||
// 如果还没完成启动就退出了,说明启动失败
|
||
if (!startupComplete) {
|
||
reject(new Error(`MySQL 进程异常退出,代码: ${code}`));
|
||
}
|
||
});
|
||
|
||
// 超时处理(30秒)
|
||
startupTimeout = setTimeout(() => {
|
||
if (!startupComplete && this.mysqlProcess) {
|
||
// 进程还在运行,认为启动成功
|
||
startupComplete = true;
|
||
this.log('success', 'MySQL 进程启动完成(超时检测)');
|
||
resolve(true);
|
||
}
|
||
}, 30000);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 进程模式:停止 MySQL 进程
|
||
*/
|
||
async stopMySQLProcess() {
|
||
if (!this.mysqlProcess) {
|
||
this.log('system', 'MySQL 进程未运行');
|
||
return;
|
||
}
|
||
|
||
this.log('system', '正在停止 MySQL 进程...');
|
||
|
||
try {
|
||
// 方式1:通过 mysqladmin 优雅关闭
|
||
const mysqladmin = path.join(this.binPath, 'mysqladmin.exe');
|
||
await this.execCommand(`"${mysqladmin}" -u root shutdown`);
|
||
|
||
// 等待进程退出
|
||
await this.sleep(3000);
|
||
|
||
if (this.mysqlProcess && !this.mysqlProcess.killed) {
|
||
// 如果还没退出,强制杀死
|
||
this.log('warn', '优雅关闭超时,强制终止...');
|
||
this.mysqlProcess.kill('SIGTERM');
|
||
await this.sleep(1000);
|
||
}
|
||
|
||
this.log('success', 'MySQL 进程已关闭');
|
||
} catch (error) {
|
||
// mysqladmin 关闭失败,强制杀死进程
|
||
this.log('warn', `优雅关闭失败: ${error.message},强制终止...`);
|
||
|
||
if (this.mysqlProcess) {
|
||
this.mysqlProcess.kill('SIGTERM');
|
||
await this.sleep(1000);
|
||
}
|
||
|
||
// 确保进程被杀死
|
||
try {
|
||
await this.execCommand('taskkill /F /IM mysqld.exe');
|
||
await this.sleep(2000); // 等待文件句柄释放
|
||
} catch (e) {
|
||
// 忽略
|
||
}
|
||
}
|
||
|
||
// 最终检查:确保所有 mysqld.exe 进程都被终止
|
||
try {
|
||
const { stdout } = await this.execCommand('tasklist /FI "IMAGENAME eq mysqld.exe" /FO CSV /NH');
|
||
if (stdout.includes('mysqld.exe')) {
|
||
this.log('warn', '检测到残留进程,强制清理...');
|
||
await this.execCommand('taskkill /F /IM mysqld.exe');
|
||
await this.sleep(2000); // 额外等待以确保文件句柄释放
|
||
}
|
||
} catch (e) {
|
||
// 忽略
|
||
}
|
||
|
||
this.mysqlProcess = null;
|
||
this.currentPort = null;
|
||
}
|
||
|
||
/**
|
||
* 检查 MySQL 进程是否在运行
|
||
*/
|
||
isMySQLRunning() {
|
||
return this.mysqlProcess !== null && !this.mysqlProcess.killed;
|
||
}
|
||
|
||
/**
|
||
* 主流程:确保 MySQL 运行(进程模式)
|
||
* @param {function} findAvailablePort - 查找可用端口的函数
|
||
* @param {function} waitForPort - 等待端口就绪的函数
|
||
* @returns {Promise<number>} 返回 MySQL 运行的端口
|
||
*/
|
||
async ensureServiceRunning(findAvailablePort, waitForPort) {
|
||
this.log('system', '开始 MySQL 进程检查流程(进程模式)');
|
||
this.log('system', `MySQL 路径: ${this.mysqlPath}`);
|
||
this.log('system', `配置文件: ${this.configFile}`);
|
||
|
||
// 1. 检查是否已有 MySQL 进程在运行
|
||
if (this.isMySQLRunning()) {
|
||
const port = this.currentPort || this.readPortFromConfig();
|
||
this.log('success', `MySQL 进程已在运行,端口: ${port}`);
|
||
return port;
|
||
}
|
||
|
||
// 2. 检查并清理残留进程
|
||
await this.checkAndKillOrphanProcess();
|
||
|
||
// 3. 查找可用端口
|
||
const port = await findAvailablePort(3306, 100);
|
||
this.log('system', `找到可用端口: ${port}`);
|
||
|
||
// 4. 检查数据库是否已初始化
|
||
if (!this.isDatabaseInitialized()) {
|
||
this.log('system', '数据库未初始化,开始初始化...');
|
||
this.generateMyIni(port);
|
||
await this.initializeDatabase();
|
||
} else {
|
||
this.log('system', '检测到已有数据库目录');
|
||
// 更新配置文件(可能端口变了)
|
||
this.generateMyIni(port);
|
||
}
|
||
|
||
// 5. 启动 MySQL 进程
|
||
this.log('system', '启动 MySQL 进程...');
|
||
await this.startMySQLProcess();
|
||
|
||
// 6. 等待端口就绪
|
||
const finalPort = this.readPortFromConfig();
|
||
this.currentPort = finalPort;
|
||
|
||
this.log('system', `等待端口 ${finalPort} 就绪...`);
|
||
const portReady = await waitForPort(finalPort, 30000);
|
||
|
||
if (!portReady) {
|
||
throw new Error(`MySQL 端口 ${finalPort} 未能在 30 秒内就绪`);
|
||
}
|
||
|
||
this.log('success', `MySQL 进程启动成功,端口: ${finalPort}`);
|
||
return finalPort;
|
||
}
|
||
}
|
||
|
||
module.exports = MySQLServiceManager;
|