一个批量链接替换小工具

最近在开发中遇到一个比较复杂的流程控制问题,本来早该写一篇分享的,奈何懒,以至于重构过程中的一些想法和遇到的问题都模糊了,就写一个最终版的吧。

因为我主要的开发语言是JavaScript,所以这个小工具是基于Node实现的。

在简介中我写的是一个比较复杂的流程控制问题,其实现在看来本就不复杂,甚至你没有上手写代码之前都没有觉得这个任务复杂。

产品来源于需求

需求背景

在前端项目中,全部的图片是放在 OSS 里边的,所以代码中的图片链接都是指向这个 OSS,因为某些原因,这个 OSS 不再使用,需要迁移到另一个 CDN 服务中。出于懒,这种复制粘贴的事自然是不能自己做了,于是就动手写一个小工具来做链接的替换。

工作流程

看完需求场景其实你已经很明白了,思路也应该能一下想到,而且比较清晰。

总共可以分为 4 步:

  1. 抽取源代码中的链接
  2. 将链接指向的图片下载下来
  3. 将这些图片上传到 CDN
  4. 获取对应 CDN 链接后替换掉原来的链接

整体设计的分析

从工作流程来看的确是很清晰,真的是这样吗,那我现在问第一个问题,上面的流程是对一个链接来说的,对于代码中几十条链接应该如何处理呢?

有两种方案:

  1. 将一个完整的流程看做一个大的异步任务,对于其中的异步环节,Node 会在 I/O Pending 时执行其他任务。
  2. 将整个流程分成很多小的流程,分模块执行异步任务。

在现在看来,这两种方案没有任何区别,因为完整流程中,每一步的运行也是异步的,这个选择稍后再讨论。

但是为了方便编写逻辑清晰的代码,以及项目工程化,我们还是应该依据是否进行 I/O 操作以及功能来进行合理拆分。注意此处说的拆分和第二中方案所说的拆分执行不是一个意思,只是代码结构逻辑上的拆分。

借助 Node 的事件循环机制,可以很容易实现模块异步执行,比如在 CDN 上传耗时阶段,继续执行链接抽取等的工作。

开始设计

按照上面所说的拆分依据,可以将整个工具拆分成 5 个模块:

  1. 文件发现模块
  2. 链接抽取模块
  3. 文件下载模块
  4. 文件上传模块
  5. 链接替换模块

在链接抽取之前,还加上了一个文件发现模块,因为如果从源代码所有文件夹中(递归)查找文件并且检索链接的话会比较慢,实际上也并不需要所有都查找一遍,大多数时候图片链接都是在某一类或者某些文件中,因此用文件发现模块来缩小范围。

模块拆分完了,第二个问题是如何让这些任务串联起来呢?

通常我们会用 Node 为 Server,在 Node 中比较出名的算是ExpressKoa了,他们最重要你的部分就是中间件,看到这里,你应该能猜到我想说什么了,在这种串联任务中,使用中间件机制来串联是一种方法。

解决了模块串联的问题,那么模块间要如何传值呢?

同样的,我们可以借助中间件机制,也可以借用 Context 机制,Context 相当于一个通用接口,一个 Payload,可以将需要传递的值放入 Context 中,在各个模块中传递 Context,无需改变模块的入参。

代码实现思路

文件发现模块其实相当于一个筛选器,比如选出某文件夹下特定扩展名的文件,或者就是指定文件,指定文件夹下的所有文件等。

链接抽取模块将读取文件发现模块返回的文件,然后用正则匹配图片链接或者指定文件扩展名的链接。

链接抽取这里还需要一些细节一点的实现,为了链接替换模块能快速定位到需要替换的链接,而不是重新查找一遍,这里需要一个索引文件记录有关的信息,比如写入一个文本文件中,一行一条记录,格式如下:

Record
1
filePath;lineNumber;startIndex;endIndex;prevLink;downloadFilePath;updateLink

读取对应的内容,内容如下:

JavaScript
1
2
3
const info = link.split(";");
const [文件路径, 所在行号, 起始位置, 结束位置, 链接, 下载链接地址, 替换链接] =
info;

除此之外,直接放在 Context 是一个更好的选择,不过,存储这个文件更多是作为比对和调试用,避免替换出错。

文件下载模块只需向抽取的链接发送一个 HTTP 请求然后保存即可。

因为 CDN 的安全特性,文件上传不能通过发包的形式来完成,所以这个地方采用 Puppeteer 来完成文件的上传,并获取链接。

链接替换将会通过索引记录读取记录的文件,找到替换的原内容,替换成新链接。

至此,这个替换工具已经基本完成了。

第一种方案是我们想要的吗

根据上面的分析,按照第一种方案来写的话,使用一个 Promise 将这个流程包裹起来就行了,Node 在 I/O Pending 时会去处理下一个任务,这样就能加快处理速度了。看似非常完美,可是并不是我们想象的这样,仔细想一下 『下一个任务』是什么呢?

应该是指处理下一条链接,而不是 Node 中执行的其他任务,所以关键问题来了,如何启动『下一个任务』,仔细想一想,方案会有很多,但是要将一个流程看作是一个整体异步任务进行封装的话,无论什么方案都是不合适的,原因就在于每次开始处理一个任务,都需要从头开始查找并抽取链接,可能会重复处理。根据上文的启发,遇到这种情况你可能会想到使用索引文件,这种情况下索引文件是比较复杂的,比如说要判断该文件是否被完全处理过,就需要遍历索引文件,还要区分未全部处理的文件和尚未处理的文件,可能需要多个索引文件才能完成。

此外,应该在何时启动『下一个任务』?也是一个问题,如果放在一个完整流程执行完成之后,就相当于是同步按流程一个链接一个链接地处理,速度较慢。

整体方案1

另一种方式是放在一个特定的步骤中(比如在下载文件之前)来启动下一个任务。但是,这样是有缺陷的,我们使用Puppeteer 来进行 CDN 上传,会设涉及到模拟点击,点击是有顺序的,所以在使用同一个 Tab 时,应该避免同时操作这个 Tab,以免操作混乱。如果开启多个 Tab,将会占用大量的内存,甚至造成卡顿。而且在函数中调用会造成比较大的函数栈,有较小的概率造成堆栈溢出。

整体方案2

因此,第一种方案的执行情况和性能并不是我们想要的。

最终设计方案

从上面对 Puppeteer 操作过程的分析,我们可能需要一个控制任务执行的任务调度器,控制 CDN 上传过程在同一时间只有一个函数来控制 Puppeteer

既然我们有调度器,使用第二种方案,分模块运行,可以更细粒度地控制各个流程的运行。

Node 的任务队列是比较粗糙的,有多少异步任务都会被塞进任务队列,这样就容易导致高并发,比如可能导致客户端一下子服务器发送几十上百个请求,触发服务器 DDos,导致拒绝服务。所以任务调度器的作用应该是控制何时将任务放进任务队列。

任务队列

关于任务队列的实现,我将详细地写一篇文章《异步任务队列实现》来阐述。

作者

KylinLee

发布于

2021-03-25

更新于

2022-02-11

许可协议

CC BY-NC-SA 4.0

评论

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×