在 Node.js 项目中经常需要调用 Python 脚本处理数据或执行算法。本文基于实际项目经验,对比两种主流方案:通过 child_process.spawn 启动子进程,以及基于 FastAPI 搭建 HTTP 服务。包括环境配置、代码实现、锁机制、超时处理与性能优化细节,帮助开发者在稳定性和效率之间做出正确选择。
一、项目环境与虚拟环境管理
推荐在项目根目录下创建 python/ 文件夹,内建 scripts/ 子目录存放 Python 脚本。进入 python/ 目录,使用 Python 内置 venv 创建隔离环境:
Windows PowerShell 中激活:
- venv\Scripts\Activate.ps1
复制代码
安装所需依赖:
- pip install pandas requests fastapi uvicorn[standard]
复制代码
生成依赖清单(提交到 Git):
- pip freeze > requirements.txt
复制代码
确保 .gitignore 忽略虚拟环境与缓存文件:
- python/venv/
- __pycache__/
- *.pyc
复制代码
服务器部署时重新创建环境:
- python -m venv venv
- pip install -r requirements.txt
复制代码
二、方案一:child_process.spawn 子进程(仅适用于低频调用)
原理:每个请求 spawn 一个 Python 进程执行脚本,通过 stdout/stderr 获取输出。适合低频、一次性任务,但不推荐高频并发使用。
2.1 关键实现
在 Node 端,需要构建 Python 解释器的绝对路径和脚本路径。使用 path.join 拼接,确保跨平台兼容。
- const { spawn } = require('child_process');
- const path = require('path');
- const rootPath = path.resolve(__dirname, '..');
- const pythonPath = path.join(rootPath, 'python', 'venv', 'Scripts', 'python.exe');
- const scriptPath = path.join(rootPath, 'python', 'scripts', 'test.py');
- let isRunning = false;
- exports.get_info = (req, res) => {
- if (isRunning) {
- return res.send({ status: 429, message: '任务执行中,请稍后再试' });
- }
- isRunning = true;
- const py = spawn(pythonPath, [scriptPath]);
- let error = '';
- py.stderr.on('data', (data) => { error += data.toString(); });
- py.on('error', (err) => {
- isRunning = false;
- return res.send({ status: 500, message: 'Python启动失败', error: err.message });
- });
- const timeout = setTimeout(() => py.kill('SIGTERM'), 300000); // 5分钟超时
- py.on('close', (code) => {
- clearTimeout(timeout);
- isRunning = false;
- if (code === 0) return res.send({ status: 200, message: '执行成功' });
- return res.send({ status: 500, message: '执行失败', error: error || '未知错误' });
- });
- };
复制代码
2.2 需要特别注意的陷阱
- 全局锁与并发限制:每个 spawn 都会创建新进程,并发请求会导致内存飙升。通过 isRunning 锁确保同一时刻只执行一个 Python 任务,后面的请求直接返回 429。
- Python 脚本内线程数控制:在脚本最顶部限制 OpenBLAS / OMP / MKL 线程数,防止多线程引发内存溢出:
- import os
- os.environ['OPENBLAS_NUM_THREADS'] = '1'
- os.environ['OMP_NUM_THREADS'] = '1'
- os.environ['MKL_NUM_THREADS'] = '1'
复制代码
- 避免 stdout 污染:Python 脚本中不要随意 print,否则输出会被 Node 端捕获并可能污染解析。建议只在必要时报 JSON 格式数据,由 Node 端正则解析。
- 路径问题:若脚本内有文件读写操作,必须使用绝对路径,否则工作目录不固定可能导致文件找不到。
- 进程退出与内存回收:超时杀死进程 (SIGTERM) 后,系统会自动回收内存,但若进程僵死则无法回收,后续请求会失败。
三、方案二:FastAPI HTTP 服务(推荐)
摆脱 spawn 的各种陷阱:buffer 溢出、JSON 污染、进程失控。让 Python 作为一个长期运行的 HTTP 服务,Node 通过 axios 发起请求。
3.1 启动 FastAPI 服务
在虚拟环境中安装依赖后,创建 api_server.py 作为入口:
- # api_server.py
- import os
- from fastapi import FastAPI
- import sys
- BASE_DIR = os.path.dirname(os.path.abspath(__file__))
- sys.path.append(BASE_DIR) # 使 scripts 可导入
- from scripts.test import get_test_info
- app = FastAPI()
- @app.get('/aaa/bbb')
- async def ccc():
- result = await get_test_info()
- return result
复制代码
注意:FastAPI 已在事件循环中,脚本文件内不可使用 asyncio.run(),而是把原来的 __main__ 逻辑改写为 async 函数并 return 可序列化的字典。
启动命令(在 python/ 目录下):
- uvicorn api_server:app --host 0.0.0.0 --port 8000 --reload
复制代码
--reload 用于开发阶段自动重启。生产部署时可去掉。
3.2 Node 端调用
使用 axios 替代 spawn:
- const axios = require('axios');
- let isRunning = false;
- exports.get_info = async (req, res) => {
- if (isRunning) {
- return res.send({ status: 429, message: '任务执行中,请稍后再试' });
- }
- isRunning = true;
- try {
- const response = await axios.get('http://127.0.0.1:8000/aaa/bbb', { timeout: 300000 });
- isRunning = false;
- return res.send({ status: 200, message: '执行成功', data: response.data });
- } catch (error) {
- isRunning = false;
- return res.send({ status: 500, message: 'FastAPI调用失败', error: error.message });
- }
- };
复制代码
3.3 优势分析
- 无 spawn 进程开销:Python 进程常驻,请求只需一次 HTTP 往返。
- 天然避免并发爆炸:FastAPI 内部处理并发,可根据需要调整 worker 数。
- 易于监控:可加健康检查端点,Prometheus 监控等。
- 错误处理清晰:HTTP 状态码与 JSON 响应,Node 端直接捕获异常。
四、方案选型建议
- 低频任务(每天数十次):可使用 spawn 单进程加锁。注意务必加上超时杀进程和线程限制,预防内存泄漏。
- 高频调用或对稳定性要求高:优先选择 FastAPI HTTP 方案。多部署一个 Python 服务,但换来稳定性和可维护性。
- 特殊场景:若 Python 脚本需要实时交互(如逐行输入),spawn 的 stdin/stdout 模式仍可用,但需设计好通信协议。
无论选择哪种方式,都需处理好路径、锁、超时和错误回传,才能保证 Node.js + Python 混合架构的健壮运行。 |