32

使用 Vue + Element UI + STS 将图片直接从前端上传至阿里云 OSS (附 Node 代码)

 4 years ago
source link: https://juejin.im/post/5e2423a66fb9a02ffe703099
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
2020年01月20日 阅读 3989

使用 Vue + Element UI + STS 将图片直接从前端上传至阿里云 OSS (附 Node 代码)

这篇文章是《不是所有的 No 'Access-Control-Allow-Origin' header... 都是跨域问题》的后记,因为在评论区中,@ redbuck 同学指出:“为啥图片不前端直接传 OSS ?后端只提供上传凭证,还省带宽内存呢。” 于是,我一拍大腿:对啊,当初怎么就没想到呢?(主要还是因为菜)

1

于是我立刻查找相关资料并实践,其间也踩了一些坑,趁着年前项目不紧张(疯狂摸鱼),根据踩坑过程,整理成了如下文章。

什么是 OSS ?

对象存储服务(Object Storage Service,OSS)是一种海量、安全、低成本、高可靠的云存储服务,适合存放任意类型的文件。容量和处理能力弹性扩展,多种存储类型供选择,全面优化存储成本。

这是阿里云的一款产品,而因为其高性价比,所以我的公司使用 OSS 做为前端页面、资源的存储仓库。前端的项目都是直接打包并上传 OSS,实现自动部署,直接访问域名,就可以浏览页面。同理,图片也可以上传到 OSS 并访问浏览。

什么是 STS ?

阿里云临时安全令牌(Security Token Service,STS)是阿里云提供的一种临时访问权限管理服务。

通过STS服务,您所授权的身份主体(RAM用户、用户组或RAM角色)可以获取一个自定义时效和访问权限的临时访问令牌。STS令牌持有者可以通过以下方式访问阿里云资源:

  • 通过编程方式访问被授权的阿里云服务API。
  • 登录阿里云控制台操作被授权的云资源。

OSS 可以通过阿里云 STS 进行临时授权访问。通过 STS,您可以为第三方应用或子用户(即用户身份由您自己管理的用户)颁发一个自定义时效和权限的访问凭证。

也就是需要获取 STS ,前端才可以一步到位,而无需像之前那样,先将文件上传到 Node 服务器,再通过服务器上传到 OSS。

项目环境(主要依赖包及版本)

  • vue (2.6.10)
  • element-ui (2.13.0)
  • ali-oss (6.1.1)
  • nestjs (6.x)
  • @alicloud/sts-sdk (1.0.2)

先说后端部分,是因为要先获取 STS 密钥,前端才能上传。这里使用的是 NestJS 框架,这是一款基于 Express 的 Node 框架。虽然是 Nest,但关键代码的逻辑是通用的,不影响 Koa 的用户理解。

1. 安装阿里云 STS 依赖包:

> npm i @alicloud/sts-sdk -S
或
> yarn add @alicloud/sts-sdk -S
复制代码

2. 引入 sts-sdk 并实例化

官方例子:

const StsClient = require('@alicloud/sts-sdk');
 
const sts = new StsClient({
  endpoint: 'sts.aliyuncs.com', // check this from sts console
  accessKeyId: '***************', // check this from aliyun console
  accessKeySecret: '***************', // check this from aliyun console
});
 
async function demo() {
  const res1 = await sts.assumeRole(`acs:ram::${accountID}:role/${roleName}`, 'xxx');
  console.log(res1);
  const res2 = await sts.getCallerIdentity();
  console.log(res2);
}

demo();
复制代码

假设你项目的目录结构是这样:

1

项目代码片段:

temp-img.service.ts

import { Injectable } from '@nestjs/common';
import config from '../../../config';
import * as STS from '@alicloud/sts-sdk';
const sts = new STS({ endpoint: 'sts.aliyuncs.com', ...config.oss });

@Injectable()
export class TempImgService {
  /**
   * 获取 STS 认证
   * @param username 登录用户名
   */
  async getIdentityFromSTS(username: string) {
    const identity = await sts.getCallerIdentity();
    // 打*号是因为涉及安全问题,具体角色需要询问公司管理阿里云的同事
    const stsToken = await sts.assumeRole(`acs:ram::${identity.AccountId}:role/aliyun***role`, `${username}`);
    return {
      code: 200,
      data: {
        stsToken,
      },
      msg: 'Success',
    };
  }
  ...
}
复制代码

3. 配置路由

temp-img.module.ts

import { Module } from '@nestjs/common';
import { TempImgService } from './temp-img.service';

@Module({
  providers: [TempImgService],
  exports: [TempImgService],
})
export class TempImgModule {}
复制代码

temp-img.controller.ts

import { Controller, Body, Post, Request, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { TempImgService } from './temp-img.service';

@Controller()
export class TempImgController {
  constructor(private readonly tempImgService: TempImgService) {}

  @UseGuards(AuthGuard('jwt')) // jwt 登录认证,非必须
  @Post('get-sts-identity')
  async getIdentityFromSTS(@Request() req: any) {
    return await this.tempImgService.getIdentityFromSTS(req.user.username);
  }
  ...
}
复制代码

现在,就可以通过/get-sts-identity来请求接口了,Postman 的请求结果如下:

1

Ps: /ac-admin 是项目统一路由前缀,在 main.ts 中配置的

前端只需要拿到上图中的 Credentials 信息就可以直接请求 OSS 了。

1. 安装依赖

> npm i ali-oss -S
或
> yarn add ali-oss -S
复制代码

2. 初始化 OSS 的 client,这里使用了单例模式:

src/utils/upload.js

const OSS = require('ali-oss');
const moment = require('moment');

const env = process.env.NODE_ENV;

let expirationTime = null; // STS token 过期时间
let client = null; // OSS Client 实例

const bucket = {
  development: 'filesdev',
  dev: 'filesdev',
  pre: 'filespre',
  beta: 'filespro',
  production: 'filespro'
};

// 初始化 oss client
export function initOssClient(accessKeyId, accessKeySecret, stsToken, expiration) {
  client = new OSS({
    accessKeyId,
    accessKeySecret,
    stsToken,
    region: 'oss-cn-hangzhou',
    bucket: bucket[`${env}`]
  });
  expirationTime = expiration;
  return client;
}

// 检查 oss 实例以及过期时间
export function checkOssClient() {
  const current = moment();
  return moment(expirationTime).diff(current) < 0 ? null : client;
}

// 用于 sts token 失效、用户登出时注销 oss client
export function destroyOssClient() {
  client = null;
}
复制代码

3. 编写业务代码:

这里使用的是 element-uiel-upload 上传组件,使用自定义的上传方法:http-request="...",部分代码如下:

<template>
  <div class="model-config-wrapper"
       ref="model-wrapper">
            ...
                    <el-form-item label="首页背景">
                      <el-upload class="upload-img"
                                 list-type="text"
                                 action=""
                                 :http-request="homepageUpload"
                                 :limit="1"
                                 :file-list="activityInfo.fileList">
                        <el-button icon="el-icon-upload"
                                   class="btn-upload"
                                   @click="setUploadImgType(1)"
                                   circle></el-button>
                      </el-upload>
                    </el-form-item>
            ...
  </div>
</template>
<script>
import uploadMixin from '@/mixin/upload.js';
export default {
    ...
    mixins: [uploadMixin],
    data() {
        return {
            activityInfo: {...},
            uploadImgType: -1,
            ...
        }
    },
    methods: {
        ...
        // 设置图片类型,用于数据库存入做区分
        setUploadImgType(type) {
            this.uploadImgType = type;
        },
        
        // 处理上传结果并保存到数据库
        async handleBgUpload(opt) {
            if (this.uploadImgType < 0) {
                this.$message.warning('未设置上传图片类型');
                return;
            }
            const path = await this.imgUpload(opt); // imgUpload 方法写在 mixin 里,下文会提到
            if (!path) {
                this.$message.error('图片上传失败');
                this.uploadImgType = -1;
                return;
            }
            const parmas = {
                activityId: this.activityId,
                type: this.uploadImgType,
                path
            };
            // 将图片路径保存到数据库
            const res = await this.$http.post('/ac-admin/save-img-path', parmas);
            if (res.code === 200) {
                this.$message.success('上传保存成功');
                this.uploadImgType = -1;
                return path;
            } else {
                this.$message.error(res.msg);
                this.uploadImgType = -1;
                return false;
            }
        },
        // 首页背景上传
        async homepageUpload(opt) {
            const path = await this.handleBgUpload(opt);
            if (path) {
                this.activityInfo.homePageBg = path;
            }
        },
        // 规则背景图上传
        async rulesBgUpload(opt) {
            const path = await this.handleBgUpload(opt);
            if (path) {
                this.activityInfo.rulesBg = path;
            }
        },
    }
}
</script>
复制代码

考虑到 this.imgUpload(opt) 这个方法会有很多页面复用,所以抽离出来,做成了 mixin,代码如下:

src/mixin/upload.js

import { checkOssClient, initOssClient } from '../utils/upload';
const uploadMixin = {
  methods: {
    // 图片上传至 oss
    async imgUpload(opt) {
      if (opt.file.size > 1024 * 1024) {
        this.$message.warning(`请上传小于1MB的图片`);
        return;
      }
      // 获取文件后缀
      const tmp = opt.file.name.split('.');
      const extname = tmp.pop();
      const extList = ['jpg', 'jpeg', 'png', 'gif'];
      // 校验文件类型
      const isValid = extList.includes(extname);
      if (!isValid) {
        this.$message.warning(`只支持上传 jpg、jpeg、png、gif 格式的图片`);
        return;
      }

      // 检查是否已有 Oss Client
      let client = checkOssClient();
      if (client === null) {
        try {
          const res = await this.$http.post('/ac-admin/get-sts-identity', {});
          if (res.code === 200) {
            const credentials = res.data.stsToken.Credentials;
            client = initOssClient(
              credentials.AccessKeyId,
              credentials.AccessKeySecret,
              credentials.SecurityToken,
              credentials.Expiration
            );
          }
        } catch (error) {
          this.$message.error(`${error}`);
          return;
        }
      }
      // 生产随机文件名
      const randomName = Array(32)
        .fill(null)
        .map(() => Math.round(Math.random() * 16).toString(16))
        .join('');
      const path = `ac/tmp-img/${randomName}.${extname}`;
      let url;
      try {
        // 使用 multipartUpload 正式上传到 oss
        const res = await client.multipartUpload(path, opt.file, {
          progress: async function(p) {
            // progress is generator
            let e = {};
            e.percent = p * 100;
            // 上传进度条,el-upload 组件自带的方法
            opt.onProgress(e);
          },
        });
        // 去除 oss 分片上传后返回所带的查询参数,否则访问会 403
        const ossPath = res.res.requestUrls[0].split('?')[0];
        // 替换协议,统一使用 'https://',否则 Android 无法显示图片
        url = ossPath.replace('http://', 'https://');
      } catch (error) {
        this.$message.error(`${error}`);
      }
      return url;
    }
  }
};

export default uploadMixin;
复制代码

其间还遇到一个坑,就是当图片大小超过 100kb 的时候,阿里云会自动启动【分片上传】模式。

点开响应信息:InvalidPartError: One or more of the specified parts could not be found or the specified entity tag might not have matched the part's entity tag.

经过一番谷歌,主要是 ETag 没有暴露出来,导致读取的是 undefined。

1

解决办法是在阿里云 OSS 控制台的【基础配置】->【跨域访问】配置中,要暴露 Headers 中的 ETag:

1

在解决完一系列坑之后,试上传一张 256kb 的图片,上传结果如下:

1


1

可以看到,阿里云将图片分片成3段来上传(6次请求,包含3次 OPTIONS),通过 uploadId 来区分认证是同一张图。

分片上传后返回的地址会带上 uploadId,要自行去掉,否则浏览会报 403 错误。

因为篇幅关系,这里没有写“超时”和“断点续传”的处理,已经有大神总结了:《字节跳动面试官:请你实现一个大文件上传和断点续传》,有兴趣的读者可以自己尝试。

通过优化,图片上传的速度大大增加,节省了宽带,为公司省了钱(我瞎说的,公司服务器包月的)。

虽然原来的上传代码又不是不能用,但是抱着前端就是要折腾的心态(不然周报憋不出内容),加上原来的业务代码确实有点冗余了,于是就重写了。

这里再次感谢 @ redbuck 同学提供的思路。

纸上得来终觉浅,绝知此事要躬行。

1

我是布拉德特皮,一个只能大器晚成的落魄前端。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK