资讯动态

Electron+Koa子进程+SQLite:桌面应用本地服务架构实践

发布时间:2026/10/17 19:16:31 来源:尧图企业网站定制
最近在调一个 Electron 桌面工具模拟项目 X时我把 Koa 微服务挪到了子进程里跑数据库用 Sqlite3 落盘整个链路跑通之后明显比之前把 HTTP 服务直接塞在主进程里省心。这个模式说白了就是Electron 主进程只负责窗口、菜单和 IPC 调度真正的业务接口由一个独立子进程提供渲染进程通过本地端口去调。适合做桌面应用内嵌本地服务、离线数据采集工具或者想把后端逻辑单独拿出来测试的开发者。下面我把设计思路、完整代码和踩过的坑一次性写清楚。1. 先想清楚一个事Koa 为什么非要跑在子进程1.1 主进程直接起 HTTP 服务的问题很多人第一次做 Electron 本地服务时会直接在主进程里app.listen(3000)。开发阶段这样做确实省事但细想有几个很实际的风险。主进程是 Electron 应用的“总调度室”窗口创建、菜单响应、原生模块调用、IPC 转发全都在这个事件循环里跑。我自己遇到过的情况是某个接口里用 SQLite 同步查询了几万行数据界面直接卡到“无响应”鼠标转圈转了三四秒。原因很简单better-sqlite3是同步 API它执行查询时整个主进程的 Node 事件循环被占住渲染进程发来的任何 IPC 都要排队等着。这不是代码写错了是架构上的根本冲突。第二个问题是异常隔离。主进程里的uncaughtException一旦没兜住整个应用直接闪退。如果只是一个服务模块出了问题就导致用户正在编辑的内容没保存这个代价很不值得。子进程崩溃就不一样可以在主进程监听退出事件拉起来一个全新的服务实例用户基本无感知。还有一个偏工程化的理由把服务独立成子进程后这个 Koa 服务可以脱离 Electron 单独启动、单独测试。我可以在纯 Node 环境里node server/index.js跑起来用 curl 调接口开发效率高很多。对于有一定业务复杂度的项目这点比什么都重要。1.2 这套架构适合什么场景并不是所有 Electron 应用都需要子进程跑 Koa。如果你的应用只是简单展示本地文件、调用系统 API那直接在渲染进程里用 IPC 就够。但下面几类场景用“Electron 主进程 子进程 Koa Sqlite3”这套结构非常合适应用需要一个稳定的 HTTP 接口给插件系统、浏览器页面或同一局域网内的其他设备调用前端页面里有很多数据 CRUD直接通过fetch走本地接口比堆砌 IPC 事件清晰得多需要把服务端逻辑和界面层解耦方便以后把服务单独迁移到一台小主机上。离线运行的核心诉求桌面工具必须能在无网环境工作数据得落在本地。我做的模拟项目 X 就是一个本地数据采集工具浏览器里有一个管理面板需要录入配置、查询历史数据同时还要能让另一台设备通过局域网访问只读接口。这种情况下渲染进程直接操作 SQLite 很不合适也不安全把 Koa 服务放到子进程就是最顺的方案。2. 技术选型拆解utilityProcess 与 SQLite 驱动的取舍2.1 为什么选 utilityProcess 而不是 child_process.forkElectron 提供了两种常见的子进程方案一个是 Node 自带的child_process.fork另一个是 Electron 专有的utilityProcess.fork。我第一次做的时候顺手用了child_process.fork发现在 Electron 环境里它有个别扭的地方fork 出来的进程会继承 Electron 的环境变量和运行时如果入口脚本里require(electron)行为可能和你预期的纯 Node 进程不一样而且消息通信 API 还要区分主进程端和子进程端的发送方式代码容易搞混。utilityProcess是 Electron 22 开始提供的方案专门为了在 Electron 内创建一个“尽可能接近 Node.js 运行环境”的进程。它有几个实实在在的好处支持MessagePort风格的双向通信语义清晰可以用stdio: pipe捕获子进程的 stdout/stderr方便日志排查进程崩溃时主进程能稳定监听exit事件。我整理了一个对比表格方便你判断选哪个对比项child_process.forkutilityProcess.fork引入方式Node 原生模块Electron 专属 API通信方式child.send()/message事件child.postMessage()/message事件子进程用parentPort环境隔离继承 Electron 环境变量容易混专门设计的纯 Node 模拟环境日志管道默认pipe要手动处理支持stdio: pipe主进程可直接监听 stdout/stderr进程名称自定义性一般支持serviceNameWindows 任务管理器里更好认适用版本所有 ElectronElectron 22结论很直接新项目直接上utilityProcess老项目如果不是 Electron 22 以下也建议往这边迁。唯一要注意的是utilityProcess并不是 100% 的 Node.js比如一些依赖底层 libuv 细节的模块可能在 behavior 上略有差异但从我实际跑的 Koa Sqlite3 来看完全没遇到兼容性问题。2.2 SQLite 驱动选项对比SQLite 在 Node/Electron 生态里有好几个驱动我逐一筛过一遍驱动API 风格原生编译性能备注better-sqlite3同步需要高最常见的生产选择文档清晰node:sqlite同步不需要高Node 22.5 内置Electron 新版本可用sql.js异步/同步都有不需要WASM中数据需要手动落盘不适合高频写库sqlite3回调需要中API 老维护状态一般better-sqlite3是目前最稳的选择。虽然同步 API 在传统 Node 服务里容易被诟病但在 Electron 子进程场景下反而成了优点子进程自己阻塞不影响主进程 UI代码写起来也比回调或 async/await 地狱直观直接db.prepare(...).run(...)一行搞定配合 Koa 的async中间件也毫无压力。不过如果你用的 Electron 版本足够新30 且内置 Node 22 以上node:sqlite可以免掉原生编译的麻烦也算是一个选项只是它的生态还不够丰富文档也比较新。最终我选的是better-sqlite3原因很简单稳定、资料多、踩坑经验容易搜。2.3 依赖安装与原生模块重编译better-sqlite3是原生模块而 Electron 内置的 Node 版本和系统里的 Node 版本经常不一致所以装完依赖后必须做一次 electron-rebuild否则加载时会报NODE_MODULE_VERSION不匹配。项目根目录下执行npm install koa koa/router koa-bodyparser better-sqlite3 # Electron 环境的原生模块重编译 npx electron-rebuild -f -w better-sqlite3建议把 rebuild 命令写进package.json的postinstall防止团队成员装完依赖后忘记编译{ scripts: { postinstall: electron-rebuild -f -w better-sqlite3 } }这一步是纯新手最容易卡住的点。系统 Node 下跑好好的一进 Electron 就报错十有八九是没重编译。我后面在打包节还会提到electron-builder里要做的配套配置这里先记好。3. 主进程落地实现启动、端口回传与退出清理3.1 从主进程 fork 子服务主进程代码我建议独立写一个模块不要在main.js里堆太多逻辑。下面这段是可以直接改改就用的启动代码// main.js const { app, BrowserWindow, utilityProcess, ipcMain } require(electron); const path require(path); let serverProcess null; let serverPort 0; function startServerProcess() { const entry path.join(__dirname, server, index.js); serverProcess utilityProcess.fork(entry, [], { serviceName: koa-local-server, stdio: pipe }); serverProcess.stdout.on(data, (chunk) { console.log([server stdout] ${chunk}); }); serverProcess.stderr.on(data, (chunk) { console.error([server stderr] ${chunk}); }); serverProcess.on(message, (message) { if (message.type server-ready) { serverPort message.port; createWindow(); } if (message.type server-error) { console.error([子进程错误] ${message.message}); } }); serverProcess.on(exit, (code) { console.warn([server] 子进程退出退出码 ${code}); }); }有两个细节想强调一下。第一stdio: pipe这个参数很重要它能让你在主进程控制台直接看到子进程的console.log调试阶段全靠它。第二utilityProcess.fork的第二个参数是args可以传业务参数但我更推荐用process.env传递数据库路径这类配置后面会讲。3.2 动态端口与生命周期管理服务端口千万不要写死。开发时3000可能被占用生产环境更不该假设端口一定空闲。最好的做法是子进程监听端口0让操作系统自动分配一个空闲端口然后把真实端口通过 IPC 回传给主进程。这样既避免端口冲突也稍微降低一点被本机其他程序猜中地址的概率虽然 127.0.0.1 的 HTTP 服务严格说不能算安全边界。优雅退出也是必须处理的。如果直接在主进程退出时杀掉子进程SQLite 的 WAL 日志可能来不及合并窗口关闭后数据库文件还会被占用一小段时间Windows 下尤其明显。我采用先发shutdown消息等子进程关闭数据库和 server 后自行退出超时再强制 killfunction stopServerProcess() { if (!serverProcess || serverProcess.isDestroyed()) return; serverProcess.postMessage({ type: shutdown }); const forceKillTimer setTimeout(() { if (!serverProcess.isDestroyed()) { serverProcess.kill(); } }, 1000); forceKillTimer.unref(); } app.on(before-quit, () { stopServerProcess(); });这里踩过一次坑before-quit里直接kill()虽然应用能退但偶尔会出现“数据库文件被占用下次启动写不进数据”的问题。加一步优雅关闭让子进程主动db.close()问题就消失了。3.3 渲染进程如何拿到服务端口拿到serverPort之后把它安全地交给渲染进程。我用的是ipcMain.handle实现“按需查询”避免在页面还没加载完时把 port 硬塞过去导致时序问题function createWindow() { const win new BrowserWindow({ width: 1100, height: 750, webPreferences: { preload: path.join(__dirname, preload.js) } }); win.loadFile(index.html); } ipcMain.handle(get-server-port, () { return serverPort; });preload 脚本通过contextBridge暴露一个安全的 API// preload.js const { contextBridge, ipcRenderer } require(electron); contextBridge.exposeInMainWorld(serverApi, { getPort: () ipcRenderer.invoke(get-server-port) });渲染进程里这样用const port await window.serverApi.getPort(); const response await fetch(http://127.0.0.1:${port}/api/records); const json await response.json();这个链路不复杂但中间每一个环节子进程启动 → 端口就绪 → 窗口创建 → 渲染层查询都必须按顺序走否则页面一上来就 fetch 会空手而归。如果遇到接口调不通先回主进程控制台看看子进程日志再确认serverPort是否已经赋值。4. 子进程里的 Koa 服务实现细节4.1 基础路由与 Sqlite3 落库子进程入口文件server/index.js是整套服务的核心。我先贴一个可运行的完整版本// server/index.js const Koa require(koa); const Router require(koa/router); const bodyParser require(koa-bodyparser); const Database require(better-sqlite3); const path require(path); const { parentPort } require(electron); const dbPath process.env.DB_PATH || path.join(__dirname, data.db); const db new Database(dbPath); db.pragma(journal_mode WAL); db.pragma(busy_timeout 5000); // 建表字段只是示例 db.exec( CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, content TEXT NOT NULL, created_at TEXT DEFAULT (datetime(now, localtime)) ); ); const app new Koa(); const router new Router(); app.use(bodyParser()); app.use(async (ctx, next) { ctx.set(Access-Control-Allow-Origin, *); ctx.set(Access-Control-Allow-Methods, GET,POST,PUT,DELETE,OPTIONS); ctx.set(Access-Control-Allow-Headers, Content-Type,Authorization); if (ctx.method OPTIONS) { ctx.status 204; return; } await next(); }); app.use(async (ctx, next) { const start Date.now(); await next(); log(${ctx.method} ${ctx.path} - ${ctx.status} ${Date.now() - start}ms); }); router.get(/api/records, (ctx) { const rows db.prepare(SELECT * FROM records ORDER BY id DESC LIMIT 50).all(); ctx.body { code: 0, data: rows }; }); router.post(/api/records, (ctx) { const { content } ctx.request.body || {}; if (!content) { ctx.status 400; ctx.body { code: 1, message: content 不能为空 }; return; } const info db.prepare(INSERT INTO records (content) VALUES (?)).run(content); ctx.body { code: 0, id: Number(info.lastInsertRowid) }; }); router.get(/api/config, (ctx) { const row db.prepare(SELECT * FROM config WHERE key ?).get(app); ctx.body { code: 0, data: row || {} }; }); router.put(/api/config, (ctx) { const value JSON.stringify(ctx.request.body || {}); db.prepare(INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value excluded.value) .run(app, value); ctx.body { code: 0 }; }); app.use(router.routes()); // 动态端口0 表示让系统自动分配空闲端口 const server app.listen(0, 127.0.0.1, () { const port server.address().port; log(Koa 微服务启动完成监听 http://127.0.0.1:${port}); if (parentPort) { parentPort.postMessage({ type: server-ready, port }); } }); function log(...args) { const text args.join( ); console.log(text); if (parentPort) { parentPort.postMessage({ type: server-log, text }); } } parentPort.on(message, (e) { const msg e.data; if (msg.type shutdown) { log(收到关闭指令正在关闭数据库和服务); db.close(); server.close(() process.exit(0)); } });这里面ON CONFLICT(key) DO UPDATE SET ...是better-sqlite3结合 SQLite 3.24 的 UPSERT 写法比先查再插要省一次往返推荐直接用。db.pragma(journal_mode WAL)能显著提升读写并发表现同时也减少了 Windows 下文件锁的概率。4.2 IPC 状态回传与日志转发utilityProcess的子进程端通信不是用process.send而是parentPort这一点和child_process很不一样。我一开始用错了 API控制台直接报process.send is not a function排查了半天才反应过来。消息格式我建议统一走“type payload”的结构方便主进程switch分发typepayload含义server-readyport服务已启动端口号多少server-errormessage启动或运行期错误信息server-logtext日志文本转发到主进程控制台shutdown无主进程命令子进程优雅退出日志转发是容易被忽略的功能。子进程有自己的 stdout但在打包后的生产环境里不一定看得到。我在log()函数里同时输出到子进程自己的 stdout 和parentPort这样开发时终端能看到运行时主进程也能接手记录两边都不会漏。实际排查时发现一个有意思的现象直接console.log在utilityProcess里不一定实时冲洗某些 Electron 版本下日志会延迟加上parentPort转发之后主进程侧日志反而更及时。所以如果你发现子进程日志半天不出现先别怀疑代码没跑先看是不是传输通道的问题。4.3 本地服务的 CORS 与安全提醒Electron 渲染进程里发起fetch请求本地服务会受浏览器同源策略影响。页面是file://或http://localhost加载的目标却是http://127.0.0.1:xxxx跨域了。有两种解法一种是关闭webSecurity我强烈不建议这会降低整个应用的安全性得不偿失另一种是在 Koa 里加 CORS 中间件就是我示例代码里那段手写逻辑。CORS 响应头的Access-Control-Allow-Origin我示例里写了*开发环境够用但生产环境建议收敛成你的页面实际来源或者干脆做 token 校验。有个安全观点想在这里特别提醒绑定127.0.0.1只能挡掉外部网络的访问本机上的任何进程仍然可以请求这个本地端口。所以不要把用户密码、密钥这类敏感信息以明文形式存进 SQLite也不要认为“本地服务等于安全服务”。如果业务实在有敏感数据可以在子进程启动时生成一个随机 token通过主进程 IPC 转给渲染进程请求时强制带上这样至少能挡掉大部分本机恶意扫描。5. 打包发布阶段容易翻车的三个点5.1 原生模块必须 asarUnpackelectron-builder打包时会把node_modules压缩进app.asar对于 JS 文件没问题但.node这样的原生二进制在 asar 压缩包里常常无法被正常加载表现为“开发环境一切正常打包后一启动就报错”。解决办法是在package.json里显式披露原生模块{ build: { asar: true, asarUnpack: [ node_modules/better-sqlite3/** ] } }这也意味着即使better-sqlite3所在目录被打进了 asar真正运行时会解压到app.asar.unpackedElectron 会自动处理查找路径。如果自定义了electron-builder的files配置记得确保node_modules/better-sqlite3没有被过滤掉。5.2 数据库文件不要放进 asarapp.asar在运行时是只读的。如果你在代码里写死了path.join(__dirname, data.db)开发环境没事打包后应用启动时想写数据库文件会静默失败或者直接抛只读错误。正确做法是把数据库文件放到用户数据目录const { app } require(electron); const userDataPath app.getPath(userData); const dbPath path.join(userDataPath, app-data.db);因为子进程在utilityProcess里也能require(electron)所以可以直接取app.getPath。如果你不想在子进程里依赖 electron 模块也可以在主进程算好路径后用环境变量传进去。我实际用的就是后一种// 主进程中 serverProcess utilityProcess.fork(entry, [], { env: { ...process.env, DB_PATH: path.join(app.getPath(userData), app-data.db) }, serviceName: koa-local-server, stdio: pipe });这样子进程入口代码里只需要process.env.DB_PATH在纯 Node 环境调试时也能自定义路径解耦得很干净。5.3 生产环境路径别用 __dirname 想当然很多人包括我早期写过类似path.join(__dirname, server, index.js)的入口配置开发环境没问题但打包后__dirname指向的是 asar 内部虽然 Electron 支持从 asar 里读文件、也能定位 JS 入口但如果你把配置、证书、外部资源放进去会和各种权限问题纠缠不清。我的习惯是凡是需要读写的文件一律放userData凡是只读的代码才可以用__dirname引用。数据库、日志、配置文件都属于前者入口脚本、前端静态资源属于后者。打包后如果某个路径看起来“不太对”优先怀疑是不是把可写文件放错地方了。6. 常见问题速查表与排查经验6.1 典型故障速查表现象常见原因处理方案启动报NODE_MODULE_VERSION错误原生模块没有针对 Electron 的 Node 版本重新编译运行npx electron-rebuild -f -w better-sqlite3打包后找不到better_sqlite3.node原生模块没有 asarUnpackpackage.json里配置asarUnpack包含 better-sqlite3服务启动成功但渲染进程 fetch 失败未加 CORS 响应头或端口还没回传就请求Koa 加 CORS 中间件通过get-server-port获取端口后再 fetch子进程日志在主进程终端看不到stdio默认不是 pipe或日志冲洗慢显式设置stdio: pipe并监听stdout/stderr退出后数据库文件仍被占用子进程没有优雅关闭 SQLite发送shutdown消息子进程执行db.close()后再server.close()端口被占用导致接口调不通写死了端口号监听端口 0动态获取端口回传主进程Windows 下反复崩溃better-sqlite3版本与 Electron ABBI 不兼容升级 better-sqlite3 到支持当前 Electron 的版本重新 rebuild这里每一个坑我都实际遇到过尤其是打包后原生模块缺失的问题当时在 Windows 上足足查了两个小时最后就是加一行asarUnpack的事。建议发布正式包之前专门拿一台干净环境的 Windows 虚拟机跑一遍安装包很多隐藏问题会自动暴露出来。6.2 排查思路先看子进程日志再看端口排查这类架构问题我的习惯是按层定位不要一上来就怀疑数据库。第一层主进程控制台有没有[server stdout]或[server stderr]输出如果完全没有说明子进程可能根本没启动或者 entry 路径不对。第二层是否有server-ready消息如果没有说明子进程里某个require或者数据库初始化抛异常了。第三层端口是否真的通了在子进程日志里看到监听地址后可以临时用命令行curl http://127.0.0.1:端口/api/records验证排除前端问题。这层过滤下来80% 的问题都能定位。剩下的才会涉及业务逻辑层面。再分享一个调试小窍门我在server/index.js顶部顺手加了process.on(uncaughtException, e log(e.stack))不要小看这行代码等你在子进程里遇到TypeError: Cannot read properties of undefined时会因为这段兜底日志而少走很多弯路。最后说点个人体会。我第一次搭这套结构时最大的误区是想把数据库查询、路由注册、日志中间件全塞进一个文件里结果改起来很别扭。后来拆成了db.js、routes.js、index.js三个模块清爽多了。Electron 和 Koa 本身都不难难的是想清楚每个进程该负责什么、哪个数据走 IPC、哪个数据走 HTTP。想清楚之后再动手这套结构在本地工具类应用里会非常顺手而且后续维护起来比想象中省心。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑