打造小程序多平台自动发布工具

2024年6月18日

打造小程序多平台自动发布工具

背景

公司一个小程序代码支持5个平台(微信、支付宝、百度、快手、抖音),除了兼容性之外,在 fix bug 的时候,需要手动取部署多个平台的代码,并且我们是小程序服务商,还要把代码上传到第三方模板库,同事项目内部还有版本号控制用户能访问哪个版本的小程序代码的流程,这使得开发到测试的流程时间非常之长,需要 40 分钟以上,所以需要将这部分的时间成本降低。

预研

Taro Cli: https://github.com/NervJS/taro/tree/main/packages/taro-plugin-mini-ci

社区开源的大部分都是接入小程序上传代码的 API,很少接入第三方服务商的 API,所以找到的并不多,这里参考了 Taro 的工具架构和具体实现方式。

用户场景

image.png image.png image.png

将结果和错误信息输出到企业微信群

image.png

方案

方案描述对比
Client Build客户端 Build环境不一致问题、并行时容错率不高。
CI BuildCI 控制 Server Build并行容错率不高、用户控制范围小。
Cloud BuildClient 选择,Server 控制开发成本相对高, 并行时容错率高

三种方案发布速度大体一致,但容错性和用户体验上较好的是 Cloud Build 方案。

整体架构

image.png image.png

多包架构

定义工具的主仓库名为 sky ,sky-mp-* 为小程序发布的工具包

sky-mp-upload

小程序多端发布包,目前支持微信、支付宝、百度、抖音、快手

  • modules
    • Logger
    • TemplateManagers
      • TemplateManager.ts
      • AliPayImpl.ts
      • WeixinImpl.ts
    • Uploader
      • Uploader.ts
      • AlipayImpl.ts
      • WeixinImpl.ts
      • ……
  • utils

sky-mp-client

小程序多端发布工具客户端,友好的交互,与 sky-mp-server 配合使用

sky-mp-server

小程序多端发布工具服务端,日志记录、并发控制等

sky-shared

通用工具包,如:判断包版本是否最新

术语

Uploader: 小程序平台上传的接口

TemplateManager: 小程序平台的模板管理接口

Preview: 小程序平台获取预览的接口

ConfigManager: 跟部署平台对接的小程序版本号管理接口

Publisher: 跟部署平台对接的小程序版本更新接口

Task: 每一个小程序上传的任务接口

业务流程

image.png

类设计

image.png

具体实现

在具体实现过程中,采取任务调度、自动发布也并不是必要的,以下我只列出 Socket、Uploader、TemplateManager 的架构,其他模块跟业务项目耦合,所以没太多参考性。

Uploader

Uploader 是各个小程序上传都继承的基础类,接受小程序的配置,然后找到对应的平台 API 进行上传代码。

// Uploader.ts

export interface Uploader {
    type: UploaderType;
    upload(config: UserConfig): Promise<any>;
}

export enum HookType {
    BEFORE_UPLOAD = 'beforeUpload',
    AFTER_UPLOAD = 'afterUpload',
    AFTER_UPDATE_VERSION = 'afterUpdateVersion',
}

type HookFn = (payload?: any) => void | Promise<any>;

type Hooks = Record<HookType, string | HookFn>;

export type UserConfig = {
    type: UploaderType;
    config: UploadConfig;
    hooks?: Hooks;
};

export type UploadConfig = {
    appInfos: unknown; // 每个平台的 UploadConfig 由对应的类定义
    desc?: string;
    version?: string; // 日期版本号 2024.08.09.01 02 03 等
    projectPath: string;
};

export type UploadResult = {
    response: any
};

export enum UploaderType {
    WEIXIN = 'weixin',
    BAIDU = 'baidu',
    KUAISHOU = 'kuaishou',
    ALIPAY = 'alipay',
    DOUYIN = 'douyin',
}

export class BaseUploader {
    type: UploaderType;
    hooks?: Hooks;

    constructor(type: UploaderType, hooks?: any) {
        this.type = type;
        this.hooks = hooks;
    }

    createTmpProject(projectPath: string, appId: string): string | undefined {}

    getTmpProjectPath(projectPath: string, appId: string) {}

    /**
     * 删除项目根目录下的 ext.json
     * @param projectPath - 项目根目录
     */
    deleteExtJsonSync(projectPath: string): boolean {}

    updateProjectConfigAppId(projectPath: string, appId: string, configName?: string): boolean {}

    deleteTmpProject(projectPath: string): boolean {}

    async callHook(hook: HookType, payload?: any): Promise<boolean> {
	    // 调用生命周期方法
    }
}
// Weixin.ts

type WeixinUploadAppInfo = {
    appid: string;
    desc?: string;
    getAccessTokenApiUrl: string;
   
		// 微信小程序上传的 API 配置, 可以按需取用或者直接透传
    privateKey?: string;
    privateKeyPath?: string;
    ignores?: string[];
    targetPlatform?: string;
    compileDefines?: {
        [key: string]: string;
    };
};

type WeixinUploadOptions = {
    setting?: MiniProgramCI.ICompileSettings;
    robot?: number;
    threads?: number;
    useCOS?: boolean;
    onProgressUpdate?: (task: MiniProgramCI.ITaskStatus | string) => void;
    allowIgnoreUnusedFiles?: boolean;
};

export type WeixinUploadUserConfig = UploadConfig & {
    appInfos: Array<WeixinUploadAppInfo>;
} & WeixinUploadOptions;

export type WeixinUserConfig = Omit<UserConfig, 'config'> & {
    config: WeixinUploadUserConfig;
};

export class WeixinUploader extends BaseUploader implements Uploader {
    constructor() {
        super(UploaderType.WEIXIN);
    }

    async upload(userConfig: WeixinUserConfig): Promise<any> {
	    // 1. 拿到用户配置
	    const uploadConfig = userConfig.config;
	    // 2. 调用 beforeUpload
	    const beforeUploadHookRes = await this.callHook(HookType.BEFORE_UPLOAD);
	    // 3. 根据 appInfo 列表来构造 miniprogram-ci 的 Project 
	      const {
            appInfos, projectPath, desc, version,
        } = uploadConfig;
        
        const project: Project[] = [];
        appInfos.forEach((appInfo) => {
	        const { appid } = appInfo;
		      // 1. 生成临时目录
		      const tmpProjectPath = this.createTmpProject(projectPath, appid);
		      // 2. 删除项目目录下的 ext.json
		      const deleteExtJsonSuccess = this.deleteExtJsonSync(tmpProjectPath);
		      // 3. 更新项目的 appid
		      const updateProjectAppIdSuccess = this.updateProjectConfigAppId(tmpProjectPath, appid);  
	        projects.push(new Project({
                type: 'miniProgram',
                appid: appInfo.appid,
                projectPath: tmpProjectPath,
                privateKey: appInfo.privateKey,
                privateKeyPath: appInfo.privateKeyPath,
                ignores: appInfo.ignores ?? ['node_modules/**/*'],
                targetPlatform: appInfo.targetPlatform,
                compileDefines: appInfo.compileDefines,
            }));
        });
        // 4. 构造 Promise
        const uploadRequests = projects.map((project) => {
            const appInfo = appInfos.find((item) => item.appid === project.appid);
            return new Promise((resolve, reject) => {
                upload({
                    project,
                    desc: appInfo?.desc ?? desc ?? '小程序更新',
                    ...uploadConfig,
                    version: (typeof version === 'function' ? version(project.appid) : version) ?? '',
                }).then((res: any) => {
                    resolve({
                        error: null,
                        appid: project.appid,
                        res,
                    });
                }).catch((err: string | undefined) => {
                    reject({
                        error: new Error(err),
                        appid: project.appid,
                        res: null,
                    });
                });
            });
        });
        return Promise.all(uploadRequests).then(async() => {
	        // 1. 全部上传完后,提交代码到模板库
	        const commitRequests = [];
	        appInfos.forEach((appInfo) => {
                const { appid } = appInfo;
                if (appInfo.getAccessTokenApiUrl) {
                    const TemplateManager = new WeixinTemplateManager({
                        getAccessTokenApiUrl: appInfo.getAccessTokenApiUrl,
                        appid,
                    });

                    const promise = TemplateManager.commitLatestDraftToTemplate().then((res) => {
                        if (res.errcode === AddToTemplateResponseCode.OK) {
                            // 更新项目部署的版本号
                        }
                    });
                    commitRequests.push(promise);
                } else {
                    console.error(` appId: ${appid} 没有配置 getAccessTokenApiUrl, 停止从草稿箱提交到模板库中`, appid);
                }
            });
        })
	      let res;
        try {
            res = await Promise.all(commitRequests);
						
						// 删除临时项目目录
            projects.forEach((project) => {
                this.deleteTmpProject(this.getTmpProjectPath(projectPath, project.appid));
            });

            const appIds = appInfos.map((appInfo) => appInfo.appid);
						
						// 调用 afterUpload Hooks
            const afterHookRes = await this.callHook(HookType.AFTER_UPLOAD, res);
            if (afterHookRes === false) {
                this.logger.error(`${HookType.AFTER_UPLOAD} hook 执行失败`, appIds);
                return res;
            }
        } catch (e: any) {
            console.error(e as string, appInfos.map((appInfo) => appInfo.appid));
        }

        return res;
    }).catch(() => {
	    // log error
	    return null;
    })

其他端大同小异,这里就暂不列举了,根据官方文档和官方的工具库可以构造出来。

// main.ts

export async function upload(userConfigs: UserConfig[], env?: string): Promise<any> {
    const { BaiduUploader } = require('./modules/Uploader/impls/Baidu');
    const { WeixinUploader } = require('./modules/Uploader/impls/Weixin');
    const { KuaishouUploader } = require('./modules/Uploader/impls/Kuaishou');
    const { AlipayUploader } = require('./modules/Uploader/impls/Alipay');
    const { DouyinUploader } = require('./modules/Uploader/impls/Douyin');

    const UploaderMapping = {
        [UploaderType.WEIXIN]: new WeixinUploader(),
        [UploaderType.BAIDU]: new BaiduUploader(),
        [UploaderType.KUAISHOU]: new KuaishouUploader(),
        [UploaderType.ALIPAY]: new AlipayUploader(),
        [UploaderType.DOUYIN]: new DouyinUploader(),
    };

    // upload
    const promises: Promise<unknown>[] = [];
    userConfigs.forEach((config) => {
        const Uploader = UploaderMapping[config.type] as Uploader;
        promises.push(Uploader.upload(config));
    });

    await Promise.all(promises);

		// 返回成功/失败日志
}

TemplateManager

模板管理类,是小程序服务商才需要的,Uploader 只负责把代码上传到平台的草稿箱,模板管理才是把草稿箱的代码提交到模板库。

模板管理的 API 是相对完善的,所以这块看官方文档即可,都是调用 HTTP 接口。

// TemplateManager.ts

export interface TemplateManager {
    addToTemplate(draftId: number | string, desc?: string): void;
    getDrafts(): Promise<any[]>;
    commitLatestDraftToTemplate(): void;
    deleteTemplate(templateId: number): Promise<any>;
    updateVersion(version: string, path?: string | undefined, ext?: any): void;
}

Socket

采用 socket.io 来通信, client 端将用户选择要上传的平台配置发到 server 端,而 server 端控制仓库的拉取、更新、并发处理。

Server 端

// main.ts
let requestId = -1; // 这里控制请求数
export const start = (config, serverOptions) => {
	const io = new Server(serverOptions);
	const cwd = config.cwd;
	
	io.on('connection', (socket) => {
		const done = (res) => {
			requestId--;
			socket.emit('done', res);
		}
	
		socket.on('upload', async (config) => {
				requestId++;
				// 1. 记录 config.userInfo 到日志,包括 ip、mac地址、git username git email 
				// 2. 新增工作目录
				fs.ensureDirSync(cwd);
				// 3. 初始化 git repo
				const repoPath = await initRepo(cwd, config.preset.repo, config.preset.branch, requestId);
				// 4. 打印配置到日志
				// 5. 切换到项目 repo 目录为工作目录
				process.chdir(repoPath);
				// 6. 执行上传,若只有一个平台上传则直接调用,若大于1个平台则多个 worker 并行调用
				const uploadConfigs = config.uploadConfigs;
				const workerTask = path.resolve(__dirname, './upload.js'); // upload.js 则调用 mp-upload 库
				const workers = [];
				if (uploadConfigs.length === 1) {
					workers.push({
						name: uploadConfigs.map(config => config.type).join(','),
						worker: child_process.fork(workerTask, [JSON.stringify(uploadCOnfigs), config.env])
					})
				} else {
					// 根据 parallel 来执行
					const parallelNum = config?.parallel ?? 2; // 默认两个
					let start = 0;
          for(let i = 0; i < Math.ceil(uploadConfigs.length / parallelNum); i++) {
              const workerUploadConfigs = uploadConfigs.slice(start, start + parallelNum);
              workers.push({
                  name: workerUploadConfigs.map(config => config.type).join(','),
                  worker: child_process.fork(workerTask, [JSON.stringify(workerUploadConfigs), config.env]),
              });
              start += parallelNum;
          }
				}
				const promises = workers.map(workerInfo => {
        return new Promise((resolve) => {
            const worker = workerInfo.worker;
            worker.on('message', (logs: Log[]) => {
                resolve({
                    name: workerInfo.name,
                    logs: logs,
                });
                worker.kill();
            });
            worker.on('close', (code) => {
                if (code != null) {
                    console.error(`${workerInfo.name} upload worker exit with code ${code}`)
                    resolve({
                        name: workerInfo.name,
                        error: `子进程意外退出, code: ${code}`,
                    });
                }
            });
        })
		})
	
	})
}

// upload.js
const { upload } = require('sky-mp-upload')

async function run(uploadConfigs = {}, env) {
    const logs = await upload(uploadConfigs, env);
    process.send(logs);
}

function main() {
    void run(JSON.parse(process.argv[2]), process.argv[3]);
}

main();

// pm2 里常驻进程
// run.js
const { start } = require('sky-mp-server');
const FiveMinute = 5 * 60 * 1000;
start({
    cwd: `项目目录`
}, {
    pingTimeout: FiveMinute, // 心跳检测最大延迟时间
})

Client 端

// main.ts

const start = async (config) => {
	// 使用 inquirer 来做选择的库
	// 1. 选择环境
	const envPrompt = '请选择你需要发布的环境';
	const envChoice = await inquirer.prompt({
		name: envPrompt,
		type: 'list'.
		choices: [
			{
				name: '测试环境',
				value: Env.DEV,
			},
			{
				name: '正式环境',
				value: Env.PROD,
			}
		]
	})
	const userUploadConfigs = config[envChoiceValue].uploadConfigs;
	
	// 2. 选择发布的平台
	// 跟上述大同小异,只不过类型是 checkbox 多选
	const platformPrompt = '请选择你需要发布的平台';
  const platformChoice = await inquirer.prompt({
        name: platformPrompt,
        type: 'checkbox',
        choices: platforms.filter((platform) => userUploadConfigs.find((item) => item.type === platform.value)),
    }) as Record<string, UploaderType>;
  const platformChoiceValue = platformChoice[platformPrompt];
  userConfig.uploadConfigs = userUploadConfigs.filter((item) => platformChoiceValue.includes(item.type));
	
	// 3. git 判断对应的代码分支没提交、没更新情况, 这里使用 simple-git 库即可
	
	// 4.调用 socket 通信到 server 端
    socket.on('connect', () => {
        let spinner = null;
        socket.emit('upload', {
	        env: envChoiceValue,
	        preset: userConfig.preset,
	        parallel: userConfig.parallel,
	        userInfo, // 记录操作的用户信息
	        uploadConfigs: userConfig.uploadConfigs,
        });
        spinner = ora('正在发布').start();

        socket.on('done', async (data: Log[]) => {
            if (spinner) {
                spinner.clear();
                spinner.stop();
            }
            // 打印日志到控制台
            printLogs(data);
            // send 企业微信 webhook
            await sendLogs(config, data);
            process.exit(0);
        });

        socket.on('log', (message: string) => {
            Logger.info(`\n${message}`);
        });

        socket.on('disconnect', (reason) => {
            if (reason === 'io client disconnect') {
                return;
            }
            if (reason === 'ping timeout') {
                if (spinner) {
                    spinner.clear();
                }
                Logger.info('ping timeout, 心跳检测超时');
            } else {
                Logger.info(`连接断开,请检查服务端是否启动, 地址: ${url}`);
            }
            if (spinner) {
                spinner.stop();
            }
            socket.close();
            process.exit(1);
        });
    });
}

// 在项目里新增 npm script
// npm run release -> node scripts/mp-client.js
// mp-client.js
const { start } = require('sky-mp-client')

start({
	dev: {
		uploadConfigs: [
				{
					// 微信配置
				},
				{
					// Alipay 配置
				},
				{
					// ...
				}
		],
		preset: {
			repo: ''.
			branch: '',
		}
	},
	prod: {
		// 同上面的结构
	}
})

遇到的限制

微信端

  1. 模板版本数量超过 200 个就需要删除 (工具内部会自动删除时间最久远的一个版本)

百度端

  1. 模板版本数量超过 50 个就需要删除 (工具内部会自动删除时间最久远的一个版本)

支付宝端

  1. 不支持并发发布多个小程序, 工具内部是串行执行
  2. 草稿箱数量超过 20 个就需要删除 (工具内部会自动删除时间最久远的一个版本)
  3. 第三方模板小程序不支持自调用 audit 接口,所以无法自动提交审核
  4. 模板小程序提交审核通过/失败不会通知到回调 URL, 所以工具内部会在发布后定时查询审核状态,如果审核成功/失败发送企微 webhook

抖音端

  1. 不支持并发发布多个小程序, 工具内部是串行执行
  2. 模板版本数量超过 200 个就需要删除 (工具内部会自动删除时间最久远的一个版本)

快手端

  1. 快手第三方模板操作没有 API 提供,所以需要拿到 Cookie 进行配置,若 Cookie 失效,需重新获取

效果

/本地微信、抖音端Dep 全部端是否阻塞
旧发布流程5 min40 min - 60 min
新发布流程3 min3 min - 5 min

结论:

1. 本地环境单次发布耗时缩短 40%

2. 预发布环境单次发布耗时缩短 90% - 92%