MinIO作为一款基于Golang 编程语言开发的一款高性能的分布式式存储方案的开源项目,有十分完善的官方文档。
文档地址:https://docs.min.io/cn/
本节介绍下使用 minio 来作为项目的对象存储,想要使用 minio,首先需要搭建 minio 服务,然后在应用端接入
Minio 服务搭建
1panel 的应用商店可以直接安装 minio,一键部署
如果使用 docker 部署,按照如下步骤:
首先我们要创建两个文件目录:一个用来存放 MinIO 的配置文件,一个用来存储我们上传文件数据。
mkdir -p /home/minio/config
mkdir -p /home/minio/data
/home/minio/config
用于存放 MinIO 的配置文件/home/minio/data
用于存储上传的文件数据
接下来我们可以通过如下命令拉取最新版镜像并创建 MinIO 容器运行。如果需要下载指定版本可以点击前往DockerHub仓库选择下载
docker run -p 9000:9000 -p 9001:9001 \
-d --restart=always \
-e "MINIO_ACCESS_KEY=admin" \
-e "MINIO_SECRET_KEY=password" \
-v /home/minio/data:/data \
-v /home/minio/config:/root/.minio \
minio/minio server \
/data \
--console-address ":9000"
命令行解释:
MINIO_ACCESS_KEY
和MINIO_SECRET_KEY
为UI界面登录账号密码-d
将容器以后台(守护进程)模式运行,并与终端分离。--restart=always
选项指定容器在停止后总是自动重启。/home/minio/data
挂载的存储上传文件的目录/home/minio/config
挂载的配置文件minio/minio server
使用MinIO 镜像并启动/data
要使用的数据目录--console-address
指定UI 界面的端口9000:9000
映射UI界面端口9001:9001
映射服务器端口
最后在浏览器中访问 http://服务器IP:9001 即可访问到MinIO的控制台
在控制台界面中,需要在 Access keys 中获取 AccessKey 和 SecretKey,这个一般在项目的配置中会用到。具体的可以参考网上的教程
整合 Minio
依赖
这里使用AWS S3 来操作 Minio,S3 存储协议兼容所有支持S3协议的云厂商,包括主流的阿里云 腾讯云 七牛云
<dependency>
<groupId>com.amazonaws</groupId>
<artifactId>aws-java-sdk-s3</artifactId>
</dependency>
OSS 对象存储配置
/**
* OSS对象存储 配置属性
*/
@Data
public class OssProperties {
/**
* 租户id
*/
private String tenantId;
/**
* 访问站点
*/
private String endpoint;
/**
* 自定义域名
*/
private String domain;
/**
* 前缀
*/
private String prefix;
/**
* ACCESS_KEY
*/
private String accessKey;
/**
* SECRET_KEY
*/
private String secretKey;
/**
* 存储空间名
*/
private String bucketName;
/**
* 存储区域
*/
private String region;
/**
* 是否https(Y=是,N=否)
*/
private String isHttps;
/**
* 桶权限类型(0private 1public 2custom)
*/
private String accessPolicy;
}
OssClient 实例创建
使用 asw-sdk-java 来对Minio Server,首先创建 AmazonS3 客户端对象
public class OssClient {
private final AmazonS3 client;
public OssClient(String configKey, OssProperties ossProperties) {
this.configKey = configKey;
this.properties = ossProperties;
try {
AwsClientBuilder.EndpointConfiguration endpointConfig =
new AwsClientBuilder.EndpointConfiguration(properties.getEndpoint(), properties.getRegion());
AWSCredentials credentials = new BasicAWSCredentials(properties.getAccessKey(), properties.getSecretKey());
AWSCredentialsProvider credentialsProvider = new AWSStaticCredentialsProvider(credentials);
ClientConfiguration clientConfig = new ClientConfiguration();
if (OssConstant.IS_HTTPS.equals(properties.getIsHttps())) {
clientConfig.setProtocol(Protocol.HTTPS);
} else {
clientConfig.setProtocol(Protocol.HTTP);
}
AmazonS3ClientBuilder build = AmazonS3Client.builder()
.withEndpointConfiguration(endpointConfig)
.withClientConfiguration(clientConfig)
.withCredentials(credentialsProvider)
.disableChunkedEncoding();
if (!StringUtils.containsAny(properties.getEndpoint(), OssConstant.CLOUD_SERVICE)) {
// minio 使用https限制使用域名访问 需要此配置 站点填域名
build.enablePathStyleAccess();
}
this.client = build.build();
createBucket();
} catch (Exception e) {
if (e instanceof OssException) {
throw e;
}
throw new OssException("配置错误! 请检查系统配置:[" + e.getMessage() + "]");
}
}
public void createBucket() {
try {
String bucketName = properties.getBucketName();
if (client.doesBucketExistV2(bucketName)) {
return;
}
CreateBucketRequest createBucketRequest = new CreateBucketRequest(bucketName);
AccessPolicyType accessPolicy = getAccessPolicy();
createBucketRequest.setCannedAcl(accessPolicy.getAcl());
client.createBucket(createBucketRequest);
client.setBucketPolicy(bucketName, getPolicy(bucketName, accessPolicy.getPolicyType()));
} catch (Exception e) {
throw new OssException("创建Bucket失败, 请核对配置信息:[" + e.getMessage() + "]");
}
}
}
文件上传的流程
前端组件 el-upload
<el-upload
multiple
:action="uploadImgUrl"
list-type="picture-card"
:on-success="handleUploadSuccess"
:before-upload="handleBeforeUpload"
:limit="limit"
:on-error="handleUploadError"
:on-exceed="handleExceed"
ref="imageUpload"
:before-remove="handleDelete"
:show-file-list="true"
:headers="headers"
:file-list="fileList"
:on-preview="handlePictureCardPreview"
:class="{ hide: fileList.length >= limit }"
>
<script setup lang="ts">
// 上传的图片服务器地址
const uploadImgUrl = ref(baseUrl + "/resource/oss/upload");
// 查询OSS对象存储列表
export function listOss(query: OssQuery): AxiosPromise<OssVO[]> {
return request({
url: '/resource/oss/list',
method: 'get',
params: query
});
}
// 删除OSS对象存储
export function delOss(ossId: string | number | Array<string | number>) {
return request({
url: '/resource/oss/' + ossId,
method: 'delete'
});
}
</script>
SysOssController.java
/**
* 上传OSS对象存储
*
* @param file 文件
*/
@SaCheckPermission("system:oss:upload")
@Log(title = "OSS对象存储", businessType = BusinessType.INSERT)
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public R<SysOssUploadVo> upload(@RequestPart("file") MultipartFile file) {
if (ObjectUtil.isNull(file)) {
return R.fail("上传文件不能为空");
}
SysOssVo oss = ossService.upload(file);
SysOssUploadVo uploadVo = new SysOssUploadVo();
uploadVo.setUrl(oss.getUrl());
uploadVo.setFileName(oss.getOriginalName());
uploadVo.setOssId(oss.getOssId().toString());
return R.ok(uploadVo);
}
SysOssServiceImpl.java
@Override
public SysOssVo upload(MultipartFile file) {
String originalfileName = file.getOriginalFilename();
String suffix = StringUtils.substring(originalfileName, originalfileName.lastIndexOf("."), originalfileName.length());
OssClient storage = OssFactory.instance();
UploadResult uploadResult;
try {
uploadResult = storage.uploadSuffix(file.getBytes(), suffix, file.getContentType());
} catch (IOException e) {
throw new ServiceException(e.getMessage());
}
// 保存文件信息
return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult);
}
private SysOssVo matchingUrl(SysOssVo oss) {
OssClient storage = OssFactory.instance(oss.getService());
// 仅修改桶类型为 private 的URL,临时URL时长为120s
if (AccessPolicyType.PRIVATE == storage.getAccessPolicy()) {
oss.setUrl(storage.getPrivateUrl(oss.getFileName(), 120));
}
return oss;
}
如果 Minio 存储桶设置的是私有类型,这里会生成一个2分钟的临时访问路径,也就是说,这个路径可以在任何地方访问,但是两分钟后会自动失效。
后续重新访问存储桶文件时,会重新生成新的临时路径
OssClient.java
public UploadResult upload(InputStream inputStream, String path, String contentType) {
if (!(inputStream instanceof ByteArrayInputStream)) {
inputStream = new ByteArrayInputStream(IoUtil.readBytes(inputStream));
}
try {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType(contentType);
metadata.setContentLength(inputStream.available());
PutObjectRequest putObjectRequest = new PutObjectRequest(properties.getBucketName(), path, inputStream, metadata);
// 设置上传对象的 Acl 为公共读
putObjectRequest.setCannedAcl(getAccessPolicy().getAcl());
client.putObject(putObjectRequest);
} catch (Exception e) {
throw new OssException("上传文件失败,请检查配置信息:[" + e.getMessage() + "]");
}
return UploadResult.builder().url(getUrl() + "/" + path).filename(path).build();
}
public String getPath(String prefix, String suffix) {
// 生成uuid
String uuid = IdUtil.fastSimpleUUID();
// 文件路径
String path = DateUtils.datePath() + "/" + uuid;
if (StringUtils.isNotBlank(prefix)) {
path = prefix + "/" + path;
}
return path + suffix;
}
getPath 方法重新生成上传的文件路径和文件名
S3 api 上传步骤
// 创建 ObjectMetadata 对象
ObjectMetadata metadata = new ObjectMetadata();
// 创建 PutObjectRequest 对象
PutObjectRequest putObjectRequest = new PutObjectRequest(properties.getBucketName(), path, inputStream, metadata);
// 上传
client.putObject(putObjectRequest);
文件删除
前端 el-upload 组件提供了删除文件功能
// 删除OSS对象存储
export function delOss(ossId: string | number | Array<string | number>) {
return request({
url: '/resource/oss/' + ossId,
method: 'delete'
});
}
/**
* 删除OSS对象存储
*
* @param ossIds OSS对象ID串
*/
@SaCheckPermission("system:oss:remove")
@Log(title = "OSS对象存储", businessType = BusinessType.DELETE)
@DeleteMapping("/{ossIds}")
public R<Void> remove(@NotEmpty(message = "主键不能为空")
@PathVariable Long[] ossIds) {
return toAjax(ossService.deleteWithValidByIds(List.of(ossIds), true));
}
@Override
public Boolean deleteWithValidByIds(Collection<Long> ids, Boolean isValid) {
if (isValid) {
// 做一些业务上的校验,判断是否需要校验
}
// 根据文件 ID 查询文件信息
List<SysOss> list = baseMapper.selectBatchIds(ids);
for (SysOss sysOss : list) {
OssClient storage = OssFactory.instance(sysOss.getService());
storage.delete(sysOss.getUrl());
}
return baseMapper.deleteBatchIds(ids) > 0;
}
OssClient.java
public void delete(String path) {
path = path.replace(getUrl() + "/", "");
try {
client.deleteObject(properties.getBucketName(), path);
} catch (Exception e) {
throw new OssException("删除文件失败,请检查配置信息:[" + e.getMessage() + "]");
}
}
文件下载
前端 oss 文件下载功能,这里使用的返回类型是 blob
,表示一个不可变、原始数据的类文件对象
async oss(ossId: string | number) {
const url = baseURL + '/resource/oss/download/' + ossId;
downloadLoadingInstance = ElLoading.service({ text: '正在下载数据,请稍候', background: 'rgba(0, 0, 0, 0.7)' });
try {
const res = await axios({
method: 'get',
url: url,
responseType: 'blob',
headers: globalHeaders()
});
const isBlob = blobValidate(res.data);
if (isBlob) {
const blob = new Blob([res.data], { type: 'application/octet-stream' });
FileSaver.saveAs(blob, decodeURIComponent(res.headers['download-filename'] as string));
} else {
this.printErrMsg(res.data);
}
downloadLoadingInstance.close();
} catch (r) {
console.error(r);
ElMessage.error('下载文件出现错误,请联系管理员!');
downloadLoadingInstance.close();
}
},
/**
* 下载OSS对象
*
* @param ossId OSS对象ID
*/
@SaCheckPermission("system:oss:download")
@GetMapping("/download/{ossId}")
public void download(@PathVariable Long ossId, HttpServletResponse response) throws IOException {
ossService.download(ossId, response);
}
SysOssServiceImpl.java
@Override
public void download(Long ossId, HttpServletResponse response) throws IOException {
SysOssVo sysOss = SpringUtils.getAopProxy(this).getById(ossId);
if (ObjectUtil.isNull(sysOss)) {
throw new ServiceException("文件数据不存在!");
}
FileUtils.setAttachmentResponseHeader(response, sysOss.getOriginalName());
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8");
OssClient storage = OssFactory.instance(sysOss.getService());
try(InputStream inputStream = storage.getObjectContent(sysOss.getUrl())) {
int available = inputStream.available();
IoUtil.copy(inputStream, response.getOutputStream(), available);
response.setContentLength(available);
} catch (Exception e) {
throw new ServiceException(e.getMessage());
}
}
@Cacheable(cacheNames = CacheNames.SYS_OSS, key = "#ossId")
@Override
public SysOssVo getById(Long ossId) {
return baseMapper.selectVoById(ossId);
}
这个在调用 getById
方法时,并不是直接调用的,而是使用了 aop 切面,为什么呢?在这段代码中,通过 SpringUtils.getAopProxy(this)
获取代理对象进行方法调用的主要目的是确保方法调用时能够触发 AOP 的增强逻辑,这里就是确保 Cacheable 注解的功能能够生效
文件下载时使用的是 application/octet-stream
二进制流格式,前端接收二进制数据后,再转化为文件