diff --git a/.gitignore b/.gitignore
index 9154f4c..01aced2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,7 @@
hs_err_pid*
replay_pid*
+/huawei-obs-springboot-starter.iml
+/huawei-obs-springboot-starter.ipr
+/huawei-obs-springboot-starter.iws
+/target/
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..35410ca
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# 默认忽略的文件
+/shelf/
+/workspace.xml
+# 基于编辑器的 HTTP 客户端请求
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..8568f26
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,74 @@
+
+
+ 4.0.0
+
+ com.njcn
+ minioss-springboot-starter
+ 1.0.0
+
+
+
+ nexus-releases
+ Nexus Release Repository
+ http://192.168.1.13:8001/nexus/content/repositories/releases/
+
+
+ nexus-snapshots
+ Nexus Snapshot Repository
+ http://192.168.1.13:8001/nexus/content/repositories/snapshots/
+
+
+
+
+ 8
+ 8
+ UTF-8
+ 0.5.3
+ 4.8.1
+ 8.2.1
+
+ 灿能minioss组件提取的starter模块
+ jar
+
+
+
+
+ com.njcn
+ common-core
+ 1.0.0
+
+
+
+ me.tongfei
+ progressbar
+ ${progressbar.version}
+
+
+ com.squareup.okhttp3
+ okhttp
+ ${okhttp.version}
+
+
+ io.minio
+ minio
+ ${minio.version}
+
+
+ com.squareup.okhttp3
+ okhttp
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+ 2.3.12.RELEASE
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/njcn/minioss/bo/MinIoUploadResDTO.java b/src/main/java/com/njcn/minioss/bo/MinIoUploadResDTO.java
new file mode 100644
index 0000000..ceda954
--- /dev/null
+++ b/src/main/java/com/njcn/minioss/bo/MinIoUploadResDTO.java
@@ -0,0 +1,24 @@
+package com.njcn.minioss.bo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * @author hongawen
+ * @version 1.0.0
+ * @date 2022年10月16日 18:40
+ */
+@Data
+public class MinIoUploadResDTO implements Serializable {
+
+ private static final long serialVersionUID = 475040120689218785L;
+ private String minFileName;
+ private String minFileUrl;
+
+ public MinIoUploadResDTO(String minFileName, String minFileUrl) {
+ this.minFileName = minFileName;
+ this.minFileUrl = minFileUrl;
+ }
+
+}
diff --git a/src/main/java/com/njcn/minioss/config/MinIoProperties.java b/src/main/java/com/njcn/minioss/config/MinIoProperties.java
new file mode 100644
index 0000000..d189102
--- /dev/null
+++ b/src/main/java/com/njcn/minioss/config/MinIoProperties.java
@@ -0,0 +1,43 @@
+package com.njcn.minioss.config;
+
+import io.minio.MinioClient;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.stereotype.Component;
+
+/**
+ * @author hongawen
+ * @version 1.0.0
+ * @date 2022年10月16日 18:37
+ */
+@Data
+@ConfigurationProperties(prefix = "min.io")
+public class MinIoProperties {
+
+ /**
+ * Minio 服务端ip
+ */
+ private String endpoint;
+
+ /**
+ * Minio 访问通行key
+ */
+ private String accessKey;
+
+ /**
+ * Minio 访问秘钥key
+ */
+ private String secretKey;
+
+ /**
+ * Minio 桶名称
+ */
+ private String bucket;
+
+ @Bean
+ public MinioClient getMinioClient() {
+ return MinioClient.builder()
+ .endpoint(endpoint).credentials(accessKey, secretKey).build();
+ }
+}
diff --git a/src/main/java/com/njcn/minioss/util/MinIoUtils.java b/src/main/java/com/njcn/minioss/util/MinIoUtils.java
new file mode 100644
index 0000000..51631a1
--- /dev/null
+++ b/src/main/java/com/njcn/minioss/util/MinIoUtils.java
@@ -0,0 +1,606 @@
+package com.njcn.minioss.util;
+
+import com.njcn.minioss.bo.MinIoUploadResDTO;
+import com.njcn.minioss.config.MinIoProperties;
+import io.minio.*;
+import io.minio.http.Method;
+import io.minio.messages.Bucket;
+import io.minio.messages.DeleteError;
+import io.minio.messages.DeleteObject;
+import io.minio.messages.Item;
+import lombok.SneakyThrows;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.annotation.Resource;
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.util.*;
+
+
+@Configuration
+@EnableConfigurationProperties({MinIoProperties.class})
+public class MinIoUtils {
+
+ @Resource
+ private MinioClient instance;
+
+ private static final String SEPARATOR_DOT = ".";
+
+ private static final String SEPARATOR_ACROSS = "-";
+
+ private static final String SEPARATOR_STR = "";
+
+ // 存储桶名称
+ private static final String chunkBucKet = "minio_bucket";
+
+ /**
+ * 不排序
+ */
+ public final static boolean NOT_SORT = false;
+
+ /**
+ * 排序
+ */
+ public final static boolean SORT = true;
+
+ /**
+ * 默认过期时间(分钟)
+ */
+ private final static Integer DEFAULT_EXPIRY = 60;
+
+ /**
+ * 删除分片
+ */
+ public final static boolean DELETE_CHUNK_OBJECT = true;
+ /**
+ * 不删除分片
+ */
+ public final static boolean NOT_DELETE_CHUNK_OBJECT = false;
+
+ /**
+ * 判断桶是否存在
+ *
+ * @param bucketName 桶名
+ * @return boolean
+ */
+ public boolean bucketExists(String bucketName) {
+ try {
+ return instance.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return false;
+ }
+
+
+ /**
+ * 创建存储桶
+ * 创建 bucket
+ *
+ * @param bucketName 桶名
+ */
+ public void makeBucket(String bucketName) {
+ try {
+ boolean isExist = bucketExists(bucketName);
+ if (!isExist) {
+ instance.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * @return java.util.List
+ * @Description 获取文件存储服务的所有存储桶名称
+ */
+ public List listBucketNames() {
+ List bucketList = listBuckets();
+ List bucketListName = new ArrayList<>();
+ for (Bucket bucket : bucketList) {
+ bucketListName.add(bucket.name());
+ }
+ return bucketListName;
+ }
+
+ /**
+ * @return java.util.List
+ * @Description 列出所有存储桶
+ */
+ @SneakyThrows
+ private List listBuckets() {
+ return instance.listBuckets();
+ }
+
+
+ /**
+ * 获取对象文件名称列表
+ *
+ * @param bucketName 存储桶名称
+ * @param prefix 对象名称前缀(文件夹 /xx/xx/xxx.jpg 中的 /xx/xx/)
+ * @return objectNames
+ */
+ public List listObjectNames(String bucketName, String prefix) {
+ return listObjectNames(bucketName, prefix, NOT_SORT);
+ }
+
+
+ /**
+ * 获取对象文件名称列表
+ *
+ * @param bucketName 存储桶名称
+ * @param prefix 对象名称前缀(文件夹 /xx/xx/xxx.jpg 中的 /xx/xx/)
+ * @param sort 是否排序(升序)
+ * @return objectNames
+ */
+ @SneakyThrows
+ public List listObjectNames(String bucketName, String prefix, Boolean sort) {
+ boolean flag = bucketExists(bucketName);
+ if (flag) {
+ ListObjectsArgs listObjectsArgs;
+ if (null == prefix) {
+ listObjectsArgs = ListObjectsArgs.builder()
+ .bucket(bucketName)
+ .recursive(true)
+ .build();
+ } else {
+ listObjectsArgs = ListObjectsArgs.builder()
+ .bucket(bucketName)
+ .prefix(prefix)
+ .recursive(true)
+ .build();
+ }
+ Iterable> chunks = instance.listObjects(listObjectsArgs);
+ List chunkPaths = new ArrayList<>();
+ for (Result- item : chunks) {
+ chunkPaths.add(item.get().objectName());
+ }
+ if (sort) {
+ chunkPaths.sort(new Str2IntComparator(false));
+ }
+ return chunkPaths;
+ }
+ return new ArrayList<>();
+ }
+
+ /**
+ * 在桶下创建文件夹,文件夹层级结构根据参数决定
+ *
+ * @param bucket 桶名称
+ * @param WotDir 格式为 xxx/xxx/xxx/
+ */
+ @SneakyThrows
+ public String createDirectory(String bucket, String WotDir) {
+ if (!this.bucketExists(bucket)) {
+ return null;
+ }
+ instance.putObject(PutObjectArgs.builder().bucket(bucket).object(WotDir).stream(
+ new ByteArrayInputStream(new byte[]{}), 0, -1)
+ .build());
+ return WotDir;
+ }
+
+
+ /**
+ * 删除一个文件
+ *
+ * @param bucketName 桶名称
+ * @param objectName /xx/xx/xxx.jpg
+ */
+ @SneakyThrows
+ public boolean removeObject(String bucketName, String objectName) {
+
+ if (!bucketExists(bucketName)) {
+ return false;
+ }
+ instance.removeObject(
+ RemoveObjectArgs.builder()
+ .bucket(bucketName)
+ .object(objectName)
+ .build());
+ return true;
+ }
+
+ /**
+ * @param bucketName 桶名称
+ * @param objectNames /xx/xx/xxx.jpg
+ * @return java.util.List
+ * @Description 删除指定桶的多个文件对象, 返回删除错误的对象列表,全部删除成功,返回空列表
+ */
+ @SneakyThrows
+ public List removeObjects(String bucketName, List objectNames) {
+ if (!bucketExists(bucketName)) {
+ return new ArrayList<>();
+ }
+ List deleteObjects = new ArrayList<>(objectNames.size());
+ for (String objectName : objectNames) {
+ deleteObjects.add(new DeleteObject(objectName));
+ }
+ List deleteErrorNames = new ArrayList<>();
+ Iterable> results = instance.removeObjects(
+ RemoveObjectsArgs.builder()
+ .bucket(bucketName)
+ .objects(deleteObjects)
+ .build());
+ for (Result result : results) {
+ DeleteError error = result.get();
+ deleteErrorNames.add(error.objectName());
+ }
+ return deleteErrorNames;
+ }
+
+
+ /**
+ * 获取访问对象的外链地址
+ * 获取文件的下载url
+ *
+ * @param bucketName 存储桶名称
+ * @param objectName 对象名称
+ * @param expiry 过期时间(分钟) 最大为7天 超过7天则默认最大值
+ * @return viewUrl
+ */
+ @SneakyThrows
+ public String getObjectUrl(String bucketName, String objectName, Integer expiry) {
+ expiry = expiryHandle(expiry);
+ return instance.getPresignedObjectUrl(
+ GetPresignedObjectUrlArgs.builder()
+ .method(Method.GET)
+ .bucket(bucketName)
+ .object(objectName)
+ .expiry(expiry)
+ .build()
+ );
+ }
+
+
+ /**
+ * 创建上传文件对象的外链
+ *
+ * @param bucketName 存储桶名称
+ * @param objectName 欲上传文件对象的名称
+ * @return uploadUrl
+ */
+ public String createUploadUrl(String bucketName, String objectName) {
+ return createUploadUrl(bucketName, objectName, DEFAULT_EXPIRY);
+ }
+
+ /**
+ * 创建上传文件对象的外链
+ *
+ * @param bucketName 存储桶名称
+ * @param objectName 欲上传文件对象的名称
+ * @param expiry 过期时间(分钟) 最大为7天 超过7天则默认最大值
+ * @return uploadUrl
+ */
+ @SneakyThrows
+ public String createUploadUrl(String bucketName, String objectName, Integer expiry) {
+ expiry = expiryHandle(expiry);
+ return instance.getPresignedObjectUrl(
+ GetPresignedObjectUrlArgs.builder()
+ .method(Method.PUT)
+ .bucket(bucketName)
+ .object(objectName)
+ .expiry(expiry)
+ .build()
+ );
+ }
+
+
+// /**
+// * 批量下载
+// *
+// * @param directory
+// * @return
+// */
+// @SneakyThrows
+// public List downLoadMore(String bucket, String directory) {
+// Iterable> objs = instance.listObjects(ListObjectsArgs.builder().bucket(bucket).prefix(directory).useUrlEncodingType(false).build());
+// List list = new ArrayList<>();
+// for (io.minio.Result
- result : objs) {
+// String objectName = null;
+// objectName = result.get().objectName();
+// ObjectStat statObject = instance.statObject(StatObjectArgs.builder().bucket(bucket).object(objectName).build());
+// if (statObject != null && statObject.length() > 0) {
+// String fileurl = instance.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder().bucket(bucket).object(statObject.name()).method(Method.GET).build());
+// list.add(fileurl);
+// }
+// }
+// return list;
+// }
+//
+
+ /**
+ * @param multipartFile 文件
+ * @param bucketName 桶名
+ * @param directory image/
+ * @return java.lang.String
+ * @Description 文件上传
+ */
+ public MinIoUploadResDTO upload(MultipartFile multipartFile, String bucketName, String directory) throws Exception {
+ if (!this.bucketExists(bucketName)) {
+ this.makeBucket(bucketName);
+ }
+ InputStream inputStream = multipartFile.getInputStream();
+ directory = Optional.ofNullable(directory).orElse("");
+ String minFileName = directory + minFileName(multipartFile.getOriginalFilename());
+ //上传文件到指定目录
+ instance.putObject(PutObjectArgs.builder()
+ .bucket(bucketName)
+ .object(minFileName)
+ .contentType(multipartFile.getContentType())
+ .stream(inputStream, inputStream.available(), -1)
+ .build());
+ inputStream.close();
+ // 返回生成文件名、访问路径
+ return new MinIoUploadResDTO(minFileName, getObjectUrl(bucketName, minFileName, DEFAULT_EXPIRY));
+ }
+
+ /**
+ * 文件流上传
+ * @param inputStream 文件流
+ * @param bucketName 桶名
+ * @param directory image/
+ * @return java.lang.String
+ */
+ public MinIoUploadResDTO uploadStream(InputStream inputStream, String bucketName, String directory, String fileName) throws Exception {
+ if (!this.bucketExists(bucketName)) {
+ this.makeBucket(bucketName);
+ }
+ directory = Optional.ofNullable(directory).orElse("");
+ String minFileName = directory + minFileName(fileName);
+ //上传文件到指定目录
+ instance.putObject(PutObjectArgs.builder()
+ .bucket(bucketName)
+ .object(minFileName)
+ .stream(inputStream, inputStream.available(), -1)
+ .build());
+ inputStream.close();
+ // 返回生成文件名、访问路径
+ return new MinIoUploadResDTO(minFileName, getObjectUrl(bucketName, minFileName, DEFAULT_EXPIRY));
+ }
+
+
+ /**
+ * @param response
+ * @return java.lang.String
+ * @Description 下载文件
+ */
+// public void download(HttpServletResponse response, String bucketName, String minFileName) throws Exception {
+// InputStream fileInputStream = instance.getObject(GetObjectArgs.builder()
+// .bucket(bucketName)
+// .object(minFileName).build());
+// response.setHeader("Content-Disposition", "attachment;filename=" + minFileName);
+// response.setContentType("application/force-download");
+// response.setCharacterEncoding("UTF-8");
+// IOUtils.copy(fileInputStream, response.getOutputStream());
+// }
+
+
+ /**
+ * 批量创建分片上传外链
+ *
+ * @param bucketName 存储桶名称
+ * @param objectMD5 欲上传分片文件主文件的MD5
+ * @param chunkCount 分片数量
+ * @return uploadChunkUrls
+ */
+ public List createUploadChunkUrlList(String bucketName, String objectMD5, Integer chunkCount) {
+ if (null == bucketName) {
+ bucketName = chunkBucKet;
+ }
+ if (null == objectMD5) {
+ return null;
+ }
+ objectMD5 += "/";
+ if (null == chunkCount || 0 == chunkCount) {
+ return null;
+ }
+ List urlList = new ArrayList<>(chunkCount);
+ for (int i = 1; i <= chunkCount; i++) {
+ String objectName = objectMD5 + i + ".chunk";
+ urlList.add(createUploadUrl(bucketName, objectName, DEFAULT_EXPIRY));
+ }
+ return urlList;
+ }
+
+ /**
+ * 创建指定序号的分片文件上传外链
+ *
+ * @param bucketName 存储桶名称
+ * @param objectMD5 欲上传分片文件主文件的MD5
+ * @param partNumber 分片序号
+ * @return uploadChunkUrl
+ */
+ public String createUploadChunkUrl(String bucketName, String objectMD5, Integer partNumber) {
+ if (null == bucketName) {
+ bucketName = chunkBucKet;
+ }
+ if (null == objectMD5) {
+ return null;
+ }
+ objectMD5 += "/" + partNumber + ".chunk";
+ return createUploadUrl(bucketName, objectMD5, DEFAULT_EXPIRY);
+ }
+
+
+ /**
+ * 获取分片文件名称列表
+ *
+ * @param bucketName 存储桶名称
+ * @param ObjectMd5 对象Md5
+ * @return objectChunkNames
+ */
+ public List listChunkObjectNames(String bucketName, String ObjectMd5) {
+ if (null == bucketName) {
+ bucketName = chunkBucKet;
+ }
+ if (null == ObjectMd5) {
+ return null;
+ }
+ return listObjectNames(bucketName, ObjectMd5, SORT);
+ }
+
+ /**
+ * 获取分片名称地址HashMap key=分片序号 value=分片文件地址
+ *
+ * @param bucketName 存储桶名称
+ * @param ObjectMd5 对象Md5
+ * @return objectChunkNameMap
+ */
+ public Map mapChunkObjectNames(String bucketName, String ObjectMd5) {
+ if (null == bucketName) {
+ bucketName = chunkBucKet;
+ }
+ if (null == ObjectMd5) {
+ return null;
+ }
+ List chunkPaths = listObjectNames(bucketName, ObjectMd5);
+ if (null == chunkPaths || chunkPaths.size() == 0) {
+ return null;
+ }
+ Map chunkMap = new HashMap<>(chunkPaths.size());
+ for (String chunkName : chunkPaths) {
+ Integer partNumber = Integer.parseInt(chunkName.substring(chunkName.indexOf("/") + 1, chunkName.lastIndexOf(".")));
+ chunkMap.put(partNumber, chunkName);
+ }
+ return chunkMap;
+ }
+
+
+ /**
+ * 合并分片文件成对象文件
+ *
+ * @param chunkBucKetName 分片文件所在存储桶名称
+ * @param composeBucketName 合并后的对象文件存储的存储桶名称
+ * @param chunkNames 分片文件名称集合
+ * @param objectName 合并后的对象文件名称
+ * @return true/false
+ */
+ @SneakyThrows
+ public boolean composeObject(String chunkBucKetName, String composeBucketName, List chunkNames, String objectName, boolean isDeleteChunkObject) {
+ if (null == chunkBucKetName) {
+ chunkBucKetName = chunkBucKet;
+ }
+ List sourceObjectList = new ArrayList<>(chunkNames.size());
+ for (String chunk : chunkNames) {
+ sourceObjectList.add(
+ ComposeSource.builder()
+ .bucket(chunkBucKetName)
+ .object(chunk)
+ .build()
+ );
+ }
+ instance.composeObject(
+ ComposeObjectArgs.builder()
+ .bucket(composeBucketName)
+ .object(objectName)
+ .sources(sourceObjectList)
+ .build()
+ );
+ if (isDeleteChunkObject) {
+ removeObjects(chunkBucKetName, chunkNames);
+ }
+ return true;
+ }
+
+ /**
+ * 合并分片文件成对象文件
+ *
+ * @param bucketName 存储桶名称
+ * @param chunkNames 分片文件名称集合
+ * @param objectName 合并后的对象文件名称
+ * @return true/false
+ */
+ public boolean composeObject(String bucketName, List chunkNames, String objectName) {
+ return composeObject(chunkBucKet, bucketName, chunkNames, objectName, NOT_DELETE_CHUNK_OBJECT);
+ }
+
+ /**
+ * 合并分片文件成对象文件
+ *
+ * @param bucketName 存储桶名称
+ * @param chunkNames 分片文件名称集合
+ * @param objectName 合并后的对象文件名称
+ * @return true/false
+ */
+ public boolean composeObject(String bucketName, List chunkNames, String objectName, boolean isDeleteChunkObject) {
+ return composeObject(chunkBucKet, bucketName, chunkNames, objectName, isDeleteChunkObject);
+ }
+
+ /**
+ * 合并分片文件,合并成功后删除分片文件
+ *
+ * @param bucketName 存储桶名称
+ * @param chunkNames 分片文件名称集合
+ * @param objectName 合并后的对象文件名称
+ * @return true/false
+ */
+ public boolean composeObjectAndRemoveChunk(String bucketName, List chunkNames, String objectName) {
+ return composeObject(chunkBucKet, bucketName, chunkNames, objectName, DELETE_CHUNK_OBJECT);
+ }
+
+
+ /**
+ * @param originalFileName 原始名称
+ * @return java.lang.String
+ * @Description 生成上传文件名
+ */
+ public String minFileName(String originalFileName) {
+ String suffix = originalFileName;
+ if (originalFileName.contains(SEPARATOR_DOT)) {
+ suffix = originalFileName.substring(originalFileName.lastIndexOf(SEPARATOR_DOT));
+ }
+ return UUID.randomUUID().toString().replace(SEPARATOR_ACROSS, SEPARATOR_STR).toUpperCase() + suffix;
+ }
+
+
+ /**
+ * 将分钟数转换为秒数
+ *
+ * @param expiry 过期时间(分钟数)
+ * @return expiry
+ */
+ private static int expiryHandle(Integer expiry) {
+ expiry = expiry * 60;
+ if (expiry > 604800) {
+ return 604800;
+ }
+ return expiry;
+ }
+
+ static class Str2IntComparator implements Comparator {
+ private final boolean reverseOrder; // 是否倒序
+
+ public Str2IntComparator(boolean reverseOrder) {
+ this.reverseOrder = reverseOrder;
+ }
+
+ @Override
+ public int compare(String arg0, String arg1) {
+ Integer intArg0 = Integer.parseInt(arg0.substring(arg0.indexOf("/") + 1, arg0.lastIndexOf(".")));
+ Integer intArg1 = Integer.parseInt(arg1.substring(arg1.indexOf("/") + 1, arg1.lastIndexOf(".")));
+ if (reverseOrder) {
+ return intArg1 - intArg0;
+ } else {
+ return intArg0 - intArg1;
+ }
+ }
+ }
+
+ /***
+ * 根据url地址获取对象名称
+ * @author hongawen
+ * @date 2022/10/17 20:05
+ * @param objectUrl 对象地址
+ * @return String 对象名称
+ */
+ public static String getObjectNameByUrl(String objectUrl) {
+ if (objectUrl.indexOf("?") < 0) {
+ return "unknownFile";
+ }
+ String objectName = objectUrl.substring(0, objectUrl.indexOf("?"));
+ return objectName.substring(objectName.lastIndexOf("/") + 1);
+ }
+}
diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories
new file mode 100644
index 0000000..2e2535f
--- /dev/null
+++ b/src/main/resources/META-INF/spring.factories
@@ -0,0 +1,5 @@
+org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
+ com.njcn.minioss.config.MinIoProperties,\
+ com.njcn.minioss.util.MinIoUtils
+
+