概述
交友项目的头像要存在哪里?本地磁盘能跑通但没法扩容,自建分布式文件系统又要运维。这一篇用最少的代码把阿里云 OSS 的 Java SDK 调通:在 mf-app-server 的单元测试里上传一张 1.jpg,落盘路径按 yyyy/MM/dd/uuid.jpg 分层,最后拿到一个能在浏览器里打开的图片地址。封装成可复用组件是下一篇的事,本篇只管"先跑通"。
纲要
- 三种图片存储方案的取舍,以及为什么选第三方对象存储
- 接入前的准备清单:开通服务 → 创建 Bucket → 选读写权限 → 申请 RAM 子账号 AK
- 三个最容易混淆的概念:
Endpoint、Bucket、Object的关系 - 阿里云 OSS Java SDK 六步调用
- 引入
aliyun-sdk-oss依赖(本项目实际坐标3.10.2) - 用
OSSClientBuilder初始化OSS客户端 - 用
PutObjectRequest/putObject上传文件 - 拿到访问地址:公共读直拼 URL vs 私有读预签名 URL
ossClient.shutdown()释放连接
- 引入
- 三种上传数据来源:本地文件、
InputStream、字节数组 - 完整可运行示例(
OssTest)与两种验证方式(浏览器 / 控制台) - 关键参数对照表、错误码排查表
- 安全与工程实践:AK 外置、大小类型校验、防盗链、404 排查
- 常见误解澄清
图片存哪里:三种方案对比
先把选型说清楚,不然会纠结"为什么不用 SpringMVC 的文件上传"。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 存服务本地硬盘(SpringMVC 文件上传) | 开发最简单、几乎零成本 | 扩容困难,多实例部署后文件不共享 | 单机 demo、临时验证 |
| 分布式文件系统(FastDFS / MinIO) | 容易水平扩容,自主可控 | 要自己搭、自己运维,开发复杂度稍高 | 有运维能力、数据不出内网 |
| 第三方对象存储(阿里云 OSS) | 开发简单、功能强、免维护、按量付费 | 付费;下行流量单独计费 | 大多数业务系统,尤其是快速上线 |
交友项目选的是第三条路:OSS 负责存,业务代码只负责往上传。注意计费口径------上行(上传)流量免费,下行(下载/访问)流量收费,所以头像这种小图随便传,但别把 OSS 当 CDN 刷大文件。
接入前的准备清单
按顺序做完这四步,代码才跑得通。任何一步漏掉,后面必然报错。
| 步骤 | 做什么 | 关键点 |
|---|---|---|
| 开通服务 | 在阿里云控制台开通 OSS | 上行免费,下行按流量计费,可先买流量包 |
| 创建 Bucket | 控制台新建存储空间 | Bucket 名全局唯一,不能与他人重名 |
| 设置读写权限 | 选公共读或私有 | 头像这类公开资源用公共读,私密资源用私有 + 签名 URL |
| 申请 AK | 创建 RAM 子账号并授权 | 用子账号、最小权限、绝不提交到 Git 仓库 |
Endpoint 与 Bucket 是什么关系
这是新手最容易搞混的一对概念,先钉死:
- Endpoint :OSS 服务的接入地址,跟地域绑定 。北京地域是
oss-cn-beijing.aliyuncs.com,杭州是oss-cn-hangzhou.aliyuncs.com,深圳是oss-cn-shenzhen.aliyuncs.com。写错地域会直接连不上或报NoSuchBucket。 - Bucket:存储空间,相当于一个"桶",文件都放在桶里。桶名全局唯一。
- Object :桶里的每一个文件,通过
objectName(对象名,也就是"桶内的路径")标识。
三者的关系可以这么记:Endpoint 是门牌号,Bucket 是大楼,Object 是楼里的房间号。一个 Endpoint 下可以有多个 Bucket,一个 Bucket 下可以有无数 Object。
#mermaid-svg-p2sQ5uipey2e344M{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-p2sQ5uipey2e344M .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-p2sQ5uipey2e344M .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-p2sQ5uipey2e344M .error-icon{fill:#552222;}#mermaid-svg-p2sQ5uipey2e344M .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-p2sQ5uipey2e344M .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-p2sQ5uipey2e344M .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-p2sQ5uipey2e344M .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-p2sQ5uipey2e344M .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-p2sQ5uipey2e344M .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-p2sQ5uipey2e344M .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-p2sQ5uipey2e344M .marker{fill:#333333;stroke:#333333;}#mermaid-svg-p2sQ5uipey2e344M .marker.cross{stroke:#333333;}#mermaid-svg-p2sQ5uipey2e344M svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-p2sQ5uipey2e344M p{margin:0;}#mermaid-svg-p2sQ5uipey2e344M .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-p2sQ5uipey2e344M .cluster-label text{fill:#333;}#mermaid-svg-p2sQ5uipey2e344M .cluster-label span{color:#333;}#mermaid-svg-p2sQ5uipey2e344M .cluster-label span p{background-color:transparent;}#mermaid-svg-p2sQ5uipey2e344M .label text,#mermaid-svg-p2sQ5uipey2e344M span{fill:#333;color:#333;}#mermaid-svg-p2sQ5uipey2e344M .node rect,#mermaid-svg-p2sQ5uipey2e344M .node circle,#mermaid-svg-p2sQ5uipey2e344M .node ellipse,#mermaid-svg-p2sQ5uipey2e344M .node polygon,#mermaid-svg-p2sQ5uipey2e344M .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-p2sQ5uipey2e344M .rough-node .label text,#mermaid-svg-p2sQ5uipey2e344M .node .label text,#mermaid-svg-p2sQ5uipey2e344M .image-shape .label,#mermaid-svg-p2sQ5uipey2e344M .icon-shape .label{text-anchor:middle;}#mermaid-svg-p2sQ5uipey2e344M .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-p2sQ5uipey2e344M .rough-node .label,#mermaid-svg-p2sQ5uipey2e344M .node .label,#mermaid-svg-p2sQ5uipey2e344M .image-shape .label,#mermaid-svg-p2sQ5uipey2e344M .icon-shape .label{text-align:center;}#mermaid-svg-p2sQ5uipey2e344M .node.clickable{cursor:pointer;}#mermaid-svg-p2sQ5uipey2e344M .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-p2sQ5uipey2e344M .arrowheadPath{fill:#333333;}#mermaid-svg-p2sQ5uipey2e344M .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-p2sQ5uipey2e344M .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-p2sQ5uipey2e344M .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-p2sQ5uipey2e344M .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-p2sQ5uipey2e344M .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-p2sQ5uipey2e344M .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-p2sQ5uipey2e344M .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-p2sQ5uipey2e344M .cluster text{fill:#333;}#mermaid-svg-p2sQ5uipey2e344M .cluster span{color:#333;}#mermaid-svg-p2sQ5uipey2e344M div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-p2sQ5uipey2e344M .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-p2sQ5uipey2e344M rect.text{fill:none;stroke-width:0;}#mermaid-svg-p2sQ5uipey2e344M .icon-shape,#mermaid-svg-p2sQ5uipey2e344M .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-p2sQ5uipey2e344M .icon-shape p,#mermaid-svg-p2sQ5uipey2e344M .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-p2sQ5uipey2e344M .icon-shape .label rect,#mermaid-svg-p2sQ5uipey2e344M .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-p2sQ5uipey2e344M .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-p2sQ5uipey2e344M .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-p2sQ5uipey2e344M :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 阿里云 OSS
应用端
putObject(bucket, objectName, stream)
OSSClient
endpoint + AK
Endpoint
oss-cn-beijing.aliyuncs.com
Bucket: mf001
Bucket: mf-avatar
Object
2021/04/19/a3824a45....jpg
Object
2021/04/19/b7f1c2d3....jpg
Bucket 读写权限怎么选
创建 Bucket 时会让你选权限,主要有三种,实测里常用的就两种:
| 权限 | 含义 | 读取方式 | 典型用途 |
|---|---|---|---|
| 公共读 | 任何人可读,只有所有者能写 | 直接拼 URL 就能访问 | 头像、商品图、公开素材 |
| 私有 | 只有签名过的请求能读 | 必须用预签名 URL 或带签名头 | 用户私密照片、证件、订单附件 |
| 公共读写 | 任何人都能读写 | 直接访问 | 绝不要用,等于把桶交给全网 |
交友项目的头像是要给别人看的,所以 mf001 这个 Bucket 用的是公共读。私有桶不是不能上传,只是访问方式不同,后面会给预签名 URL 的代码。
AK 为什么必须用 RAM 子账号
代码里要填 accessKeyId 和 accessKeySecret。用主账号的 AK 能过,但风险极高------主账号 AK 拥有所有云产品的全部 API 权限,一旦泄漏,攻击者可以删库、开高配实例、盗刷流量。
正确做法是在 RAM 控制台(https://ram.console.aliyun.com)建一个子用户,只授予目标 Bucket 的读写权限,然后拿这个子账号的 AK 来用。工程里的两个值长这样(示例值,不要照抄):
text
accessKeyId = 你的AccessKeyId
accessKeySecret = 你的AccessKeySecret
AK Secret 只在创建时明文展示一次,之后想看只能重置。所以别截图外发,别提交仓库------后面会讲怎么外置。
工程结构
本篇涉及的模块是 mf-app-server(业务服务,测试类写在这里)。OSS SDK 依赖放在 mf-autoconfig(自动装配模块)里统一管理。
tree
mf-test
├── pom.xml # 父 pom,统一版本管理
├── mf-autoconfig # 自动装配模块:第三方服务封装
│ ├── pom.xml # ★ 阿里云 OSS SDK 依赖在这里
│ └── src/main/java/com/mf/autoconfig
│ ├── properties/OssProperties.java # OSS 配置属性类(下一篇用)
│ ├── template/OssTemplate.java # OSS 上传模板(下一篇用)
│ └── mfAutoConfiguration.java # 自动装配入口
├── mf-app-server # 业务服务
│ ├── pom.xml # 依赖 mf-autoconfig
│ └── src
│ ├── main/resources/application.yml # ★ mf.oss 配置
│ └── test/java/com/wb/test
│ └── OssTest.java # ★ 本篇的入门案例测试类
├── mf-model # 实体 / VO
└── mf-dubbo # Dubbo 服务
阿里云 SDK 六步调用
阿里云官方文档给了现成的 Java 示例,左侧导航是"开发指南 → SDK 示例 → Java → 上传文件",里面按上传方式分了简单上传、分片上传、追加上传等好几种。入门先看简单上传 ,再选上传字节数组/输入流的示例最省事。下面按六步拆开讲。
引入 SDK 依赖
本项目用的是官方 aliyun-sdk-oss。实读 mf-autoconfig/pom.xml 的坐标如下------版本是 3.10.2:
xml
<!--阿里云oss存储API-->
<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.10.2</version>
</dependency>
放在父 pom 的 <dependencyManagement> 里统一管版本、在子模块里只写 groupId + artifactId 也可以,项目里两种写法都能跑。如果你的 JDK 是 9 及以上,还得额外补上 JAXB 相关依赖(javax.xml.bind:jaxb-api、org.glassfish.jaxb:jaxb-runtime、javax.activation:activation),因为 JDK 11 起模块里不再内置 JAXB,OSS SDK 内部会用到。本项目跑在 JDK 8 上,不用管这事。
初始化 OSS 客户端
OSS 是文件上传的核心对象,由 OSSClientBuilder 构建。老版本 SDK 还能写 new OSSClient(endpoint, akId, akSecret),但那个构造方法已经被标记为过时,新代码统一用 OSSClientBuilder:
java
package com.mfeima.test;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
public class OssClientDemo {
public static void main(String[] args) {
// Bucket 所在地域对应的 Endpoint,北京地域
String endpoint = "oss-cn-beijing.aliyuncs.com";
String accessKeyId = "你的AccessKeyId";
String accessKeySecret = "你的AccessKeySecret";
// 创建 OSSClient 实例
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
// ... 后续上传、下载等操作
// 用完必须关闭,否则连接池里的连接会被泄漏
ossClient.shutdown();
}
}
endpoint 可以不写 https:// 前缀,SDK 默认走 HTTPS;写成 https://oss-cn-beijing.aliyuncs.com 也认。但地域段(cn-beijing)必须和 Bucket 实际所在地域一致。
上传文件
上传用 putObject,三个参数:Bucket 名、objectName、输入流。
objectName 就是对象在 Bucket 里的路径名 ,不是本地文件路径,也不能包含 Bucket 名字 。比如 Bucket 是 mf001,objectName 写 2021/04/19/a3824a45-70e3-4655-8106-a1e1be009a5e.jpg,那么访问地址就是:
text
https://mf001.oss-cn-beijing.aliyuncs.com/2021/04/19/a3824a45-70e3-4655-8106-a1e1be009a5e.jpg
objectName 里的 / 不是真实目录,OSS 是扁平结构,斜杠只是对象名的一部分------但控制台会按斜杠自动折叠成文件夹给你看,所以按日期分层在管理上非常实用。
三种写法对应三种数据来源:
java
// 写法一:上传本地文件(构造 FileInputStream)
ossClient.putObject("mf001", objectName, new FileInputStream(new File(localPath)));
// 写法二:上传 InputStream(业务里最常见,比如用户上传的头像流)
ossClient.putObject("mf001", objectName, inputStream);
// 写法三:上传字节数组
ossClient.putObject("mf001", objectName, new ByteArrayInputStream(bytes));
想要更完整的参数控制(比如指定 Content-Type、设置存储类型 STANDARD / IA / ARCHIVE),用 PutObjectRequest:
java
package com.mfeima.test;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.ObjectMetadata;
import com.aliyun.oss.model.PutObjectRequest;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
public class PutObjectDemo {
public static void main(String[] args) throws Exception {
String endpoint = "oss-cn-beijing.aliyuncs.com";
String accessKeyId = "你的AccessKeyId";
String accessKeySecret = "你的AccessKeySecret";
String bucketName = "mf001";
String objectName = "2021/04/19/demo.jpg";
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
InputStream is = new FileInputStream(new File("/Users/lemon/Desktop/1.jpg"));
// 元信息:指定 Content-Type,浏览器就能直接预览而不是下载
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType("image/jpeg");
PutObjectRequest request = new PutObjectRequest(bucketName, objectName, is);
request.setMetadata(metadata);
ossClient.putObject(request);
ossClient.shutdown();
}
}
拿到可访问的 URL:公共读 vs 私有读
上传成功不等于能访问,能不能打开取决于 Bucket 权限 + URL 拼法。
公共读 Bucket 直接把域名和 objectName 拼起来就行。域名格式固定是 https://{bucketName}.{endpoint}/{objectName}:
java
String url = "https://" + bucketName + "." + endpoint + "/" + objectName;
// 结果:https://mf001.oss-cn-beijing.aliyuncs.com/2021/04/19/demo.jpg
System.out.println(url);
私有 Bucket 直链会返回 AccessDenied,必须用预签名 URL------SDK 用你的 AK 在本地算一个带签名的临时地址,签名信息放在 query string 里,过期即失效:
java
package com.mfeima.test;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import java.net.URL;
import java.util.Date;
public class PresignedUrlDemo {
public static void main(String[] args) {
String endpoint = "oss-cn-beijing.aliyuncs.com";
String accessKeyId = "你的AccessKeyId";
String accessKeySecret = "你的AccessKeySecret";
String bucketName = "mf001";
String objectName = "2021/04/19/demo.jpg";
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
// 过期时间:当前时间 + 1 小时
Date expiration = new Date(System.currentTimeMillis() + 3600 * 1000L);
URL url = ossClient.generatePresignedUrl(bucketName, objectName, expiration);
// 形如 https://mf001.oss-cn-beijing.aliyuncs.com/2021/04/19/demo.jpg?Expires=1618...&OSSAccessKeyId=...&Signature=...
System.out.println(url);
// 必须在 shutdown 之前生成,客户端关了就算不出签名了
ossClient.shutdown();
}
}
两种方式怎么选:
| 场景 | 选哪种 | 原因 |
|---|---|---|
| 头像、封面、公开素材 | 公共读 + 直链 | 简单、无过期问题、可被 CDN 缓存 |
| 用户私密照片、证件、订单附件 | 私有 + 预签名 URL | 链接有过期时间,泄漏了也只在有效期内可用 |
| 需要严格控制下载行为的资源 | 私有 + 后端代理下载 | 服务端校验权限后再转发,最安全但最耗带宽 |
关闭客户端
ossClient.shutdown() 必须调。OSS 内部维护的是 HTTP 连接池,不关的话连接不会归还,进程长时间运行会累积到连接耗尽,表现为上传越来越慢直至超时。用完即关 是最省心的用法;如果单次请求量大,可以在业务里复用一个长生命周期的客户端,但那时要交给 Spring 容器管理生命周期,而不是每次 new 一个又不关。
完整可运行示例
把前面的步骤拼起来,就是讲义里的入门案例:把 03-资料/1.jpg 上传到 mf001,落盘路径按 yyyy/MM/dd 分层,文件名用 UUID 保证唯一。
关键的那行路径拼接值得单独看:
java
String filename = new SimpleDateFormat("yyyy/MM/dd").format(new Date())
+ "/" + UUID.randomUUID().toString() + path.substring(path.lastIndexOf("."));
为什么不用原文件名 1.jpg?因为真实项目里所有人都在往同一个 Bucket 传文件 ,你的 1.jpg 和别人的 1.jpg 会撞名,后传的会把先传的覆盖掉。所以文件名必须唯一,用 UUID 最省事,path.substring(path.lastIndexOf(".")) 只是为了把 .jpg 后缀原样保留下来。
完整测试类如下,放在 mf-app-server/src/test/java/com/mfeima/test/OssTest.java:
java
package com.mfeima.test;
import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import org.junit.Test;
import java.io.File;
import java.io.FileInputStream;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.UUID;
public class OssTest {
/**
* 案例:
* 将资料中的 1.jpg 上传到阿里云 OSS
* 存放的位置 /yyyy/MM/dd/xxxx.jpg
*/
@Test
public void testOss() throws Exception {
// 1、配置本地图片路径
String path = "/Users/lemon/Desktop/课程资源/02-完善用户信息/03-资料/1.jpg";
// 2、构造 FileInputStream
FileInputStream inputStream = new FileInputStream(new File(path));
// 3、拼写对象路径:yyyy/MM/dd + UUID + 原后缀
String filename = new SimpleDateFormat("yyyy/MM/dd").format(new Date())
+ "/" + UUID.randomUUID().toString() + path.substring(path.lastIndexOf("."));
// Bucket 所在地域对应的 Endpoint(北京)
String endpoint = "oss-cn-beijing.aliyuncs.com";
// 主账号 AK 权限过大,生产环境请换成 RAM 子账号的 AK
String accessKeyId = "你的AccessKeyId";
String accessKeySecret = "你的AccessKeySecret";
// 创建 OSSClient 实例
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
// 上传:Bucket 名称 + Object 完整路径(不含 Bucket 名)+ 输入流
ossClient.putObject("mf001", filename, inputStream);
// 关闭 OSSClient
ossClient.shutdown();
// 拼出访问地址
String url = "https://mf001.oss-cn-beijing.aliyuncs.com/" + filename;
System.out.println(url);
}
}
跑 testOss(),控制台会打印形如下面的地址,中间那段日期和 UUID 每次不同:
text
https://mf001.oss-cn-beijing.aliyuncs.com/2021/04/19/a3824a45-70e3-4655-8106-a1e1be009a5e.jpg
怎么验证上传成功
两种方式,缺一不可,因为"上传返回没抛异常"和"文件真的能访问"是两件事:
- 浏览器直接打开控制台打印的那条 URL。图片能正常显示(不是下载对话框、不是 XML 报错页)就算通过。
- 登录 OSS 控制台 看对象列表:进入
https://oss.console.aliyun.com,左侧选 Bucket 列表 → mf001 → 文件管理 ,能看到自动生成的2021/04/19/目录,点进去就是刚上传的图片。控制台的"文件管理"页里每个对象右侧还会给出文件 URL,可以拿来和代码里拼的地址对照。
浏览器 Bucket: mf001 Endpoint OSS 客户端 OssTest(JUnit) 浏览器 Bucket: mf001 Endpoint OSS 客户端 OssTest(JUnit) #mermaid-svg-YRFYmFrSEQ7wKCbo{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-YRFYmFrSEQ7wKCbo .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YRFYmFrSEQ7wKCbo .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YRFYmFrSEQ7wKCbo .error-icon{fill:#552222;}#mermaid-svg-YRFYmFrSEQ7wKCbo .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YRFYmFrSEQ7wKCbo .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YRFYmFrSEQ7wKCbo .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YRFYmFrSEQ7wKCbo .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YRFYmFrSEQ7wKCbo .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YRFYmFrSEQ7wKCbo .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YRFYmFrSEQ7wKCbo .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YRFYmFrSEQ7wKCbo .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YRFYmFrSEQ7wKCbo .marker.cross{stroke:#333333;}#mermaid-svg-YRFYmFrSEQ7wKCbo svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YRFYmFrSEQ7wKCbo p{margin:0;}#mermaid-svg-YRFYmFrSEQ7wKCbo .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YRFYmFrSEQ7wKCbo text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-YRFYmFrSEQ7wKCbo .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-YRFYmFrSEQ7wKCbo .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-YRFYmFrSEQ7wKCbo .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-YRFYmFrSEQ7wKCbo .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-YRFYmFrSEQ7wKCbo #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-YRFYmFrSEQ7wKCbo .sequenceNumber{fill:white;}#mermaid-svg-YRFYmFrSEQ7wKCbo #sequencenumber{fill:#333;}#mermaid-svg-YRFYmFrSEQ7wKCbo #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-YRFYmFrSEQ7wKCbo .messageText{fill:#333;stroke:none;}#mermaid-svg-YRFYmFrSEQ7wKCbo .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YRFYmFrSEQ7wKCbo .labelText,#mermaid-svg-YRFYmFrSEQ7wKCbo .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-YRFYmFrSEQ7wKCbo .loopText,#mermaid-svg-YRFYmFrSEQ7wKCbo .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-YRFYmFrSEQ7wKCbo .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-YRFYmFrSEQ7wKCbo .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-YRFYmFrSEQ7wKCbo .noteText,#mermaid-svg-YRFYmFrSEQ7wKCbo .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-YRFYmFrSEQ7wKCbo .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YRFYmFrSEQ7wKCbo .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YRFYmFrSEQ7wKCbo .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-YRFYmFrSEQ7wKCbo .actorPopupMenu{position:absolute;}#mermaid-svg-YRFYmFrSEQ7wKCbo .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-YRFYmFrSEQ7wKCbo .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-YRFYmFrSEQ7wKCbo .actor-man circle,#mermaid-svg-YRFYmFrSEQ7wKCbo line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-YRFYmFrSEQ7wKCbo :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. new OSSClientBuilder().build(endpoint, AK, SK) 2. 拼 objectName = yyyy/MM/dd/uuid.jpg 3. putObject(bucket, objectName, inputStream) HTTP PUT(带签名头) 写入 Object 200 OK 返回(无异常即成功) 4. shutdown() 5. 拼 url = https://bucket.endpoint/objectName 打开 URL GET /objectName 返回图片(公共读)
关键参数与概念
| 参数 / 概念 | 含义 | 注意点 |
|---|---|---|
endpoint |
OSS 服务接入地址 | 跟地域绑定,北京 oss-cn-beijing.aliyuncs.com;写错地域直接连不上 |
bucketName |
存储空间名 | 全局唯一,创建后不可改;不能含大写和特殊字符 |
objectName |
对象在桶内的路径名 | 不能包含 Bucket 名 ;建议 UUID 或 yyyy/MM/dd 分层,避免中文和特殊字符 |
accessKeyId |
访问身份标识 | 用 RAM 子账号,最小权限 |
accessKeySecret |
访问密钥 | 只在创建时展示一次;绝不提交仓库 |
| 存储类型 | STANDARD / IA / ARCHIVE 等 |
标准型适合频繁访问;低频/归档更便宜但取回有延迟和费用 |
| 权限 | 公共读 / 私有 / 公共读写 | 公开资源选公共读,私密资源选私有;公共读写禁用 |
| 上行 / 下行流量 | 上传免流量,下载计费 | 下行流量要买流量包 |
错误码排查表
OSS 抛的异常基本都是 OSSException(服务端返回的错误)或 ClientException(本地/网络问题),消息里有明确的错误码。对照下面这张表排查,比盲目改代码快得多:
| 错误码 | 含义 | 排查方向 |
|---|---|---|
AccessDenied |
没有权限 | AK/SK 是否写错;RAM 子账号是否被授予该 Bucket 的读写策略;Bucket 权限是否与操作匹配 |
NoSuchBucket |
Bucket 不存在 | 桶名拼写错误;Bucket 所在地域与 endpoint 不一致(跨地域访问报这个或连不通) |
InvalidAccessKeyId |
AK 无效 | AK 写错、被禁用、或已被删除;确认复制时没有多余空格 |
RequestTimeTooSkewed |
请求时间偏差过大 | 本机时间与服务器时间差超过 15 分钟 。这是个很容易被忽略的坑:虚拟机休眠、Docker 容器时间漂移都会触发,ntpdate 校准一下就好 |
ConnectionTimeout / UnknownHostException |
连不上 | endpoint 域名写错;本机网络或代理问题;防火墙拦截 |
SignatureDoesNotMatch |
签名不匹配 | 预签名 URL 生成后被人改过参数;用的 AK 和生成签名时的 AK 不是同一个 SECRET |
EntityTooLarge |
文件超限 | 单次简单上传上限 5 GB,超了改用分片上传 |
| 预签名 URL 打开报签名错误 | 链接已过期 | 检查生成时的 expiration,过期就重新生成 |
安全与工程实践
这套做法和前面的《阿里云短信:入门案例》是同一套方法论------第三方服务的接入姿势应当保持一致,所以这里把结论系统地说一遍。
AK 外置,不写死在代码里。 工程里 mf-app-server/src/main/resources/application.yml 已经有对应配置:
yaml
mf:
oss:
accessKey: 你的AccessKeyId
secret: 你的AccessKeySecret
endpoint: oss-cn-beijing.aliyuncs.com
bucketName: mf001
url: https://mf001.oss-cn-beijing.aliyuncs.com/
即使外置到 yml,也不要把生产环境的 yml 提交到仓库 。更稳的做法是走环境变量或配置中心(Nacos 配置管理),并在 .gitignore 里挡住本地配置文件。
最小权限。 RAM 子账号只授予 oss:PutObject、oss:GetObject 这类该业务真正需要的动作,且资源范围限定到 acs:oss:*:*:mf001/*,不要给 *。
上传大小与类型在服务端做校验,别只靠前端。 前端 accept="image/*" 和 JS 大小判断随手就能绕过------直接拿 Postman 打你的上传接口就行。Java 侧必须再校验一遍:
java
package com.mfeima.test;
import org.springframework.web.multipart.MultipartFile;
import java.util.Arrays;
import java.util.List;
public class UploadValidator {
private static final long MAX_SIZE = 5 * 1024 * 1024L; // 5 MB
private static final List<String> ALLOWED_EXT = Arrays.asList(".jpg", ".jpeg", ".png");
public static void validate(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("上传文件为空");
}
if (file.getSize() > MAX_SIZE) {
throw new IllegalArgumentException("图片不能超过 5 MB");
}
String name = file.getOriginalFilename();
String ext = name == null ? "" : name.substring(name.lastIndexOf(".")).toLowerCase();
if (!ALLOWED_EXT.contains(ext)) {
throw new IllegalArgumentException("只支持 jpg / jpeg / png 格式");
}
}
}
更严格的场景还要读文件头(magic number)判断真实类型,防止改后缀名绕过。交友项目下一步的人脸识别校验,本质上也是"上传后不信任输入"的同一思路。
私密资源用私有读 + 签名 URL。 用户私密照片别放公共读桶里------Bucket 一旦是公共读,任何人都能遍历访问(只要猜到 objectName)。私有桶 + 短过期时间的预签名 URL 才是正确姿势。
防盗链。 公共读的 Bucket 建议在控制台配 Referer 白名单,只允许自己的域名引用,防止被别人盗图刷下行流量。这一步在"数据管理 → 防盗链"里配。
清理脏数据。 头像这类文件上传后立刻可用,但删除用户时 OSS 里的对象不会自动删。要么用生命周期规则,要么业务删除时同步调 deleteObject。
上传成功但访问 404 / 打不开
这是最高频的一类问题,按下面顺序排查,基本能定位:
| 现象 | 常见原因 | 排查 |
|---|---|---|
| 返回 XML 错误页 | objectName 拼错,或 URL 里多写了/少写了斜杠 |
把代码里打印的 objectName 和控制台"文件管理"里的路径逐字符对比 |
返回 AccessDenied |
Bucket 是私有,却用了直链 | 改成预签名 URL,或者把 Bucket 权限调成公共读 |
返回 NoSuchBucket |
桶名写错,或域名里的地域段与桶实际地域不符 | 对照控制台概览页的"外网访问 Endpoint",直接复制 |
| 浏览器变成下载而不是预览 | 上传时没设 Content-Type |
用 ObjectMetadata.setContentType("image/jpeg") |
| 上传返回成功但桶里找不到 | Bucket 名写错,传到了别的桶 | 检查 putObject 的第一个参数 |
常见误解澄清
- "Endpoint 是固定的"------不。不同地域的 Endpoint 不同,跨地域访问要么报错要么走公网绕路。控制台概览页能直接复制当前 Bucket 的"外网访问 Endpoint"。
- "Bucket 名随便起" ------不。Bucket 名在整个阿里云 OSS 范围内全局唯一,别人占了你就用不了;且创建后不可改。
- "上传成功就一定能访问"------不。上传只代表写成功,能不能读取决于 Bucket 权限和 URL 拼法。
- "前端校验就够了"------不。前端校验只提升体验,服务端校验才是安全边界。
- "OSS 客户端不用关" ------不。不调
shutdown()会泄漏 HTTP 连接,跑久了把连接池耗干。 - "字节数组和输入流随便选" ------看数据来源。字节数组适合小文件、已在内存里的数据;输入流适合大文件,不占额外内存。业务里上传头像最常见的是
MultipartFile.getInputStream()。 - "下行流量也免费"------不。上行(上传)免费,下行(下载/访问)收费,这是 OSS 计费的关键区别。
API 速览
| API / 类 | 作用 |
|---|---|
OSSClientBuilder().build(endpoint, akId, akSecret) |
创建 OSS 客户端实例 |
ossClient.putObject(bucketName, objectName, InputStream) |
简单上传,最常用 |
ossClient.putObject(PutObjectRequest) |
带元信息的上传(Content-Type、存储类型等) |
ossClient.generatePresignedUrl(bucket, object, expiration) |
生成私有桶的临时访问 URL |
ObjectMetadata.setContentType(...) |
设置对象的内容类型,影响浏览器预览行为 |
ossClient.deleteObject(bucket, object) |
删除对象 |
ossClient.shutdown() |
关闭客户端,释放连接 |
官方文档
总结
入门案例的流程可以压缩成四件事:建桶 → 配 AK → putObject 上传 → 拼 URL 访问 。真正容易踩的坑不在 putObject 这一行,而在它周围------Endpoint 和地域不匹配、objectName 写了中文、Bucket 是私有却用直链、AK 硬编码进代码、客户端忘了关。把这几条守住,SDK 调用本身其实只有十几行。
还有一件事没做:现在每次用 OSS 都要在业务代码里重复 new OSSClientBuilder().build(...),AK 也散落在各处。下一篇《阿里云 oss:抽取自动装配组件》会把这套逻辑封成 OssTemplate + OssProperties,通过 @ConfigurationProperties 读 mf.oss 前缀的配置,再用 SpringBoot 自动装配把它变成一个可 @Autowired 的 Bean------和短信的封装思路完全一致。工程里 com.mf.autoconfig.template.OssTemplate 和 com.mf.autoconfig.properties.OssProperties 已经就位,那是下一章要拆开讲的内容。