這篇文章是《不是全部的 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 同窗提供的思路。
紙上得來終覺淺,絕知此事要躬行。
我是布拉德特皮,一個只能大器晚成的落魄前端。
·