Java 项目实战: 交友项目 - 阿里云 OSS 入门案例:从创建 Bucket 到上传拿到可访问 URL

概述

交友项目的头像要存在哪里?本地磁盘能跑通但没法扩容,自建分布式文件系统又要运维。这一篇用最少的代码把阿里云 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

怎么验证上传成功

两种方式,缺一不可,因为"上传返回没抛异常"和"文件真的能访问"是两件事:

  1. 浏览器直接打开控制台打印的那条 URL。图片能正常显示(不是下载对话框、不是 XML 报错页)就算通过。
  2. 登录 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 已经就位,那是下一章要拆开讲的内容。

相关推荐
裕晟资质规划3 小时前
三类涉密资质延续时限差异分析:军工60日与集成印制3个月的边界
java·服务器·前端·网络·安全
念念清晰3 小时前
000-OpenJDK17 源码构建:MacOS环境(M芯片)
java·后端
ha_lydms3 小时前
Bitwise Functions(位运算函数)
大数据·hadoop·阿里云·spark·数据处理·dataworks·maxcompute
Wang's Blog4 小时前
Java 项目实战: 交友项目 - 百度人脸识别入门案例
java·百度·交友
wuminyu4 小时前
JMM学习总结:深入理解happens-before关系
java·linux·c语言·jvm·c++
电商API_180079052474 小时前
反向海淘多币种结算:汇率该在哪个时点被冻结
java·开发语言·数据库·跨境电商·1688 api
开开心心就好5 小时前
多份文档怎么批量打印?支持多种格式
java·服务器·开发语言·pdf·ocr·散列表·启发式算法
Pniubi5 小时前
模拟一个充值送token怎么保证幂等?一致性?原子性?
java
企业数字化笔记5 小时前
多租户配额怎么控制?用量口径、并发预留与超额处理的实现方法
java·数据库·spring boot