一止长渊

分布式下文件上传

N 人看过
字数:3.5k字 | 预计阅读时长:16分钟

image.png
在微服务分布式情况下,不同于以前的单体项目校园中铺项目,是将用户上传的图片放置在单体项目所在服务器的某个文件夹下,单体应用也在这个文件夹,然后单体应用就可以直接访问。但是在分布式情况下,一个服务可能有很多实例部署在多台服务器上,如果仍然使用向单体项目一样的图片存储,则会导致该服务的其他实例的服务器上没有该用户上传的图片,所以为了解决这个问题,分布式中通过使用将用户的图片同一放置在一个位置,然后每个服务都来访问这个位置来存取图片就可以了,这里使用阿里云的对象存储 OSS。

阿里 OSS 中这么几个概念:

  • Bucket 就是容器,一般一个微服务对应一个容器即可,存放该微服务所需的照片
  • 对象:就是用户上传的图片或者文件
  • 地域:存储地理位置
  • 访问域名:就是微服务拿取某一张照片,该照片的 URL 地址,例如图中标注
  • 访问密匙:用在身份验证,保证只有拥有密匙才可以向该容器中写入文件,读取的话可以设置公共读不需要验证密匙

image.png
截屏2021-03-27 00.01.22.png
因为文件上传是写入,所以需要使用到 AccessKey,文件上传也有两种方式:

  • 用户上传图片到我们的应用服务器(也就是微服务端),然后微服务端添加上 AccessKey 再通过流的形式发给 OSS

缺点:用户上传图片需要经过应用服务器,只是为了添加一项 AccessKey,这是十分没有必要的。而且在多用户的情况下,这里会成为瓶颈

截屏2021-03-27 00.04.31.png

  • 用户直接在客户端上传图片到 OSS 中,在上传之前向应用服务器请求 Policy 上传策略,这里的上传策略就是服务器根据阿里云的 AccessKey 生成一个签名,签名中包含授权令牌以及上传到阿里云的哪个位置等信息,前端拿到这些授权信息后(这些信息并没有账号密码),而是利用账号密码生成的防伪签名,前端戴着防伪签名和上传的文件直接发送到阿里云 OSS,阿里云对防伪签名验证才接受该文件

截屏2021-03-27 00.07.41.png

开通阿里云存储 => 创建角色 => 分配权限 => 引入依赖 => 测试
创建角色目的:一个微服务可能有相应的运维人员,不能给运维人员所有的权限,所以通过创建角色赋予相应权限的方式来保证安全

一、浏览器通过文件上传到服务器,服务器再加上 accessKey 和 accessSecret 上传

  1. 创建角色,分配权限

截屏2021-03-27 12.52.35.png截屏2021-03-27 12.55.31.png截屏2021-03-27 12.58.58.png
截屏2021-03-27 13.00.02.png截屏2021-03-27 13.00.27.png

  1. 引入依赖
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alicloud-oss</artifactId>
            <version>2.1.0.RELEASE</version>
        </dependency>
  1. application.yml 配置 accessKey
spring:
  cloud:
    alicloud:
      access-key: xxxxxx # oss文件上传key
      secret-key: xxxxxxx # oss文件上传密匙
      oss:
        endpoint: oss-cn-beijing.aliyuncs.com # bucket名,oss文件上传到哪个容器

key 和密匙在阿里云 oss 分配的角色中可以看见
截屏2021-03-27 12.57.10.png截屏2021-03-27 12.50.30的副本.png

  1. 代码上传
    @Resource //这里使用@Resource而不是@Autowired,虽然@Autowired也成功了但是爆红
    private OSSClient ossClient;

    @Test
    public void testUpload() throws FileNotFoundException {
//        // yourEndpoint填写Bucket所在地域对应的Endpoint。以华东1(杭州)为例,Endpoint填写为https://oss-cn-hangzhou.aliyuncs.com。
//        String endpoint = "oss-cn-beijing.aliyuncs.com";
//        // 阿里云账号AccessKey拥有所有API的访问权限,风险很高。强烈建议您创建并使用RAM用户进行API访问或日常运维,请登录RAM控制台创建RAM用户。
//        String accessKeyId = "xxxx";
//        String accessKeySecret = "xxxx";
//
//        // 创建OSSClient实例。
//        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
//
        // 填写本地文件的完整路径。如果未指定本地路径,则默认从示例程序所属项目对应本地路径中上传文件流。
        InputStream inputStream = new FileInputStream("/Users/yizhichangyuan/Downloads/60404dae27434.jpg");
        // 填写Bucket名称和Object完整路径。Object完整路径中不能包含Bucket名称。
        ossClient.putObject("doermail-product", "00745YaMgy1gnbcq5f9amj30ta1a8qa1.jpg", inputStream);

        // 关闭OSSClient
        ossClient.shutdown();
        System.out.println("上传成功");
    }

上传成功
截屏2021-03-27 15.55.21.png

二、浏览器经服务端签名后直传 OSS

由于我们很多微服务都需要上传图片,以往我们在 common 模块采用工具类子模块继承的方式虽然可以避免多次覆写,但是存在着对业务入侵,所以这里我们直接新建一个模块专门处理所有微服务模块所需要的公共服务,例如对浏览器上传图片到 OSS 进行签名。
选中 Spring Initalizer => 选中 web 依赖和 OpenFeign 依赖 => 继承 common 模块依赖(主要是为了继承其中服务注册发现依赖)
截屏2021-03-27 16.10.05.png

  1. 添加依赖
   <!--注意其中的spring-cloud版本需要指定为Greenwich.SR3,否则相互依赖会有问题-->
        <spring-cloud.version>Greenwich.SR3</spring-cloud.version>



        <!--阿里OSS对象存储依赖-->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alicloud-oss</artifactId>
            <version>2.1.0.RELEASE</version>
        </dependency>

如果添加依赖后爆红,请在 MAVEN 配置文件中添加上阿里云的镜像地址即可解决
**

  1. 该服务注册到 nacos

本地新建 bootstrap.properties 文件,添加 nacos 配置中心地址

spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.cloud.nacos.config.namespace=c87021e5-fc07-4112-afa6-78871153cc65
spring.cloud.nacos.config.ext-config[0].data-id=oss.yml //读取nacos中的动态文件上传配置
spring.cloud.nacos.config.ext-config[0].group=DEFAULT_GROUP
spring.cloud.nacos.config.ext-config[0].refresh=true

本地新建 application.yml,添加 nacos 服务注册发现地址

# 配置nacos服务注册地址
spring:
  cloud:
    nacos:
      discovery:
        server-addr: 127.0.0.1:8848

  application:
    name: doermail-third-party

server:
  port: 30000

启动类添加上@EnableDiscoveryClient 注解,开启服务发现注册

  1. 使用 nacos 配置上传文件的配置 oss.yml
spring:
  cloud:
    alicloud:
      access-key: xxxxx # 上传者id
      secret-key: xxxxx # 对应密匙
      oss:
        endpoint: oss-cn-beijing.aliyuncs.com # 上传域名
        bucket: doermail-product # 上传文件到哪个容器中

**
截屏2021-03-27 23.22.35.png

  1. 后端编写签名代码
/**
 * @PackageName:com.lookstarry.doermail.thirdparty.controller
 * @NAME:OSSController
 * @Description:
 * @author: yizhichangyuan
 * @date:2021/3/27 20:23
 */
@RefreshScope // 运行期间自动刷新nacos配置,如果有变化的话
@RestController
@RequestMapping(value="/oss")
public class OSSController {

    // 注入nacos动态配置中的内容
    @Value("${spring.cloud.alicloud.access-key}")
    private String accessId;

    @Value("${spring.cloud.alicloud.secret-key}")
    private String accessKey;

    @Value("${spring.cloud.alicloud.oss.endpoint}")
    private String endpoint;

    @Value("${spring.cloud.alicloud.oss.bucket}")
    private String bucket;

    @Autowired
    private OSS ossClient;
//
//    @Bean
//    public OSS getOSS(){
//        // 创建OSSClient实例。
//        OSS ossClient = new OSSClientBuilder().build(endpoint, accessId, accessKey);
//        return ossClient;
//    }

    @RequestMapping(value="/policy")
    public R policy() {
        // 上传地址:https://doermail-product.oss-cn-beijing.aliyuncs.com/
        String host = "https://" + bucket + "." + endpoint; // host的格式为 bucketname.endpoint
        // callbackUrl为 上传回调服务器的URL,请将下面的IP和Port配置为您自己的真实信息。
        // String callbackUrl = "http://88.88.88.88:8888";

        // 文件存储到OSS容器中对应当天日期的文件夹下
        String format = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
        String dir = format + "/"; // 用户上传文件时指定的前缀。

        Map<String, String> respMap = new LinkedHashMap<String, String>();
        try {
            long expireTime = 30;
            long expireEndTime = System.currentTimeMillis() + expireTime * 1000;
            Date expiration = new Date(expireEndTime);
            // PostObject请求最大可支持的文件大小为5 GB,即CONTENT_LENGTH_RANGE为5*1024*1024*1024。
            PolicyConditions policyConds = new PolicyConditions();
            policyConds.addConditionItem(PolicyConditions.COND_CONTENT_LENGTH_RANGE, 0, 1048576000);
            policyConds.addConditionItem(MatchMode.StartWith, PolicyConditions.COND_KEY, dir);

            String postPolicy = ossClient.generatePostPolicy(expiration, policyConds);
            byte[] binaryData = postPolicy.getBytes("utf-8");
            String encodedPolicy = BinaryUtil.toBase64String(binaryData);
            String postSignature = ossClient.calculatePostSignature(postPolicy);

            respMap.put("accessid", accessId);
            respMap.put("policy", encodedPolicy);
            respMap.put("signature", postSignature); // 上传签名
            respMap.put("dir", dir);
            respMap.put("host", host);
            respMap.put("expire", String.valueOf(expireEndTime / 1000));
        } catch (Exception e) {
            // Assert.fail(e.getMessage());
            System.out.println(e.getMessage());
        } finally {
            ossClient.shutdown();
        }
        return R.ok().put("data", respMap);
    }
}
  1. 前端在调用文件上传后,会引起跨域文件,因为上传文件 URL 与地址栏 URL 不一致,在 OSS 中修改,添加头部信息允许跨域

截屏2021-03-27 21.52.39.png截屏2021-03-27 23.01.52.png截屏2021-03-27 23.03.17.png

  1. 前端在添加用户文件后,在上传到 OSS 前,会先向后端服务请求签名地址,然后同用户上传的文件一同上传给阿里云 OSS
  • 前端上传文件前向后端请求签名
import http from '@/utils/httpRequest.js' export function policy() { return new
Promise((resolve,reject)=>{ http({ url: http.adornUrl("/thirdparty/oss/policy"),
method: "get", params: http.adornParams({}) }).then(({ data }) => {
resolve(data); }) }); }
  • 文件上传

这里使用饿了么的 elementUI 中的 upload 上传功能,此为自动上传,即用户添加文件后自动上传

<template>
   
  <div>
    <el-upload
      action="http://doermail-product.oss-cn-beijing.aliyuncs.com"
      :data="dataObj"
      list-type="picture"
      :multiple="false"
      :show-file-list="showFileList"
      :file-list="fileList"
      :before-upload="beforeUpload"
      :on-remove="handleRemove"
      :on-success="handleUploadSuccess"
      :on-preview="handlePreview"
    >
      <el-button size="small" type="primary">点击上传</el-button>
      <div slot="tip" class="el-upload__tip">
        只能上传jpg/png文件,且不超过10MB
      </div>
    </el-upload>
    <el-dialog :visible.sync="dialogVisible">
      <img width="100%" :src="fileList[0].url" alt="" />
    </el-dialog>
  </div>
</template>
<script>
import { policy } from "./policy"; // 上传前请求后台签名
import { getUUID } from "@/utils"; // 这里的含义表示相对src文件夹,因为utils文件夹有很多类,这里使用解构符号{}只取出getUUID方法

export default {
  name: "singleUpload", //导出模块名
  props: {
    value: String,
  },
  computed: {
    imageUrl() {
      return this.value;
    },
    imageName() {
      if (this.value != null && this.value !== "") {
        return this.value.substr(this.value.lastIndexOf("/") + 1);
      } else {
        return null;
      }
    },
    fileList() {
      return [
        {
          name: this.imageName,
          url: this.imageUrl,
        },
      ];
    },
    showFileList: {
      get: function () {
        return (
          this.value !== null && this.value !== "" && this.value !== undefined
        );
      },
      set: function (newValue) {},
    },
  },
  data() {
    return {
      dataObj: {
        policy: "",
        signature: "",
        key: "",
        ossaccessKeyId: "",
        dir: "",
        host: "",
        // callback:'',
      },
      dialogVisible: false,
    };
  },
  methods: {
    emitInput(val) {
      // 选用input,向父组件传入输入的值,在父组件直接可以通过v-model绑定子组件传来的文件上传地址url
      this.$emit("input", val);
    },
    handleRemove(file, fileList) {
      this.emitInput("");
    },
    handlePreview(file) {
      this.dialogVisible = true;
    },
    // 钩子函数,在上传文件前自动调用该方法请求后端服务器上传文件签名
    // 并封装在dataObj,在<el-upload>使用:data="dataObj"绑定参数,文件上传时同文件一起上传给指定的action="http://doermail-product.oss-cn-beijing.aliyuncs.com"
    beforeUpload(file) {
      let _self = this;
      return new Promise((resolve, reject) => {
        policy()
          .then((response) => {
            _self.dataObj.policy = response.data.policy;
            _self.dataObj.signature = response.data.signature;
            _self.dataObj.ossaccessKeyId = response.data.accessid;
            _self.dataObj.key =
              response.data.dir + getUUID() + "/" + "${filename}"; // 防止文件上传oss后重名就被覆盖,这里使用UUID随机创建一个文件夹后再保存
            _self.dataObj.dir = response.data.dir;
            _self.dataObj.host = response.data.host;
            resolve(true);
          })
          .catch((err) => {
            reject(false);
          });
      });
    },
    // 文件上传成功后的钩子函数
    handleUploadSuccess(res, file) {
      console.log("上传成功...");
      this.showFileList = true;
      this.fileList.pop();
      this.fileList.push({
        name: file.name,
        url:
          this.dataObj.host +
          "/" +
          this.dataObj.key.replace("${filename}", file.name),
      });
      // 向父组件传入文件地址url,便于后续入库地址
      this.emitInput(this.fileList[0].url);
    },
  },
};
</script>
<style></style>
  • 表单中使用 SingleUpload 组件

这里直接使用来 使用前面编写好的组件,这里使用了 v-model 直接上传后文件地址绑定到了 dataForm 对象中 logo 属性便于向后端插入一条数据库记录

<template>
  <el-dialog
    :title="!dataForm.id ? '新增' : '修改'"
    :close-on-click-modal="false"
    :visible.sync="visible"
  >
    <el-form
      :model="dataForm"
      :rules="dataRule"
      ref="dataForm"
      @keyup.enter.native="dataFormSubmit()"
      label-width="140px"
    >
      <el-form-item label="品牌名" prop="name">
        <el-input v-model="dataForm.name" placeholder="品牌名"></el-input>
      </el-form-item>
      <el-form-item label="品牌logo地址" prop="logo">
        <!-- <el-input v-model="dataForm.logo" placeholder="品牌logo地址"></el-input> -->
        <!--这里直接使用<single-upload>或<single-upload>来 使用前面编写好的组件
                    这里使用了v-model直接上传后文件地址绑定到了dataForm对象中logo属性便于向后端插入一条数据库记录-->
        <single-upload v-model="dataForm.logo"></single-upload>
      </el-form-item>
      <el-form-item label="介绍" prop="descript">
        <el-input v-model="dataForm.descript" placeholder="介绍"></el-input>
      </el-form-item>
      <el-form-item label="显示状态" prop="showStatus">
        <el-switch
          v-model="dataForm.showStatus"
          active-color="#13ce66"
          inactive-color="#ff4949"
        >
        </el-switch>
      </el-form-item>
      <el-form-item label="检索首字母" prop="firstLetter">
        <el-input
          v-model="dataForm.firstLetter"
          placeholder="检索首字母"
        ></el-input>
      </el-form-item>
      <el-form-item label="排序" prop="sort">
        <el-input v-model="dataForm.sort" placeholder="排序"></el-input>
      </el-form-item>
    </el-form>
    <span slot="footer" class="dialog-footer">
      <el-button @click="visible = false">取消</el-button>
      <el-button type="primary" @click="dataFormSubmit()">确定</el-button>
    </span>
  </el-dialog>
</template>

<script>
import SingleUpload from "@/components/upload/singleUpload"
export default {
  components: {SingleUpload},
    data() {
    return {
      visible: false,
      dataForm: {
        brandId: 0,
        name: "",
        logo: "",
        descript: "",
        showStatus: "",
        firstLetter: "",
        sort: "",
      }
    };
    }
    methods: ...
  }

截屏2021-03-27 23.14.43.png

本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 (CC BY-NC-ND 4.0) 进行许可。