基于 Vue + Node.js 批处理bat脚本实现多环境一键部署

在前端项目迭代过程中,部署环节的效率和易用性直接影响团队协作效率。对于已基于 Node.js实现核心部署逻辑的项目,通过批处理(bat)脚本封装执行入口,能让非技术人员也能轻松完成多环境部署,彻底告别手动输入命令、频繁切换配置的繁琐操作。本文将详细讲解如何设计和实现适配多场景的批处理部署脚本。

一、目录结构

几个js文件的代码参考《基于 Vue + Node.js 的打包后自动发布服务脚本》,里面已经做详细描述,这里不再进行阐述。资源地址

二、脚本实现与解析

1、 index.js与《基于 Vue + Node.js 的打包后自动发布服务脚本》文章有些许区别,加了日志配置和断点重连配置

javascript 复制代码
/**
 * 自动化部署脚本
 * @description 实现项目打包、压缩、上传、备份、解压等自动化部署功能
 */
const { NodeSSH } = require("node-ssh");
const archiver = require("archiver");
const fs = require("fs");
const path = require("path");
const { exec } = require("child_process");
const util = require("util");
const execPromise = util.promisify(exec);

// 加载部署工具配置
let deployConfig;
try {
  deployConfig = require("./config.deploy.js");
} catch (error) {
  // 如果配置文件不存在,使用默认配置
  deployConfig = {
    log: { enabled: false },
    retry: { enabled: true, maxAttempts: 3, delay: 3000 },
  };
}

// 日志流(用于同时输出到控制台和文件)
let logStream = null;

/**
 * 获取环境配置
 * @returns {Object} 配置对象
 */
function getConfig() {
  // 从命令行参数获取环境,例如: node deploy/index.js --env=test
  const args = process.argv.slice(2);
  let env = "prod"; // 默认生产环境

  // 解析命令行参数
  for (const arg of args) {
    if (arg.startsWith("--env=")) {
      env = arg.split("=")[1];
    }
  }

  // 如果有环境变量 DEPLOY_ENV,优先使用
  if (process.env.DEPLOY_ENV) {
    env = process.env.DEPLOY_ENV;
  }

  // 处理 production 别名
  if (env === "production") {
    env = "prod";
  }

  // 直接根据环境名称构建配置文件路径
  const configFile = `./config.${env}.js`;
  const configPath = path.join(__dirname, configFile);

  // 检查配置文件是否存在
  if (!fs.existsSync(configPath)) {
    throw new Error(
      `配置文件不存在: ${configFile}\n` +
        `请创建 deploy/config.${env}.js 文件,或使用已有的环境配置。\n` +
        `可用环境示例: test, prod, xj, mj`
    );
  }

  console.log(`\x1b[36m[INFO]\x1b[0m 使用配置环境: ${env}`);
  console.log(`\x1b[36m[INFO]\x1b[0m 配置文件: ${configFile}`);

  return require(configFile);
}

const config = getConfig();

const ssh = new NodeSSH();

/**
 * 输出带颜色的日志
 */
const log = {
  info: (msg) => logMessage("INFO", msg, "\x1b[36m"),
  success: (msg) => logMessage("SUCCESS", msg, "\x1b[32m"),
  error: (msg) => logMessage("ERROR", msg, "\x1b[31m"),
  warning: (msg) => logMessage("WARNING", msg, "\x1b[33m"),
};

/**
 * 日志输出函数(同时输出到控制台和文件)
 * @param {string} level 日志级别
 * @param {string} msg 日志消息
 * @param {string} color 颜色代码
 */
function logMessage(level, msg, color) {
  const timestamp = new Date().toISOString().replace("T", " ").substring(0, 19);
  const consoleMsg = `${color}[${level}]\x1b[0m ${msg}`;
  const fileMsg = `[${timestamp}] [${level}] ${msg}`;

  // 输出到控制台
  console.log(consoleMsg);

  // 输出到日志文件
  if (logStream && deployConfig.log.enabled) {
    logStream.write(fileMsg + "\n");
  }
}

/**
 * 初始化日志文件
 * @returns {void}
 */
function initLogFile() {
  if (!deployConfig.log.enabled) return;

  const logDir = path.join(process.cwd(), deployConfig.log.dir);

  // 创建日志目录
  if (!fs.existsSync(logDir)) {
    fs.mkdirSync(logDir, { recursive: true });
  }

  // 生成日志文件名
  const timestamp = new Date()
    .toISOString()
    .replace(/:/g, "-")
    .replace("T", "_")
    .substring(0, 19);
  const env = process.env.DEPLOY_ENV || "prod";
  const logFileName = `deploy_${env}_${timestamp}.log`;
  const logFilePath = path.join(logDir, logFileName);

  // 创建日志文件流
  logStream = fs.createWriteStream(logFilePath, { flags: "a" });

  log.info(`日志文件: ${logFilePath}`);

  // 清理旧日志文件
  cleanOldLogs(logDir);
}

/**
 * 清理旧的日志文件
 * @param {string} logDir 日志目录
 * @returns {void}
 */
function cleanOldLogs(logDir) {
  try {
    const files = fs
      .readdirSync(logDir)
      .filter((f) => f.startsWith("deploy_") && f.endsWith(".log"))
      .map((f) => ({
        name: f,
        path: path.join(logDir, f),
        time: fs.statSync(path.join(logDir, f)).mtime.getTime(),
      }))
      .sort((a, b) => b.time - a.time);

    // 保留最新的 N 个文件
    const maxFiles = deployConfig.log.maxFiles || 30;
    if (files.length > maxFiles) {
      const filesToDelete = files.slice(maxFiles);
      filesToDelete.forEach((f) => {
        fs.unlinkSync(f.path);
        log.info(`已删除旧日志: ${f.name}`);
      });
    }
  } catch (error) {
    log.warning(`清理旧日志失败: ${error.message}`);
  }
}

/**
 * 关闭日志文件
 * @returns {void}
 */
function closeLogFile() {
  if (logStream) {
    logStream.end();
    logStream = null;
  }
}

/**
 * 步骤1: 执行项目打包
 * @returns {Promise<void>}
 */
async function buildProject() {
  log.info("开始执行项目打包...");
  try {
    const { stdout, stderr } = await execPromise(config.build.command);
    if (stderr && !stderr.includes("warning")) {
      log.warning(`构建警告: ${stderr}`);
    }
    log.success("项目打包完成!");
    return true;
  } catch (error) {
    log.error(`项目打包失败: ${error.message}`);
    throw error;
  }
}

/**
 * 步骤2: 压缩dist文件夹为zip
 * @returns {Promise<string>} 返回zip文件路径
 */
async function compressDistToZip() {
  log.info("开始压缩dist文件夹...");

  const { localDistPath, localZipName } = config.deploy;
  const zipPath = path.join(process.cwd(), localZipName);

  // 如果已存在zip文件,先删除
  if (fs.existsSync(zipPath)) {
    fs.unlinkSync(zipPath);
  }

  // 检查 dist 目录是否存在
  if (!fs.existsSync(localDistPath)) {
    throw new Error(`dist目录不存在: ${localDistPath}`);
  }

  return new Promise((resolve, reject) => {
    const output = fs.createWriteStream(zipPath);
    const archive = archiver("zip", {
      zlib: { level: 9 }, // 压缩级别
    });

    let fileCount = 0;

    output.on("close", () => {
      const size = (archive.pointer() / 1024 / 1024).toFixed(2);
      log.success(`压缩完成! 文件大小: ${size} MB, 文件数: ${fileCount}`);
      log.info(`压缩包路径: ${zipPath}`);
      resolve(zipPath);
    });

    archive.on("error", (err) => {
      log.error(`压缩失败: ${err.message}`);
      reject(err);
    });

    archive.on("warning", (err) => {
      if (err.code === "ENOENT") {
        log.warning(`压缩警告: ${err.message}`);
      } else {
        reject(err);
      }
    });

    // 监听文件添加事件
    archive.on("entry", (entry) => {
      fileCount++;
    });

    archive.pipe(output);

    // 将dist文件夹内容添加到压缩包根目录
    // false 参数表示不包含 dist 文件夹本身,直接将其内容放在 zip 根目录
    archive.directory(localDistPath, false);

    archive.finalize();
  });
}

/**
 * 步骤3: 连接SSH服务器(带重试功能)
 * @returns {Promise<void>}
 */
async function connectSSH() {
  const maxAttempts = deployConfig.retry.enabled
    ? deployConfig.retry.maxAttempts
    : 1;
  const delay = deployConfig.retry.delay || 3000;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      if (attempt > 1) {
        log.info(
          `第 ${attempt}/${maxAttempts} 次尝试连接SSH服务器...(${
            delay / 1000
          }秒后重试)`
        );
        await sleep(delay);
      } else {
        log.info("正在连接SSH服务器...");
      }

      await ssh.connect({
        host: config.server.host,
        port: config.server.port,
        username: config.server.username,
        password: config.server.password,
        readyTimeout: 30000, // 30秒超时
      });

      log.success(`成功连接到服务器: ${config.server.host}`);
      return;
    } catch (error) {
      log.error(
        `SSH连接失败 (尝试 ${attempt}/${maxAttempts}): ${error.message}`
      );

      if (attempt === maxAttempts) {
        throw new Error(
          `SSH连接失败,已重试 ${maxAttempts} 次: ${error.message}`
        );
      }
    }
  }
}

/**
 * 延迟函数
 * @param {number} ms 延迟毫秒数
 * @returns {Promise<void>}
 */
function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

/**
 * 步骤4: 备份服务器上的dist目录
 * @returns {Promise<void>}
 */
async function backupRemoteDist() {
  log.info("正在备份服务器上的dist目录...");

  const { remoteDir, remoteDist, backupDir } = config.deploy;
  const remoteDistPath = `${remoteDir}/${remoteDist}`;

  // 生成时间戳:格式如 20260204_153045
  const now = new Date();
  const year = now.getFullYear();
  const month = String(now.getMonth() + 1).padStart(2, "0");
  const day = String(now.getDate()).padStart(2, "0");
  const hours = String(now.getHours()).padStart(2, "0");
  const minutes = String(now.getMinutes()).padStart(2, "0");
  const seconds = String(now.getSeconds()).padStart(2, "0");
  const timestamp = `${year}${month}${day}_${hours}${minutes}${seconds}`;

  const backupPath = `${backupDir}/${remoteDist}_backup_${timestamp}`;
  log.info(`备份时间: ${year}-${month}-${day} ${hours}:${minutes}:${seconds}`);

  try {
    // 检查dist目录是否存在
    const checkCmd = `[ -d "${remoteDistPath}" ] && echo "exists" || echo "not_exists"`;
    const checkResult = await ssh.execCommand(checkCmd);

    if (checkResult.stdout.trim() === "exists") {
      // 创建备份目录
      await ssh.execCommand(`mkdir -p ${backupDir}`);

      // 备份dist目录
      const backupCmd = `cp -r ${remoteDistPath} ${backupPath}`;
      await ssh.execCommand(backupCmd);
      log.success(`备份完成: ${backupPath}`);
    } else {
      log.warning("服务器上不存在dist目录,跳过备份");
    }
  } catch (error) {
    log.error(`备份失败: ${error.message}`);
    throw error;
  }
}

/**
 * 步骤5: 上传zip文件到服务器(已合并到步骤6)
 * @deprecated 此步骤已合并
 */

/**
 * 步骤6: 上传zip文件到服务器
 * @param {string} zipPath 本地zip文件路径
 * @returns {Promise<void>}
 */
async function uploadZipToServer(zipPath) {
  log.info("正在上传dist.zip到服务器...");

  const { remoteDir, localZipName } = config.deploy;
  const remoteZipPath = `${remoteDir}/${localZipName}`;

  try {
    // 获取文件大小
    const stats = fs.statSync(zipPath);
    const fileSize = stats.size;
    const fileSizeMB = (fileSize / 1024 / 1024).toFixed(2);
    log.info(`文件大小: ${fileSizeMB} MB`);

    // 上传文件并显示进度(提高并发数加速上传)
    await ssh.putFile(zipPath, remoteZipPath, null, {
      concurrency: 20, // 提高并发数,加速上传
      chunkSize: 32768, // 32KB 块大小
      step: (transferred, chunk, total) => {
        const percent = ((transferred / total) * 100).toFixed(2);
        const transferredMB = (transferred / 1024 / 1024).toFixed(2);
        const totalMB = (total / 1024 / 1024).toFixed(2);
        const speed = (chunk / 1024 / 1024).toFixed(2);
        // 使用\r回到行首,实现进度条效果
        process.stdout.write(
          `\r\x1b[36m[INFO]\x1b[0m 上传进度: ${percent}% (${transferredMB}MB / ${totalMB}MB) 速度: ${speed}MB/s`
        );
      },
    });

    // 上传完成后换行
    console.log("");
    log.success(`上传完成: ${remoteZipPath}`);
  } catch (error) {
    console.log(""); // 确保错误信息另起一行
    log.error(`上传失败: ${error.message}`);
    throw error;
  }
}

/**
 * 步骤7: 在服务器上解压zip文件并替换dist
 * @returns {Promise<void>}
 */
async function unzipAndReplaceDist() {
  log.info("正在解压dist.zip并准备替换...");

  const { remoteDir, localZipName, remoteDist } = config.deploy;
  const remoteZipPath = `${remoteDir}/${localZipName}`;
  const remoteDistPath = `${remoteDir}/${remoteDist}`;

  try {
    // 检查zip文件是否存在
    const checkZipCmd = `[ -f "${remoteZipPath}" ] && echo "exists" || echo "not_exists"`;
    const checkZipResult = await ssh.execCommand(checkZipCmd);

    if (checkZipResult.stdout.trim() !== "exists") {
      throw new Error("服务器上的zip文件不存在");
    }

    log.info("正在检查zip文件内容...");
    const listZipCmd = `unzip -l ${remoteZipPath} | head -20`;
    const listResult = await ssh.execCommand(listZipCmd);
    log.info("zip文件内容预览:");
    console.log(listResult.stdout);

    // 创建临时目录并解压
    const tempDir = `${remoteDir}/temp_dist_${Date.now()}`;
    log.info(`解压到临时目录: ${tempDir}`);

    // 解压命令:使用 -o 覆盖已存在文件
    const unzipCmd = `mkdir -p ${tempDir} && unzip -o -q ${remoteZipPath} -d ${tempDir}`;
    const unzipResult = await ssh.execCommand(unzipCmd);

    if (unzipResult.code !== 0) {
      log.error(`解压错误: ${unzipResult.stderr}`);
      // 清理临时目录
      await ssh.execCommand(`rm -rf ${tempDir}`);
      throw new Error(`解压失败: ${unzipResult.stderr}`);
    }

    // 检查解压后的内容和文件数量
    const checkContentCmd = `ls -lah ${tempDir} | head -20 && echo "--- 文件统计 ---" && find ${tempDir} -type f | wc -l`;
    const contentResult = await ssh.execCommand(checkContentCmd);
    log.info("解压后的内容:");
    console.log(contentResult.stdout);

    // 删除旧dist并用临时目录替换(使用原子操作)
    log.info(`正在替换目标目录: ${remoteDistPath}`);

    // 方案:先删除旧的,再重命名新的(更可靠)
    const deleteOldCmd = `rm -rf ${remoteDistPath}`;
    const deleteResult = await ssh.execCommand(deleteOldCmd);

    if (deleteResult.code !== 0 && deleteResult.stderr) {
      log.warning(`删除旧目录警告: ${deleteResult.stderr}`);
    }

    const moveCmd = `mv ${tempDir} ${remoteDistPath}`;
    const moveResult = await ssh.execCommand(moveCmd);

    if (moveResult.code !== 0) {
      log.error(`移动失败: ${moveResult.stderr}`);
      throw new Error(`替换文件失败: ${moveResult.stderr}`);
    }

    log.success("替换完成");

    // 验证最终结果 - 显示详细信息
    log.info("正在验证部署结果...");
    const verifyCmd = `
      echo "=== 目录内容 ===" && 
      ls -lah ${remoteDistPath} | head -20 && 
      echo "" && 
      echo "=== 文件统计 ===" && 
      echo "总文件数: $(find ${remoteDistPath} -type f | wc -l)" && 
      echo "总目录数: $(find ${remoteDistPath} -type d | wc -l)" && 
      echo "总大小: $(du -sh ${remoteDistPath} | cut -f1)" &&
      echo "" &&
      echo "=== index.html 信息 ===" &&
      ls -lh ${remoteDistPath}/index.html 2>/dev/null || echo "index.html 不存在"
    `;
    const verifyResult = await ssh.execCommand(verifyCmd);
    console.log(verifyResult.stdout);

    if (verifyResult.code !== 0) {
      log.warning("验证过程中出现警告,但部署已完成");
    }

    // 删除zip文件
    await ssh.execCommand(`rm -f ${remoteZipPath}`);
    log.success("清理临时文件完成");
  } catch (error) {
    log.error(`操作失败: ${error.message}`);
    // 清理可能的临时目录
    await ssh.execCommand(`rm -rf ${remoteDir}/temp_dist_*`);
    throw error;
  }
}

/**
 * 步骤8: 清理本地zip文件
 * @param {string} zipPath 本地zip文件路径
 * @returns {void}
 */
function cleanupLocalZip(zipPath) {
  log.info("正在清理本地临时文件...");
  try {
    if (fs.existsSync(zipPath)) {
      fs.unlinkSync(zipPath);
      log.success("本地临时文件清理完成");
    }
  } catch (error) {
    log.warning(`清理本地文件失败: ${error.message}`);
  }
}

/**
 * 主部署流程
 */
async function deploy() {
  const startTime = Date.now();

  // 初始化日志文件
  initLogFile();

  console.log("\n");
  log.info("========================================");
  log.info("          开始自动化部署流程");
  log.info("========================================");
  log.info(`目标服务器: ${config.server.host}`);
  log.info(`部署目录: ${config.deploy.remoteDir}/${config.deploy.remoteDist}`);
  log.info(`构建模式: ${config.build.mode}`);
  log.info("========================================\n");

  let zipPath = "";

  try {
    // 1. 打包项目
    await buildProject();
    console.log("\n");

    // 2. 压缩dist
    zipPath = await compressDistToZip();
    console.log("\n");

    // 3. 连接SSH
    await connectSSH();
    console.log("\n");

    // 4. 备份服务器dist
    await backupRemoteDist();
    console.log("\n");

    // 5. 上传zip
    await uploadZipToServer(zipPath);
    console.log("\n");

    // 6. 解压zip并替换dist
    await unzipAndReplaceDist();
    console.log("\n");

    // 8. 清理本地zip
    cleanupLocalZip(zipPath);
    console.log("\n");

    const duration = ((Date.now() - startTime) / 1000).toFixed(2);
    log.info("========================================");
    log.success(`       部署成功! 耗时: ${duration}秒`);
    log.info("========================================\n");
  } catch (error) {
    log.error(`\n部署失败: ${error.message}\n`);
    // 清理本地文件
    if (zipPath) {
      cleanupLocalZip(zipPath);
    }
    closeLogFile();
    process.exit(1);
  } finally {
    // 断开SSH连接
    if (ssh.isConnected()) {
      ssh.dispose();
      log.info("SSH连接已关闭");
    }
    // 关闭日志文件
    closeLogFile();
  }
}

// 执行部署
if (require.main === module) {
  deploy();
}

module.exports = { deploy };

2、config.deploy.js配置

javascript 复制代码
/**
 * 部署工具配置
 * @description 部署权限、日志、重试等配置
 */
module.exports = {
  // 日志配置
  log: {
    enabled: true, // 是否启用日志保存
    dir: "./deploy-logs", // 日志保存目录
    maxFiles: 30, // 最多保留日志文件数
  },

  // SSH 重试配置
  retry: {
    enabled: true, // 是否启用自动重试
    maxAttempts: 3, // 最大重试次数
    delay: 3000, // 重试延迟(毫秒)
  },
};

3. 单环境一键部署脚本(以生产环境为例)

针对生产环境这类高频部署场景,设计 deploy-prod.bat 脚本,双击即可触发部署,无需任何手动输入。

bash 复制代码
@echo off
chcp 65001 >nul
title 部署到生产环境

:: 切换到批处理文件所在目录的上一级(项目根目录)
cd /d "%~dp0.."

echo.
echo ========================================
echo     正在部署到生产环境 (prod)
echo     服务器: 10.160.100.35
echo ========================================

node deploy/index.js --env=prod

echo.
if %errorlevel% equ 0 (
    echo ========================================
    echo     部署成功!
    echo ========================================
) else (
    echo ========================================
    echo     部署失败,请检查错误信息
    echo ========================================
)
echo.
pause

:: 暂停窗口,便于查看结果(避免执行后直接关闭)
pause

关键语法解析

语法 作用
@echo off 关闭命令回显,让输出更整洁
chcp 65001 >nul 设置编码为 UTF-8,解决中文乱码问题
cd /d "%~dp0..." %~dp0 获取脚本所在目录,... 跳转到上级(项目根目录)
%errorlevel% 系统错误码,0 表示命令执行成功,非 0 表示失败
pause 暂停窗口,等待用户按任意键后关闭

4. 菜单式多环境部署脚本

针对测试、定制化等多环境切换场景,设计 deploy.bat 脚本,通过可视化菜单选择部署环境,降低参数输入错误风险。

bash 复制代码
@echo off
chcp 65001 >nul
title 项目部署工具

:: 保存当前批处理文件所在目录
set "SCRIPT_DIR=%~dp0"
:: 切换到批处理文件所在目录的上一级(项目根目录)
cd /d "%SCRIPT_DIR%.."

:: 设置颜色
color 0A

:menu
cls
echo.
echo ========================================
echo          项目部署工具
echo ========================================
echo.
echo 请选择部署环境:
echo.
echo [1] 生产环境 (prod)   - ip
echo [2] 测试环境 (test)   - ip
echo [3] 开发环境 (xj)     - ip
echo.
echo [0] 退出
echo.
echo ========================================
echo.

set /p choice=请输入选项 [0-3]: 

if "%choice%"=="1" goto deploy_prod
if "%choice%"=="2" goto deploy_test
if "%choice%"=="3" goto deploy_xj
if "%choice%"=="0" goto end
goto invalid

:deploy_prod
cls
echo.
echo ========================================
echo     正在部署到生产环境 (prod)
echo ========================================
echo.
node deploy/index.js --env=prod
goto finish

:deploy_test
cls
echo.
echo ========================================
echo     正在部署到测试环境 (test)
echo ========================================
echo.
node deploy/index.js --env=test
goto finish

:deploy_xj
cls
echo.
echo ========================================
echo     正在部署到开发环境 (xj)
echo ========================================
echo.
node deploy/index.js --env=xj
goto finish

:invalid
echo.
echo 无效选项,请重新选择!
timeout /t 2 >nul
goto menu

:finish
echo.
echo ========================================
if %errorlevel% equ 0 (
    echo     部署完成!
) else (
    echo     部署失败,请检查错误信息
)
echo ========================================
echo.
echo 按任意键返回菜单...
pause >nul
goto menu

:end
echo.
echo 感谢使用,再见!
timeout /t 1 >nul
exit

二、使用说明

  • 环境准备
    本地已安装 Node.js(匹配部署脚本的运行环境);
    已配置好各环境的 config.xxx.js 文件(服务器地址、部署路径等);
    将脚本放在项目根目录或子目录(脚本内已适配路径跳转)。
  • 执行方式
    一键部署:双击对应环境脚本(如 deploy-prod.bat),等待执行完成即可;
    菜单部署:双击 deploy.bat,输入环境对应的数字(如 1 = 生产环境),按回车执行。
  • 扩展新环境
    新增部署环境仅需 3 步:
    新增 config.newenv.js 配置文件;
    在菜单脚本中添加新分支(如 :DEPLOY_NEWENV);
    在菜单选项中增加对应的数字选择项。

三、总结

通过批处理脚本封装 Node.js 核心部署逻辑,既保留了自动化部署的高效性,又大幅降低了使用门槛:

一键脚本适配高频部署场景,操作零成本;

菜单脚本适配多环境切换,降低人为失误;

脚本结构清晰易扩展,新增环境仅需少量改动;

交互友好、反馈明确,非技术人员也能轻松上手。

这套方案无需引入复杂工具,仅通过原生批处理脚本即可快速落地,适合中小团队的前端项目自动化部署需求。

相关推荐
AC赳赳老秦1 小时前
云原生AI趋势:DeepSeek与云3.0架构协同,提升AI部署性能与可移植性
大数据·前端·人工智能·算法·云原生·架构·deepseek
程序哥聊面试2 小时前
React + TS 初始化新项目报错解决方法
前端·react.js·npm
codeGoogle2 小时前
2026 年 IM 怎么选?聊聊 4 家主流即时通讯方案的差异
android·前端·后端
Elastic 中国社区官方博客2 小时前
Elasticsearch 8.17.2 升级到 9.2.4 完整升级过程
大数据·运维·数据库·elasticsearch·搜索引擎·全文检索·运维开发
C澒2 小时前
从单体到分布式:SLDS 2.0 全球物流履约网络架构演进之路
前端·分布式·架构·系统架构·教育电商·交通物流
We་ct2 小时前
LeetCode 21. 合并两个有序链表:两种经典解法详解
前端·算法·leetcode·链表·typescript
2501_941982052 小时前
Python开发:外部群消息自动回复
java·前端·数据库
未来之窗软件服务2 小时前
服务器运维(三十六)SSL会话缓存配置指南—东方仙盟
运维·服务器·缓存·ssl·服务器运维·仙盟创梦ide·东方仙盟
独自归家的兔2 小时前
Ubuntu环境下 Harbor docker安装教程
运维·docker·容器