一、项目结构与基础代码

创建一个极简的 Node.js + Express Web 项目,包含基础页面和启动脚本,后续对接 Jenkins 实现自动化部署。

1. 项目目录结构
nodejs-jenkins-demo/
├── public/           # 静态资源目录
│   ├── css/          # 样式文件目录
│   │   └── style.css # 页面样式
│   └── index.html    # 前端页面
├── Jenkinsfile       # Jenkins流水线配置
├── README.md         # 项目说明文档
├── app.js            # Express服务入口
├── app.test.js       # 测试文件(如单元测试)
├── package.json      # 依赖与脚本配置
└── package-lock.json # 依赖版本锁定文件
2. 核心代码编写
(1) package.json(依赖配置)
{
  "name": "nodejs-jenkins-demo",
  "version": "1.0.0",
  "description": "Node.js + Jenkins 自动化部署示例",
  "main": "app.js",
  "scripts": {
    "start": "node app.js",
    "dev": "nodemon app.js",
    "stop": "pkill -f node app.js || echo '服务未运行'",
    "test": "jest --runInBand --no-cache"
  },
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "jest": "^27.5.1",
    "nodemon": "^3.0.1"
  }
}
(2) app.js(Express 服务)

const express = require('express');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3300;

// 静态资源目录
app.use(express.static(path.join(__dirname, 'public')));

// 根路由返回首页
app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

// 关键修复:仅当直接运行 app.js 时才启动服务(测试导入时跳过)
if (require.main === module) {
  // 启动服务
  app.listen(PORT, () => {
    console.log(`服务运行在 http://localhost:${PORT}`);
  });
}

// 导出 app 实例(供测试使用)
module.exports = app;

(3) public/index.html(前端页面)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Node.js + Jenkins 自动化部署示例</title>
  <style>
    body {
      text-align: center;
      margin-top: 100px;
      font-family: Arial, sans-serif;
    }
    .container {
      color: #333;
    }
    .version {
      color: #666;
      margin-top: 20px;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>🎉 Node.js 自动化部署成功!</h1>
    <p>该页面由 Jenkins 自动部署</p>
    <div class="version">部署时间: <span id="deployTime"></span></div>
  </div>
  <script>
    // 显示当前时间(模拟部署版本)
    document.getElementById('deployTime').textContent = new Date().toLocaleString();
  </script>
</body>
</html>
(4) Jenkinsfile(Jenkins 流水线脚本)
pipeline {
  agent any  

  environment {
    DEPLOY_DIR = '/u01/nodejs-jenkins-demo'
    NODEJS_NAME = 'NodeJs 16.20.2'
    PORT = 3300
    APP_ENTRY = 'app.js'
    SERVICE_NAME = 'nodejs-jenkins-demo' // 系统服务名
    TEST_PORT = 3301 // 测试专用端口(避免和生产端口冲突)
  }

  stages {
    stage('拉取代码') {
      steps {  
        echo '📥 开始拉取代码...'
        git url: 'github的仓库地址',
            branch: 'main',
            credentialsId: 'github的Personal access tokens (classic)'
      }
    }

    stage('安装依赖') {
      steps {
        echo '📦 安装项目依赖(含测试依赖)...'
        nodejs(nodeJSInstallationName: env.NODEJS_NAME) {
          // 注意:移除 --production,安装所有依赖(含devDependencies中的Jest)
          sh 'npm install'
        }
      }
    }

    // ========== 新增:自动化测试阶段 ==========
    stage('自动化测试') {
      steps {
        echo '🧪 执行 Node.js 服务自动化测试...'
        nodejs(nodeJSInstallationName: env.NODEJS_NAME) {
          sh '''
            echo "📌 开始执行 Jest 测试(Node.js 版本:$(node -v))"
            echo "📌 测试专用端口:${TEST_PORT}"
            
            # 1. 执行 Jest 测试(适配 Node 16,单进程运行)
            npm test -- --runInBand --no-cache
            
            # 2. 测试失败则直接终止部署(Jest 非0退出码会触发Pipeline失败)
            if [ $? -ne 0 ]; then
              echo "❌ 自动化测试失败,终止部署流程!"
              exit 1
            fi
            
            echo "✅ 所有自动化测试用例执行通过!"
          '''
        }
      }
      // 测试失败时直接终止Pipeline,不进入后续部署阶段
      post {
        failure {
          echo '❌ 自动化测试失败,部署流程终止!'
          sh 'echo "测试失败时间:$(date)" >> test-fail.log'
        }
      }
    }
    // ========== 新增结束 ==========

    stage('清理旧服务') {
      steps {
        echo '🛑 停止旧的 Node.js 服务并释放端口...'
        sh '''
          # 1. 停止 systemd 管理的旧服务
          echo "停止 ${SERVICE_NAME} 系统服务..."
          systemctl stop ''' + env.SERVICE_NAME + ''' 2>/dev/null || echo "系统服务未运行"
          
          # 2. 杀死占用端口的残留进程(兜底)
          PID=$(lsof -ti:''' + env.PORT + ''' || echo "none")
          if [ "$PID" != "none" ] && [ -n "$PID" ]; then
            echo "杀死占用 ''' + env.PORT + ''' 端口的进程:$PID"
            kill -9 $PID
            sleep 2
          else
            echo "''' + env.PORT + ''' 端口未被占用"
          fi

          # 3. 杀死旧服务进程(兜底)
          OLD_PID=$(ps -ef | grep -v grep | grep "node ''' + env.APP_ENTRY + '''" | awk \'{print $2}\' | tr -d \' \' || echo "none")
          if [ "$OLD_PID" != "none" ] && [ -n "$OLD_PID" ] && [[ "$OLD_PID" =~ ^[0-9]+$ ]]; then
            echo "杀死旧服务进程:$OLD_PID"
            kill -9 $OLD_PID
            sleep 2
          else
            echo "无旧的 node ''' + env.APP_ENTRY + ''' 服务进程需要停止"
          fi
        '''
      }
    }

    stage('部署代码') {
      steps {
        echo '🚀 部署新代码到服务器...'
        sh '''
          mkdir -p ''' + env.DEPLOY_DIR + '''
          # 备份旧日志
          cp ''' + env.DEPLOY_DIR + '''/app.log ''' + env.DEPLOY_DIR + '''/app.log.bak 2>/dev/null || true
          # 清空新日志
          > ''' + env.DEPLOY_DIR + '''/app.log
          # 复制代码(覆盖原有文件)
          cp -r ./* ''' + env.DEPLOY_DIR + '''/
          # 设置权限(适配 systemd 中 root 用户运行)
          chmod -R 755 ''' + env.DEPLOY_DIR + '''
          chown -R root:root ''' + env.DEPLOY_DIR + '''
        '''
      }
    }

    stage('启动新服务') {
      steps {  
        echo '🔄 通过 systemctl 启动新的 Node.js 服务...'
        nodejs(nodeJSInstallationName: env.NODEJS_NAME) {
          sh '''
            echo "📋 当前执行用户信息:"
            whoami
            echo "当前用户 ID 和所属组:"
            id
            echo "当前工作目录:"
            pwd
            echo "=============================================="
            
            # 1. 进入部署目录
            cd ''' + env.DEPLOY_DIR + '''
            
            # 2. 重载 systemd 配置(防止服务文件修改)
            echo "重载 systemd 配置..."
            systemctl daemon-reload
            
            # 3. 启动系统服务
            echo "启动 ${SERVICE_NAME} 服务..."
            systemctl start ''' + env.SERVICE_NAME + '''
            
            # 4. 检查服务启动状态
            if ! systemctl is-active --quiet ''' + env.SERVICE_NAME + '''; then
              echo "❌ 服务启动失败!查看 systemd 日志:"
              journalctl -u ''' + env.SERVICE_NAME + ''' -n 50
              exit 1
            fi
            echo "✅ 服务启动成功,状态:active(running)"
            
            # 5. 检测端口是否监听
            sleep 3 # 等待服务完全启动
            PORT_LISTEN=$(ss -tulpn | grep ":''' + env.PORT + '''" | grep "node" || echo "none")
            if [ "$PORT_LISTEN" = "none" ]; then
              echo "❌ 服务未监听 ''' + env.PORT + ''' 端口!查看应用日志:"
              cat ''' + env.DEPLOY_DIR + '''/app.log
              exit 1
            fi
            echo "✅ ''' + env.PORT + ''' 端口已被 node 进程监听:"
            ss -tulpn | grep ":''' + env.PORT + '''"

            # 6. 检测服务是否可访问
            HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:''' + env.PORT + ''')
            if [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "301" ] || [ "$HTTP_CODE" = "302" ]; then
              echo "✅ 服务可正常访问,HTTP 状态码:$HTTP_CODE"
            else
              echo "⚠️ 服务进程启动但访问失败,状态码:$HTTP_CODE"
              echo "应用日志:"
              cat ''' + env.DEPLOY_DIR + '''/app.log
              # exit 1  // 可选:非关键服务可注释
            fi
          '''
        }
      }
    }
  }

  post {
    success {
      echo "✅ 部署成功!访问地址: http://服务器IP:${env.PORT}"
      sh '''
        echo "当前服务状态:"
        systemctl status ''' + env.SERVICE_NAME + ''' --no-pager
        echo "当前进程列表:"
        ps -ef | grep -v grep | grep "node ''' + env.APP_ENTRY + '''"
      '''
    }
    failure {
      echo '❌ 部署失败,请检查日志!'
      sh '''
        echo "systemd 服务日志:"
        journalctl -u ''' + env.SERVICE_NAME + ''' -n 50 --no-pager
        echo "应用日志:"
        cat ''' + env.DEPLOY_DIR + '''/app.log || echo "日志文件不存在"
      '''
    }
  }
}
(5) app.test.js(测试文件)
const http = require('http');
const fs = require('fs');
const path = require('path');
const app = require('./app');

// 1. 全局增加 Jest 超时时间(解决钩子函数超时)
jest.setTimeout(15000); // 延长到15秒,覆盖服务器启动/关闭耗时

const TEST_PORT = 3301;
let server;

// 2. 优化前置启动逻辑:确保服务器真正启动后再执行测试
beforeAll((done) => {
  // 先杀死可能占用测试端口的残留进程(关键:避免端口占用导致启动失败)
  const killPortProcess = () => {
    const { exec } = require('child_process');
    exec(`lsof -ti:${TEST_PORT} | xargs kill -9 2>/dev/null`, (err) => {
      if (err) console.log(`测试端口 ${TEST_PORT} 无残留进程`);
      // 启动服务器
      server = http.createServer(app);
      // 监听错误,避免端口占用导致阻塞
      server.on('error', (err) => {
        console.error('服务器启动失败:', err);
        done(err);
      });
      // 确认服务器监听成功后执行done
      server.listen(TEST_PORT, () => {
        console.log(`测试服务器启动在端口 ${TEST_PORT}`);
        done();
      });
    });
  };
  killPortProcess();
});

// 3. 优化后置关闭逻辑:强制关闭+容错处理
afterAll((done) => {
  if (!server) {
    console.log('服务器未启动,直接结束');
    return done();
  }
  // 强制关闭服务器,忽略错误
  server.close((err) => {
    if (err) {
      console.warn('服务器关闭警告:', err);
    }
    // 兜底:再次杀死测试端口进程
    const { exec } = require('child_process');
    exec(`lsof -ti:${TEST_PORT} | xargs kill -9 2>/dev/null`, () => {
      console.log(`测试端口 ${TEST_PORT} 已释放`);
      done();
    });
  });
});

// 封装请求方法(增加超时)
const sendRequest = (path, method = 'GET') => {
  return new Promise((resolve, reject) => {
    const options = {
      hostname: 'localhost',
      port: TEST_PORT,
      path: path,
      method: method,
      timeout: 5000 // 请求超时5秒
    };

    const req = http.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => { data += chunk; });
      res.on('end', () => {
        resolve({
          statusCode: res.statusCode,
          headers: res.headers,
          body: data
        });
      });
    });

    // 请求超时处理
    req.on('timeout', () => {
      req.destroy();
      reject(new Error(`请求 ${path} 超时`));
    });
    req.on('error', (err) => reject(err));
    req.end();
  });
};

// 测试用例(保持不变,或增加单例超时)
describe('Express 服务器测试(原生 http 模块)', () => {
  // 单个测试用例超时(可选)
  test('GET / 应返回 200 状态码和 HTML 内容', async () => {
    const response = await sendRequest('/');
    expect(response.statusCode).toBe(200);
    expect(response.headers['content-type']).toMatch(/text\/html/);
    const indexPath = path.join(__dirname, 'public', 'index.html');
    const expectedHtml = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '';
    expect(response.body).toBe(expectedHtml);
  }, 10000); // 单个用例超时10秒

  test('静态资源 /css/style.css 可访问', async () => {
    const cssPath = path.join(__dirname, 'public/css/style.css');
    if (!fs.existsSync(cssPath)) {
      fs.mkdirSync(path.dirname(cssPath), { recursive: true });
      fs.writeFileSync(cssPath, 'body { margin: 0; }');
    }
    const response = await sendRequest('/css/style.css');
    expect(response.statusCode).toBe(200);
    expect(response.headers['content-type']).toMatch(/text\/css/);
    expect(response.body).toBe('body { margin: 0; }');
  }, 10000);

  test('GET /non-existent 应返回 404', async () => {
    const response = await sendRequest('/non-existent');
    expect(response.statusCode).toBe(404);
  }, 10000);
});

其他文件非必须就不做展示了!

二、Jenkins 环境准备

1. 安装必要插件

在 Jenkins 插件管理中安装:

  • Git Plugin(代码拉取)
  • NodeJS Plugin(Node.js 环境管理)
  • Pipeline Plugin(流水线支持)
  • Plain Credentials Plugin(token认证)
    在这里插入图片描述#### 2. 配置 Node.js 环境
  • 进入 Jenkins → 全局工具配置 → Node.js → 新增 Node.js
  • 填写名称(如 Node.js 16.x),选择自动安装,保存
    在这里插入图片描述
3. 配置 Git 凭证

在这里插入图片描述

  • 进入 Jenkins → 凭证管理 → 全局凭证 → 添加凭证
  • 类型选择「密钥文本」,填写github的Personal access tokens (classic),保存
    在这里插入图片描述
    在这里插入图片描述

三、Jenkins 流水线创建

  1. 新建「流水线」类型的任务;
  2. 在「流水线」配置中选择「Pipeline script from SCM」;
  3. 选择 Git,填写代码仓库地址、分支、凭证 ID;
  4. 脚本路径填写 Jenkinsfile,保存;
  5. git代码推送时,即可触发自动化部署。
    在这里插入图片描述
    在这里插入图片描述
    需要在git仓库里面配置Webhooks(网络钩子),关键步骤:通过钩子能及时把git的Pull信息推送给Jenkins,进行更新部署。格式:https://Jenkins用户名:APIToken@Jenkins服务器IP或域名+端口/github-webhook
    在这里插入图片描述
    而且还有Jenkins的用户授权
    在这里插入图片描述
    ApiToken配置Webhooks需要
    在这里插入图片描述

github仓库地址以及分支
在这里插入图片描述
对应代码里面的文件名Jenkinsfile

在这里插入图片描述

四、服务器节点

服务器资源有限,就用一个节点演示!

配置服务器节点

新增节点
在这里插入图片描述
节点命名
在这里插入图片描述
节点服务器配置
在这里插入图片描述
在这里插入图片描述

配置服务器验证

保存后服务上会生成对应的包!
在这里插入图片描述
部署日志
在这里插入图片描述

五、测试

当把修改后的代码提交推送到github后,就会触发Jenekins的自动部署。
在这里插入图片描述
和github上的日志相同
在这里插入图片描述

点击Bulids下面的部署记录,可以看到每次自动化部署的日志。
在这里插入图片描述

六、验证部署

部署成功后,访问 http://服务器IP:3300,即可看到前端页面,表示部署生效。
在这里插入图片描述

  • 公众号 行走之飞鱼
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐