对象存储及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
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 }
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 }