0. 前言
1. 获取需要拉取的gitlab仓库地址
1.1 gitlab Access Token
1.2 gitlab 开放Api
2. clone with oauth2
3. 在 node 里运行 shell 脚本
4. 优化 - 大量项目的异步队列
0. 前言
当拿到一个「批量拉取特定 gitlab 项目到指定地址」的需求时,你会觉得这是个复杂需求吗?
并不会,毕竟这个需求拆解下来,只需要两步,获取地址,和 clone。
不过,实际实施下来,还是踩了一些坑,做了一些额外的优化,记录在这里,希望能给看文章的你带来一点收获。
1. 获取需要拉取的gitlab仓库地址
1.1 gitlab Access Token
众所周知,自建的gitlab一般是有权限的,拉取自然也不例外。为了能够正常拉取项目,需要一个 Access Token。获取方式非常简单:右上角头像 -> Settings -> 左侧Access Tokens,按照页面的指示生成一个。
1.2 gitlab 开放Api
回想一下,当我们日常使用 git clone 命令时,需要什么?项目拉取的链接。去获取链接直观的思路就是通过gitlab的开放api。
直接放上链接:https://docs.gitlab.com/ee/api/projects.html 。
简单概括一下我们需要用到的点:
- 请求的域名为:https://gitlab.example.com/api/v4 (example 换成自己的域名)
- 获取所有有权限的项目列表的接口为
/projects,注意 会拉取到所有开放级别为public的项目。 - 获取特定群组下所有项目列表的接口为
/groups/${groupId}/projects,这次为了不拉到public的项目,选择这个接口。 - 这是一个有权限的接口,所以我们需要把 Access Token 放入请求头,对应key为
PRIVATE-TOKEN,value为「1.1」获取到的 token - 这是一个分页接口,所以我们需要提供页码(page)和每页数据量(per_page),因为我们想要减少请求次数,所以这里per_page选最大的100
综上所述,可以用postman等工具尝试发出请求:
curl --location --request GET 'https://gitlab.example.com/api/v4/projects?page=1&per_page=100'--header 'PRIVATE-TOKEN: 生成的Access Token'
获取到的 name 和 http_url_to_repo 是我们需要的两个字段。
2. clone with oauth2
经过第一步,我们可以获取到一个形如下列的数组:
[{ name: 'a', http_url_to_repo: '' },{ name: 'b', http_url_to_repo: '' },]
如果是人工 clone 的,就可以直接 git clone 了,出提示框填个人信息;但是一个自动化的过程肯定不能这样被阻断,这时候就需要使用特殊的网址来做啦↓
git clone https://oauth2:{gitlabToken}@{去掉http://或者https://的仓库链接}
// eg.function clearHttpHead(url) {return url.replace(/^https:\/\//, '');}`git clone https://oauth2:${gitlabToken}@${clearHttpHead(item.http_url_to_repo)}`
3. 在 node 里运行 shell 脚本
语句拼好了,接下来的问题就到了如何在 node 里运行了。
概括来说,node 提供 child_process 来运行 shell脚本,那在理论上来说,当然可以使用
const child = require('child_process')const { promisify } = require('util'); // 可以把各种用回调的转成promiseawait promisify(child.exec)(`git clone https://oauth2:${gitlabToken}@${clearHttpHead(item.http_url_to_repo)}`)
的方式运行git clone 命令,但是 child.exec 千好万好,报错就退出这一点在批量跑的时候还是不大方便,所以建议使用第三方库 shelljs 来做这个操作,也即是:
const shell = require('shelljs');await promisify(shell.exec)(`git clone https://oauth2:${gitlabToken}@${clearHttpHead(item.http_url_to_repo)}`);
可以理解为 shelljs 提供了一个真空的终端环境,在里面怎么报错,外面的代码拿到的都是普通的输出而非报错输出。
到了这一步,如果是少量项目的批量,就可以满足需求了。
4. 优化 - 大量项目的异步队列
由上文可以看到,我特意使用了 await,将异步的 shell.exec 转成了同步,这是因为如果有超过 10 (服务器上是5)个项目同时开始下载,node 就会报错「同时进行的进程超出最大限制(大意)」,把所有下载都同步进行是最简单但也效率最低的方法。
为了解决这个问题,让脚本能在限制下最高效的运行,我们需要产生出一个异步队列,效果为同时开始n个异步任务,任一异步任务结束后从剩余任务中取一个继续执行,直到所有任务结束。
实现思路也很简单,先写一个Promise.race,抽出生成 Promise 的方法,将元数据存储为剩余任务;当有promise任务结束之后,取剩余任务的第一个生成对应的promise;循环这个操作直到剩余任务的数量为0,改为Promise.all,等待所有任务结束。
话不多说,实现的代码如下:
/*** 声明示例*/class Race {constructor(data) {// rest 总数据队列// queueLength 同时进行n个任务的`n`// method 生成promise的方法const { rest, queueLength, method } = data;this.rest = rest || [];this.queueLength = queueLength || 2;this.queue = [];this.promiseMethods = method || (() => {});return new Promise((resolve) => {this.startRace(resolve);});}race(resolve) {if (this.rest.length > 0) {Promise.race(this.queue).then((res) => {this.queue.splice(res, 1, this.promiseMethods(this.rest[0], res));this.rest.shift();console.log(`剩余${this.rest.length}个项目`);this.race(resolve);}, (e) => {console.log('race error!');console.log(e);});} else {Promise.all(this.queue).then((res) => {resolve(res);}, (e) => {console.log('promise.all error!');console.log(e);});}}startRace(resolve) {for (let i = 0; i < this.queueLength; i++) {this.queue.push(this.promiseMethods(this.rest[i], this.queue.length));}this.rest.splice(0, this.queueLength);this.race(resolve);}}module.exports = Race;
/*** 使用示例*//*** 获取 Promise 异步任务* @param {object} item* @param {number} index*/const getTask = async (item, index) => {if (item && item.http_url_to_repo) {console.log(chalk.green(`${item.name} start`));try {await promisify(shell.exec)(`git clone https://oauth2:${gitlabToken}@${clearHttpHead(item.http_url_to_repo)} ${path.join(cloneToPath, item.name)}`);successProject.push(item.name);console.log(chalk.green(`${item.name} over`));} catch (e) {console.log(chalk.red(`${item.name} fail`));console.error(e);failProjects.push(item.name);}return index;}return Promise.resolve(index);};new Race({rest: [...projects],queueLength: 5,method: getTask,}).then(() => {console.log();console.log(chalk.green('success finished!!'));console.log();console.log(chalk.green('success projects:'));console.log(successProject.join(',') || 'no Success project');console.log();console.log(chalk.red('failed projects:'));console.log(failProjects.join(',') || 'no Fail project');});
当然,因为是用的Promise.all,所以Promise一定要是resolve的,这也是上文使用shelljs的另一个原因。
