这篇文章是《不是全部的 No 'Access-Control-Allow-Origin' header... 都是跨域问题》的后记,由于在评论区中,@ redbuck 同窗指出:“为啥图片不前端直接传 OSS ?后端只提供上传凭证,还省带宽内存呢。” 因而,我一拍大腿:对啊,当初怎么就没想到呢?(主要仍是由于菜)javascript
因而我马上查找相关资料并实践,其间也踩了一些坑,趁着年前项目不紧张(疯狂摸鱼),根据踩坑过程,整理成了以下文章。前端
对象存储服务(Object Storage Service,OSS)是一种海量、安全、低成本、高可靠的云存储服务,适合存听任意类型的文件。容量和处理能力弹性扩展,多种存储类型供选择,全面优化存储成本。vue
这是阿里云的一款产品,而由于其高性价比,因此个人公司使用 OSS 作为前端页面、资源的存储仓库。前端的项目都是直接打包并上传 OSS,实现自动部署,直接访问域名,就能够浏览页面。同理,图片也能够上传到 OSS 并访问浏览。java
阿里云临时安全令牌(Security Token Service,STS)是阿里云提供的一种临时访问权限管理服务。面试
经过STS服务,您所受权的身份主体(RAM用户、用户组或RAM角色)能够获取一个自定义时效和访问权限的临时访问令牌。STS令牌持有者能够经过如下方式访问阿里云资源:typescript
- 经过编程方式访问被受权的阿里云服务API。
- 登陆阿里云控制台操做被受权的云资源。
OSS 能够经过阿里云 STS 进行临时受权访问。经过 STS,您能够为第三方应用或子用户(即用户身份由您本身管理的用户)颁发一个自定义时效和权限的访问凭证。数据库
也就是须要获取 STS ,前端才能够一步到位,而无需像以前那样,先将文件上传到 Node 服务器,再经过服务器上传到 OSS。npm
前端:编程
后端:element-ui
先说后端部分,是由于要先获取 STS 密钥,前端才能上传。这里使用的是 NestJS 框架,这是一款基于 Express 的 Node 框架。虽然是 Nest,但关键代码的逻辑是通用的,不影响 Koa 的用户理解。
> npm i @alicloud/sts-sdk -S
或
> yarn add @alicloud/sts-sdk -S
复制代码
官方例子:
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();
复制代码
假设你项目的目录结构是这样:
项目代码片断:
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',
};
}
...
}
复制代码
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 的请求结果以下:
Ps:
/ac-admin
是项目统一路由前缀,在 main.ts 中配置的
前端只须要拿到上图中的 Credentials
信息就能够直接请求 OSS 了。
> npm i ali-oss -S
或
> yarn add ali-oss -S
复制代码
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;
}
复制代码
这里使用的是 element-ui
的 el-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。
解决办法是在阿里云 OSS 控制台的【基础配置】->【跨域访问】配置中,要暴露 Headers 中的 ETag:
在解决完一系列坑以后,试上传一张 256kb 的图片,上传结果以下:
能够看到,阿里云将图片分片成3段来上传(6次请求,包含3次 OPTIONS
),经过 uploadId
来区分认证是同一张图。
分片上传后返回的地址会带上 uploadId,要自行去掉,不然浏览会报 403 错误。
由于篇幅关系,这里没有写“超时”和“断点续传”的处理,已经有大神总结了:《字节跳动面试官:请你实现一个大文件上传和断点续传》,有兴趣的读者能够本身尝试。
经过优化,图片上传的速度大大增长,节省了宽带,为公司省了钱(我瞎说的,公司服务器包月的)。
虽然原来的上传代码又不是不能用,可是抱着前端就是要折腾的心态(否则周报憋不出内容),加上原来的业务代码确实有点冗余了,因而就重写了。
这里再次感谢 @ redbuck 同窗提供的思路。
纸上得来终觉浅,绝知此事要躬行。
我是布拉德特皮,一个只能大器晚成的落魄前端。
·