25年了,别再手动部署小程序了-小程序自动化部署实践

2,403 阅读6分钟

前言

众所周知,微信小程序的开发需要依赖微信官方提供的开发工具(IDE),整个开发上线流程中还是存在不少痛点的:

  • 版本信息和描述都要手动填写,构建-上传-设置体验版-发布二维码通知测试这一套流程过于繁琐
  • 非开发人员获取体验码依赖开发人员,分支切换、环境切换、构建、代码上传,预览码生成,操作无脑但耗时
  • 小程序有自己独立的部署发布流程,脱离了团队工程化管控
    • 小伙伴可以自行将本地代码上传、甚至提审发布新版本,即使代码并没有合入主分支,导致线上代码与主分支代码不一致
    • 环境管理混乱,甚至会有上线发布的是测试环境代码的情况,传统web开发流程中我们一般会将测试、预发、线上区分开,按步骤严格执行
    • 版本管理混乱,版本号可随意设置
    • 多人员同时开发时易冲突,随意设置体验版易相互覆盖

因此,需要实现一个服务满足以下场景,从而实现将小程序部署收敛到一处:

  • 提供接口给前端页面接入,调用接口可以生成对应小程序的开发版预览码和体验版预览码,并支持指定要构建的接口环境
  • 提供接口给 CI/CD 工具接入,支持构建相应环境并且上传代码生成体验版

技术方案概述

本方案采用以下技术实现小程序自动化部署:

  • Node.js: 作为服务器端脚本运行环境。

  • miniprogram-ci: 微信小程序官方提供的持续集成工具,用于编译、上传、预览小程序代码。

  • Fastify: 轻量级的 Node.js 框架,用于构建 Web API。

  • GitLab API: 用于从 GitLab 仓库拉取代码。

我们的项目是一个单仓项目,项目下有多个小程序,所以大概实现思路为:拉取单仓仓库代码,构建单仓中的其中某个小程序生成二维码或上传代码。

具体实现

整个自动化部署的流程如下:

  1. 接口接收请求参数(小程序应用、环境等)
  2. 获取单仓仓库最新的Git提交信息
  3. 检查是否需要重新拉取代码
  4. 拉取代码并解压
  5. 单仓目录下安装全局依赖
  6. cd到小程序项目目录下根据环境执行构建
  7. 上传小程序代码或生成预览二维码
  8. 返回结果

创建服务

创建一个 fastfy 服务,使用 cluster 模块创建多进程应用以提高并发量

/**
 * @summary 服务入口
 * @description
 * 使用集群模式启动服务
 *
 */

import path from 'path';
import cluster from 'cluster';
import os from 'os';
import Fastify, { FastifyInstance } from 'fastify';
import fastifyCors from '@fastify/cors';
import { config as dotenvConfig } from 'dotenv';
import miniprogramCi from '../modules/miniprogramci/routes/index';
import signVerify from '@/plugins/signVerify';
import ratelimer from '@/plugins/ratelimter';

const cwd = process.cwd();

// 从 .env.local 加载环境变量
dotenvConfig({
  path: path.resolve(cwd, '.env.local'),
});

const numCPUs = os.cpus().length; // 启动多个工作进程

if (cluster.isPrimary) {
  console.log(`主进程 ${process.pid} 正在运行`);

  // 创建工作进程
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, _code, _signal) => {
    console.log(`工作进程 ${worker.process.pid} 已退出`);
    // 重新启动一个新的工作进程
    cluster.fork();
  });
} else {
  const fastify: FastifyInstance = Fastify({
    logger: true,
  });

  /** --- 限流 --- */
  fastify.register(ratelimer);

  /** --- 鉴权 --- */
  fastify.register(signVerify);

  /** --- 跨域配置 --- */
  fastify.register(fastifyCors, {
    origin: true, // 动态允许源 (对 EventSource 请求无效)
    credentials: true, // 允许发送 Cookie
  });

  // 注册小程序ci路由
  fastify.register(miniprogramCi, { prefix: '/miniprogram' });

  /** --- 启动服务 --- */
  const start = async () => {
    try {
      await fastify.listen({
        port: 8080,
        host: '0.0.0.0',
      });
      console.log(`工作进程 ${process.pid} 已启动`);
    } catch (err) {
      fastify.log.error(err);
      process.exit(1);
    }
  };
  start();
}

编写路由,定义一系列常量

// ../modules/miniprogramci/routes/index.ts
import fs from 'fs';
import path from 'path';
import { exec as execCallback } from 'child_process';
import { promisify } from 'util';
import { fileURLToPath } from 'url';
import fetch from 'node-fetch';
import ci from 'miniprogram-ci';
import { FastifyInstance } from 'fastify';
import { formatDateTime } from '@/utils/timeUtils';

const exec = promisify(execCallback);

// 获取当前文件路径
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// 项目根目录
const rootDir = path.resolve(__dirname, '../../../../');

// gitlib token
const token = process.env.GITLIB_TOKEN || '';

// 单仓中的小程序信息
const miniprogramInfoMap: Record<string, { appId: string, appName: string }> = {
  'A': {
    appId: 'wxxxx',
    appName: 'xxx', // 对应小程序的文件夹名称
  },
  'B': {
    appId: 'wxxxx',
    appName: 'xxx', // 对应小程序的文件夹名称
  },
  'C': {
    appId: 'wxxxx',
    appName: 'xxx', // 对应小程序的文件夹名称
  },
};

// gitlib api 地址
const gitlibBaseUrl = 'http://gitlab.xxx.xxx.cn/api/v4';

// 仓库名
const repoName = 'miniprograms-monorepo';

// 仓库 id
const repoId = 8405;

// 构建的分支
const branch = 'master';

GITLIB_TOKEN 需要使用 dotenv 库来加载环境变量获取,不要明文写在代码中,GITLIB_TOKEN的获取方法这里不做过多说明,大家自行搜索吧。

仓库名、仓库id、要构建的分支有需要的话可以由参数传入,动态获取。

然后开始定义路由

定义需要传的参数,使用 schema 会有更好的性能。


interface ReqParam {
  Querystring: {
    app: string; // A | B | C
    env: 'prod' | 'pre' | 'dev';
    desc?: string;
    actionType?: 'upload' | 'preview';
    page?: string; // /pages/index/index 仅preview模式支持指定
    queryStr?: string; // 'a=1&b=2' 仅preview模式支持指定
    robot?: number; // 2-30 仅preview模式支持指定
  };
  Headers: {
    origin: string;
  };
}

// 定义允许访问的域名列表
const allowedOrigins = [
  'https://c.b.com',
];

const buildSchema = {
  querystring: {
    type: 'object',
    required: ['app', 'env'],
    properties: {
      app: { type: 'string' },
      env: { type: 'string', enum: ['prod', 'pre', 'dev'] },
      page: { type: 'string' },
      queryStr: { type: 'string' },
      desc: { type: 'string', maxLength: 50 },
      actionType: { type: 'string', enum: ['upload', 'preview'] },
      robot: { type: 'number', minimum: 2, maximum: 30 },
    },
  },
  headers: {
    type: 'object',
    properties: {
      origin: { type: 'string' },
    },
    required: ['origin'],
  },
};

async function routes(fastify: FastifyInstance) {
  fastify.get<{ Querystring: ReqParam['Querystring']; Headers: ReqParam['Headers'] }>(
    '/build',
    { schema: buildSchema },
    async (request, reply) => {
      // 实现逻辑
    }
  );
}

使用 text/event-stream 来实现实时给客户端推送进度的效果,整个代码都有详细的注释,大家可以对照理解


async function routes(fastify: FastifyInstance) {
  /**
   * @description 发起构建
   * 支持 sse 推送实时进度
   */
  fastify.get<{ Querystring: ReqParam['Querystring']; Headers: ReqParam['Headers'] }>(
    '/build',
    {
      schema: buildSchema,
    },
    async (request, reply) => {
      let clientDisconnectedFlag = false; // 客户端断连标志

      const { origin } = request.headers;

      // 检查 origin 是否在允许的列表中
      if (origin && allowedOrigins.includes(origin)) {
        // 设置响应头,允许特定的 origin
        reply.raw.writeHead(200, {
          'Content-Type': 'text/event-stream',
          'Cache-Control': 'no-cache',
          'Access-Control-Allow-Origin': origin, // 动态设置请求的 origin
          'Access-Control-Allow-Credentials': 'true', // 如果需要发送 cookies
          Connection: 'keep-alive',
        });
      } else {
        // 如果 origin 不在允许列表中,拒绝访问
        reply.code(403).send('Forbidden');
        return;
      }

      // 监听客户端关闭链接
      reply.raw.on('close', () => {
        clientDisconnectedFlag = true;
        reply.raw.end();
      });

      // 一旦客户端关闭时及时中断请求
      const writeToClient = (data) => {
        if (!clientDisconnectedFlag) {
          reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
        } else {
          throw new Error('Connection closed');
        }
      };

      try {
        writeToClient({ progress: 1, msg: '开始任务' });

        const { app, env, page = '', queryStr, desc, actionType = 'upload', robot = 2 } = request.query;

        // 获取最新分支信息
        const commits = await fetch(`${gitlibBaseUrl}/projects/${repoId}/repository/commits?ref_name=${branch}&per_page=1`, {
          headers: { 'PRIVATE-TOKEN': token },
          timeout: 1000,
        }).then((res) => res.json());

        const lastCommit = commits[0];

        // 获取最新提交的哈希值
        const latestCommitHash = lastCommit.id;

        // 小程序解压后的文件夹路径(默认生成,无法指定文件夹名称)
        const miniprogramDir = path.resolve(rootDir, `${repoName}-${branch}-${latestCommitHash}`);

        // 检查是否已经存在一个具有相同签名的文件夹
        const folderExists = fs.existsSync(miniprogramDir);

        // 发送 SSE 事件给客户端, 进度10%
        writeToClient({ progress: 10, msg: `查询分支是否有更新...${!folderExists}` });

        // hash 没有变过的话不再重复下载解压和构建
        if (!folderExists) {
          // 获取根目录下的所有文件和文件夹
          const files = fs.readdirSync(rootDir);

          // 找到已下载过的仓库
          const foldersToDelete = files.filter((file) => file.startsWith(repoName));

          // 删除已下载仓库,重新拉取代码
          for (const folder of foldersToDelete) {
            await exec(`rm -rf ${path.join(rootDir, folder)}`);
          }

          // 发送 SSE 事件给客户端, 进度15%
          writeToClient({ progress: 15, msg: '开始拉取项目代码...' });

          // 从GitLab拉取最新的代码
          const url = `${gitlibBaseUrl}/projects/${repoId}/repository/archive.zip?sha=master`;
          const buffer = await fetch(url, {
            headers: { 'PRIVATE-TOKEN': token },
          }).then((res) => res.buffer());

          writeToClient({ progress: 30, msg: '拉取代码完成,开始解压代码...' });

          // 写入到zip文件
          await fs.promises.writeFile('repo.zip', buffer);
          // 解压缩文件
          await exec('unzip -o repo.zip');

          // 使用node进程的权限异步添加权限(用于后续的删除) 7 (4+2+1): 所有者有读、写、执行权限
          fs.chmod(miniprogramDir, 0o777, (err) => {
            if (err) throw err;
            console.log('The permissions for file "file" have been changed!');
          });

          // 变为一个git仓库,否则husky报错
          await exec(`cd ${miniprogramDir} && git init`);

          // 发送 SSE 事件给客户端, 进度40%
          writeToClient({ progress: 40, msg: '解压完成,开始安装依赖...' });

          // 安装依赖 忽略 peer dependencies 冲突
          await exec(`cd ${miniprogramDir} && npm i --legacy-peer-deps --loglevel=error`);
        }

        // 发送 SSE 事件给客户端, 进度60%
        writeToClient({ progress: 60, msg: '依赖已安装,开始构建' });

        const curMiniprogramInfo = miniprogramInfoMap[app];
        const { appName } = curMiniprogramInfo;

        // 根据环境参数执行构建命令
        const buildCommand = env === 'dev' ? 'build:dev' : env === 'pre' ? 'build:pre' : 'build';
        await exec(`cd ${miniprogramDir}/${appName} && npm run ${buildCommand}`).catch(() => {
          throw new Error(`${miniprogramDir}/${appName}: Build failed, please check the code`); // 自定义错误信息
        });

        // 发送 SSE 事件给客户端, 进度80%
        writeToClient({ progress: 80, msg: '构建完成,开始上传' });

        const param = {
          appid: curMiniprogramInfo.appId,
          type: 'miniProgram',
          projectPath: path.resolve(miniprogramDir, `${appName}/dist`),
          privateKeyPath: path.resolve(rootDir, 'src/assets/keys', `private.${curMiniprogramInfo.appId}.key`),
          ignores: ['node_modules/**/*'],
        };

        // 使用miniprogram-ci上传代码并生成预览码
        const project = new ci.Project(param);

        if (actionType === 'upload') {
          // 上传体验版
          await ci.upload({
            project,
            version: formatDateTime(new Date(), 'YY.M.DH'), // 以时间为版本号eg:24.7.918 24年7月9日18点
            desc: desc || lastCommit.title, // 如果没有desc取最新提交信息
            setting: {
              // es6: true,
            },
            // onProgressUpdate: console.log,
            robot: 1, // 专用于体验版的机器人
            threads: 12,
          });

          // 返回预览码
          const qrcodePath = path.resolve(rootDir, 'src/assets/qrcodes', `${appName}.png`);
          const imageBuffer = fs.readFileSync(qrcodePath);
          const base64Data = imageBuffer.toString('base64');
          const base64Image = `data:image/jpeg;base64,${base64Data}`;

          // 请求结束,发送二维码数据
          writeToClient({ progress: 100, base64Image, msg: '上传成功!' });
        } else {
          if (robot > 30 || robot < 2) {
            const error = new Error('robot value is allowed only 2-30');
            return reply.code(400).send(error);
          }
          // 预览开发版
          await ci.preview({
            project,
            desc: desc || lastCommit.title, // 此备注将显示在“小程序助手”开发版列表中
            setting: {},
            qrcodeFormat: 'base64', // 可选 image
            qrcodeOutputDest: `${__dirname}/${appName}.txt`, // 二维码文件保存路径
            // onProgressUpdate: console.log,
            pagePath: page,
            searchQuery: queryStr, // 'a=1&b=2'
            robot,
            scene: 1011,
            threads: 12,
          } as any);

          const qrcodePath = `${__dirname}/${appName}.txt`;
          const imageBuffer = fs.readFileSync(qrcodePath); // 返回 Buffer 字节数组
          const base64Image = imageBuffer.toString();

          // 请求结束,发送二维码数据
          writeToClient({ progress: 100, base64Image, msg: '上传成功!' });
        }
      } catch (error) {
        if (error instanceof Error) {
          writeToClient({ msg: error.message });
        } else {
          writeToClient({ msg: 'An unknown error occurred' });
        }
        reply.raw.end();
      }
    },
  );
}

以上做了一个仓库是否有新提交的判断,以避免没有新代码提交时也走一遍下载、解压、安装依赖的流程,浪费较多的cpu资源。

另外有一个点需要注意,下载下来的代码不能一直存在,这样会导致占用的磁盘空间越来越大,所以要在有新代码时删掉旧代码,永远保持只有一个代码仓库。但是想要删掉项目内的文件夹的话需要权限,所以需要设置下权限才行。

  // 使用node进程的权限异步添加权限(用于后续的删除) 7 (4+2+1): 所有者有读、写、执行权限
  fs.chmod(miniprogramDir, 0o777, (err) => {
    if (err) throw err;
    console.log('The permissions for file "file" have been changed!');
  });

那么服务运行起来后客户端该怎样使用呢 ?

    const eventSource = new EventSource(url);

    eventSource.onmessage = function (event) {
      const data = JSON.parse(event.data);
      console.log('收到消息: ' + JSON.stringify(data))

      const {progress, msg, base64Image} = data
      // 进度更新时做一些事

      if (progress === 100) {
        eventSource.close(); // 关闭连接
        // 完成时做一些事
      }
    };

    eventSource.onerror = function (err) {
      console.error('发生错误:', err);
      eventSource.close(); // 关闭连接
    };

总结

该版本的自动化部署是一个 node 服务,需要有对应的前端页面去进行各种操作或者在流水线的脚本里使用,而非直接在小程序项目下接入,但是这些场景都大同小异,本篇文章都能给你足够的参考。

自动化部署不仅减少了开发过程中的沟通和繁复操作也规范了小程序开发到上线的流程,但是注意,出于安全考虑,提审和发布还是要手动处理的。