Spring Boot整合Minio实现文件上传

Spring Boot整合Minio后,前端的文件上传有两种方式:

  1. 文件上传到后端,由后端保存到Minio

  2. 这种方式好处是完全由后端集中管理,可以很好的做到、身份验证、权限控制、文件与处理等,并且可以做一些额外的业务逻辑,比如生成缩略图、提取元数据等。

  3. 缺点也很明显:

  4. 延迟时间高了,本来花费上传一次文件的时间,现在多了后端保存到Minio的时间

  5. 后端资源占用,后端本来可以只处理业务请求,现在还要负责文件流,增加了性能压力

  6. 单点故障,Minio即便做了集群,但是如果后端服务器故障,也会导致Minio不可用

  7. 所以,实际上我们不会把文件传到后端,而是直接传给Minio,其实这也符合OSS服务的使用方式。

  8. 文件向后端申请上传凭证,然后直接上传到Minio

  9. 为了避免Minio被攻击,我们需要结合后端,让后端生成并返回一个有时效的上传凭证,前端拿着这个凭证才能去上传,通过这种方式,我们可以做到一定程度的权限控制,本文要分享的就是这种方式。

环境准备

  • 部署好的Minio环境:http://mylocalhost:9001

Spring Boot整合Minio

简单过一下整合方式把。

先引入Minio依赖

# pom.xml 
<dependency> <groupId>io.minio</group Id> 
<artifactId>minio</artifact Id> 
<version>7.1.0</version> 
</dependency>

然后定义配置信息

# application.yml
minio:
endpoint: http://mylocalhost:9001 
accessKey: minio 
secretKey: minio123 
bucket: demo

定义一个属性类

@Component 
@ConfigurationProperties(prefix = "minio")
public class MinioProperties { /** * 对象存储服务的URL */ private String endpoint; /** * Access key就像用户ID,可以唯一标识你的账户 */ private String accessKey;/** * Secret key是你账户的密码*/private String secretKey; /*** 默认文件桶*/ private String bucket; ...
}

定义Minio配置类

@Configuration 
public class MinioConfig { @Beanpublic MinioClient minioClient(MinioProperties properties){ try { MinioClient.Builder builder = MinioClient.builder(); builder.endpoint(properties.getEndpoint());if (StringUtils.hasLength(properties.getAccessKey()) && StringUtils.hasLength(properties.getSecretKey())) { builder.credentials(properties.getAccessKey(),properties.getSecretKey()); } return builder.build(); } catch (Exception e){ return null;}}
}

现在启动服务即可。

上传凭证

写一个接口,返回上传凭证:

@RequestMapping(value = "/presign", method = {RequestMethod.POST}) 
public Map<String, String> presign(@RequestBody PresignParam presignParam) {//如果前端不指定桶,那么给一个默认的if (StringUtils.isEmpty(presignParam.getBucket())) {presignParam.setBucket("demo"); } // 前端不指定文件名称,就给一个UUID if (StringUtils.isEmpty(presignParam.getFilename())) { presignParam.setFilename(UUID.randomUUID().toString());} // 如果想要以子目录的方式保存,就在前面加上斜杠来表示 //      presignParam.setFilename("/2023/" + presignParam.getFilename());// 设置凭证过期时间 ZonedDateTime expirationDate = ZonedDateTime.now().plusMinutes(10); // 创建一个凭证 PostPolicy policy = new PostPolicy(presignParam.getBucket(), presignParam.getFilename(), expirationDate); // 限制文件大小,单位是字节byte,也就是说可以设置如:只允许10M以内的文件上传 // policy.setContentRange(1, 10 * 1024); // 限制上传文件请求的ContentType // policy.setContentType("image/png");try {// 生成凭证并返回final Map<String, String> map = minioClient.presignedPostPolicy(policy);for (Map.Entry<String, String> entry : map.entrySet()) { System.out.println(entry.getKey() + " = " + entry.getValue()); } return map; } catch (MinioException | InvalidKeyException | IOException | NoSuchAlgorithmException e) { e.printStackTrace();}return null;
}

上面的示例代码可以知道,我们还可以加一些权限认证,以判断用户是否有以下权限:

  • 上传权限

  • 可上传的文件大小

  • 可上传的文件类型

请求参数类:

public class PresignParam { // 桶名private String bucket; // 文件名 private String filename; ...
}

这个接口的返回结果是:

bucket: demo 
x-amz-date: 20230831T042351Z 
x-amz-signature: 79cc2ae0baee274d1d47cb29bdd5e99127059033503c2a02f904f0478a73ecac
key: 寂寞的季节.mp4 
x-amz-algorithm: AWS4-HMAC-SHA256
x-amz-credential: minio/20230831/us-east-1/s3/aws4_request 
policy: eyJleHBpcmF0aW9uIjoiMjAyMy0wOC0zMVQwNDozMzo1MS42MzZaIiwiY29uZGl0aW9ucyI6W1siZXEiLCIkYnVja2V0IiwiZGVtbyJdLFsiZXEiLCIka2V5Iiwi5a+C5a+e55qE5a2j6IqCLm1wNCJdLFsiZXEiLCIkeC1hbXotYWxnb3JpdGhtIiwiQVdTNC1ITUFDLVNIQTI1NiJdLFsiZXEiLCIkeC1hbXotY3JlZGVudGlhbCIsIm1pbmlvLzIwMjMwODMxL3VzLWVhc3QtMS9zMy9hd3M0X3JlcXVlc3QiXSxbImVxIiwiJHgtYW16LWRhdGUiLCIyMDIzMDgzMVQwNDIzNTFaIl1dfQ==
  • bucket:表示目标桶

  • x-amz-date:时间戳

  • x-amz-signature:签名

  • key:文件名

  • x-amz-algorithm:签名算法

  • x-amz-credential:认证授权

  • policy:凭证token

前端收到后,将该凭证连同文件流一并上传到Minio服务器:

uploadFile(file, policy) { console.log("准备上传文件:") console.log("file:" + file) console.log("policy:" + policy) var formData = new FormData() formData.append('file', file) formData.append('key', policy['key']) formData.append('x-amz-algorithm', policy['x-amz-algorithm'])formData.append('x-amz-credential', policy['x-amz-credential']) formData.append('x-amz-signature', policy['x-amz-signature']) formData.append('x-amz-date', policy['x-amz-date']) formData.append('policy', policy['policy'])return new Promise(((resolve, reject) => {$.ajax({method: 'POST',url: 'http://mylocalhost:9001/' + policy['bucket'],data: formData, dataType: 'json', contentType: false, // 必须设置为 false,不设置 contentType,让浏览器自动设置processData: false, // 必须设置为 false,不对 FormData 进行序列化处理 // async: false, // 设置同步,方便等下做分片上传xhr: function xhr() {//获取原生的xhr对象 var xhr = $.ajaxSettings.xhr(); if (xhr.upload) { //添加 progress 事件监听 xhr.upload.addEventListener('progress', function (e) {//e.loaded 已上传文件字节数 //e.total 文件总字节数var percentage = parseInt(e.loaded / e.total * 100) vm.uploadResult = percentage + "%" + ":" + policy['key']}, false); } return xhr; }, success: function (result) { vm.uploadResult = '文件上传成功:' + policy['key'] resolve(result) }, error: function (e) { reject()} }) }))
}

这样就完成了获取上传凭证并上传文件。

分片上传、秒传、断点续传

分片上传

分片上传可以用在大文件上传上,一个100M的文件可以分成10份,每份10M,一共传输10次,这有以下好处:

  • Minio做了集群,用Nginx转发,那么分片上传可以降低单台Minio服务器的性能压力

  • 多线程上传可以加快上传效率

秒传

现在说说秒传,我们上传一个文件之前,可以用工具生成MD5字符串,就好像这样:

3cc1f3c3c2d1a29ecf60ffad4de278c7

然后拼接上文件名:

3cc1f3c3c2d1a29ecf60ffad4de278c7_寂寞的季节.mp4

这时候去向后端申请上传凭证的时候,后端可以先去看看文件是否已存在,如果文件已存在,就不用生成凭证了,直接告诉前端该文件已经上传完毕,由此实现文件秒传。
这样的好处是:

  • 降低Minio服务器压力

  • 响应秒回,用户体验提高

断点续传

结合分片上传和秒传的原理,我们可以来做到断点续传。

场景:当我们要上传一个大文件的时候,进度到一半了,这时候网络掉线导致上传失败,网络恢复后又要重新上传,这就很崩溃。

处理方式:大文件也可以分成一个个小文件来上传,这样即便上传到一半网络掉线,恢复上传的时候可以跳过前一半已上传的部分,接着上传后面一半。

文件合并

当我们分片上传后,后端还需要提供接口,来将所有分片数据合并:

@GetMapping("/compose")public void merge() {List<ComposeSource> sources = new ArrayList<>();// 分片数据放到另一个桶里面:slice sources.add(ComposeSource.builder() .bucket("slice") .object("0寂寞的季节.mp4").build()); sources.add(ComposeSource.builder().bucket("slice") .object("1寂寞的季节.mp4").build());sources.add(ComposeSource.builder().bucket("slice").object("2寂寞的季节.mp4") .build());final ComposeObjectArgs args = ComposeObjectArgs.builder() .bucket("demo") .object("寂寞的季节.mp4").sources(sources).build(); try { minioClient.composeObject(args);} catch (MinioException | InvalidKeyException | IOException | NoSuchAlgorithmException e) {e.printStackTrace(); } }

上面的示例很简单,因为只做演示说明。
前端需要传的参数是:

  • 分片桶:slice

  • 分片数据数组: 0寂寞的季节.mp4 1寂寞的季节.mp4 2寂寞的季节.mp4

  • 目标桶:demo

然后调用composeObject函数完成合并。

前端示例代码分享

上面就是关于实战经验分享的全部了,因为需要前端配置来使用,所以这里给出我这篇文章的前端示例,很简单的单页面(技术栈就别吐槽了):

<!DOCTYPE html><html lang="en"> <head> <meta charset="UTF-8"><title>Title</title> <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.7.14/vue.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.4/jquery.js"></script> <script src="https://cdn.bootcdn.net/ajax/libs/spark-md5/3.0.2/spark-md5.min.js"></script> 
</head> 
<body> 
<div id="app"> <h1>{{title}}</h1>
<br> <form @submit.prevent="getPolicyForm"><label> 桶名 <input type="text" v-model="policyParams.bucket"></label>
<br><label> 文件名 <input type="text" v-model="policyParams.filename"> </label> 
<br> <button type="submit">获取上传凭证</button>
<br> <div v-for="(val, key) in policy" :key="key">{{ key }}: <span>{{ val }}</span></div></form> 
<br> <form @submit.prevent="uploadFileForm" v-show="policy != null"><label> 文件 <input type="file" @change="fileChange"></label> 
<br> <br><button type="submit" v-show="file != null">上传文件</button>
</form> --- 
<br> <div v-show="file != null"> <button @click="sliceEvent">测试文件分片上传</button> | <button @click="sliceComposeEvent">分片文件合并</button> </div>
<br><br> <br> <p>{{uploadResult}}</p> 
<ul> <!-- <li v-for="item in sliceUploadResult">{{ item }}</li>--> <li v-for="(item, index) in sliceUploadResult" :key="index">{{ item }}</li> </ul> <br> </div> <script> var vm = new Vue({el: "#app", data() { return { title: "Minio测试" // 请求凭证参数 , policyParams: { bucket: null , filename: null } // 请求到的凭证 , policy: null // 待上传文件 , file: null// 上传文件参数 , uploadParams: { file: null } // 分片上传参数 , sliceParams: { bucket: "" , filename: "" , file: null } , slicePolicys: [] , sliceCount: 0// 上传结果回调 , uploadResult: null // 分片上传结果回调 , sliceUploadResult: null };}, methods: { getPolicyForm() {this.policyParams.bucket = "demo" this.policyParams.filename = "寂寞的季节.mp4" this.requestPolicy(this.policyParams)}, requestPolicy(params) { return new Promise(((resolve, reject) => { $.ajax({ type: "POST", url: "http://localhost:8888/presign", contentType: "application/json", data: JSON.stringify(params),// async: false, success: function (result) { console.log(result) vm.policy = result; resolve(result) }, error: function (e) { reject() }});}))}, fileChange(event) { const file = event.target.files[0] this.file = file }, uploadFileForm() { this.uploadFile(this.file, this.policy) }, uploadFile(file, policy) { console.log("准备上传文件:") console.log("file:" + file) console.log("policy:" + policy) var formData = new FormData() formData.append('file', file) formData.append('key', policy['key']) formData.append('x-amz-algorithm', policy['x-amz-algorithm']) formData.append('x-amz-credential', policy['x-amz-credential']) formData.append('x-amz-signature', policy['x-amz-signature'])formData.append('x-amz-date', policy['x-amz-date']) formData.append('policy', policy['policy']) return newPromise(((resolve, reject) => { $.ajax({ method: 'POST', url: 'http://mylocalhost:9001/' + policy['bucket'], data: formData, dataType: 'json', contentType: false, // 必须设置为 false,不设置 contentType,让浏览器自动设置 processData: false,// 必须设置为 false,不对 FormData 进行序列化处理 // async: false, // 设置同步,方便等下做分片上传 xhr: function xhr() { //获取原生的xhr对象 var xhr = $.ajaxSettings.xhr(); if (xhr.upload) { //添加 progress 事件监听 xhr.upload.addEventListener('progress', function (e) {//e.loaded 已上传文件字节数 //e.total 文件总字节数 var percentage = parseInt(e.loaded / e.total * 100) vm.uploadResult = percentage + "%" + ":" + policy['key'] }, false);} return xhr; }, success: function (result) { vm.uploadResult = '文件上传成功:' + policy['key'] resolve(result) }, error: function (e) { reject() } })}))
}, sliceEvent() { // 获取文件 var file = this.file// 设置分片大小:5MB var chunkSize = 5 * 1024 * 1024 // 计算总共有多少个分片 var totalChunk = Math.ceil(file.size / chunkSize)// 数组存放所有分片 var chunks = [] // 遍历所有分片 for (var i = 0; i < totalChunk; i++) { // 利用slice获取分片 var start = i * chunkSize var end = Math.min(file.size, start + chunkSize) var blob = file.slice(start, end)// 添加分片到数组 chunks.push(blob) } console.log(totalChunk) this.sliceUploadResult = Array(totalChunk).fill(0) for (let i = 0; i < chunks.length; i++) { var file = chunks[i]; this.calculateMD5(file) .then((md5) => { console.log(md5);// 输出计算出的 MD5 值 }) .catch((error) => { console.error(error); // 处理错误 }); } return// 创建序号 var index = 0; // 循环上传分片 while (index < totalChunk) { console.log('------------------------------') params = { "bucket": "slice", "filename": index + "寂寞的季节.mp4" } var policyPromise = this.requestPolicy(params); (function (index) { var file = chunks[index] policyPromise.then(function (result) { var filename = result['key'] console.log('准备上传文件:', filename, ',序号为:', index) vm.uploadFile(file, result).then(function (result) { console.log('上传完成:' + filename) vm.sliceUploadResult[index] = ('分片文件上传成功:' + filename) }) }) })(index) index++ } }, sliceComposeEvent() { var parmas = {} $.ajax({ method: 'POST', url: 'http://localhost:8888/compose', data: formData, dataType: 'json', contentType: false, // 必须设置为 false,不设置 contentType,让浏览器自动设置 processData: false, // 必须设置为 false,不对 FormData 进行序列化处理 // async: false, // 设置同步,方便等下做分片上传 xhr: function xhr() { //获取原生的xhr对象 var xhr = $.ajaxSettings.xhr(); if (xhr.upload) { //添加 progress 事件监听 xhr.upload.addEventListener('progress', function (e) { //e.loaded 已上传文件字节数 //e.total 文件总字节数 var percentage = parseInt(e.loaded / e.total * 100) vm.uploadResult = percentage + "%" + ":" + policy['key'] }, false);} return xhr; },success: function (result) { vm.uploadResult = '文件上传成功:' + policy['key'] resolve(result) },error: function (e) { reject() }
}) 
}, calculateMD5(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); // 读取文件内容 reader.readAsArrayBuffer(file); reader.onload = () => { const spark = new SparkMD5.ArrayBuffer(); spark.append(reader.result); // 将文件内容添加到 MD5 计算器中 const md5 = spark.end();// 计算 MD5 值 resolve(md5); }; reader.onerror = (error) => { reject(error); }; });}
}, mounted() { 
}, created() {
}, 
});
</script> 
</body> 
</html>

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/503784.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

[笔记] Jenkins 安装与配置全攻略:Ubuntu 从零开始搭建持续集成环境

随着 DevOps 流程的普及&#xff0c;持续集成&#xff08;CI&#xff09;和持续交付&#xff08;CD&#xff09;已成为现代软件开发中不可或缺的一部分。Jenkins 作为一款开源的自动化服务器&#xff0c;广泛应用于 CI/CD 管道的构建与管理。它不仅支持多种编程语言和工具链&am…

精选2款.NET开源的博客系统

前言 博客系统是一个便于用户创建、管理和分享博客内容的在线平台&#xff0c;今天大姚给大家分享2款.NET开源的博客系统。 StarBlog StarBlog是一个支持Markdown导入的开源博客系统&#xff0c;后端基于最新的.Net6和Asp.Net Core框架&#xff0c;遵循RESTFul接口规范&…

Keepalived 进阶秘籍:全方位配置优化

文章目录 1.sysctl.conf参数优化2. limits参数优化3. global_defs模块区域4.vrrp_script模块区域5.vrrp_instance VI_1实例定义配置模块区域6. virtual_server模块区域7.Keepalived与Heartbeat、Corosync比较 前言&#xff1a; 作为一台Keepalived服务器&#xff0c;有必要对内…

51单片机——中断(重点)

学习51单片机的重点及难点主要有中断、定时器、串口等内容&#xff0c;这部分内容一定要认真掌握&#xff0c;这部分没有学好就不能说学会了51单片机 1、中断系统 1.1 概念 中断是为使单片机具有对外部或内部随机发生的事件实时处理而设置的&#xff0c;中断功能的存在&#…

rabbitmq——岁月云实战笔记

1 rabbitmq设计 生产者并不是直接将消息投递到queue,而是发送给exchange,由exchange根据type的规则来选定投递的queue,这样消息设计在生产者和消费者就实现解耦。 rabbitmq会给没有type预定义一些exchage,而实际我们却应该使用自己定义的。 1.1 用户注册设计 用户在…

基于Python的投资组合收益率与波动率的数据分析

基于Python的投资组合收益率与波动率的数据分析 摘要&#xff1a;該文通过研究马科维茨的投资组合模型&#xff0c;并将投资组合模型应用到包含6只金融股票的金融行业基金中。首先通过开源的财经接口Tushare获取股票原始数据&#xff0c;接着利用数据分析的黄金组合库&#xf…

Linux部署web项目【保姆级别详解,Ubuntu,mysql8.0,tomcat9,jdk8 附有图文】

文章目录 部署项目一.安装jdk1.1 官网下载jdk81.2 上传到Linux1.3 解压1.4 配置环境变量1.5 查看是jdk是否安装成功 二.安装TomCat2.1 官网下载2.2 上传到Linux2.3 解压2.4配置2.5 启动Tomcat2.6 验证是否成功 三.安装mysql四.部署javaweb项目4.1 打包4.2 启动tomcat 部署项目 …

前端基础--网络

http1到http2有哪些新增和区别 HTTP/1.0版本主要增加了 1&#xff0c;增加了HEAD,POST等方法 2&#xff0c;增加了状态码 3&#xff0c;增加了请求头和响应头 4&#xff0c;引入content-type&#xff0c;传输不在仅限于文本 5&#xff0c;在请求中加入了HTTP版本号 HTTP…

Maven 详细配置:Maven 项目 POM 文件解读

Maven 是 Java 开发领域中广泛使用的项目管理和构建工具&#xff0c;通过其核心配置文件——POM&#xff08;Project Object Model&#xff09;文件&#xff0c;开发者能够定义项目的基本信息、依赖关系、插件配置以及构建生命周期等关键要素。POM 文件不仅是 Maven 项目的核心…

加速物联网HMI革命,基于TouchGFX的高效GUI显示方案

TouchGFX 是一款针对 STM32 微控制器优化的先进免费图形软件框架。 TouchGFX 利用 STM32 图形功能和架构&#xff0c;通过创建令人惊叹的类似智能手机的图形用户界面&#xff0c;加速了物联网 HMI 革命。 TouchGFX 框架包括 TouchGFX Designer (TouchGFXDesigner)&#xff08;…

服务器漏洞修复解决方案

漏洞1、远程桌面授权服务启用检测【原理扫描】 Windows Remote Desktop Licensing Service is running: Get Server version: 0x60000604 1、解决方案&#xff1a;建议禁用相关服务避免目标被利用 方法一&#xff1a;使用服务管理器 打开“运行”对话框&#xff08;WinR&am…

Centos源码安装MariaDB 基于GTID主从部署(一遍过)

MariaDB安装 安装依赖 yum install cmake ncurses ncurses-devel bison 下载源码 // 下载源码 wget https://downloads.mariadb.org/interstitial/mariadb-10.6.20/source/mariadb-10.6.20.tar.gz // 解压源码 tar xzvf mariadb-10.5.9.tar.gz 编译安装 cmake -DCMAKE_INSTA…

基于SpringBoot实现的保障性住房管理系统

&#x1f942;(❁◡❁)您的点赞&#x1f44d;➕评论&#x1f4dd;➕收藏⭐是作者创作的最大动力&#x1f91e; &#x1f496;&#x1f4d5;&#x1f389;&#x1f525; 支持我&#xff1a;点赞&#x1f44d;收藏⭐️留言&#x1f4dd;欢迎留言讨论 &#x1f525;&#x1f525;&…

Vue进阶(贰幺叁)node 版本切换

文章目录 一、前言1.1 什么是nvm? 二、查看已安装好的 node 版本三、下载 node 版本四、切换 node 版本五、查看在用 node 版本六、拓展阅读 一、前言 项目开发阶段&#xff0c;会涉及多node版本切换应用场景&#xff0c;可应用nvm实现node版本切换。 1.1 什么是nvm? nvm是…

Java-数据结构-链表-高频面试题(1)

在上一篇文章中&#xff0c;我们学习了链表中的"单向链表"&#xff0c;但学可不代表就是学会了&#xff0c;能够运用链表的地方比比皆是&#xff0c;解题方法也是层出不穷&#xff0c;今天就让我们巩固一下"单向链表"的知识吧~ 第一题&#xff1a;相交链表…

低空管控技术-无人机云监视技术详解!

一、无人机监听技术的原理 无人机监听技术主要依赖于射频&#xff08;RF&#xff09;探测、光学和红外传感器等技术手段。这些技术通过被动监听和监测无人机与飞行员&#xff08;或控制器&#xff09;之间的通信链路传输&#xff0c;以确定无人机的位置&#xff0c;甚至在某些…

STM32-WWDG/IWDG看门狗

WWDG/IWDG一旦开启不能关闭&#xff0c;可通过选项字节在上电时启动硬件看门狗&#xff0c;看门狗计数只能写入不能读取。看门狗启用时&#xff0c;T6bit必须置1&#xff0c;防止立即重置。 一、原理 独立看门狗-超时复位 窗口看门狗-喂狗&#xff08;重置计数器&#xff0c;…

【形式篇】年终总结怎么写:PPT如何将内容更好地表现出来

——细节满满&#xff0c;看完立马写出一篇合格的PPT 总述 形式服务于内容&#xff0c;同时合理的形式可以更好地表达和彰显内容 年终总结作为汇报型PPT&#xff0c;内容一定是第一位的&#xff0c;在内容篇(可点击查看)已经很详细地给出了提纲思路&#xff0c;那如何落实到…

分享3个国内使用正版GPT的网站【亲测有效!2025最新】

1. molica 传送入口&#xff1a;https://ai-to.cn/url/?umolica 2. 多帮AI 传送入口&#xff1a;https://aigc.openaicloud.cn?inVitecodeMYAAGGKXVK 3. 厉害猫 传送入口&#xff1a;https://ai-to.cn/url/?ulihaimao

使用免费内网穿透(p2p)网络环境搭建小型文件管理服务器(简单操作)

目录 前言 “节点小宝” 使用环境&#xff1a; 应用场景&#xff1a; 准备工作 安装 …