申请会员ID:lllomh
1、申 请 I D: 芈渡
2、个人邮箱: lllomh@qq.com
3、原创技术文章: https://www.lllomh.com/article/details?id=1773196745
【原创】从零开始用 Electron + React 打造 N2N 内网穿透联机工具——完整踩坑记录
一、项目背景与动机
相信很多人都有过这样的经历:想和朋友联机玩游戏,却因为各自处于不同的网络环境下(家庭宽带、校园网、4G热点)而无法直接组建局域网。市面上虽然有 Hamachi、ZeroTier 这类商业方案,但要么需要注册账号,要么有人数限制,要么速度堪忧。
N2N 是一个开源的 P2P VPN 工具,原理是通过一台有公网 IP 的 supernode 服务器做中转协调,让各个 edge 节点之间建立虚拟局域网,实际数据传输走点对点,延迟低、速度快。但它的问题在于:纯命令行操作,参数繁琐,普通玩家根本用不来。
于是我决定用 Electron + React + Node.js + MySQL 给它做一个图形化管理工具,实现:
- 可视化配置和管理多个联机房间
- 一键连接/断开,自动拼装 N2N 参数
- 实时显示房间内在线成员列表
- 本地持久化保存房间配置,下次直接用
这篇文章完整记录了开发过程中遇到的技术难点和解决思路,特别是 Node.js 调用并管理 N2N 子进程 这块,踩了相当多的坑。
二、技术选型与整体架构
2.1 为什么选 Electron
- 需要调用系统底层(子进程、网卡驱动),纯 Web 方案做不到
- 团队熟悉 React,用 Electron 可以复用前端技术栈
- 跨平台打包方便,Windows/Mac 都能出包
2.2 整体架构
┌──────────────────────────────────────────────┐
│ React 渲染进程(前端) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ 房间列表 │ │ 成员列表 │ │ 配置面板 │ │
│ └──────────┘ └──────────┘ └───────────┘ │
└───────────────────┬──────────────────────────┘
│ IPC (ipcRenderer / ipcMain)
┌───────────────────▼──────────────────────────┐
│ Electron 主进程 │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ IPC Handler 层 │ │
│ │ connect-room / disconnect-room 等 │ │
│ └──────────────────┬───────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────┐ │
│ │ ProcessManager │ │
│ │ 进程池 / 生命周期管理 / 输出解析 │ │
│ └──────────────────┬───────────────────┘ │
│ │ child_process.spawn │
└─────────────────────┼────────────────────────┘
│
┌────────────▼────────────┐
│ N2N 子进程 │
│ edge.exe / supernode │
└─────────────────────────┘
│
┌────────────▼────────────┐
│ MySQL 本地数据库 │
│ 房间配置 / 历史记录 │
└─────────────────────────┘
主进程负责所有系统调用和进程管理,渲染进程只负责展示和交互,两者通过 IPC 通信,严格遵循 Electron 的安全模型。
三、项目初始化与工程结构
3.1 初始化项目
npx create-react-app n2n-tool
cd n2n-tool
npm install electron electron-builder concurrently wait-on --save-dev
npm install mysql2 electron-store
3.2 目录结构
n2n-tool/
├── public/
│ └── electron.js # Electron 主进程入口
├── src/
│ ├── components/
│ │ ├── RoomList.jsx # 房间列表组件
│ │ ├── MemberList.jsx # 在线成员组件
│ │ └── RoomConfig.jsx # 房间配置表单
│ ├── hooks/
│ │ └── useRoom.js # 房间状态 Hook
│ ├── ipc/
│ │ └── renderer.js # 渲染进程 IPC 封装
│ └── App.jsx
├── preload.js # 预加载脚本
├── ipc/
│ ├── roomHandlers.js # IPC 处理器
│ └── processManager.js # N2N 进程管理核心
├── db/
│ └── index.js # 数据库操作封装
└── n2n-bin/ # N2N 可执行文件
├── edge.exe
└── supernode.exe
3.3 package.json 关键配置
{
"main": "public/electron.js",
"scripts": {
"dev": "concurrently \"npm start\" \"wait-on http://localhost:3000 && electron .\"",
"build": "react-scripts build && electron-builder",
"electron": "electron ."
},
"build": {
"appId": "com.n2ntool.app",
"productName": "N2N好朋友联机工具",
"win": {
"requestedExecutionLevel": "requireAdministrator",
"target": "nsis"
},
"extraResources": [
{ "from": "n2n-bin", "to": "n2n-bin" }
],
"files": [
"build/**/*",
"public/electron.js",
"preload.js",
"ipc/**/*",
"db/**/*"
]
}
}
注意 requestedExecutionLevel: requireAdministrator,N2N 操作 TAP 网卡需要管理员权限,这个不设置的话在 Windows 上会静默失败,非常难排查。
四、数据库设计与封装
4.1 表结构设计
-- 房间配置表
CREATE TABLE rooms (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL UNIQUE COMMENT '房间名,即N2N community字段',
password VARCHAR(128) NOT NULL COMMENT '房间密码,N2N加密密钥',
virtual_ip VARCHAR(15) NOT NULL COMMENT '本机在虚拟网络中的IP',
supernode VARCHAR(128) NOT NULL COMMENT 'supernode服务器地址',
port INT DEFAULT 7654 COMMENT 'supernode端口',
n2n_path VARCHAR(256) NOT NULL COMMENT 'N2N可执行文件所在目录',
last_used_at TIMESTAMP NULL COMMENT '最后连接时间',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 连接历史表
CREATE TABLE connect_history (
id INT AUTO_INCREMENT PRIMARY KEY,
room_name VARCHAR(64) NOT NULL,
connected_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
duration_sec INT DEFAULT 0 COMMENT '本次连接持续时间(秒)',
INDEX idx_room (room_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
4.2 数据库操作封装(db/index.js)
const mysql = require('mysql2/promise')
let pool = null
function getPool() {
if (!pool) {
pool = mysql.createPool({
host: '127.0.0.1',
user: 'root',
password: '',
database: 'n2n_tool',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0
})
}
return pool
}
// 初始化建表(应用启动时调用)
async function initDB() {
const conn = await getPool().getConnection()
try {
await conn.query(`CREATE DATABASE IF NOT EXISTS n2n_tool`)
await conn.query(`USE n2n_tool`)
await conn.query(`
CREATE TABLE IF NOT EXISTS rooms (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(64) NOT NULL UNIQUE,
password VARCHAR(128) NOT NULL,
virtual_ip VARCHAR(15) NOT NULL,
supernode VARCHAR(128) NOT NULL,
port INT DEFAULT 7654,
n2n_path VARCHAR(256) NOT NULL,
last_used_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
// ... 其他表
console.log('[DB] 初始化完成')
} finally {
conn.release()
}
}
async function getRooms() {
const [rows] = await getPool().query(
'SELECT * FROM rooms ORDER BY last_used_at DESC, created_at DESC'
)
return rows
}
async function saveRoom(config) {
await getPool().query(
`INSERT INTO rooms (name, password, virtual_ip, supernode, port, n2n_path)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
password = VALUES(password),
virtual_ip = VALUES(virtual_ip),
supernode = VALUES(supernode),
port = VALUES(port),
n2n_path = VALUES(n2n_path)`,
[config.name, config.password, config.virtualIp,
config.supernode, config.port, config.n2nPath]
)
}
async function updateLastUsed(roomName) {
await getPool().query(
'UPDATE rooms SET last_used_at = NOW() WHERE name = ?',
[roomName]
)
}
async function deleteRoom(roomName) {
await getPool().query('DELETE FROM rooms WHERE name = ?', [roomName])
}
module.exports = { initDB, getRooms, saveRoom, updateLastUsed, deleteRoom }
五、核心难点:N2N 进程管理(processManager.js)
这是整个项目技术含量最高、踩坑最多的部分,单独拎出来详细说。
5.1 为什么不能用 exec
// 错误示范,千万别这么写
const { exec } = require('child_process')
exec('edge.exe -c room1 -k pass -a 10.0.0.1 -l 1.2.3.4:7654', (err, stdout) => {
// edge 是持续运行的进程,这个回调永远不会触发
// 同时 stdout 也只有进程退出才能拿到,完全没用
})
exec 的本质是等进程退出后一次性返回输出,而 N2N 的 edge 是一个持续运行的守护进程,所以这条路完全行不通。
5.2 spawn 基础用法
const { spawn } = require('child_process')
const path = require('path')
function buildEdgeArgs(config) {
return [
'-c', config.name, // community(房间名)
'-k', config.password, // 加密密钥
'-a', config.virtualIp, // 本机虚拟IP
'-l', `${config.supernode}:${config.port}`, // supernode地址
'-f', // 前台运行(不 daemonize)
'-v' // 详细输出,方便解析
]
}
function startEdge(config, n2nDir) {
const edgeBin = path.join(n2nDir, 'edge.exe')
const args = buildEdgeArgs(config)
const proc = spawn(edgeBin, args, {
cwd: n2nDir,
windowsHide: true, // 隐藏 cmd 黑框,用户体验关键
stdio: ['ignore', 'pipe', 'pipe'] // stdin忽略,stdout/stderr都接管
})
return proc
}
5.3 进程池设计——防僵尸进程
最初版本没有进程池,直接把进程对象存在一个普通变量里,结果发现:
- 切换房间时旧进程没被 kill,两个 edge 同时跑,网络混乱
- 应用崩溃时子进程变成僵尸,下次启动报端口占用
- 无法从其他地方(如 IPC handler)拿到进程引用
改成 Map 实现进程池后解决了这些问题:
// ipc/processManager.js
const { spawn } = require('child_process')
const path = require('path')
const { app } = require('electron')
// 进程池:roomName -> { process, startTime, connected }
const pool = new Map()
// 获取 N2N 二进制文件目录(开发/生产环境路径不同)
function getN2nDir() {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'n2n-bin')
}
return path.join(__dirname, '..', 'n2n-bin')
}
function isRunning(roomName) {
return pool.has(roomName) && pool.get(roomName).process !== null
}
function killRoom(roomName) {
if (!pool.has(roomName)) return
const entry = pool.get(roomName)
if (entry.process) {
entry.process.kill('SIGTERM')
// Windows 上 SIGTERM 可能无效,再补一刀
setTimeout(() => {
try { entry.process.kill('SIGKILL') } catch (_) {}
}, 2000)
}
pool.delete(roomName)
}
function killAll() {
pool.forEach((_, roomName) => killRoom(roomName))
}
// 应用退出时自动清理所有进程
app.on('before-quit', () => {
console.log('[ProcessManager] 清理所有 N2N 进程...')
killAll()
})
module.exports = { pool, getN2nDir, isRunning, killRoom, killAll }
5.4 输出解析与连接状态检测
edge 的 stdout 输出格式大概是这样的(不同版本略有差异):
20230901 12:00:01 [edge] Starting n2n edge
20230901 12:00:01 [edge] Registering with supernode [1.2.3.4:7654]
20230901 12:00:02 [edge] Registered with supernode
20230901 12:00:05 [edge] Rx 10.0.0.2 [aa:bb:cc:dd:ee:ff]
根据这些特征做状态解析:
function createOutputParser(roomName, mainWindow) {
let connected = false
const members = new Map() // ip -> { mac, lastSeen }
// 连接成功特征
const CONNECTED_PATTERN = /Registered with supernode/i
// 成员在线特征(收到该成员的包,说明对方在线)
const MEMBER_PATTERN = /Rx\s+([\d.]+)\s+\[([0-9a-f:]+)\]/i
// 连接失败特征
const FAILED_PATTERN = /registration failed|unable to connect/i
function parse(data) {
const lines = data.toString().split('\n')
for (const line of lines) {
if (!line.trim()) continue
// 检测连接成功
if (!connected && CONNECTED_PATTERN.test(line)) {
connected = true
mainWindow.webContents.send('room-status', {
roomName,
status: 'connected'
})
console.log(`[${roomName}] 连接 supernode 成功`)
}
// 检测在线成员
const memberMatch = line.match(MEMBER_PATTERN)
if (memberMatch) {
const ip = memberMatch[1]
const mac = memberMatch[2]
members.set(ip, { mac, lastSeen: Date.now() })
// 推送最新成员列表到前端
mainWindow.webContents.send('members-update', {
roomName,
members: Array.from(members.values()).map((v, i) => ({
ip: Array.from(members.keys())[i],
...v
}))
})
}
// 检测连接失败
if (FAILED_PATTERN.test(line)) {
mainWindow.webContents.send('room-status', {
roomName,
status: 'failed',
reason: line.trim()
})
}
}
}
return { parse, getMembers: () => members, isConnected: () => connected }
}
5.5 超时检测——避免无限等待
spawn 之后如果 supernode 连不上,进程会一直挂着,用户不知道发生了什么。加个超时机制:
const CONNECT_TIMEOUT_MS = 30000 // 30秒超时
function startRoom(config, mainWindow) {
const n2nDir = getN2nDir()
// 先 kill 掉该房间可能存在的旧进程
if (isRunning(config.name)) {
killRoom(config.name)
}
const proc = spawn(
path.join(n2nDir, 'edge.exe'),
buildEdgeArgs(config),
{ cwd: n2nDir, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }
)
const parser = createOutputParser(config.name, mainWindow)
// 超时定时器
const connectTimer = setTimeout(() => {
if (!parser.isConnected()) {
console.warn(`[${config.name}] 连接超时,自动 kill`)
killRoom(config.name)
mainWindow.webContents.send('room-status', {
roomName: config.name,
status: 'timeout'
})
}
}, CONNECT_TIMEOUT_MS)
proc.stdout.on('data', (data) => {
parser.parse(data)
// 连接成功后清除超时
if (parser.isConnected()) {
clearTimeout(connectTimer)
}
})
proc.stderr.on('data', (data) => {
// 有些版本的 edge 把日志打到 stderr
parser.parse(data)
})
proc.on('close', (code) => {
clearTimeout(connectTimer)
pool.delete(config.name)
console.log(`[${config.name}] 进程退出,code=${code}`)
mainWindow.webContents.send('room-status', {
roomName: config.name,
status: 'disconnected',
exitCode: code
})
})
proc.on('error', (err) => {
clearTimeout(connectTimer)
console.error(`[${config.name}] 进程错误:`, err)
mainWindow.webContents.send('room-status', {
roomName: config.name,
status: 'error',
reason: err.message
})
})
pool.set(config.name, {
process: proc,
startTime: Date.now(),
connected: false
})
return proc
}
module.exports = { startRoom, killRoom, killAll, isRunning }
六、IPC 通信层设计
6.1 预加载脚本(preload.js)
Electron 新版本要求开启 contextIsolation,不能直接在渲染进程里用 require,必须通过 preload 暴露安全的 API:
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
// 房间操作
getRooms: () => ipcRenderer.invoke('get-rooms'),
saveRoom: (config) => ipcRenderer.invoke('save-room', config),
deleteRoom: (name) => ipcRenderer.invoke('delete-room', name),
connectRoom: (config) => ipcRenderer.invoke('connect-room', config),
disconnectRoom: (name) => ipcRenderer.invoke('disconnect-room', name),
getRoomStatus: (name) => ipcRenderer.invoke('get-room-status', name),
// 事件监听(主进程 -> 渲染进程)
onRoomStatus: (cb) => ipcRenderer.on('room-status', (_, d) => cb(d)),
onMembersUpdate: (cb) => ipcRenderer.on('members-update', (_, d) => cb(d)),
// 清理监听器,防止内存泄漏
removeRoomListeners: () => {
ipcRenderer.removeAllListeners('room-status')
ipcRenderer.removeAllListeners('members-update')
}
})
6.2 IPC Handler(ipc/roomHandlers.js)
const { ipcMain } = require('electron')
const db = require('../db')
const { startRoom, killRoom, isRunning } = require('./processManager')
let mainWindow = null
function registerHandlers(win) {
mainWindow = win
ipcMain.handle('get-rooms', async () => {
return await db.getRooms()
})
ipcMain.handle('save-room', async (_, config) => {
await db.saveRoom(config)
return { success: true }
})
ipcMain.handle('delete-room', async (_, name) => {
if (isRunning(name)) killRoom(name)
await db.deleteRoom(name)
return { success: true }
})
ipcMain.handle('connect-room', async (_, config) => {
try {
startRoom(config, mainWindow)
await db.updateLastUsed(config.name)
return { success: true }
} catch (err) {
console.error('[connect-room] 失败:', err)
return { success: false, error: err.message }
}
})
ipcMain.handle('disconnect-room', async (_, name) => {
killRoom(name)
return { success: true }
})
ipcMain.handle('get-room-status', async (_, name) => {
return { running: isRunning(name) }
})
}
module.exports = { registerHandlers }
6.3 主进程入口(public/electron.js)
const { app, BrowserWindow } = require('electron')
const path = require('path')
const { initDB } = require('../db')
const { registerHandlers } = require('../ipc/roomHandlers')
let mainWindow
async function createWindow() {
// 先初始化数据库
await initDB()
mainWindow = new BrowserWindow({
width: 1000,
height: 680,
minWidth: 800,
minHeight: 500,
webPreferences: {
preload: path.join(__dirname, '..', 'preload.js'),
contextIsolation: true, // 安全模式,必须开启
nodeIntegration: false // 渲染进程不直接用 Node,必须关闭
}
})
// 注册 IPC 处理器
registerHandlers(mainWindow)
const isDev = !app.isPackaged
if (isDev) {
mainWindow.loadURL('http://localhost:3000')
mainWindow.webContents.openDevTools()
} else {
mainWindow.loadFile(path.join(__dirname, '../build/index.html'))
}
}
app.whenReady().then(createWindow)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
七、React 前端关键实现
7.1 房间状态 Hook(useRoom.js)
import { useState, useEffect, useCallback } from 'react'
export function useRoom() {
const [rooms, setRooms] = useState([])
const [roomStatus, setRoomStatus] = useState({}) // roomName -> status
const [members, setMembers] = useState({}) // roomName -> members[]
// 加载房间列表
const loadRooms = useCallback(async () => {
const data = await window.electronAPI.getRooms()
setRooms(data)
}, [])
// 连接房间
const connectRoom = useCallback(async (config) => {
setRoomStatus(prev => ({ ...prev, [config.name]: 'connecting' }))
const result = await window.electronAPI.connectRoom(config)
if (!result.success) {
setRoomStatus(prev => ({ ...prev, [config.name]: 'error' }))
}
}, [])
// 断开房间
const disconnectRoom = useCallback(async (name) => {
await window.electronAPI.disconnectRoom(name)
setRoomStatus(prev => ({ ...prev, [name]: 'disconnected' }))
setMembers(prev => ({ ...prev, [name]: [] }))
}, [])
// 监听主进程推送的状态变更
useEffect(() => {
window.electronAPI.onRoomStatus((data) => {
setRoomStatus(prev => ({ ...prev, [data.roomName]: data.status }))
})
window.electronAPI.onMembersUpdate((data) => {
setMembers(prev => ({ ...prev, [data.roomName]: data.members }))
})
loadRooms()
return () => {
window.electronAPI.removeRoomListeners()
}
}, [loadRooms])
return { rooms, roomStatus, members, loadRooms, connectRoom, disconnectRoom }
}
7.2 房间列表组件(RoomList.jsx,节选)
import React from 'react'
import { useRoom } from '../hooks/useRoom'
const STATUS_LABEL = {
connecting: { text: '连接中...', color: '#faad14' },
connected: { text: '已连接', color: '#52c41a' },
disconnected: { text: '未连接', color: '#999' },
timeout: { text: '连接超时', color: '#ff4d4f' },
error: { text: '连接失败', color: '#ff4d4f' },
}
export function RoomList() {
const { rooms, roomStatus, members, connectRoom, disconnectRoom } = useRoom()
return (
<div className="room-list">
{rooms.map(room => {
const status = roomStatus[room.name] || 'disconnected'
const { text, color } = STATUS_LABEL[status] || STATUS_LABEL.disconnected
const roomMembers = members[room.name] || []
return (
<div key={room.name} className="room-card">
<div className="room-header">
<span className="room-name">{room.name}</span>
<span style={{ color }}>{text}</span>
</div>
<div className="room-info">
<span>虚拟IP:{room.virtual_ip}</span>
<span>Supernode:{room.supernode}:{room.port}</span>
</div>
{status === 'connected' && roomMembers.length > 0 && (
<div className="member-list">
<div className="member-title">在线成员({roomMembers.length})</div>
{roomMembers.map(m => (
<div key={m.ip} className="member-item">
{m.ip} <span className="mac">{m.mac}</span>
</div>
))}
</div>
)}
<div className="room-actions">
{status === 'connected' ? (
<button onClick={() => disconnectRoom(room.name)}>断开</button>
) : (
<button
onClick={() => connectRoom(room)}
disabled={status === 'connecting'}
>
{status === 'connecting' ? '连接中...' : '连接'}
</button>
)}
</div>
</div>
)
})}
</div>
)
}
八、打包与发布踩坑
8.1 打包路径问题
这是最常见的坑之一。开发时 __dirname 指向源码目录,打包后路径完全不同:
// 错误:直接用 __dirname,打包后路径错误
const n2nDir = path.join(__dirname, 'n2n-bin')
// 正确:根据是否已打包动态选择路径
const n2nDir = app.isPackaged
? path.join(process.resourcesPath, 'n2n-bin')
: path.join(__dirname, '..', 'n2n-bin')
8.2 把 N2N 二进制文件打进包里
在 package.json 的 build.extraResources 里声明:
"extraResources": [
{
"from": "n2n-bin",
"to": "n2n-bin",
"filter": ["**/*"]
}
]
打包后这些文件会放到 resources/n2n-bin/ 目录,通过 process.resourcesPath 访问。
8.3 Windows 上 SIGTERM 的坑
Linux/macOS 上 process.kill('SIGTERM') 一般够用,但 Windows 上 Node.js 的 SIGTERM 实现有问题,有时 kill 不干净。解决方案是改用 taskkill:
const { exec } = require('child_process')
function forceKillWindows(pid) {
exec(`taskkill /F /T /PID ${pid}`, (err) => {
if (err) console.warn('taskkill 失败:', err.message)
})
}
function killRoom(roomName) {
if (!pool.has(roomName)) return
const { process: proc } = pool.get(roomName)
if (proc && proc.pid) {
if (process.platform === 'win32') {
forceKillWindows(proc.pid)
} else {
proc.kill('SIGTERM')
}
}
pool.delete(roomName)
}
/F 表示强制,/T 表示连同子树一起 kill,避免残留子进程。
九、总结与收获
做完这个工具,技术层面最大的几点收获:
1. child_process 选型很重要
exec 适合短命令,execFile 适合不需要 shell 的短命令,spawn 才是管理长驻进程的正确姿势,fork 则用于 Node.js 子进程间通信。
2. 进程生命周期管理是核心
用 Map 做进程池,每次启动前先 kill 旧进程;应用退出时统一清理;Windows 平台用 taskkill 强制终止。这几点做好了,就不会有僵尸进程。
3. Electron IPC 安全模型
contextIsolation: true + nodeIntegration: false + preload 暴露白名单 API,这套组合是 Electron 安全的基础,不要为了方便省略。
4. 开发/生产环境路径差异
凡是涉及文件路径的地方,一定要用 app.isPackaged 做判断,否则打包后必出问题。
5. 输出解析要有容错
N2N 不同版本输出格式有差异,正则匹配要尽量宽松,同时做好异常捕获,一行解析失败不能影响整体。
工具目前在自己和朋友之间使用,联机效果稳定,延迟比 Hamachi 低不少。后续计划加上 supernode 一键部署功能,有感兴趣的朋友欢迎交流。