你是否经历过服务器因文件上传流量暴增而崩溃的噩梦?当用户同时上传大量高清图片或视频时,传统的中转服务器方案不仅消耗宝贵的带宽资源,还会导致响应延迟飙升。更糟糕的是,这种架构下服务器需要承担文件存储和分发的双重压力,运维成本居高不下。
今天我们要探讨的客户端直传 OSS 方案,能够将文件上传流量直接分流到阿里云对象存储,服务器仅需处理核心业务逻辑。这种架构下,服务器负载降低 70% 以上,用户上传速度提升 3-5 倍,真正实现了双赢。但直传方案也面临着权限控制、安全策略、大文件处理等一系列技术挑战,这正是 STS (安全令牌服务) 方案大显身手的地方。
在深入技术细节前,让我们先理清两种架构的本质区别。传统服务器中转上传的工作流程是这样的:
这种架构存在三个致命缺陷:
而客户端直传 OSS 的架构则优雅得多:
code复制客户端 → [获取临时凭证] → 直传OSS → [回调通知] → 业务服务器
关键优势对比:
| 指标 | 传统方案 | 直传OSS方案 |
|---|---|---|
| 服务器带宽消耗 | 高 (文件大小×2) | 极低 (仅凭证交换) |
| 上传延迟 | 较高 | 低 |
| 服务器存储需求 | 需要临时存储 | 无 |
| 安全性 | 依赖服务器防护 | 临时凭证限时有效 |
| 扩展性 | 受服务器限制 | 近乎无限 |
实际测试数据:在 100 个并发用户上传 10MB 文件的场景下,传统方案服务器带宽峰值达到 1Gbps,而直传方案仅需处理 1Mbps 左右的凭证请求流量。
STS (Security Token Service) 是阿里云提供的临时访问凭证服务,它解决了直传架构中最关键的安全问题。其核心思想是:最小权限+临时有效。
完整的 STS 流程包含以下步骤:
java复制// Spring Boot 中生成 STS 凭证的示例
public StsToken generateSTSToken(String userId, String bucket, String path) {
AssumeRoleRequest request = new AssumeRoleRequest();
request.setRoleArn("acs:ram::123456789012****:role/upload-role");
request.setRoleSessionName(userId);
request.setPolicy(buildPolicy(bucket, path));
request.setDurationSeconds(900); // 15分钟有效期
AssumeRoleResponse response = client.getAcsResponse(request);
return new StsToken(
response.getCredentials().getAccessKeyId(),
response.getCredentials().getAccessKeySecret(),
response.getCredentials().getSecurityToken(),
response.getCredentials().getExpiration()
);
}
Policy 是 STS 方案的安全核心,它定义了临时凭证的精确操作权限。一个完善的 Policy 应该包含以下要素:
json复制{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": ["oss:PutObject"],
"Resource": ["acs:oss:*:*:my-bucket/user-uploads/${userId}/*"],
"Condition": {
"NumericLessThanEquals": {"oss:ContentLength": 104857600},
"StringEquals": {"oss:ContentType": ["image/jpeg","image/png"]}
}
}
]
}
常见权限控制陷阱:
"Action": ["oss:*"] 是极其危险的"Resource": ["acs:oss:*:*:my-bucket/*"] 会导致用户可以覆盖任意文件首先确保项目中包含必要的阿里云 SDK:
xml复制<dependencies>
<!-- 阿里云核心SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>4.6.3</version>
</dependency>
<!-- STS SDK -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-sts</artifactId>
<version>3.1.0</version>
</dependency>
<!-- OSS SDK -->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.15.1</version>
</dependency>
</dependencies>
在阿里云控制台需要完成以下配置:
oss-upload-role)acs:ram::123456789012****:role/oss-upload-role)最佳实践:为不同业务场景创建独立的 RAM 角色,例如
user-avatar-upload和document-upload,每个角色分配不同的 Policy。
典型的 STS 接口实现需要考虑以下要素:
java复制@RestController
@RequestMapping("/api/upload")
public class UploadController {
@GetMapping("/sts-token")
public ResponseEntity<StsToken> getSTSToken(
@RequestParam String bucket,
@RequestParam String path,
@AuthenticationPrincipal User user) {
// 构建自定义Policy
String policy = buildCustomPolicy(bucket, path, user.getId());
// 获取STS凭证
AssumeRoleResponse response = stsService.assumeRole(
"oss-upload-role",
"user-" + user.getId(),
policy,
900
);
// 返回标准化结果
return ResponseEntity.ok(StsToken.fromResponse(response));
}
private String buildCustomPolicy(String bucket, String path, String userId) {
return String.format("""
{
"Version": "1",
"Statement": [
{
"Effect": "Allow",
"Action": ["oss:PutObject"],
"Resource": ["acs:oss:*:*:%s/%s/${userId}/*"],
"Condition": {
"NumericLessThanEquals": {"oss:ContentLength": 104857600},
"StringEquals": {"oss:ContentType": ["image/jpeg","image/png"]}
}
}
]
}
""", bucket, path).replace("${userId}", userId);
}
}
Element Plus 的 el-upload 组件是 Vue 生态中最成熟的上传解决方案之一。针对 OSS 直传场景,我们需要重点关注:
javascript复制<template>
<el-upload
:action="''"
:http-request="handleUpload"
:before-upload="beforeUpload"
:on-progress="onProgress"
:multiple="false"
:limit="5"
:file-list="fileList"
>
<el-button type="primary">点击上传</el-button>
</el-upload>
</template>
<script>
import OSS from 'ali-oss';
export default {
data() {
return {
fileList: [],
stsToken: null
};
},
methods: {
async handleUpload({ file, onProgress }) {
// 获取STS凭证
if (!this.stsToken || this.stsToken.isExpired()) {
this.stsToken = await this.fetchSTSToken();
}
// 初始化OSS客户端
const client = new OSS({
region: 'oss-cn-hangzhou',
accessKeyId: this.stsToken.accessKeyId,
accessKeySecret: this.stsToken.accessKeySecret,
stsToken: this.stsToken.securityToken,
bucket: 'my-bucket',
refreshSTSToken: async () => {
const newToken = await this.fetchSTSToken();
return {
accessKeyId: newToken.accessKeyId,
accessKeySecret: newToken.accessKeySecret,
stsToken: newToken.securityToken
};
},
refreshSTSTokenInterval: 300000 // 5分钟刷新
});
// 执行分片上传
try {
const result = await client.multipartUpload(
`user-uploads/${Date.now()}_${file.name}`,
file,
{
progress: p => {
onProgress({ percent: p * 100 });
},
partSize: 5 * 1024 * 1024, // 5MB分片
parallel: 4 // 并发数
}
);
this.$message.success('上传成功');
return result;
} catch (err) {
this.$message.error('上传失败');
throw err;
}
},
async fetchSTSToken() {
const { data } = await axios.get('/api/upload/sts-token', {
params: {
bucket: 'my-bucket',
path: 'user-uploads'
}
});
return data;
},
beforeUpload(file) {
const isImage = ['image/jpeg', 'image/png'].includes(file.type);
const isLt100M = file.size / 1024 / 1024 < 100;
if (!isImage) {
this.$message.error('只能上传JPG/PNG图片!');
}
if (!isLt100M) {
this.$message.error('文件大小不能超过100MB!');
}
return isImage && isLt100M;
},
onProgress(event, file) {
console.log(`当前进度: ${Math.floor(event.percent)}%`);
}
}
};
</script>
通过记录已上传的分片信息,可以在网络中断后恢复上传:
javascript复制const checkpointFile = new Map(); // 保存checkpoint信息
const uploadWithResume = async (client, file) => {
const checkpointKey = `upload_${file.name}_${file.size}`;
// 尝试从本地恢复checkpoint
const checkpoint = checkpointFile.get(checkpointKey);
const result = await client.multipartUpload(
`uploads/${file.name}`,
file,
{
checkpoint,
async progress(p, checkpoint) {
// 保存checkpoint以便恢复
checkpointFile.set(checkpointKey, checkpoint);
}
}
);
// 上传完成后清除checkpoint
checkpointFile.delete(checkpointKey);
return result;
};
通过调整 parallel 参数优化上传性能:
javascript复制// 根据网络环境动态调整并发数
const getOptimalParallel = () => {
const connection = navigator.connection;
if (connection) {
switch (connection.effectiveType) {
case '4g': return 6;
case '3g': return 3;
default: return 2;
}
}
return 4;
};
client.multipartUpload(name, file, {
parallel: getOptimalParallel()
});
OSS 直传最常见的跨域错误通常表现为:
code复制Access to XMLHttpRequest at 'https://bucket.oss-cn-hangzhou.aliyuncs.com/'
from origin 'http://localhost:8080' has been blocked by CORS policy
正确配置 OSS CORS 规则:
json复制[
{
"AllowedOrigin": ["https://yourdomain.com", "http://localhost:*"],
"AllowedMethod": ["PUT", "POST"],
"AllowedHeader": ["*"],
"ExposeHeader": ["ETag", "x-oss-request-id"],
"MaxAgeSeconds": 3600
}
]
注意:在生产环境应该严格限制 AllowedOrigin,避免使用通配符 *
客户端需要处理各种网络异常情况:
javascript复制// 封装带有重试机制的上传方法
const uploadWithRetry = async (client, file, maxRetries = 3) => {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
return await client.multipartUpload(file.name, file);
} catch (err) {
lastError = err;
await new Promise(resolve => setTimeout(resolve, 1000 * (i + 1)));
}
}
throw lastError;
};
IP 限制:在 Policy 中添加源 IP 限制
json复制"Condition": {
"IpAddress": {"acs:SourceIp": ["192.168.0.0/24"]}
}
临时凭证刷新:设置合理的 refreshSTSTokenInterval
上传回调验证:配置 OSS 回调服务器验证
java复制// Spring Boot 中验证OSS回调签名
public boolean verifyOSSCallback(HttpServletRequest request) {
String authorization = request.getHeader("Authorization");
String pubKeyUrl = request.getHeader("x-oss-pub-key-url");
// 获取OSS公钥
String pubKey = fetchPublicKey(pubKeyUrl);
// 构建待签名字符串
String path = request.getRequestURI();
String query = request.getQueryString();
String body = IOUtils.toString(request.getInputStream());
String authStr = path + (query != null ? "?" + query : "") + "\n" + body;
// 验证签名
return verifyRSASignature(authStr, authorization, pubKey);
}
文件预处理:在上传前压缩图片
javascript复制const compressImage = async (file, quality = 0.8) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.src = event.target.result;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
canvas.toBlob(resolve, 'image/jpeg', quality);
};
};
reader.readAsDataURL(file);
});
};
并发上传队列:管理多个文件上传顺序
STS 缓存:适当缓存 STS 凭证减少 API 调用
java复制@Cacheable(value = "stsTokens", key = "#userId")
public StsToken getCachedSTSToken(String userId, String bucket, String path) {
// ...正常获取STS逻辑
}
动态 Policy 生成:根据用户权限生成不同策略
监控与告警:跟踪 STS 使用情况
分片大小调整:根据网络环境优化
javascript复制const partSize = networkIsSlow ? 2 * 1024 * 1024 : 10 * 1024 * 1024;
区域选择:使用离用户最近的 OSS 区域
CDN 加速:为 OSS 绑定自定义域名并启用 CDN
利用 OSS 的图片处理功能,在上传时生成缩略图:
javascript复制// 上传后获取处理后的图片URL
const imageURL = client.signatureUrl('example.jpg', {
process: 'image/resize,w_300,h_200/quality,q_80'
});
对于视频上传场景,可以考虑阿里云视频点播服务:
对于更高安全要求的场景,可以采用服务端签名模式:
java复制// 生成签名示例
public String generatePostPolicy(String bucket, String key, long expireTime) {
long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
Date expiration = new Date(expireEndTime);
PolicyConditions policyConds = new PolicyConditions();
policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 104857600);
policyConds.addConditionItem(PolicyConditions.COND_KEY, key);
String postPolicy = client.generatePostPolicy(expiration, policyConds);
return client.calculatePostSignature(postPolicy);
}
在实际项目中,我们团队发现最棘手的不是技术实现,而是各种边界条件的处理。比如用户在上传过程中关闭浏览器,或者移动端应用切换到后台,这些场景都需要特殊的恢复机制。一个实用的技巧是在 localStorage 中保存上传状态,当用户返回时可以提示恢复上传。