对象存储及Ceph

对象存储

是什么

对象存储(Object Storage)是一种无目录结构(即扁平)、无固定数据格式(即支持任意数据类型)、支持海量数据、安全、低成本、高可靠的云存储服务。对象存储是由AWS首先推出的一个存储产品形态,AWS的S3(Amazon Simple Storage Service ) 协议也成为对象存储事实标准,该协议是基于HTTP协议的,后续跟风的其他对象存储服务通常都会支持S3。

国外的对象存储:Amazon S3、Ceph等。

国内的对象存储服务:主要有腾讯云的COS(Cloud Object Storage)、阿里云的OSS(Object Storage Service)、七牛云的KODO。

项目中对象存储为何选Ceph,与OSS、AWS S3等相比有何优势。

Ceph开源的,生态更丰富,应用实践更多,产品更稳定可靠;OSS是商业的,只国内少数用
Ceph支持对象存储(RADOS)、块存储(RBD)、文件系统存储(Ceph FS);OSS则主要是对象存储,基于其的文件系统NAS需要单独购买
OSS须花钱,且和阿里云服务器实例绑定;Ceph开源,自建集群,免费;私有部署只能用开源的因为是局域网。显然Ceph更灵活可控
架构实现。没详细了解OSS的,按尿性估计国内的都是抄人家的且适当阉割,所以差不多

数据模型及API

对象存储读写数据时通常要指定 bucket、objectKey、data 三个信息,前两者用于唯一确定一个data而data可以是文件、文件流、字符串等等,可见对象存储也可以认为是KV存储

API:对象存储的事实协议标准是S3,是基于http之上的应用协议,核心接口极其简单。对象存储对外只提供两个抽象概念:桶,对象。这两个对象有哪些接口,下面列举最主要的几个:

桶(bucket):查、删、增

LIST:列举桶

DELETE:删除桶

CREATE:创建桶

对象(object):查、删、增改

GET:下载对象

DELETE:删除对象

PUT:上传对象

可见,对象存储提供增、删、查的功能,但不提供改部分数据的功能。

对象存储的客户端有很多,推荐使用 "s3cmd"。s3cmd 是一个 python 实现的知名的 s3 命令行客户端,其通过实现s3协议提供功能,提供了非常方便和人性化的手段让你使用 s3 对象存储。详情可参阅这篇文章

 

使用场景

基本上互联网的产品服务都使用了对象存储;
  海量短视频:比如抖音,快手的音视频数据就非常适用于对象存储
  静态网站,图床等
大数据,AI数据这些数据也非常适合对象存储
监控视频,海量日志,等归档数据,流式数据
国内公有云厂商一般还会添加附加价值,比如多媒体数据处理

 

关于对象存储的更多通俗易懂介绍可参阅这篇文章

 

 

例子——Ceph

Ceph基础知识和基础架构简介

http://www.xuxiaopang.com/2020/10/09/list/#more大话Ceph

http://www.xuxiaopang.com/2016/11/08/easy-ceph-CRUSH/ 大话Ceph CRUSH算法

Ceph是一种对象存储,用C++开发而成。

在Ceph中一切数据(字符串、文本、图片、音频、视频等)都看成个对象来存储(读取其二进制流存储),而不以其格式来区分它们。

 

架构

RADOS:对象存储。底层存储系统,最常用的存储方式

LIBRADOS:各种语言的客户端库

RADOSGW、RBD、CEPH FS:基于RADOS和LIBRADOS进一步实现的 Web API库、块存储、文件系统。

可见,提供三种存储方式:对象存储、块存储、文件系统存储。其中,后两种是基于第一种实现的!!!

CephFS是基于Ceph对象存储进行逻辑上的封装使得看上去像一个有目录结构的文件系统,因此具有真正文件系统所具有的大多功能。

PS:国外有的,国内肯定也就有了抄袭版:CephFS之于Ceph对象存储 好比 阿里云NAS之于阿里云OSS

 

内部原理

原理本质上与HBase等分布式数据库类似,是个数据分区管理系统。其底层用osd存储(可理解为一个硬盘),主要要做的是对数据进行分区到多节点

概念

pool(逻辑上,对象存储池,即bucket)

pg(逻辑上,placement group对象归置组)

osd(物理上,object storage device可理解为一个硬盘,一台主机里可能有多个)

pool、pg是逻辑上的概念,起到namespace的作用,以对数据进行分区,在创建一个pool时指定pg数;每个pg内的对象统一存到一个osd上。

数据存储位置计算(数据分区原理)

存储一个对象时毋庸置疑需要指定对象名(如 objectKey),此外也指定了所属的pool,要做的就是确定对象最终应存在哪个OSD上。

由于pool即bucket是应用层概念,若只根据pool确定osd则显然同bucket的数据在存储时会聚到一起,这样数据就分布不均会有热点问题。为解决该问题,内部通过pg来打散所有数据的分布

计算过程可由服务端完成,但是这样的话任何一个对象存储时都需要服务端计算存储位置,服务端压力会大。一个合格的分布式存储系统肯定应将此计算任务交由客户端来完成。

计算过程主要包括两部分(PS:数据分布原理很简单,官方吹嘘时总爱故弄玄虚。万变不离其宗,数据分布原理与读硕时所做时空数据索引系统的本质上大同小异):

1、一个文件被分割为多个小对象,每个对象的pool和objectId(比如objectKey+序号)确定所属的pg,Ceph用hash实现(pg数改了后重hash的问题?由于创建一个pool时指定了pg数,相当于pg数固定所以不用考虑重hash?)

2、确定一个pg映射到哪个或哪些osd上,对应到多个是为了冗余存储提高可靠性。Ceph采用CRUSH算法,本质上就是一种根据osd存储容量的随机选择的过程

对于一个pg:

a、CRUSH_HASH( PG_ID, OSD_ID, r ) ===> draw :其和每个osd分别确定一个随机数,称为draw

b、( draw &0xffff ) * osd_weight ===> osd_straw :各osd的权重(该osd以T为单位的存储容量值)乘各自的draw得到一个值,称为straw

c、pick up high_osd_straw :选straw最大的osd作为pg应存入的osd

这里第一步中的 r 为一个常数,用于通过调节之来为同一个pg对应到多个osd上,如分别为0、1、2等。

原理图如下:

为了确定对象应该在哪个OSD上,进行了一堆的计算,其目的就是要让对象尽可能均匀分散存储到多个OSD上,以减少数据倾斜

可见,内部原理并不复杂,它与HBase等分布式数据库很像,就是个数据分区的过程。类比:

一条文件数据被分割得到多个小文件,通过 hash(pool/bucket, objectKey, sequence) 得到各小文件唯一标识 oid。这里每个小文件就类似于HBase的一行数据

每个小文件根据 hash(oid) 得到该文件对应哪个pg。这里的pg类似于HBase中的HRegion,只不过HBase是根据rowKey值的范围确定一行数据对应到哪个HRegion。

对于一个pg,用CRUSH算法得到该pg对应哪个或哪些OSD,该算法本质是个与osd存储容量有关的随机选择算法。该过程类似于HBase中HRegion对应到哪个HRegionServer的过程。

 

使用示例

利用Ceph实现一个HTTP网盘(本质上相当于CephFS的简化版):

  1 package com.marchon.sensestudy.web;
  2 
  3 import java.io.BufferedOutputStream;
  4 import java.io.IOException;
  5 import java.io.InputStream;
  6 import java.net.URL;
  7 import java.net.URLEncoder;
  8 import java.util.ArrayList;
  9 import java.util.Comparator;
 10 import java.util.Date;
 11 import java.util.HashMap;
 12 import java.util.HashSet;
 13 import java.util.List;
 14 import java.util.Map;
 15 import java.util.Set;
 16 import java.util.regex.Pattern;
 17 import java.util.stream.Collectors;
 18 import java.util.zip.ZipEntry;
 19 import java.util.zip.ZipOutputStream;
 20 
 21 import javax.servlet.http.HttpServletRequest;
 22 import javax.servlet.http.HttpServletResponse;
 23 
 24 import org.slf4j.Logger;
 25 import org.slf4j.LoggerFactory;
 26 import org.springframework.beans.factory.annotation.Autowired;
 27 import org.springframework.core.io.InputStreamResource;
 28 import org.springframework.http.HttpStatus;
 29 import org.springframework.http.MediaType;
 30 import org.springframework.http.ResponseEntity;
 31 import org.springframework.security.access.prepost.PreAuthorize;
 32 import org.springframework.web.bind.annotation.DeleteMapping;
 33 import org.springframework.web.bind.annotation.GetMapping;
 34 import org.springframework.web.bind.annotation.PostMapping;
 35 import org.springframework.web.bind.annotation.PutMapping;
 36 import org.springframework.web.bind.annotation.RequestBody;
 37 import org.springframework.web.bind.annotation.RequestParam;
 38 import org.springframework.web.bind.annotation.RestController;
 39 import org.springframework.web.multipart.MultipartFile;
 40 
 41 import com.amazonaws.services.s3.model.CannedAccessControlList;
 42 import com.amazonaws.services.s3.model.DeleteObjectsResult;
 43 import com.amazonaws.services.s3.model.ObjectListing;
 44 import com.amazonaws.services.s3.model.S3ObjectSummary;
 45 import com.marchon.sensestudy.common.config.ConfigParam;
 46 import com.marchon.sensestudy.common.utils.CephClientUtils;
 47 import com.marchon.sensestudy.common.utils.ControllerUtils;
 48 import com.marchon.sensestudy.responsewrapper.ApiCustomException;
 49 import com.marchon.sensestudy.responsewrapper.ApiErrorCode;
 50 
 51 /**
 52  * 用户存储空间管理(网盘功能)。存储位于Ceph上,Ceph本身是key-value存储,为模拟文件系统目录,这里key采用层级路径形式:${userId}+分隔符+${absolutePath}。<br>
 53  * 可在读或写时模拟文件系统层级关系,若 读维护则增删快查改慢、写维护则与之相反。这里在读时维护<br>
 54  * <br>
 55  * note:<br>
 56  * 1. 增、改需要校验文件(夹)名有效性,查、删不需要。文件或目录名不能包含/等;<br>
 57  * 2. 在Ceph上并没有目录概念,也不存储目录,所有数据都是以key-value存储的,这里key命名时采用层级路径形式如/a/b/1.jpg、/a/b/2、/a/b/3/,展现给用户的文件夹或文件列表通过解析层级路径来获取。<br>
 58  * 为区分Ceph中一个key代表的是用户的文件还是文件夹,此以最后一层级后是否带"/"来区分:带与不带分别表示用户的文件夹和文件。可见:<br>
 59  * <li><b>用户在"/a/b/c"下可看到文件夹"d"的充要条件是Ceph中存在以"/a/b/c/d/"为前缀的key(注意d后的"/"不可少)</b></li> <br>
 60  * <li>用户看到的文件夹"b"可能来源于Ceph中的两种形式:实际创建的文件夹(在层级末尾,即如key="/a/b/")和虚拟文件夹(在层级中间,如key="/a/b/c/"或"/a/b/c.jpg")。两者可能在Ceph中同时存在,如用户创建一个文件夹再往其中传文件时</li>
 61  * 3. 写(上传文件、创建文件夹、重命名)时要解决重名问题,确保存储的:同级"目录"下文件之间、文件夹之间、文件和文件夹之间都不重名。解决:加数字后缀(传文件或文件夹时)、抛错(重命名时)<br>
 62  * <br>
 63  * TODO 涉及到组合操作,如重命名时复制原数据到新数据然后删除原数据,如何确保原子性?
 64  */
 65 @RestController
 66 public class AccountStorageController {// 操作:查(文件信息、文件下载)、删、增(文件夹、文件)、改、存储空间大小(查、改)
 67     private static final Logger logger = LoggerFactory.getLogger(AccountStorageController.class);
 68     private static final String SEPERATOR_FOR_KEY = "/";
 69 
 70     @Autowired
 71     private ControllerUtils controllerUtils;
 72 
 73     // 1. 以下为网盘CRUD相关
 74     /** 查。模拟文件系统目录形式展现Ceph上的文件。可通过Ceph的带delimiter的listObjects获取到当前目录下文件和文件夹列表,但此时文件夹没有大小、修改时间等信息,故弃之自实现 */
 75     @GetMapping("/api/v1/account/storage/entries")
 76     @PreAuthorize("hasAnyRole( 'ROLE_STUDENT' , 'ROLE_TEACHER' )")
 77     public List<EntryItem> getFileOrFolderList(@RequestParam("parentDirAbsPath") String parentDirAbsPath,
 78             HttpServletRequest request) {
 79         parentDirAbsPath = parentDirAbsPath.endsWith(SEPERATOR_FOR_KEY) ? parentDirAbsPath
 80                 : (parentDirAbsPath + SEPERATOR_FOR_KEY);// 确保以分隔符结尾,否则查询结果会有false
 81         // positive
 82         String userId = controllerUtils.getUserIdFromJwt(request);
 83         String objKeyPrefix = getJoinedObjKey(userId, parentDirAbsPath);
 84 
 85         // 查询,获取key以objKeyPrefix为前缀的所有对象信息
 86         List<S3ObjectSummary> listRes = CephClientUtils
 87                 .listObjects(ConfigParam.cephBucketName_accountStorage, objKeyPrefix).stream()
 88                 .map(objList -> objList.getObjectSummaries()).flatMap(s3ObjList -> s3ObjList.stream())
 89                 .collect(Collectors.toList());
 90         // System.out.println(listRes);
 91 
 92         // 提取文件夹名或文件名
 93         Map<String, EntryItem> res = new HashMap<>();
 94         for (S3ObjectSummary s3ObjSum : listRes) {
 95             if (s3ObjSum.getKey().equals(objKeyPrefix)) {
 96                 continue;
 97             }
 98             // 获取纯文件名或目录名
 99             String entryName = s3ObjSum.getKey().substring(objKeyPrefix.length());// 去除前缀
100             boolean isFolder = entryName.indexOf(SEPERATOR_FOR_KEY) >= 0;// 对于用户创建的文件夹Ceph存储key时以分隔符结尾
101             entryName = isFolder ? entryName.substring(0, entryName.indexOf(SEPERATOR_FOR_KEY)) : entryName;// 得到目录名或文件夹名,不包含任何额外路径信息
102 
103             // 获取fileType:folder, file, txt, doc, ...
104             int lastIndexOfDot = entryName.lastIndexOf(".");
105             String fileType = isFolder ? SpecialFileType.FOLDER.typeName
106                     : (lastIndexOfDot >= 0 && lastIndexOfDot < entryName.length() - 1
107                             ? entryName.substring(lastIndexOfDot + 1)
108                             : SpecialFileType.FILE.typeName);
109             // 组织层级信息返回
110             EntryItem entryItem = null;
111             if (!res.containsKey(entryName)) {// 新建entry
112                 entryItem = new EntryItem(parentDirAbsPath, entryName, s3ObjSum.getSize(), s3ObjSum.getLastModified(),
113                         ConfigParam.cephBucketName_accountStorage + "/" + s3ObjSum.getKey(), fileType);
114                 res.put(entryName, entryItem);
115             } else {// 更新entry,只有文件夹会有此操作
116                 entryItem = res.get(entryName);
117                 // 更新数据大小
118                 entryItem.setByteSize(entryItem.getByteSize() + s3ObjSum.getSize());
119                 // 更新最后修改时间
120                 if (s3ObjSum.getLastModified().after(entryItem.getLastModify())) {
121                     entryItem.setLastModify(s3ObjSum.getLastModified());
122                 }
123             }
124         }
125         return res.values().stream().sorted(new Comparator<EntryItem>() {// 排序:文件夹靠前、再先按类型排、再按时间倒排
126             private String fileType1, fileType2;
127 
128             @Override
129             public int compare(EntryItem o1, EntryItem o2) {
130                 fileType1 = o1.getFileType();
131                 fileType2 = o2.getFileType();
132 
133                 // 文件夹靠前排
134                 if (fileType1.equals(SpecialFileType.FOLDER.typeName)
135                         && !fileType2.equals(SpecialFileType.FOLDER.typeName)) {
136                     return -1;
137                 } else if (!fileType1.equals(SpecialFileType.FOLDER.typeName)
138                         && fileType2.equals(SpecialFileType.FOLDER.typeName)) {
139                     return 1;
140                 }
141                 // 同为文件夹 或 同非文件夹:按类型排,类型相同再按时间倒排
142                 if (fileType1.equals(fileType2)) {
143                     return -(o1.getLastModify().compareTo(o2.getLastModify()));
144                 }
145                 return fileType1.compareTo(fileType2);
146 
147             }
148         }).collect(Collectors.toList());
149     }
150 
151     /** 查。下载文件或文件夹。zip压缩使用org.apache.tools.zip库而不是JDK自带zip库,后者继承自前者故常用API几乎一样。 */
152     @GetMapping(value = "/api/v1/account/storage/entries/download")
153     @PreAuthorize("hasAnyRole( 'ROLE_STUDENT' , 'ROLE_TEACHER' )")
154     // @GetMapping(value = WebSecurityConfig.URL_TEST_PUBLIC)
155     public ResponseEntity<InputStreamResource> downLoadFiles(@RequestParam("parentDirAbsPath") String parentDirAbsPath,
156             @RequestParam("entryNames") List<String> entryNames, HttpServletRequest request,
157             HttpServletResponse response) throws IOException {
158         // 获取待下载entry的绝对路径
159         parentDirAbsPath = parentDirAbsPath.endsWith(SEPERATOR_FOR_KEY) ? parentDirAbsPath
160                 : parentDirAbsPath + SEPERATOR_FOR_KEY;
161         List<String> entryAbsPathes = new ArrayList<>(entryNames.size());
162         for (String entryName : entryNames) {
163             entryAbsPathes.add(parentDirAbsPath + entryName);
164         }
165 
166         String userId = controllerUtils.getUserIdFromJwt(request);
167         // String userId = "63b3fb3e-a8a7-49bb-8d3b-93517955cf13";
168         boolean isDownloadSingleFile = false;
169 
170         // 判断是否是单文件下载
171         String objKey;
172         if (entryAbsPathes.size() == 1) {
173             // 检测是否是文件
174             String entryAbsPath = entryAbsPathes.get(0);
175             objKey = getJoinedObjKey(userId, entryAbsPath);
176             if (!entryAbsPath.endsWith(SEPERATOR_FOR_KEY)
177                     && CephClientUtils.isObjExist(ConfigParam.cephBucketName_accountStorage, objKey)) {// 文件存在
178                 isDownloadSingleFile = true;
179             }
180         }
181 
182         // 下载
183         if (isDownloadSingleFile) {// 单文件,直接下载
184             objKey = getJoinedObjKey(userId, entryAbsPathes.get(0));
185             if (CephClientUtils.isObjExist(ConfigParam.cephBucketName_accountStorage, objKey)) {// 文件存在
186                 URL url = CephClientUtils.generatePresignedUrl(ConfigParam.cephBucketName_accountStorage, objKey, null);
187                 // InputStream in = CephClientUtils.getObject(ConfigParam.cephBucketName_accountStorage, objKey)
188                 // .getObjectContent();// doesn't work, can only get the first byte
189 
190                 String fileName = objKey.substring(objKey.lastIndexOf(SEPERATOR_FOR_KEY) + 1);
191                 InputStream in = url.openStream();
192                 // System.out.println("file " + objKey + " size: " + in.available());
193                 return ResponseEntity.ok().contentLength(in.available()).contentType(MediaType.APPLICATION_OCTET_STREAM)
194                         .header("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileName, "UTF-8"))
195                         .body(new InputStreamResource(in));
196             }
197         } else if (entryAbsPathes.size() > 0) {// 多文件下载,边压缩边下载
198             // 获取指定路径在Ceph中的所有对象key
199             Set<String> downloadObjKeys = new HashSet<>();
200             for (String entryAbsPath : entryAbsPathes) {
201                 if (entryAbsPath.trim().equals("")) {
202                     continue;
203                 }
204                 objKey = getJoinedObjKey(userId, entryAbsPath);
205                 if (!entryAbsPath.endsWith(SEPERATOR_FOR_KEY)
206                         && CephClientUtils.isObjExist(ConfigParam.cephBucketName_accountStorage, objKey)) {// 文件存在
207                     downloadObjKeys.add(objKey);
208                 } else {// 当成下载文件夹
209                     entryAbsPath = entryAbsPath.endsWith(SEPERATOR_FOR_KEY) ? entryAbsPath
210                             : entryAbsPath + SEPERATOR_FOR_KEY;// 确保查询时key以分隔符结尾,防止false
211                     // positive
212                     objKey = getJoinedObjKey(userId, entryAbsPath);
213                     // 列取该文件夹下的所有文件的key
214                     List<String> tmpObjKeys = CephClientUtils
215                             .listObjects(ConfigParam.cephBucketName_accountStorage, objKey).stream()
216                             .map(objList -> objList.getObjectSummaries()).flatMap(s3ObjList -> s3ObjList.stream())
217                             .map(e -> e.getKey())//
218                             // .filter(key -> !key.endsWith(SEPERATOR_FOR_KEY))// 过滤掉空文件夹
219                             .collect(Collectors.toList());
220                     downloadObjKeys.addAll(tmpObjKeys);
221                 }
222             }
223             // System.out.println(downloadObjKeys);
224 
225             // 下载
226             response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
227             response.setHeader("Content-Disposition", "attachment;filename="
228                     + URLEncoder.encode(entryNames.size() == 1 ? entryNames.get(0) + ".zip" : "download.zip", "UTF-8"));// 若只有一个文件夹则压缩包直接以文件夹命名
229 
230             ZipOutputStream zipos = new ZipOutputStream(new BufferedOutputStream(response.getOutputStream())); // 设置压缩流:直接写入response,实现边压缩边下载
231             zipos.setMethod(ZipOutputStream.DEFLATED); // 设置压缩方法
232             // zipos.setEncoding("UTF-8");
233 
234             logger.info("user {} download {} files", userId, downloadObjKeys.size());
235             byte[] buffer = new byte[20 * 1024];
236             ZipEntry zipEntry;
237             for (String downloadObjKey : downloadObjKeys) {
238                 // 获取文件流
239                 URL url = CephClientUtils.generatePresignedUrl(ConfigParam.cephBucketName_accountStorage,
240                         downloadObjKey, null);
241                 InputStream inputStream = url.openStream();
242                 logger.info("file {} size: {}", downloadObjKey, inputStream.available());
243 
244                 // 添加ZipEntry
245                 String fileName = downloadObjKey.substring(userId.length() + 1)// 去除userId前缀,包括分隔符
246                         .substring(parentDirAbsPath.length() == 1 ? 0 : parentDirAbsPath.length());// 去除parentDirAbsPath前缀,parentDirAbsPath本身包括分隔符
247                 zipEntry = new ZipEntry(fileName);
248                 zipEntry.setComment("generated by marchon");
249                 zipos.putNextEntry(zipEntry);// 名字带"/"后缀即可添加文件夹如/a/b/会添加文件夹b,/a/b/1.txt也会添加文件夹b。直接打开zip文件可能看不到空文件夹,但解压后即有
250                 // 传数据
251                 {
252                     int length = 0;
253                     while ((length = inputStream.read(buffer)) != -1) {
254                         zipos.write(buffer, 0, length);
255                     }
256                 }
257                 // 关闭流
258                 inputStream.close();
259                 zipos.closeEntry();
260             }
261             // 关闭流
262             zipos.close();
263         }
264         return null;
265     }
266 
267     /** 删。"*"表示删除当前所在目录下的所有内容 */
268     @DeleteMapping("/api/v1/account/storage/entries")
269     @PreAuthorize("hasAnyRole( 'ROLE_STUDENT' , 'ROLE_TEACHER' )")
270     public List<String> deleteFiles(@RequestBody List<EntryItem> entryItems, HttpServletRequest request) {
271         String userId = controllerUtils.getUserIdFromJwt(request);
272 
273         List<String> deletedEntryNameList = new ArrayList<>();
274         for (EntryItem entryItem : entryItems) {
275             String parentDirAbsPath = entryItem.getParentDirAbsPath();
276             String entryName = entryItem.getEntryName();
277             String fileType = entryItem.getFileType();// 用以确定是否是目录
278 
279             // 若entryItems元素非空,则每个元素的fileType、entryName必传
280             ApiCustomException.assertTrue(HttpStatus.BAD_REQUEST, ApiErrorCode.NOT_FOUND_PARAM,
281                     parentDirAbsPath != null,
282                     "Bad Request: Required String parameter 'parentDirAbsPath' is not present");
283             ApiCustomException.assertTrue(HttpStatus.BAD_REQUEST, ApiErrorCode.NOT_FOUND_PARAM, entryName != null,
284                     "Bad Request: Required String parameter 'entryName' is not present");
285             ApiCustomException.assertTrue(HttpStatus.BAD_REQUEST, ApiErrorCode.NOT_FOUND_PARAM, fileType != null,
286                     "Bad Request: Required String parameter 'fileType' is not present");
287 
288             // 组合获取key
289             parentDirAbsPath = parentDirAbsPath.endsWith(SEPERATOR_FOR_KEY) ? parentDirAbsPath
290                     : (parentDirAbsPath + SEPERATOR_FOR_KEY);// 确保以分隔符结尾
291 
292             // 删除
293             String objKeyPrefix;
294             if (entryName.trim().equals("*") || fileType.equals(SpecialFileType.FOLDER.typeName)) {// 删除所有 或 删除文件夹
295                 if (entryName.trim().equals("*")) {// 删除当前目录下的所有文件
296                     objKeyPrefix = getJoinedObjKey(userId, parentDirAbsPath);
297                 } else {
298                     entryName = entryName.endsWith(SEPERATOR_FOR_KEY) ? entryName : entryName + SEPERATOR_FOR_KEY;// 若是文件夹则确保以分隔符结尾,避免误删。如若指定文件夹名为code,若不加/则codeExamples文件夹也会被删
299                     objKeyPrefix = getJoinedObjKey(userId, parentDirAbsPath + entryName);
300                 }
301                 DeleteObjectsResult deletedRes = CephClientUtils
302                         .deleteObjects(ConfigParam.cephBucketName_accountStorage, objKeyPrefix);
303 
304                 List<String> deletedObjPathes = deletedRes.getDeletedObjects().stream().map(ele -> ele.getKey())// 获取key
305                         .map(key -> key.substring(userId.length(), key.length()))// 去掉userId前缀和/后缀
306                         .collect(Collectors.toList());
307                 deletedEntryNameList.addAll(deletedObjPathes);
308             } else {// 删除文件
309                 String objKey = getJoinedObjKey(userId, parentDirAbsPath + entryName);
310                 if (CephClientUtils.isObjExist(ConfigParam.cephBucketName_accountStorage, objKey)) {
311                     CephClientUtils.deleteObject(ConfigParam.cephBucketName_accountStorage, objKey);
312                     deletedEntryNameList.add(objKey.substring(userId.length()));
313                 }
314             }
315 
316             // 解决删除后父目录丢失的问题
317             {
318                 // 删除完后对用户所见而言有可能丢了若干层父目录,如对于请求parentDirAbsPath=/a/b、fileType=folder,若b是虚拟文件夹且/a/b/下只有一个entry,则执行删除后,用户就看不到b文件夹了,若a是虚拟文件夹则a用户也看不到了,依之类推。
319                 // 解决:创建一个parentDirAbsPath key
320                 objKeyPrefix = getJoinedObjKey(userId, parentDirAbsPath);
321                 int numOfKeyWithparentDirAbsPathPrefix = CephClientUtils
322                         .listObjects(ConfigParam.cephBucketName_accountStorage, objKeyPrefix).stream()
323                         .map(objList -> objList.getObjectSummaries()).flatMap(s3ObjList -> s3ObjList.stream())
324                         .collect(Collectors.toList()).size();// 以parentDirAbsPath为前缀的对象数
325                 // System.out.println(numOfKeyWithparentDirAbsPathPrefix);
326                 if (numOfKeyWithparentDirAbsPathPrefix < 1) {
327                     CephClientUtils.writeObject(ConfigParam.cephBucketName_accountStorage, objKeyPrefix, "");
328                 }
329             }
330         }
331 
332         {// 更新capacity信息
333             updateCurrentCapacity(userId);
334         }
335 
336         return deletedEntryNameList;
337     }
338 
339     /** 增。创建文件夹。须确保存到Ceph时文件夹名以 / 结尾;已存在该名字的文件或文件夹时加数字后缀 */
340     @PostMapping(value = "/api/v1/account/storage/folder", produces = "application/json")
341     @PreAuthorize("hasAnyRole( 'ROLE_STUDENT' , 'ROLE_TEACHER' )")
342     public String createFolder(@RequestBody Map<String, Object> reqMap, HttpServletRequest request) {
343         String parentDirAbsPath = (String) reqMap.get("parentDirAbsPath");
344         String inputFolderName = (String) reqMap.get("folderName");
345 
346         ApiCustomException.assertTrue(HttpStatus.BAD_REQUEST, ApiErrorCode.NOT_FOUND_PARAM, parentDirAbsPath != null,
347                 "Bad Request: Required String parameter 'parentDirAbsPath' is not present");
348         ApiCustomException.assertTrue(HttpStatus.BAD_REQUEST, ApiErrorCode.NOT_FOUND_PARAM, inputFolderName != null,
349                 "Bad Request: Required String parameter 'inputFolderName' is not present");
350 
351         ApiCustomException.assertTrue(ApiErrorCode.ILLEGAL_VALUE, isEntryNameValid(inputFolderName, true),
352                 "Invalid folder name");
353 
354         String userId = controllerUtils.getUserIdFromJwt(request);
355         parentDirAbsPath = parentDirAbsPath.endsWith(SEPERATOR_FOR_KEY) ? parentDirAbsPath
356                 : (parentDirAbsPath + SEPERATOR_FOR_KEY);// 确保以分隔符结尾
357         inputFolderName = inputFolderName.endsWith(SEPERATOR_FOR_KEY)
358                 ? inputFolderName.substring(0, inputFolderName.length() - 1)
359                 : inputFolderName;// 确保不易/结尾
360 
361         // 若当前目录下已存在同名文件夹或文件则加数字后缀
362         String savedFolderName = getRenamedEntrynameIfExist(userId, parentDirAbsPath, inputFolderName, true);
363 
364         // 创建
365         savedFolderName = savedFolderName + SEPERATOR_FOR_KEY;// 存入时确保以分隔符结尾
366         String objKey = getJoinedObjKey(userId, parentDirAbsPath + savedFolderName);
367         CephClientUtils.writeObject(ConfigParam.cephBucketName_accountStorage, objKey, "");
368         logger.info("upload folder to ceph: " + objKey);
369         return parentDirAbsPath + savedFolderName;
370     }
371 
372     /** 增。上传文件。即使文件夹上传获取到的也只是其中的所有文件之。已存在该名字的文件或文件夹时加数字后缀 */
373     @PostMapping(value = "/api/v1/account/storage/entries")
374     @PreAuthorize("hasAnyRole( 'ROLE_STUDENT' , 'ROLE_TEACHER' )")
375     public List<String> uploadFiles(@RequestParam("userFile") List<MultipartFile> files,
376             @RequestParam("parentDirAbsPath") String parentDirAbsPath, HttpServletRequest request) throws Exception {
377 
378         parentDirAbsPath = parentDirAbsPath.endsWith(SEPERATOR_FOR_KEY) ? parentDirAbsPath
379                 : (parentDirAbsPath + SEPERATOR_FOR_KEY);// 确保以分隔符结尾
380         String userId = controllerUtils.getUserIdFromJwt(request);
381         List<String> res = new ArrayList<>();
382         for (MultipartFile file : files) {
383             if (file.isEmpty()) {
384                 continue;
385             }
386             ApiCustomException.assertTrue(ApiErrorCode.ILLEGAL_VALUE,
387                     isEntryNameValid(file.getOriginalFilename(), false),
388                     "Invalid file name '" + file.getOriginalFilename() + "'");// 不能包含/等
389 
390             {// TODO 文件过滤
391                 // if (!file.getOriginalFilename().endsWith(".csv")) {// 类型过滤
392                 // throw new ApiCustomException(ApiErrorCode.INVALID_FILE_CONTENT, "only support .csv file");
393                 // }
394                 // if (file.getSize() > 100) {// 大小过滤
395                 // throw new ApiCustomException(ApiErrorCode.INVALID_FILE_CONTENT, "file to large");
396                 // }
397             }
398 
399             // 若当前目录下已存在同名文件夹或文件则加数字后缀
400             String savedEntryName = getRenamedEntrynameIfExist(userId, parentDirAbsPath, file.getOriginalFilename(),
401                     false);
402 
403             // 存储
404             String objKey = getJoinedObjKey(userId, parentDirAbsPath + savedEntryName);
405 
406             {// TODO 修改权限,不能全public
407                 CephClientUtils.writeObject(ConfigParam.cephBucketName_accountStorage, objKey, file,
408                         CannedAccessControlList.PublicRead);
409                 CephClientUtils.generatePublicUrl(ConfigParam.cephBucketName_accountStorage, objKey);
410             }
411             res.add(parentDirAbsPath + savedEntryName);
412 
413             logger.info("upload file to ceph:{}, size:{}", objKey, file.getSize());
414 
415         }
416 
417         {// 更新capacity信息
418             updateCurrentCapacity(userId);
419         }
420         return res;
421     }
422 
423     /** 改。新名字的文件夹或文件已存在时抛错 */
424     @PutMapping("/api/v1/account/storage/entry")
425     @PreAuthorize("hasAnyRole( 'ROLE_STUDENT' , 'ROLE_TEACHER' )")
426     public List<EntryItem> updateEntryName(@RequestBody Map<String, Object> reqMap, HttpServletRequest request) {
427         String parentDirAbsPath = (String) reqMap.get("parentDirAbsPath");
428         String fileType = (String) reqMap.get("fileType");
429         String oldEntryName = (String) reqMap.get("oldEntryName");
430         String newEntryName = (String) reqMap.get("newEntryName");
431 
432         // 各参数必传
433         ApiCustomException.assertTrue(HttpStatus.BAD_REQUEST, ApiErrorCode.NOT_FOUND_PARAM, parentDirAbsPath != null,
434                 "Bad Request: Required String parameter 'parentDirAbsPath' is not present");
435         ApiCustomException.assertTrue(HttpStatus.BAD_REQUEST, ApiErrorCode.NOT_FOUND_PARAM, fileType != null,
436                 "Bad Request: Required String parameter 'fileType' is not present");
437         ApiCustomException.assertTrue(HttpStatus.BAD_REQUEST, ApiErrorCode.NOT_FOUND_PARAM, oldEntryName != null,
438                 "Bad Request: Required String parameter 'oldEntryName' is not present");
439         ApiCustomException.assertTrue(HttpStatus.BAD_REQUEST, ApiErrorCode.NOT_FOUND_PARAM, newEntryName != null,
440                 "Bad Request: Required String parameter 'newEntryName' is not present");
441 
442         // 确保新旧名不同
443         if (oldEntryName.equals(newEntryName)) {
444             return getFileOrFolderList(parentDirAbsPath, request);
445         }
446 
447         String userId = controllerUtils.getUserIdFromJwt(request);
448         parentDirAbsPath = parentDirAbsPath.endsWith(SEPERATOR_FOR_KEY) ? parentDirAbsPath
449                 : (parentDirAbsPath + SEPERATOR_FOR_KEY);// 确保以分隔符结尾
450         boolean isFolder = fileType.equals(SpecialFileType.FOLDER.typeName);
451 
452         ApiCustomException.assertTrue(ApiErrorCode.ILLEGAL_VALUE, isEntryNameValid(newEntryName, isFolder),
453                 "Invalid file/folder name.");// 不能包含/等
454 
455         if (isFolder) {// 文件夹重命名
456             oldEntryName = oldEntryName.endsWith(SEPERATOR_FOR_KEY) ? oldEntryName : oldEntryName + SEPERATOR_FOR_KEY;
457             newEntryName = newEntryName.endsWith(SEPERATOR_FOR_KEY) ? newEntryName : newEntryName + SEPERATOR_FOR_KEY;
458             String oldObjKeyPrefix = getJoinedObjKey(userId, parentDirAbsPath + oldEntryName);
459             String newObjKeyPrefix = getJoinedObjKey(userId, parentDirAbsPath + newEntryName);
460 
461             // 确保新文件夹名的文件夹未存在
462             if (CephClientUtils.listObjects(ConfigParam.cephBucketName_accountStorage, newObjKeyPrefix)
463 
464                     .stream().map(objList -> objList.getObjectSummaries()).flatMap(s3ObjList -> s3ObjList.stream())
465                     .collect(Collectors.toList()).size() > 0) {
466                 throw new ApiCustomException(ApiErrorCode.DUPLICATE_DATA, "the target folder already exists");
467             }
468             // 确保原文件夹存在
469             List<S3ObjectSummary> listRes = CephClientUtils
470                     .listObjects(ConfigParam.cephBucketName_accountStorage, oldObjKeyPrefix).stream()
471                     .map(objList -> objList.getObjectSummaries()).flatMap(s3ObjList -> s3ObjList.stream())
472                     .collect(Collectors.toList());
473             if (listRes.size() < 1) {
474                 throw new ApiCustomException(ApiErrorCode.NOT_EXIST_DATA, "the source folder doesn't exist");
475             }
476 
477             // 移动
478             String oldObjKey, newObjKey;
479             for (S3ObjectSummary s3ObjSum : listRes) {
480                 // 获取重命名后的新key,别直接replace因为有可能原key中有多处被replace掉
481                 oldObjKey = s3ObjSum.getKey();
482                 newObjKey = newObjKeyPrefix + oldObjKey.substring(oldObjKeyPrefix.length());// 去掉旧前缀,加上新前缀
483                 // 转存
484                 CephClientUtils.copyObject(ConfigParam.cephBucketName_accountStorage, oldObjKey,
485                         ConfigParam.cephBucketName_accountStorage, newObjKey);
486                 CephClientUtils.deleteObject(ConfigParam.cephBucketName_accountStorage, oldObjKey);
487             }
488         } else {// 文件重命名
489             String oldObjKey = getJoinedObjKey(userId, parentDirAbsPath + oldEntryName);
490             String newObjKey = getJoinedObjKey(userId, parentDirAbsPath + newEntryName);
491 
492             // 确保新文件名的文件未存在
493             if (CephClientUtils.isObjExist(ConfigParam.cephBucketName_accountStorage, newObjKey)) {
494                 throw new ApiCustomException(ApiErrorCode.DUPLICATE_DATA, "the target file already exists");
495             }
496             // 确保原文件存在
497             if (!CephClientUtils.isObjExist(ConfigParam.cephBucketName_accountStorage, oldObjKey)) {
498                 throw new ApiCustomException(ApiErrorCode.NOT_EXIST_DATA, "the source file doesn't exist");
499             }
500 
501             CephClientUtils.copyObject(ConfigParam.cephBucketName_accountStorage, oldObjKey,
502                     ConfigParam.cephBucketName_accountStorage, newObjKey);
503             CephClientUtils.deleteObject(ConfigParam.cephBucketName_accountStorage, oldObjKey);
504         }
505         return getFileOrFolderList(parentDirAbsPath, request);
506     }
507 
508     // 2. 以下为工具方法或工具类
509     /** 检查当前目录下是否已经有与输入名同名的文件或文件夹,有则加数字后缀:若是文件夹或不带扩展名的文件则直接在尾部加后缀如 a -> a(1)、若是带扩展名的文件则在扩展名前加后缀如 a.jpg -> a(1).jpg */
510     private String getRenamedEntrynameIfExist(String userId, String parentDirAbsPath, String inputEntryName,
511             boolean isInputEntryFolder) {
512 
513         // 获取当前目录下同前缀的文件或文件夹key列表
514         String objKey = getJoinedObjKey(userId, parentDirAbsPath + inputEntryName);
515         List<ObjectListing> objectListings = CephClientUtils.listObjects(ConfigParam.cephBucketName_accountStorage,
516                 objKey, SEPERATOR_FOR_KEY);
517         List<String> fileKeysWithCommonprefix = objectListings.stream().map(e -> e.getObjectSummaries())
518                 .flatMap(e -> e.stream()).map(e -> e.getKey()).collect(Collectors.toList());// 每个元素形如
519                                                                                             // xx/a/b/1.jpg、xx/a/b/2
520         List<String> folderKeysWithCommonprefix = objectListings.stream().map(e -> e.getCommonPrefixes())
521                 .flatMap(e -> e.stream())// 每个元素形如xx/a/b/,末尾有"/"
522                 .map(key -> key.substring(0, key.length() - 1)).collect(Collectors.toList());// 去除额外添加的"/"后缀以与filekey的形式一样
523 
524         // 查重
525         Set<String> existedKeySet = new HashSet<>(fileKeysWithCommonprefix);
526         existedKeySet.addAll(folderKeysWithCommonprefix);
527         // System.out.println(existedKeySet);
528         if (existedKeySet.contains(objKey)) {// 与同级目录下的现有文件或文件夹重名
529             int lastIndexOfDot = inputEntryName.lastIndexOf(".");
530             String prefix = inputEntryName;
531             String suffix = "";
532             // System.out.println(prefix + "," + suffix);
533 
534             // 带扩展名的文件由于在扩展名前加后缀,故还需选出去与除扩展名后的前缀同名的文件或文件夹。如已有文件a.jpg、文件夹a(1).jpg,此时再添加文件a.jpg时需把该文件夹也选出
535             if (!isInputEntryFolder && 0 <= lastIndexOfDot && lastIndexOfDot < inputEntryName.length() - 1) {
536                 prefix = inputEntryName.substring(0, lastIndexOfDot);
537                 suffix = inputEntryName.substring(lastIndexOfDot);
538                 String tmpObjKey = getJoinedObjKey(userId, parentDirAbsPath + prefix);
539                 List<ObjectListing> tmpObjListings = CephClientUtils
540                         .listObjects(ConfigParam.cephBucketName_accountStorage, tmpObjKey, SEPERATOR_FOR_KEY);
541                 List<String> tmpFileKeysWithCommonprefix = tmpObjListings.stream().map(e -> e.getObjectSummaries())
542                         .flatMap(e -> e.stream()).map(e -> e.getKey()).collect(Collectors.toList());
543                 List<String> tmpFolderKeysWithCommonprefix = tmpObjListings.stream().map(e -> e.getCommonPrefixes())
544                         .flatMap(e -> e.stream()).map(key -> key.substring(0, key.length() - 1))
545                         .collect(Collectors.toList());
546                 existedKeySet.addAll(tmpFileKeysWithCommonprefix);
547                 existedKeySet.addAll(tmpFolderKeysWithCommonprefix);
548                 // System.out.println(existedKeySet);
549             }
550             // 找出最小的未使用的数字后缀
551             int size = existedKeySet.size();
552             String tmpName;
553             for (int i = 1; i <= size; i++) {
554                 tmpName = prefix + "(" + i + ")" + suffix;
555                 if (!existedKeySet.contains(getJoinedObjKey(userId, parentDirAbsPath + tmpName))) {
556                     inputEntryName = tmpName;
557                     break;
558                 }
559             }
560         }
561         return inputEntryName;
562     }
563 
564     /** 以一个分隔符依次连接字段。需要确保输入的userId不以分隔符结尾 */
565     private String getJoinedObjKey(String userId, String fileNameOrPath) {
566         // 确保以一个 分隔符 连接各字段
567         userId = userId.endsWith(SEPERATOR_FOR_KEY) ? userId.substring(0, userId.length() - 1) : userId;
568         fileNameOrPath = fileNameOrPath.startsWith(SEPERATOR_FOR_KEY) ? fileNameOrPath
569                 : SEPERATOR_FOR_KEY + fileNameOrPath;
570         return userId + fileNameOrPath;
571     }
572 
573     /** 增、改需要校验名字,查、删不需要 */
574     private boolean isEntryNameValid(String entryName, boolean isFolder) {
575         // String regEx = "[ _`~!@#$%^&*()+=|{}':;',\\[\\].<>/?~!@#¥%……&*()——+|{}【】‘;:”“’。,、?]|\n|\r|\t";
576         String regEx = "[<>|*?,]";// <,>,|,*,?, 文件不能包含这些特殊字符,文件夹还不能包含分隔符等字符
577 
578         boolean res = entryName.length() > 0 && !Pattern.compile(regEx).matcher(entryName).find();
579         if (isFolder) {
580             res = res && !entryName.contains(SEPERATOR_FOR_KEY) && !entryName.equals(SpecialDirectory.CURRENT.name)
581                     && !entryName.equals(SpecialDirectory.PARENT.name);// .和..分别表示当前目录和上层目录,系统默认,不能取该名。若前后或中间加空格则允许,不视为该二特殊目录
582         }
583         return res;
584     }
585 
586     enum SpecialFileType {
587         FOLDER("folder"), // 文件夹类型
588         FILE("file");// 未知具体格式者指定为此类型
589         public String typeName;
590 
591         private SpecialFileType(String typeName) {
592             this.typeName = typeName;
593         }
594     }
595 
596     enum SpecialDirectory {
597         CURRENT("."), // 当前目录
598         PARENT("..");// 父目录
599         String name;
600 
601         private SpecialDirectory(String name) {
602             this.name = name;
603         }
604     }
605 
606     // 3. 以下为存储容量相关。容量信息存到Ceph,需要确保不与上传文件或文件夹的key重名。key:userId, value:
607     // ${currentCapacity}_${totalCapacity}。
608     /** 获取容量,优先从capacityInfo取currentCapacity而不是通过listObjects算得,以减少开销。 */
609     @GetMapping("/api/v1/account/storage/capacity")
610     @PreAuthorize("hasAnyRole( 'ROLE_STUDENT' , 'ROLE_TEACHER' )")
611     public Map<String, Long> getCapacityInfo(HttpServletRequest request) {
612 
613         String userId = controllerUtils.getUserIdFromJwt(request);
614         List<Long> capacityList = getCapacity(userId);
615 
616         // 组装结果
617         Map<String, Long> res = new HashMap<>();
618         res.put("currentCapacityByte", capacityList.get(0));
619         res.put("totalCapacityByte", capacityList.get(1));
620         return res;
621     }
622 
623     /** 扩容 */
624     @PutMapping("/api/v1/account/storage/capacity")
625     @PreAuthorize("hasAnyRole( 'ROLE_STUDENT' , 'ROLE_TEACHER' )")
626     public Map<String, Long> updateTotalCapacity(@RequestParam("totalCapacityGB") Double newTotalCapacityGB,
627             HttpServletRequest request) {
628 
629         String userId = controllerUtils.getUserIdFromJwt(request);
630         List<Long> capacityList = getCapacity(userId);
631 
632         long currentCapacity = capacityList.get(0);
633 
634         long newTotalCapacityBytes = (long) (newTotalCapacityGB * 1024 * 1024 * 1024);
635         ApiCustomException.assertTrue(ApiErrorCode.ILLEGAL_VALUE, newTotalCapacityBytes >= currentCapacity,
636                 "the totalCapacity is less than currentCapacity");
637 
638         // 更新
639         String objKey = userId;
640         String objValue = currentCapacity + "_" + newTotalCapacityBytes;
641         CephClientUtils.writeObject(ConfigParam.cephBucketName_accountStorage, objKey, objValue);
642 
643         // 组装结果
644         Map<String, Long> res = new HashMap<>();
645         res.put("currentCapacityByte", currentCapacity);
646         res.put("totalCapacityByte", newTotalCapacityBytes);
647         return res;
648     }
649 
650     /** 通过listObjects更新存储的currentCapacity信息,以供查询使用。应该在增或删操作之后调用此 */
651     private long updateCurrentCapacity(String userId) {
652         long currentCapacity, totalCapacity;
653 
654         String objKey = userId;
655         if (CephClientUtils.isObjExist(ConfigParam.cephBucketName_accountStorage, objKey)) {
656             String tmpStr = CephClientUtils.getObjectStr(ConfigParam.cephBucketName_accountStorage, objKey);
657             String[] capacityStrs = tmpStr.split("_");
658             totalCapacity = Long.parseLong(capacityStrs[1]);
659         } else {
660             totalCapacity = (long) (ConfigParam.cephAccountStorageDefaultCapacityGB * 1024 * 1024 * 1024);
661         }
662 
663         currentCapacity = getCurrentCapacityByListingObjects(userId);
664 
665         String objValue = currentCapacity + "_" + totalCapacity;
666         CephClientUtils.writeObject(ConfigParam.cephBucketName_accountStorage, objKey, objValue);
667 
668         return currentCapacity;
669     }
670 
671     /** 获取Capacity信息,返回两个值:依次为currentCapacity、totalCapacity */
672     private List<Long> getCapacity(String userId) {
673         long currentCapacity, totalCapacity;
674 
675         String objKey = userId;
676         if (CephClientUtils.isObjExist(ConfigParam.cephBucketName_accountStorage, objKey)) {
677             String tmpStr = CephClientUtils.getObjectStr(ConfigParam.cephBucketName_accountStorage, objKey);
678             String[] capacityStrs = tmpStr.split("_");
679             currentCapacity = Long.parseLong(capacityStrs[0]);
680             totalCapacity = Long.parseLong(capacityStrs[1]);
681         } else {
682             currentCapacity = getCurrentCapacityByListingObjects(userId);
683             totalCapacity = (long) (ConfigParam.cephAccountStorageDefaultCapacityGB * 1024 * 1024 * 1024);
684         }
685 
686         List<Long> res = new ArrayList<>();
687         res.add(currentCapacity);
688         res.add(totalCapacity);
689         return res;
690     }
691 
692     /** 获取指定用户的实际已用存储空间,单位byte。通过listObjects实现,比较耗时耗资源,尽量少调用 */
693     private long getCurrentCapacityByListingObjects(String userId) {
694         long currentCapacity = 0;
695 
696         String objKeyPrefix = userId.endsWith(SEPERATOR_FOR_KEY) ? userId : userId + SEPERATOR_FOR_KEY;// 确保以分隔符结尾,以免存储capacity的object也被包含在内
697         List<S3ObjectSummary> s3ObjectSummaryList = CephClientUtils
698                 .listObjects(ConfigParam.cephBucketName_accountStorage, objKeyPrefix).stream()
699                 .map(e -> e.getObjectSummaries()).flatMap(e -> e.stream()).collect(Collectors.toList());
700         for (S3ObjectSummary s3ObjSum : s3ObjectSummaryList) {
701             currentCapacity += s3ObjSum.getSize();
702         }
703         return currentCapacity;
704     }
705 }
706 
707 /** 文件或文件夹抽象表示为Entry */
708 class EntryItem {
709     private String parentDirAbsPath;
710     private String entryName;
711     private Long byteSize;
712     private Date lastModify;
713     private String accessPath;
714     private String fileType;// pdf, xls, txt ...
715 
716     public EntryItem() {// necessary
717 
718     }
719 
720     public EntryItem(String parentDirAbsPath, String entryName, Long byteSize, Date lastModify, String accessPath,
721             String fileType) {
722         this.parentDirAbsPath = parentDirAbsPath;
723         this.entryName = entryName;
724         this.byteSize = byteSize;
725         this.lastModify = lastModify;
726         this.accessPath = accessPath;
727         this.fileType = fileType;
728     }
729 
730     public String getParentDirAbsPath() {
731         return parentDirAbsPath;
732     }
733 
734     public void setParentDirAbsPath(String parentDirAbsPath) {
735         this.parentDirAbsPath = parentDirAbsPath;
736     }
737 
738     public String getEntryName() {
739         return entryName;
740     }
741 
742     public void setEntryName(String entryName) {
743         this.entryName = entryName;
744     }
745 
746     public Long getByteSize() {
747         return byteSize;
748     }
749 
750     public void setByteSize(Long byteSize) {
751         this.byteSize = byteSize;
752     }
753 
754     public Date getLastModify() {
755         return lastModify;
756     }
757 
758     public void setLastModify(Date lastModify) {
759         this.lastModify = lastModify;
760     }
761 
762     public String getAccessPath() {
763         return accessPath;
764     }
765 
766     public void setAccessPath(String accessPath) {
767         this.accessPath = accessPath;
768     }
769 
770     public String getFileType() {
771         return fileType;
772     }
773 
774     public void setFileType(String fileType) {
775         this.fileType = fileType;
776     }
777 }
CRUD API
  1 package com.marchon.sensestudy.common.utils;
  2 
  3 import java.io.BufferedReader;
  4 import java.io.IOException;
  5 import java.io.InputStream;
  6 import java.io.InputStreamReader;
  7 import java.io.UnsupportedEncodingException;
  8 import java.net.URL;
  9 import java.util.ArrayList;
 10 import java.util.Date;
 11 import java.util.List;
 12 import java.util.stream.Collectors;
 13 
 14 import org.springframework.web.multipart.MultipartFile;
 15 
 16 import com.amazonaws.ClientConfiguration;
 17 import com.amazonaws.Protocol;
 18 import com.amazonaws.auth.AWSCredentials;
 19 import com.amazonaws.auth.BasicAWSCredentials;
 20 import com.amazonaws.services.s3.AmazonS3;
 21 import com.amazonaws.services.s3.AmazonS3Client;
 22 import com.amazonaws.services.s3.model.CannedAccessControlList;
 23 import com.amazonaws.services.s3.model.CopyObjectResult;
 24 import com.amazonaws.services.s3.model.DeleteObjectsRequest;
 25 import com.amazonaws.services.s3.model.DeleteObjectsRequest.KeyVersion;
 26 import com.amazonaws.services.s3.model.DeleteObjectsResult;
 27 import com.amazonaws.services.s3.model.ListObjectsRequest;
 28 import com.amazonaws.services.s3.model.ObjectListing;
 29 import com.amazonaws.services.s3.model.ObjectMetadata;
 30 import com.amazonaws.services.s3.model.PutObjectRequest;
 31 import com.amazonaws.services.s3.model.PutObjectResult;
 32 import com.amazonaws.services.s3.model.S3Object;
 33 import com.marchon.sensestudy.common.config.ConfigParam;
 34 
 35 public class CephClientUtils {
 36 
 37     private static AmazonS3 amazonS3Client = null;
 38     private static Protocol protocol = Protocol.HTTP;
 39     public static String CEPH_URL_STR = protocol.name() + "://" + ConfigParam.cephHost + ":" + ConfigParam.cephPort;
 40 
 41     static {
 42         AWSCredentials credentials = new BasicAWSCredentials(ConfigParam.cephAccessKey, ConfigParam.cephSecretKey);
 43         ClientConfiguration clientConfig = new ClientConfiguration();
 44         clientConfig.setProtocol(protocol);
 45         amazonS3Client = new AmazonS3Client(credentials, clientConfig);
 46         amazonS3Client.setEndpoint(ConfigParam.cephHost + ":" + ConfigParam.cephPort);
 47     }
 48 
 49     /** 批量删除key以指定前缀开头的数据 */
 50     public static DeleteObjectsResult deleteObjects(String bucketName, String keyPrefix) {
 51         List<String> keyStrs = amazonS3Client.listObjects(bucketName, keyPrefix).getObjectSummaries().stream()
 52                 .map(e -> e.getKey()).collect(Collectors.toList());
 53         List<KeyVersion> keys = keyStrs.stream().map(e -> new KeyVersion(e)).collect(Collectors.toList());
 54         DeleteObjectsRequest deleteObjectsRequest = new DeleteObjectsRequest(bucketName);
 55         deleteObjectsRequest.setKeys(keys);
 56         return amazonS3Client.deleteObjects(deleteObjectsRequest);
 57     }
 58 
 59     /** 删除精确指定key的数据 */
 60     public static void deleteObject(String bucketName, String objKey) {
 61         amazonS3Client.deleteObject(bucketName, objKey);
 62     }
 63 
 64     /**
 65      * 获取存储的资源信息列表。通过正确指定prefix和delimiter可以以类文件系统的方式列取资源信息,即若多个文件在当前目录的同一个子文件夹下则它们只以一个子文件夹的形合并显示在当前目录下。这里的合并规则为
 66      * ”${prefix}\w*${first_delimiter}“,即 prefix到delimiter首次出现
 67      * 间(包括prefix和delimiter自身)的subKey视为共有文件夹。缺点:这里的”文件夹“缺乏最近修改时间、大小等元信息。<br>
 68      * 典型应用:<br>
 69      * <li>若delimiter为空串则查得所有以prefix为前缀的key;</li><br>
 70      * <li>若所存的key是以"/"分隔的如"xx/a/b"、"xx/a/b"等,则当delimiter为"/"是:若prefix以"/"结尾则查得的是当前目录下的所有文件夹或文件、否则查得的是当前目录下以prefix为前缀的所有文件夹或文件</li>
 71      * 
 72      * @return 返回的ObjectListing List至少有一个元素
 73      */
 74     public static List<ObjectListing> listObjects(String bucketName, String keyPrefix, String delimiter) {
 75         ListObjectsRequest listObjectsRequest = new ListObjectsRequest();
 76         listObjectsRequest.setBucketName(bucketName);
 77         listObjectsRequest.setPrefix(keyPrefix);
 78         listObjectsRequest.setDelimiter(delimiter);
 79         // listObjectsRequest.setMaxKeys(2);// 1000 in default
 80         // listObjectsRequest.setMarker(null);
 81 
 82         // 可能有多个truncate
 83         List<ObjectListing> objectListings = new ArrayList<>();
 84         ObjectListing res = amazonS3Client.listObjects(listObjectsRequest);
 85         objectListings.add(res);
 86         while (res.isTruncated()) {
 87             res = amazonS3Client.listNextBatchOfObjects(res);
 88             objectListings.add(res);
 89         }
 90         return objectListings;
 91     }
 92 
 93     public static List<ObjectListing> listObjects(String bucketName, String keyPrefix) {
 94         return listObjects(bucketName, keyPrefix, null);
 95     }
 96 
 97     /** 文件存储 */
 98     public static PutObjectResult writeObject(String bucketName, String objKey, MultipartFile file,
 99             CannedAccessControlList cannedAccessControlList) {
100         PutObjectResult res;
101         if (!amazonS3Client.doesBucketExist(bucketName)) {
102             amazonS3Client.createBucket(bucketName);
103         }
104         ObjectMetadata objectMetadata = new ObjectMetadata();
105         objectMetadata.setContentLength(file.getSize()); // must set, otherwise stream contents will be buffered in
106                                                             // memory and could result in out of memory errors.
107         // objectMetadata.getUserMetadata().put("type", "pdf");//are added in http request header, which cann't only
108         // contain iso8859-1 charset
109         InputStream inputStream = null;
110         try {
111             inputStream = file.getInputStream();
112         } catch (IOException e1) {
113             e1.printStackTrace();
114         }
115         res = amazonS3Client.putObject(new PutObjectRequest(bucketName, objKey, inputStream, objectMetadata));
116         if (null != cannedAccessControlList) {
117             amazonS3Client.setObjectAcl(bucketName, objKey, cannedAccessControlList);
118         }
119         try {
120             inputStream.close();
121         } catch (IOException e) {
122             e.printStackTrace();
123         }
124         return res;
125     }
126 
127     /** 文件获取 */
128     public static S3Object getObject(String bucketName, String objKey) {
129         return amazonS3Client.getObject(bucketName, objKey);
130     }
131 
132     public static PutObjectResult writeObject(String bucketName, String objKey, MultipartFile file) {
133         return writeObject(bucketName, objKey, file, null);
134     }
135 
136     /** 生成直接访问的永久URL。要求被访问资源的权限为public才能访问到 */
137     public static URL generatePublicUrl(String bucketName, String objKey) {
138         return amazonS3Client.getUrl(bucketName, objKey);
139     }
140 
141     /** 生成直接访问的临时URL,失效时间默认15min */
142     // url formate returned: scheme://host[:port]/bucketName/objKey?{Query}
143     public static URL generatePresignedUrl(String bucketName, String key, Date expiration) {
144         return amazonS3Client.generatePresignedUrl(bucketName, key, expiration);// 失效时间点必设,默认为15min,最多只能7天
145     }
146 
147     /**
148      * 5 default metadata are set, for example: <br>
149      * {Accept-Ranges=bytes, Content-Length=5, Content-Type=text/plain, ETag=5d41402abc4b2a76b9719d911017c592,
150      * Last-Modified=Sun Nov 04 15:35:17 CST 2018}
151      */
152     public static ObjectMetadata getObjectMetaData(String bucketName, String objKey) {
153         return amazonS3Client.getObjectMetadata(bucketName, objKey);
154     }
155 
156     public static boolean isObjExist(String bucketName, String objKey) {
157         return amazonS3Client.doesObjectExist(bucketName, objKey);
158     }
159 
160     public static CopyObjectResult copyObject(String sourceBucketName, String sourceKey, String destinationBucketName,
161             String destinationKey) {
162         return amazonS3Client.copyObject(sourceBucketName, sourceKey, destinationBucketName, destinationKey);
163     }
164 
165     /** 文本存储,会被s3 sdk以UTF-8格式编码成字节流存储 */
166     public static PutObjectResult writeObject(String bucketName, String objKey, String objContent) {
167         if (null == objContent) {// objContent为null时下面putObject会出错
168             objContent = "";
169         }
170         if (!amazonS3Client.doesBucketExist(bucketName)) {
171             amazonS3Client.createBucket(bucketName);
172         }
173         return amazonS3Client.putObject(bucketName, objKey, objContent);// String will be encoded to bytes with UTF-8
174                                                                         // encoding.
175     }
176 
177     /** 文本获取 */
178     public static String getObjectStr(String bucketName, String objKey) {
179         if (!amazonS3Client.doesObjectExist(bucketName, objKey)) {// 判断是否存在,不存在时下面直接获取会报错
180             return null;
181         }
182 
183         S3Object s3Object = amazonS3Client.getObject(bucketName, objKey);
184         BufferedReader bufferedReader = null;
185         try {
186             bufferedReader = new BufferedReader(new InputStreamReader(s3Object.getObjectContent(), "UTF-8"));// 存入时被UTF-8编码了,故对应之
187         } catch (UnsupportedEncodingException e1) {
188             e1.printStackTrace();
189         }
190 
191         String res = null;
192         try {
193             res = bufferedReader.readLine();
194         } catch (IOException e) {
195             e.printStackTrace();
196         }
197         try {
198             bufferedReader.close();
199         } catch (IOException e) {
200             e.printStackTrace();
201         }
202 
203         return res;
204     }
205 
206     private static byte[] getObjectBytes(String bucketName, String objKey) throws IOException {
207         S3Object s3Object = amazonS3Client.getObject(bucketName, objKey);
208         InputStream in = s3Object.getObjectContent();
209 
210         byte[] bytes = new byte[(int) s3Object.getObjectMetadata().getInstanceLength()];
211         in.read(bytes);
212 
213         in.close();
214         return bytes;
215     }
216 }
CephClientUtils

 

posted @ 2018-08-24 17:58  March On  阅读(1240)  评论(0编辑  收藏  举报
top last
Welcome user from
(since 2020.6.1)