Skip to content
On this page

总结

  1. 使用 OSS 云存储服务,有两点可继续优化
    • 上传时间优化:按需上传,对比文件,文件未变更,无需上传。并发控制,批量上传。
    • 云空间优化:旧资源剔除,节省云空间。
  2. 按需上传判断:
    • 资源不在 OSS :需要上传
    • 对于 hash 资源:如果存在 OSS 中,不需要上传
    • 对于不带 hash 资源:添加元数据即自定义头部,比对标志(mtime),不同则需要上传。
  3. 不带 hash 资源对比标志为什么选用 mtime (修改时间)
    • 对比的目的:确认 资源的内容 是否更新,这跟响应头 Etag 的设计一致。
    • 所以可以参考 Etag 的实现,nginx 使用的是资源的 mtimesize,即修改时间和资源大小,去计算得到 Etag
      mtime 只能作用于秒级的改变,1s 内如果有重复更改,mtime 会保持不变。所以需要 size 去辅助计算,减小未覆盖场景。但 nginx 的 Etag 还是有个场景未覆盖到:1s 内多次更改内容,且更改后资源大小保存一致。
    • 我们上传的是打包完的静态资源,打包时间不可能在 1s 内,不会发生 1s 内重复更改资源的情况,所以只用 mtime 就够了,mtime 改变意味着内容改变。同时也要 webpack5 的持久化缓存,不对未更改的内容重新编译,文件不会被覆盖,mtime 也不会更改。
  4. 并发控制:一个一个资源串行上传太耗时,可以引入 p-queue 并行上传,优化上传时间。
  5. Rclone:Go 语言编写的一款高性能云文件同步的命令行工具,Go 语言编译出来是一个二进制文件,性能比 js 这种解释型语言快很多。且使用 obsutil 去编写脚本还是太繁琐了,Rclone 可以帮我们更好去操作,性能更好。
  6. 云空间优化:删除 OBS 中冗余资源。
    • 非多版本并存的情况下,可以对比云空间和本地资源,如果属于多余资源则删除。
    • 多版本并存情况下,则通过打包路径 output.path 输出到不同路径区分不同版本,对比云空间和本地资源,如果属于多余资源则删除。
  7. 为什么不在上传资源后,同步进行删除冗余资源的操作:
    部署到线上生效有一段时间,且可能因为用户的缓存问题,会访问到这些旧资源,所以删除操作不要过急。删除冗余资源的目的是节省空间,但优先级低,低于用户体验。
  8. 定时任务周期性删除 OSS 上的冗余资源:使用 CRON,linux 上的定时任务。
    sh
     # CRON:Linux 定时任务,在 /etc/crontab 配置。
     # 格式: * * * * * command
     #      分钟(0-59) 小时(0-23) 日期(1-31) 月份(1-12) 星期(0-6,0代表星期天)  命令
    
     # 每晚的 02:00 执行脚本
     0 2 * * * /usr/local/bin/node /xx/deleteOBS.mjs
    

1. 对象存储优化


当公司内将一个静态资源部署云服务的前端项目持续跑了 N 年后,部署了上万次后,可能出现几种情况。

  1. 时间过长:如构建后的资源全部上传到对象存储,然而有些资源内容并未发生变更,将会导致过多的上传时间。
  2. 冗余资源:前端每改一行代码,便会生成一个新的资源,而旧资源将会在 OSS 不断堆积,占用额外体积。 从而导致更多的云服务费用。

2. 静态资源上传优化: 按需上传与并发控制

在前端构建过程中存在无处不在的缓存

  1. 当源文件内容未发生更改时,将不会对 Module 重新使用 Loader 等进行重新编译。这是利用了 webpack5 的持久化缓存。
  2. 当源文件内容未发生更改时,构建生成资源的 hash 将不会发生变更。此举有利于 HTTP 的 Long Term Cache(强缓存)。

2.1 对比

通过对比,如果未改变则不向 OSS 进行上传操作。这一步将会提升静态资源上传时间,进而提升每一次前端部署的时间。

对比依据:

  • 资源不在 OBS 直接返回 false
  • 对于 hash 资源:如果存在 OBS 中,直接返回 true
  • 对于不带 hash 资源:比对唯一标志(mtime)。

通过阿里云或华为云的 node SDK,进行操作。华为云 npm install esdk-obs-nodejs 文档

js
/* 自定义文件标记,使用文件的 ctime 和 size */
const getHash = stats => encodeURI(`${stats.ctimeMs}-${stats.size}`)


/**
 * 判断文件 (Object)是否在 OBS 中存在
 * 文件不在 OBS 直接返回 false
 * 对于 hash,如果存在 OBS 中,直接返回 true
 * 对于非 hash,比对唯一标志(mtime,size)
 * @param {Object} entry 文件信息,带有 stats 对象
 * @param {String} Key  文件路径
 * @param {Boolean} withHash  该文件名是否携带 hash 值
 */
async function isExistObject ({entry, Key, withHash}) {
  try {
    const res = await client.getObjectMetadata({
			Bucket,
			Key
		})
    if(res.CommonMsg.Status === 404) { // 文件不存在
      return false
    }

    if (withHash) { // hash 提前返回
      return true
    }

    // 非 hash 存在的情况下 判断文件的 元数据-自定义响应头
    const {Metadata = {}} = res.InterfaceResult
    const {hash = ''} = Metadata
    return hash === getHash(entry.stats)

  } catch (e) {
    return false
  }
}

2.2 按需上传、设置缓存策略

根据有无 hash 设置对应的缓存策略。

js

/**
 * 上传文件,设置对应缓存策略
 * @param {String} objectName  文件路径
 * @param {Boolean} withHash  该文件名是否携带 hash 值
 */
async function uploadFile ({entry, withHash = false}) {
  let Key = entry.path.replace(/\\/g, '/') // 兼容windows
  if(withHash) {
    Key = `static/${Key}`
  }
  const file = resolve('./build', Key)
  // 判断文件是否存在
  const exist = await isExistObject({entry, Key, withHash})
  if (!exist) {
    // 设置缓存策略
    const CacheControl = withHash ? 'max-age=31536000' : 'no-cache'

    try {
      // 为了加速传输速度,这里使用 stream
      /* 上传对象 */
      let res = await client.putObject({
        Bucket,
        Key,
        // 创建文件流,其中 file 为待上传的本地文件路径,需要指定到具体的文件名
        Body: createReadStream(file),
      })
      
      if(res.CommonMsg.Status !== 200) {
        throw res
      }

      /* 设置对象元数据 */
      const Metadata = {
        hash: getHash(entry.stats)
      }
      res = await client.setObjectMetadata({
        Bucket,
        Key,
        MetadataDirective: 'REPLACE_NEW',
        CacheControl,
        Metadata
      })
      
      if(res.CommonMsg.Status !== 200) {
        throw res
      }

      console.log(`Done: ${Key}`)
    } catch(err) {
      console.log(`${err.CommonMsg?.Message}${Key}`)
    }
  } else {
    // 如果该文件在 OBS 已存在,则跳过该文件 (Object)
    console.log(`Skip: ${Key}`)
  }
}

2.3 并发控制

可以通过 p-queue 控制资源上传的并发数量

js
import PQueue from 'p-queue'
import readdirp from 'readdirp'

const queue = new PQueue({ concurrency: 10 }) // 线程池 10 

async function main() {
  // 首先上传不带 hash 的文件
  for await (const entry of readdirp('./build', { depth: 0, type: 'files', alwaysStat: true })) {
    queue.add(() => uploadFile({entry}))
  }
  // 上传携带 hash 的文件
  for await (const entry of readdirp('./build/static', { type: 'files', alwaysStat: true  })) {
    queue.add(() => uploadFile({entry, withHash: true}))
  }
}

3. Rclone: 按需上传

使用 obsutil 去编写脚本还是太繁琐了,Rclone 可以帮我们更好去操作,且性能更好。
Rclonersync for cloud storage,是使用 Go 语言编写的一款高性能云文件同步的命令行工具,可理解为云存储版本的 rsync,或者更高级的 ossutil

它支持以下功能:

  1. 按需复制,每次仅仅复制更改的文件
  2. 断点续传
  3. 压缩传输

3.1 下载安装

Windows 和 Mac 可以直接下载exe,下载列表

Docker中引入 参照文档 https://rclone.org/install/#install-with-docker

3.2 初始化

我个人使用的是华为 obs 作为云存储,参考文档 https://rclone.org/s3/#huawei-obs

首先执行 rclone config,进行初始化,按照文档进行配置,主要留意 type 为 s3,其 provider 为 HuaweiOBS。整个设置流程提示很充足,比较简单。

3.3 上传

sh
# 将资源上传到 OBS Bucket
# obs: 通过 rclone 配置的云服务名称,此处为华为的 obs,个人取名为 obs
# bucketName: oss 中的 bucket 名称
$ rclone copy --exclude 'static/**' --header 'Cache-Control: no-cache' build obs:/bucketName --progress 

# 将带有 hash 资源上传到 OBS Bucket,并且配置长期缓存
$ rclone copy --header  'Cache-Control: max-age=31536000' build/static obs:/bucketName/static --progress

为了方便,将两条命令维护到 npm scripts

json
{
  "scripts": {
    "obs:rclone": "rclone copy --exclude 'static/**' --header 'Cache-Control: no-cache' build obs:/bucketName --progress && rclone copy --header 'Cache-Control: max-age=31536000' build/static obs:/bucketName/static --progress",
  }
}

4. 删除 OBS 中冗余资源

在生产环境中,OBS 只需保留最后一次线上环境所依赖的资源。(多版本共存情况下除外) 此时可根据 OBS 中所有资源与最后一次构建生成的资源一一对比文件名,进行删除。

js
// 列举出来最新被使用到的文件: 即当前目录
// 列举出来OBS上的所有文件,遍历判断该文件是否在当前目录,如果不在,则删除
async function main() {
  const files = await getCurrentFiles()
  const objects = await getAllObjects()
  for (const object of objects) {
    // 如果当前目录中不存在该文件,则该文件可以被删除
    if (!files.includes(object.name)) {
      await client.delete(object.name)
      console.log(`Delete: ${object.name}`)
    }
  }
}

命令维护到 npm scripts

json
{
  "scripts": {
    "obs:prune": "node scripts/deleteOBS.mjs"
  }
}

而对于清除任务可通过定时任务周期性删除 OSS 上的冗余资源,比如通过 CRON 配置每天凌晨两点进行删除。由于该脚本定时完成,所以无需考虑性能问题,故不适用 p-queue 进行并发控制。

CRON:Linux 定时任务,在 /etc/crontab 配置。
格式: * * * * * command
分钟(0-59) 小时(0-23) 日期(1-31) 月份(1-12) 星期(0-6,0代表星期天)  命令
每晚的 02:00 执行脚本
0 2 * * * /usr/local/bin/node deleteOBS.mjs

生产环境发布了多个版本的前端,如 AB 测试,toB 面向不同大客户的差异化开发与部署,此时可针对不同版本对应不同的 output.path 打包输出到不同路径来解决。

output.path 可通过环境变量注入 webpack 选项,而环境变量可通过以下命令置入。(或置入 .env)

sh
export COMMIT_SHA=$(git rev-parse --short HEAD)

export COMMIT_REF_NAME=$(git branch --show-current)
export COMMIT_REF_NAME=$(git rev-parse --abbrev-ref HEAD)

疑问

遗留

提问

  • [x] 使用 rclone 上传文件至 oss
  • [x] 针对你们项目静态资源的存储及上传做了那些优化 资源存储再云存储服务 oss 上,通过按需上传和并发控制优化上传速度,按需上传的判断标准是:oss 没有的资源,直接上传。oss hash 资源已有,不上传,oss 非 hash 资源已有,则判断元数据 mtime,不一样则上传。且通过定时任务,对比云服务上的资源和本地资源,将冗余资源删除。