1.背景
公司早前使用的是fastdfs文件存储服务器,并且是以单点的形式存在了多年。不管从维护性和未来的扩展性考虑,决定进行升级更换,调研之后,发现minio比较适合公司现状,也是未来的趋势。
2.升级minio,如果兼容fastdfs数据?
由于fastdfs的文件不能直接迁移至minio中使用,文件链接地址存储在数据库中,格式也大不相同,只能进行逐步手动迁移的思路进行。
(1)对于有时效性的文件,比如要求存在3个月,半年的这种文件,可以不进行迁移,保留fastdfs服务器的运行,提供文件链接下载功能。
(2)重新生成上传的文件,系统均对接至minio服务器上,保证新文件均落在minio上。
(3)系统内部进行文件下载,不能局限于minio提供的下载方法,应该编写通用方法,通过文件链接进行文件下载。因为有可能通过fastdfs或者minio进行下载;
(4)对于系统内部的文件,这些不确定文件时效性的,或者’永久’需要的,如果少量文件,可以手工进行文件迁移并修改数据库文件链接地址。量多考虑程序批量迁移。
(5)数据迁移完成,如果fastdfs上的文件不需要提供下载,便可以停止fastdfs的维护。
3.代码实现
服务端
创建一个公共微服务,提供文件上传下载等操作。
maven依赖,忽略基本springcloud依赖:
<!--通过文件类型得到contenttype-->
<dependency>
<groupId>com.j256.simplemagic</groupId>
<artifactId>simplemagic</artifactId>
<version>1.16</version>
</dependency>
<!--minio版本-->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>7.1.2</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.11</version>
</dependency>
以下为yml配置文件,忽略注册中心等基础配置:
minio:
endpoint: https://xx.xxxxxx.xx
access-key: minio
secret-key: minio
bucket-name: public
gateway: ${minio.endpoint}${minio.bucket-name}/
加载配置类:
@Configuration
@ConfigurationProperties(prefix = "minio")
@Data
public class MinioConfig {
public String endpoint;
public String accessKey;
public String secretKey;
public String bucketName;
public String gateway;
}
接口类:
public interface IFileService {
/**
* @description 文件上传
* @param file:待上传文件
**/
String uploadFile(MultipartFile file);
/**
* @Description 上传文件到自定义桶,bucketName为空时,上传到默认桶(配置项public)
* @Param [bucketName, file]
*/
String uploadFileByBucket(String bucketName, MultipartFile file);
/**
* @Description 文件上传,通过文件流和contentType
* @Param [bucketName,fileInputStream, fileType,contentType]
*/
String uploadFileByByte(String bucketName,InputStream fileInputStream,String fileType, String contentType);
/**
* @description 文件下载(读取)
* @param filePath: 文件路径
**/
byte[] downloadFile(String filePath);
}
实现类:
@Service
public class FileServiceImpl implements IFileService {
private final Logger logger = LoggerFactory.getLogger(FileServiceImpl.class);
@Autowired
private MinioConfig minioConfig;
/**
* url分隔符
*/
public static final String URI_DELIMITER = "/";
/**
* 定义一个单例的MinioClient对象
*/
private static MinioClient minioClient;
/**
* @Description 获取客户端单例对象
* @Param []
*/
private MinioClient getInstance() {
if (minioClient == null) {
minioClient = MinioClient.builder().endpoint(minioConfig.endpoint).credentials(minioConfig.accessKey, minioConfig.secretKey).build();
}
return minioClient;
}
/**
* @Description 文件上传
* @Param [file]
*/
@Override
public String uploadFile(MultipartFile file) {
return putObject(null, file);
}
/**
* @Description 自定义桶上传文件,当为null,上传默认桶public
* @Param [bucketName, file]
*/
@Override
public String uploadFileByBucket(String bucketName, MultipartFile file) {
return putObject(bucketName, file);
}
/**
* jsonObject包含参数
* 1.bytes为basr64bianma后的byte数组
* 2.fileType上传文件的后缀扩展名,例如'pdf'
* 3.contentType文件上传类型,例如‘application/pdf’
* 4.bucketName桶名称,为空则默认桶public
*/
@Override
public String uploadFileByByte(String bucketName,InputStream fileInputStream, String fileType, String contentType) {
return putObject(bucketName, fileInputStream, fileType, contentType);
}
/**
* @Description 文件下载
* @Param [filePath 通过文件链接得到字节数组]
*/
@Override
public byte[] downloadFile(String filePath) {
logger.info("下载文件:{}",filePath);
try {
URL url = new URL(filePath);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
//设置超时间为3秒
conn.setConnectTimeout(3 * 1000);
//设置请求头
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/57.0.2987.110 Safari/537.36");
//得到输入流
InputStream inputStream = conn.getInputStream();
//获取自己数组
byte[] bs = readInputStream(inputStream);
return bs;
} catch (Exception e) {
throw new RuntimeException("下载文件异常:",e);
}
}
/**
* 从输入流中获取字节数组
*
* @param inputStream
* @return
* @throws IOException
*/
public static byte[] readInputStream(InputStream inputStream) {
byte[] buffer = new byte[1024];
int len = 0;
ByteArrayOutputStream bos = new ByteArrayOutputStream();
try {
while ((len = inputStream.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
return bos.toByteArray();
} catch (Exception e) {
throw new RuntimeException("读取文件流异常:", e);
} finally {
if (bos != null) {
try {
bos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (inputStream != null) {
try {
inputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
/**
* @Description 根据桶名,上传文件
* @Param [bucketName, multipartFile]
*/
private String putObject(String bucketName, MultipartFile multipartFile) {
logger.info("开始上传文件,文件桶名:{},文件名:{}",bucketName,multipartFile.getOriginalFilename());
try {
// 获取网关地址
String gateway = getGateway(bucketName);
// 若桶名不存在
if (bucketName == null) {
// 上传默认桶
bucketName = minioConfig.bucketName;
// 默认网关地址
gateway = minioConfig.gateway;
}
minioClient = getInstance();
// UUID重命名
String fileName = UUID.randomUUID().toString().replace("-", "") + "." + getSuffix(multipartFile.getOriginalFilename());
// 年/月/日/file
String finalPath = new StringBuilder()
.append(getDateFolder())
.append(URI_DELIMITER)
.append(fileName).toString();
minioClient.putObject(PutObjectArgs.builder()
.stream(multipartFile.getInputStream(), multipartFile.getSize(), PutObjectArgs.MIN_MULTIPART_SIZE)
.object(finalPath)
.contentType(multipartFile.getContentType())
.bucket(bucketName)
.build());
finalPath = gateway + finalPath;
logger.info("返回文件地址:" + finalPath);
return finalPath;
} catch (Exception e) {
logger.error("文件上传出现异常:{}", e.getMessage());
throw new RuntimeException("文件上传出现异常:", e);
}
}
/**
* @Description 根据桶名,文件流,文件扩张名,文件上传类型,例如‘application/pdf’
* @Param [bucketName, fileInputStream, fileType, contentType]
*/
private String putObject(String bucketName, InputStream fileInputStream, String fileType, String contentType) {
try {
// 获取网关地址
String gateway = getGateway(bucketName);
// 若桶名不存在
if (bucketName == null) {
// 上传默认桶
bucketName = minioConfig.bucketName;
// 默认网关地址
gateway = minioConfig.gateway;
}
minioClient = getInstance();
//构造文件上传路径
String finalPath = setUpdaloadFilePath(fileType);
logger.info("文件上传路径为" + finalPath);
minioClient.putObject(PutObjectArgs.builder()
.stream(fileInputStream, fileInputStream.available(), PutObjectArgs.MIN_MULTIPART_SIZE)
.object(finalPath)
.contentType(contentType)
.bucket(bucketName)
.build());
logger.info("返回文件地址:" + gateway + finalPath);
return gateway + finalPath;
} catch (Exception e) {
throw new RuntimeException("文件上传出现异常", e);
}
}
/**
* 获取年月日[2020/09/01]
*
* @return
*/
private static String getDateFolder() {
Date d = new Date();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd");
return sdf.format(d);
}
/**
* 获取文件后缀
*
* @param fileName
* @return
*/
protected static String getSuffix(String fileName) {
int index = fileName.lastIndexOf(".");
if (index != -1) {
String suffix = fileName.substring(index + 1);
if (!suffix.isEmpty()) {
return suffix;
}
}
throw new IllegalArgumentException("非法文件名称:" + fileName);
}
/**
* @Description 获取自定义桶的网关地址
* @Param [bucketName]
*/
private String getGateway(String bucketName) {
String tempEndpoint = minioConfig.endpoint;
if (!tempEndpoint.endsWith(URI_DELIMITER)) {
tempEndpoint += URI_DELIMITER;
}
return tempEndpoint + bucketName + URI_DELIMITER;
}
/**
* @Description 构造上传文件的上传路径
* @Param [fileType 文件扩展名]
*/
private String setUpdaloadFilePath(String fileType){
if(StringUtils.isBlank(fileType)){
fileType = "";
}
// UUID重命名
String fileName = UUID.randomUUID().toString().replace("-", "") + "." + fileType;
// 年/月/日/file
String finalPath = new StringBuilder()
.append(getDateFolder())
.append(URI_DELIMITER)
.append(fileName).toString();
return finalPath;
}
}
controller层:
@RestController
@RequestMapping("/fileService")
public class FileController {
private final Logger logger = LoggerFactory.getLogger(FileController.class);
@Autowired
private FileServiceImpl fileService;
/**
* @Description 上传文件
* @Param [file]
*/
@PostMapping(value = "/uploadFile", produces = {MediaType.APPLICATION_JSON_UTF8_VALUE},consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public String uploadFile(@RequestPart(value = "file") MultipartFile file) {
return fileService.uploadFile(file);
}
/**
* @Description 自定义桶上传文件
* @Param [bucketName, file]
*/
@PostMapping(value = "/uploadFileByBucket", produces = {MediaType.APPLICATION_JSON_UTF8_VALUE},consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public String uploadFileByBucket(@RequestParam(value = "bucketName") String bucketName,@RequestPart(value = "file") MultipartFile file) {
return fileService.uploadFileByBucket(bucketName, file);
}
/**
* jsonObject包含参数
* 1.bytes必填,为basr64编码后的byte数组,org.apache.commons.codec.binary.Base64.encodeBase64String
* 2.fileType必填,上传文件的后缀扩展名,例如'pdf'
* 3.contentType可为空,文件上传类型,例如‘application/pdf',如果该参数不存在,则工具类中寻
* 4.bucketName可为空,桶名称为空则默认桶public
*/
@PostMapping(value = "/uploadFileByByte")
public String uploadFileByByte(@RequestBody JSONObject jsonObject) {
String bytes = jsonObject.getString("bytes");
String fileType = jsonObject.getString("fileType");
String contentType = jsonObject.getString("contentType");
String bucketName = jsonObject.getString("bucketName");
InputStream input = new ByteArrayInputStream(Base64.decodeBase64(bytes));
// 如果没有contentType,则在工具类中寻找
if(StringUtils.isBlank(contentType)){
ContentInfo info = ContentInfoUtil.findExtensionMatch(fileType);
if(info == null){
logger.error("未找到fileType:{},对应的contentType,文件上传失败",fileType);
throw new IllegalArgumentException("未找到fileType:"+fileType+",对应的contentType,文件上传失败");
}
contentType = info.getMimeType();
}
return fileService.uploadFileByByte(bucketName,input, fileType,contentType);
}
/**
* @Description 下载文件返回字节数组
* @Param [filePath]
*/
@GetMapping("/readFile")
public byte[] readFile(@RequestParam String filePath) {
return fileService.downloadFile(filePath);
}
}
重点内容说明:
1.接口注解中指定MediaType
produces = {MediaType.APPLICATION_JSON_UTF8_VALUE},consumes = MediaType.MULTIPART_FORM_DATA_VALUE
2.文件对象参数使用注解
@RequestPart
3.接口uploadFileByByte,通过文件字节数组上传文件,需要参数fileType文件类型与contentType。通过文件类型得到文件的contentType,例如pdf文件的contentType为application/pdf,推荐一个工具包
<dependency>
<groupId>com.j256.simplemagic</groupId>
<artifactId>simplemagic</artifactId>
<version>1.16</version>
</dependency>
通过以下方法即可得到:
ContentInfo info = ContentInfoUtil.findExtensionMatch(fileType);
String contentType = info.getMimeType();
客户端
feign配置:
@FeignClient(value = "file-service-api", configuration = FileService.MultipartSupportConfig.class)
public interface FileService {
@PostMapping(value = "/fileService/uploadFile", produces = {MediaType.APPLICATION_JSON_UTF8_VALUE},consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
String uploadFile(@RequestPart(value = "file") MultipartFile file);
@PostMapping(value = "/fileService/uploadFileByBucket", produces = {MediaType.APPLICATION_JSON_UTF8_VALUE},consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
String uploadFileByBucket(@RequestParam(value = "bucketName") String bucketName,@RequestPart(value = "file") MultipartFile file);
@PostMapping(value = "/fileService/uploadFileByByte")
String uploadFileByByte(@RequestBody JSONObject jsonObject);
@GetMapping("/fileService/readFile")
byte[] readFile(@RequestParam String filePath);
@Configuration
class MultipartSupportConfig {
@Autowired
private ObjectFactory<HttpMessageConverters> messageConverters;
@Bean
@Primary
@Scope("prototype")
public Encoder feignFormEncoder() {
return new SpringFormEncoder(new SpringEncoder(messageConverters));
}
}
}
重点内容说明:
1.接口注解中指定MediaType
produces = {MediaType.APPLICATION_JSON_UTF8_VALUE},consumes = MediaType.MULTIPART_FORM_DATA_VALUE
2.文件对象参数使用注解
@RequestPart
3.feign本身不支持文件传输,需要配置Configuration中的内容才可支持,但仅支持单个文件传输。
注意事项
1.配置文件中,其中minio.endpoint配置minio的地址域名,结合nginx使用,注意nginx配置只能配置root目录,即匹配根路径。附nginx配置示例:
events {
worker_connections 1024;
}
http {
client_max_body_size 20m;
include mime.types;
default_type application/octet-stream;
upstream minio-server {
server 192.168.xxx.xxx:9000 weight=25 max_fails=2 fail_timeout=30s;
server 192.168.xxx.xxx:9000 weight=25 max_fails=2 fail_timeout=30s;
server 192.168.xxx.xxx:9000 weight=25 max_fails=2 fail_timeout=30s;
server 192.168.xxx.xxx:9000 weight=25 max_fails=2 fail_timeout=30s;
}
server {
listen 80;
server_name localhost;
charset utf-8;
location /{
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $remote_addr;
client_body_buffer_size 10M;
client_max_body_size 1G;
proxy_buffers 1024 4k;
proxy_read_timeout 300;
proxy_next_upstream error timeout http_404;
proxy_pass http://minio-server;
}
}
}
2.文件服务接口返回的文件链接地址,是不带时效性的,即永久可用。需要修改minio服务器桶策略,可读可写。否则文件链接最大时效性为7天。
3.安全方面考虑,正式环境需要关闭minio的web端管理,在minio启动脚本中指定参数即可:
export MINIO_BROWSER=off
但是为了方便运维人员,还是需要web管理端,所以可以通过网关的形式访问,启动对应的网关服务即可,这样即使关闭了web管理端,但是运维人员仍然可以通过网关地址进行访问。
4.minio对于大于5MB的文件上传会自动进行分片处理,对于客户端是无感的,若需要前端进行分片上传,可使用tus协议或uppy文件上传组件处理。
参考文档:
http://docs.minio.org.cn/docs