关于微服务项目的文件存储(其二,完结)

接着上一期,上一期讲到了,我们将图片文件传给微服务端,再进行上传。但是,这种方式其实是不被推荐的,上传的人一多,就会有瓶颈。我们应该直接使用浏览器将图片文件提交给 OSS,而微服务端只需要提供签名数据(Policy)就行了。接下来,就来大部分项目中实际会写的代码:

  1. 创建新模块

    我们创建一个新模块,叫做 service-third-party,意思是第三方服务,以后我们还不止是需要第三方提交图片文件,还需要短信、查物流等第三方调用。

  2. 添加依赖

    在新建的模块中,添加依赖:


    <dependency>
       <groupId>com.aliyun.ossgroupId>
       <artifactId>aliyun-sdk-ossartifactId>
       <version>3.17.4version>
    dependency>
    <dependency>
     <groupId>com.aliyungroupId>
     <artifactId>sts20150401artifactId>
     <version>1.1.6version>
    dependency>
  3. 编写后端程序

    package com.sangui.sanguimall.thirdparty.oss.web;


    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.sangui.sanguimall.result.R;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;

    import org.apache.commons.codec.binary.Base64;

    import javax.crypto.Mac;
    import javax.crypto.spec.SecretKeySpec;
    import java.time.*;
    import java.time.format.DateTimeFormatter;
    import java.util.*;

    import java.time.Instant;
    import java.time.ZoneId;
    import java.time.ZoneOffset;
    import java.time.ZonedDateTime;
    import java.util.*;

    /**
    * @Author: sangui
    * @CreateTime: 2025-11-08
    * @Description: 对象存储 OSS 的 Controller
    * @Version: 1.0
    */
    @RestController
    @RequestMapping("/oss")
    public class OssController {

       String dir = "test06/";
       String bucketName = "sanguimall-test";
       String endpoint = "oss-cn-beijing.aliyuncs.com";
       String accessKeyId = System.getenv("OSS_ACCESS_KEY_ID");
       String accessKeySecret = System.getenv("OSS_ACCESS_KEY_SECRET");

       /**
        * 通过指定有效的时长(秒)生成过期时间。
        *
        * @param seconds 有效时长(秒)。
        * @return ISO8601 时间字符串,如:"2014-12-01T12:00:00.000Z"。
        */
       public static String generateExpiration(long seconds) {
           // 获取当前时间戳(以秒为单位)
           long now = Instant.now().getEpochSecond();
           // 计算过期时间的时间戳
           long expirationTime = now + seconds;
           // 将时间戳转换为Instant对象,并格式化为ISO8601格式
           Instant instant = Instant.ofEpochSecond(expirationTime);
           // 定义时区为UTC
           ZoneId zone = ZoneOffset.UTC;
           // 将 Instant 转换为 ZonedDateTime
           ZonedDateTime zonedDateTime = instant.atZone(zone);
           // 定义日期时间格式,例如2023-12-03T13:00:00.000Z
           DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
           // 格式化日期时间
           String formattedDate = zonedDateTime.format(formatter);
           // 输出结果
           return formattedDate;
      }

       @GetMapping("/getPolicy")
       public R getPostSignatureForOssUpload() throws Exception {


           if (accessKeyId == null || accessKeySecret == null) {
               return R.fail("OSS 密钥未配置");
          }

           Map<String, Object> policy = new HashMap<>();
           policy.put("expiration", generateExpiration(3600));

           List<Object> conditions = new ArrayList<>();
           conditions.add(Map.of("bucket", bucketName));
           conditions.add(Arrays.asList("content-length-range", 1, 104857600));
           conditions.add(Arrays.asList("eq", "$success_action_status", "200"));
           conditions.add(Arrays.asList("starts-with", "$key", dir));

           policy.put("conditions", conditions);

           ObjectMapper mapper = new ObjectMapper();
           String jsonPolicy = mapper.writeValueAsString(policy);
           String encodedPolicy = Base64.encodeBase64String(jsonPolicy.getBytes());

           Mac mac = Mac.getInstance("HmacSHA1");
           mac.init(new SecretKeySpec(accessKeySecret.getBytes(), "HmacSHA1"));
           byte[] signBytes = mac.doFinal(encodedPolicy.getBytes());
           String signature = Base64.encodeBase64String(signBytes);

           Map<String, String> resp = new HashMap<>();
           resp.put("host", "http://" + bucketName + "." + endpoint);
           resp.put("dir", dir);
           resp.put("policy", encodedPolicy);
           resp.put("baseUrl", "https://" + bucketName + "." + endpoint);
           resp.put("signature", signature);
           resp.put("accessKeyId", accessKeyId);

           return R.ok(resp);
      }
    }

    注意:这个后端的 Controller 写得并不规范,因为 Controller 中不写逻辑代码,我是因为要在一个程序能写完,就先没写 Service,请自行修改,另外,常量类也需自己另写文件存储

  4. 编写前端程序

    <template>
     <div>
       <el-upload
           class="upload-demo"
           :http-request="customUpload"
           action="#"
           :show-file-list="false"
           :limit="1"
           :on-exceed="() => messageTip('一次只能上传一个文件', 'warning')"
       >
         <el-button type="primary">点击上传图片el-button>
         <template #tip>
           <div class="el-upload__tip">
            jpg/png files with a size less than 10MB.
           div>
         template>
       el-upload>
       <div v-if="uploadedImageUrl" style="margin-top: 16px; display: flex; align-items: center; gap: 12px;">
         <span style="color: #67c23a; font-weight: 500;">已上传:span>
         <el-image :src="uploadedImageUrl" style="width: 80px; height: 80px;" fit="cover"/>
         <el-button size="small" type="text" @click="copyUrl">复制路径el-button>
         <el-button size="small" type="danger" @click="clearUploaded">清除el-button>
       div>
     div>
    template>
    <script>
    import {defineComponent} from "vue";
    import {doGet, doPut} from "../../http/HttpRequest.js";
    import {getUUID, messageAlert, messageTip} from "../../util/util.js";
    export default defineComponent({
     name: "BrandView",
     data() {
       return {
           uploadedImageUrl: '',  // 例如: https://xxx.com/test06/abc123.jpg
        ]
      }
    },
     methods: {
       // 自定义上传:核心!直传 OSS
       customUpload(params) {
         const file = params.file;

         // 校验
         if (!['image/jpeg', 'image/jpg', 'image/png'].includes(file.type)) {
           messageTip('只能上传 jpg/png 文件!', 'error');
           return Promise.reject();
        }
         if (file.size / 1024 > 10000) {
           messageTip('文件不能超过 10MB!', 'error');
           return Promise.reject();
        }
         doGet("/api/thirdParty/oss/getPolicy", {
           dir: "test7/"
        }).then(resp => {
           if (resp.data.code === 200) {
             const data = resp.data.data;
             const uploadUrl = `//${data.host.split('://')[1]}`;

             // 生成唯一文件名
             const uuid = getUUID();
             const fileExt = file.name.split('.').pop();
             const ossFileName = `${uuid}.${fileExt}`;  // 例如: abc123.jpg

             const formData = new FormData();
             formData.append('key', data.dir + ossFileName);
             formData.append('OSSAccessKeyId', data.accessKeyId);
             formData.append('policy', data.policy);
             formData.append('signature', data.signature);
             formData.append('success_action_status', '200');
             formData.append('file', file);

             doPost(uploadUrl, formData).then((response) => {
               // console.log("ali 返回内容:")
               // console.log(response);
               if (response.status === 200) {
                 // 拼接完整 URL
                 const finalUrl = `${data.baseUrl}/${data.dir}${ossFileName}`;
                 // 并保存到变量
                 this.uploadedImageUrl = finalUrl;
                 params.onSuccess({url: finalUrl}, params.file);
                 messageTip("上传成功!", "success");
              } else {
                 messageTip("上传失败!", "error");
              }
            })
          } else {
             messageTip("用户验证失败!", "error");
          }
        })
      },
       // 复制路径
       copyUrl() {
         navigator.clipboard.writeText(this.uploadedImageUrl).then(() => {
           messageTip('路径已复制到剪贴板', 'success');
        });
      },

       // 清除已上传
       clearUploaded() {
         this.uploadedImageUrl = '';
         messageTip('已清除', 'info');
      },

       // ... 你的其他 methods ...
    }
    script>
    <style scoped>
    .upload-demo {
     margin-top: 20px;
    }
    style>

    注意,这里我前端使用的是 Vue 来实现上传的,且使用了 Element-plus 框架。我的的请求方法也是之前封装好的,之前博客出现过。

  5. 配置 OSS CORS

    此时我们试着运行编写好的程序,浏览器会报出 No 'Access-Control-Allow-Origin' 错误,即 CORS 错误。这就需要我们在阿里云的 OSS管理控制台 就行配置跨域问题。

    首先进入自己对应的 Bucket,进去之后,找到数据安全下面的跨域设置,点击创建规则。开始创建跨域规则。

    来源这一块填自己前端的具体 ip 端口,我写的是:http://localhost:8080 ,或者直接写一个 * 也行。

    允许 Methods 这一块勾选 POST 就行。

    允许 Headers 这一块,写一个 * 就好。

    其他的字段无需填写。

    最后点击确定即可创建 OSS 的跨域规则。

至此,我们就完成了项目中最常见的图片上传功能

但是,还差最后一个步骤,就是图片预览,或者说图片查看功能。

在我们创建我们自己的 OSS 对象的时候,阿里云会默认开启阻止公共访问,也就是说,一开始我们的 Bucket 的权限是 private 的,也就是 私有的。Bucket 总共有三种权限:

  1. public-read-write(公共读写)

  2. public-read(公共读)

  3. private(私有)

这三种方式,各自有各自的优势和缺点。

先说公共读写,在这个权限下,任何人都可以对你的 Bucket 里的内容进行读写,这种模式很少使用,因为我们的云存储都是计费的,若是任何人都可以写,极有可能导致额外计费,所以一般不推荐使用这个权限。

再说公共读,在这个模式下,当程序员通过可验证的签名上传一个文件之后,会生成一个固定的 url,这个 url 是不会变的,任何人访问这个 url 都可以获取到这个文件,但是却只有程序员可以上传文件,其他人不能上传,因为没有签名。优点是程序方便简单,防止了恶意写文件。缺点是可以通过频繁访问已知的 url ,而消耗掉对应 OSS 的流量,导致额外计费。

最后说私有,这个权限下,并不是说任何人都看不了上传的文件了,若是这样,那云存储也便没了意义。私有权限下,程序员这一块还是和公共读一样,通过可验证的签名上传文件,但是,OSS 端会将这个文件,会生成一个 基础固定的 url + 动态签名,这个动态签名包含了此 url 的过期时间、可验证的签名信息。无论是访问过期的 url 不能验证的签名的 url,都是不可浏览的。但是若是这样,没有签名的用户该怎么预览图片呢?很简单,我们在后端的数据库中存储基础的 url 文本,而用户每次要请求预览图片的时候,前端发送请求给后端,后端传给前端一个动态签名的 url(包含基础url+过期时间 + 可验证的签名信息),用户便可通过这个动态的 url 来访问。优点是权限保护得很好,不会产生额外的费用。缺点是程序较公共读稍复杂,多一个获取动态签名的步骤。

前端的实现:

data() {
   return {
     // 保存上传成功的 URL,例如: test06/abc123.jpg
     uploadedImageUrl: '',
     // 进过签名验证的完整 URL,该路径可直接预览
     signedImageUrl: '',  
  }
}
methods: {
 customUpload(params) {
     const file = params.file;

     // 校验
     if (!['image/jpeg', 'image/jpg', 'image/png'].includes(file.type)) {
       messageTip('只能上传 jpg/png 文件!', 'error');
       return Promise.reject();
    }
     if (file.size / 1024 > 10000) {
       messageTip('文件不能超过 10MB!', 'error');
       return Promise.reject();
    }
     doGet("/api/thirdParty/oss/getPolicy", {
       dir: "test7/"
    }).then(resp => {
       if (resp.data.code === 200) {
         const data = resp.data.data;
         const uploadUrl = `//${data.host.split('://')[1]}`;

         // 生成唯一文件名
         const uuid = getUUID();
         const fileExt = file.name.split('.').pop();
         const ossFileName = `${uuid}.${fileExt}`;  // 例如: abc123.jpg

         const formData = new FormData();
         formData.append('key', data.dir + ossFileName);
         formData.append('OSSAccessKeyId', data.accessKeyId);
         formData.append('policy', data.policy);
         formData.append('signature', data.signature);
         formData.append('success_action_status', '200');
         formData.append('file', file);

         doPost(uploadUrl, formData).then((response) => {
           // console.log("ali 返回内容:")
           // console.log(response);
           if (response.status === 200) {
             // 拼接完整 URL
             const finalUrl = `${data.baseUrl}/${data.dir}${ossFileName}`;
             // 并保存到变量
             this.uploadedImageUrl = data.dir + ossFileName;
             params.onSuccess({url: finalUrl}, params.file);

             this.getSignedImageUrl(data.dir + ossFileName);

             messageTip("上传成功!", "success");
          } else {
             messageTip("上传失败!", "error");
          }
        })
      } else {
         messageTip("用户验证失败!", "error");
      }
    })
  },
   // 工具函数:获取签名 URL
   getSignedImageUrl(url) {
     doGet('/api/thirdParty/oss/getSignedUrl', {
       uploadedImageUrl: url
    }).then(resp => {
       if (resp.data.code === 200) {
         this.signedImageUrl = resp.data.data;
      }
    })
  },
}

总结讲解一下这个程序,这个程序的主体还是之前上传的那个程序的主体,我增加了一个 getSignedImageUrl 方法,这个方法调用给后端传本次图片的 url (例如:test01/abc.jpg),以获取真实的、经过验证的 url ,通过这个网址,可以直接浏览对应的图片。

后端的实现::

@GetMapping("/getSignedUrl")
public R getSignedUrl(@RequestParam(value = "uploadedImageUrl") String uploadedImageUrl) {
   String endpoint = "oss-cn-beijing.aliyuncs.com";
   String accessKeyId = System.getenv("OSS_ACCESS_KEY_ID");
   String accessKeySecret = System.getenv("OSS_ACCESS_KEY_SECRET");
   String bucketName = "sanguimall-test";

   OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);

   // 设置过期时间:300秒
   Date expiration = new Date(System.currentTimeMillis() + 300 * 1000L);
   URL url = ossClient.generatePresignedUrl(bucketName, uploadedImageUrl, expiration);
   ossClient.shutdown();
   System.out.println("后端返回的最终 url = "+ url.toString());
   return R.ok(url.toString());
}

注意:我的这个 Controller 方法依然写了逻辑,后续我会自行写入 service 方法里,这里为了展示方便,什么常量也直接写了。这里调用 OSS ,通过自己的 accessKey 等的信息,获取一个可供查看的 url。

最后一点,我目前写的程序中,只能针对一张图片的预览(单次),在一些多图片预览的表格、文章等会一次性展示多张图片的场景,其实不太适用这种方法。

我的想法是这样的,数据库中存储的图片地址,就是在 OSS 中的相对路径(例如:test01/abc.jpg、ccc.png 等),前端向后端对应的微服务发送多次图片的请求时,对应的微服务使用 OpenFeign 来调用我写好的第三方微服务的 getSignedUrl 。放弃前端反复发送给后端的异步请求获取完整的 url,直接在微服务之间互相调用。下面来看我的一个展示多张图片的例子:

我的微服务叫做商品微服务,前端有一个页面,就是展示所有商品的品牌,而每个品牌都有它的品牌 logo 图片,这就是我的展示多张图片的例子。

@Override
public PageInfo<BrandDo> getBrandsByPage(Integer current) {
   // 1. 设置 PageHelper
   PageHelper.startPage(current, Constants.PAGE_SIZE);
   // 2. 查询
   List<BrandDo> list = brandMapper.selectBrandByPage();

   // 3. 封装分页数据到 PageInfo
   return new PageInfo<>(list);
}

以上代码就是我的原本的代码。

现在我在我的商品微服务中,加入 OpenFeign 的接口:

package com.sangui.sanguimall.product.feign;


import com.sangui.sanguimall.result.R;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

/**
* @Author: sangui
* @CreateTime: 2025-11-09
* @Description: 第三方服务的 Feign 客户端接口
* @Version: 1.0
*/
@FeignClient("service-third-party")
public interface ThirdPartyFeignClient {
   @GetMapping("/oss/getSignedUrl")
   R getSignedUrl(@RequestParam(value = "uploadedImageUrl") String uploadedImageUrl);
}

具体关于 OpenFeign 的讲解我在之前博客中有涉及,简单来说 OpenFeign 就是能负载均衡得访问其他微服务的 Controller 的组件。这里,我直接访问的时 第三方服务的 Oss 的 getSignedUrl ,即是我之前写的获取真实 url 的方法。

@Override
public PageInfo<BrandDo> getBrandsByPage(Integer current) {
   // 1. 设置 PageHelper
   PageHelper.startPage(current, Constants.PAGE_SIZE);
   // 2. 查询
   List<BrandDo> list = brandMapper.selectBrandByPage();

   for (BrandDo brandDo : list) {
       String url = (String)thirdPartyFeignClient.getSignedUrl(brandDo.getLogo()).getData();
       brandDo.setLogo(url);
  }
   // 3. 封装分页数据到 PageInfo
   return new PageInfo<>(list);
}

上述代码就是我,改进之后的 getBrandsByPage ,增加了四行代码,将 logo 设置成 真实的 url。

好了,关于 OSS 的图片上传、图片浏览、多图片浏览就是这样,我今天就先讲到这里,谢谢大家!

  • 微信
  • 赶快加我聊天吧
  • QQ
  • 赶快加我聊天吧
  • weinxin
三桂

发表评论 取消回复 您未登录,登录后才能评论,前往登录