- Pre
- 需求
- 使用步骤
- 1. 引入pom依赖
- 2. 配置
- 2. 使用注解
- 实现
- 自动装配类 和 属性文件
- FileStorageFactory
- 本地存储实现
- FTP存储实现
- SFTP存储实现
- S3存储实现(MINIO)
- spring.factories
- pom
Pre
Spring Boot - 手把手教小师妹自定义Spring Boot Starter
需求
系统中,文件存储是个非常常规的需求,大家都需要重复开发,何不封装一个starter支持多协议文件存储的呢?
目前规划了如下的功能:
- 支持 多种存储, FTP , SFTP ,本地存储 , S3协议客户端(MINIO、 阿里云等)
- 支持自定义属性配置
- 开箱即用
使用步骤
各位看官,先看看符不符合你的需要,先演示下开发完成后的如何集成到自己的业务系统中。
1. 引入pom依赖2. 配置com.artisan artisan-filestorage-spring-boot-starter 1.0
artisan:
filestorage:
storage-type: s3
ftp:
host: 192.168.126.140
port: 21
username: ftptest
password: ftptest
mode: Passive
base-path: /artisan
s3:
endpoint: http://192.168.126.140:9000
access-key: admin
access-secret: password
bucket: artisan-bucket
sftp:
base-path: /root/abc
username: root
password: artisan
host: 192.168.126.140
port: 22
local:
base-path: D://test
核心: 根据 storage-type 来决定实例化哪种实例对象。 其它配置为实例对象的属性配置。
2. 使用注解
package com.artisan.doc.controller;
import cn.hutool.core.io.IoUtil;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import net.zfsy.frame.file.storage.FileStorageFactory;
import net.zfsy.frame.operatelog.core.util.ServletUtils;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
@Api(tags = "S3文件存储")
@RestController
@RequestMapping("/s3")
@Validated
@Slf4j
public class S3FileController {
@Resource
private FileStorageFactory fileStorageFactory;
@PostMapping("/upload")
@ApiOperation("上传文件")
@ApiImplicitParams({
@ApiImplicitParam(name = "path", value = "文件相对路径", example = "soft", dataTypeClass = String.class),
@ApiImplicitParam(name = "file", value = "文件附件", required = true, dataTypeClass = MultipartFile.class)
})
public String uploadFile(String path, @RequestParam("file") MultipartFile file) throws Exception {
return fileStorageFactory.getStorage().createFile(path, file.getOriginalFilename(), IoUtil.readBytes(file.getInputStream()));
}
@DeleteMapping("/delete")
@ApiOperation("删除文件")
@ApiImplicitParams({
@ApiImplicitParam(name = "path", value = "文件相对路径", required = true, dataTypeClass = String.class),
@ApiImplicitParam(name = "fileName", value = "文件名称", required = true, dataTypeClass = String.class)
})
public void deleteFile(String path, @RequestParam("fileName") String fileName) throws Exception {
fileStorageFactory.getStorage().deleteFile(path, fileName);
}
@GetMapping("/get")
@ApiOperation("下载文件")
@ApiImplicitParams({
@ApiImplicitParam(name = "path", value = "文件相对路径", required = true, dataTypeClass = String.class),
@ApiImplicitParam(name = "fileName", value = "文件名称", required = true, dataTypeClass = String.class)
})
public void getFileContent(HttpServletResponse response,
String path, @RequestParam("fileName") String fileName) throws Exception {
byte[] content = fileStorageFactory.getStorage().getFileContent(path, fileName);
if (content == null) {
log.warn("[getFileContent][path({}) fileName({}) 文件不存在]", path, fileName);
response.setStatus(HttpStatus.NOT_FOUND.value());
return;
}
ServletUtils.writeAttachment(response, fileName, content);
}
}
实现 自动装配类 和 属性文件
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(ArtisanFileUploadProperties.class)
@ConditionalOnProperty(prefix = ArtisanFileUploadProperties.PREFIX, name = "enabled", havingValue = "true", matchIfMissing = true)
public class ArtisanFileUploadAutoConfiguration implements InitializingBean, DisposableBean {
private static final Logger logger = LoggerFactory.getLogger(ZhongFuFileUploadAutoConfiguration.class);
private ZhongFuFileUploadProperties config;
public ZhongFuFileUploadAutoConfiguration(ZhongFuFileUploadProperties config) {
this.config = config;
}
@Bean
public FileStorageFactory fileStorageFactory(){
return new FileStorageFactory(config);
}
@Override
public void destroy() {
logger.info("<== 【销毁--自动化配置】----多协议文件上传组件【ZhongFuFileUploadAutoConfiguration】");
}
@Override
public void afterPropertiesSet() {
logger.info("==> 【初始化--自动化配置】----多协议文件上传组件【ZhongFuFileUploadAutoConfiguration】");
}
}
package net.zfsy.frame.file.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
@ConfigurationProperties(ArtisanFileUploadProperties.PREFIX)
@Data
public class ArtisanFileUploadProperties {
public static final String PREFIX = "zf.filestorage";
private StorageType storageType;
private LocalStorageProperties local;
private FtpStorageProperties ftp;
private SftpStorageProperties sftp;
private S3StorageProperties s3;
public enum StorageType {
local,
ftp,
sftp,
s3
}
@Data
public static class LocalStorageProperties {
@NotEmpty(message = "基础路径不能为空")
private String basePath;
}
@Data
public static class FtpStorageProperties {
@NotEmpty(message = "基础路径不能为空")
private String basePath;
@NotEmpty(message = "host 不能为空")
private String host;
@NotNull(message = "port 不能为空")
private Integer port;
@NotEmpty(message = "用户名不能为空")
private String username;
@NotEmpty(message = "密码不能为空")
private String password;
@NotEmpty(message = "连接模式不能为空")
private String mode;
}
@Data
public static class SftpStorageProperties {
@NotEmpty(message = "基础路径不能为空")
private String basePath;
@NotEmpty(message = "host 不能为空")
private String host;
@NotNull(message = "port 不能为空")
private Integer port;
@NotEmpty(message = "用户名不能为空")
private String username;
@NotEmpty(message = "密码不能为空")
private String password;
}
@Data
public static class S3StorageProperties {
@NotNull(message = "endpoint 不能为空")
private String endpoint;
@NotNull(message = "bucket 不能为空")
private String bucket;
@NotNull(message = "accessKey 不能为空")
private String accessKey;
@NotNull(message = "accessSecret 不能为空")
private String accessSecret;
}
}
FileStorageFactory
public class FileStorageFactory {
private Logger logger = LoggerFactory.getLogger(FileStorageFactory.class);
private ZhongFuFileUploadProperties config;
private Map uploader = new ConcurrentHashMap<>();
public FileStorageFactory(ZhongFuFileUploadProperties config) {
this.config = config;
}
public FileStorage getStorage() {
// 获取配置文件中配置的存储类型
String type = config.getStorageType().name();
// 缓存对象,避免重复创建
if (ZhongFuFileUploadProperties.StorageType.local.name().equalsIgnoreCase(type) && uploader.get(type) == null) {
uploader.put(type, new LocalFileStorage(config));
} else if (ZhongFuFileUploadProperties.StorageType.ftp.name().equalsIgnoreCase(type) && uploader.get(type) == null) {
uploader.put(type, new FtpFileStorage(config));
} else if (ZhongFuFileUploadProperties.StorageType.sftp.name().equalsIgnoreCase(type) && uploader.get(type) == null) {
uploader.put(type, new SftpFileStorage(config));
} else if (ZhongFuFileUploadProperties.StorageType.s3.name().equalsIgnoreCase(type) && uploader.get(type) == null) {
uploader.put(type, new S3FileStorage(config));
} else {
if (uploader.get(type) == null) {
uploader.put(type, new LocalFileStorage(config));
logger.warn("未找到配置的文件存储类型, 将使用默认LocalFileStorage");
}
}
// 返回实例化存储对象
return uploader.get(type);
}
}
public interface FileStorage {
String createFile(String path, String fileName, byte[] content) throws Exception;
void deleteFile(String path, String fileName) throws Exception;
byte[] getFileContent(String path, String fileName) throws Exception;
}
public abstract class AbstractFileStorage implements FileStorage {
public final void ext() {
doExt();
}
protected abstract void doExt();
本地存储实现
public class LocalFileStorage extends AbstractFileStorage {
private Logger logger = LoggerFactory.getLogger(LocalFileStorage.class);
private ZhongFuFileUploadProperties config;
public LocalFileStorage(ZhongFuFileUploadProperties config) {
this.config = config;
ZhongFuFileUploadProperties.LocalStorageProperties local = this.config.getLocal();
Assert.notNull(local, "本地存储配置信息不能为空,请配置 basePath 属性");
// 补全风格 Linux 是 /,Windows 是
if (!local.getBasePath().endsWith(File.separator)) {
local.setBasePath(local.getBasePath() + File.separator);
}
logger.info("初次调用, 实例化LocalFileStorage");
}
@Override
public String createFile(String path, String fileName, byte[] content) {
// 执行写入
File file = FileUtil.writeBytes(content, getAbsFilePath(path, fileName));
logger.info("LOCAL-文件写入操作:入参path->{} , 文件名->{} , 文件存储路径->{}", path, fileName, file.getAbsolutePath());
return file.getAbsolutePath();
}
@Override
public void deleteFile(String path, String fileName) {
String filePath = getAbsFilePath(path, fileName);
FileUtil.del(filePath);
logger.info("LOCAL-文件删除操作:入参path->{} , 绝对路径->{}", path, filePath);
}
@Override
public byte[] getFileContent(String path, String fileName) {
String filePath = getAbsFilePath(path, fileName);
logger.info("LOCAL-文件读取操作:入参path->{} , 绝对路径->{}", path, filePath);
return FileUtil.readBytes(filePath);
}
private String getAbsFilePath(String path, String fileName) {
return StrUtil.isBlank(path) ? (config.getLocal().getBasePath() + File.separator + fileName) : (config.getLocal().getBasePath() + path + File.separator + fileName);
}
@Override
protected void doExt() {
}
FTP存储实现
public class FtpFileStorage extends AbstractFileStorage {
private Logger logger = LoggerFactory.getLogger(FtpFileStorage.class);
private Ftp ftp;
private ZhongFuFileUploadProperties config;
public FtpFileStorage(ZhongFuFileUploadProperties config) {
this.config = config;
ZhongFuFileUploadProperties.FtpStorageProperties ftpConfig = config.getFtp();
Assert.notNull(ftpConfig, "ftp客户端配置信息不能为空");
// TODO fix me when publish (File.separator )测试 临时使用 "/"
if (!ftpConfig.getBasePath().endsWith("/")) {
ftpConfig.setBasePath(ftpConfig.getBasePath() + "/");
}
// 初始化 Ftp 对象
this.ftp = new Ftp(ftpConfig.getHost(), ftpConfig.getPort(), ftpConfig.getUsername(), ftpConfig.getPassword(),
CharsetUtil.CHARSET_UTF_8, null, null, FtpMode.valueOf(ftpConfig.getMode()));
logger.info("初次调用, 实例化FtpFileStorage");
}
@Override
protected void doExt() {
}
@Override
public String createFile(String path, String fileName, byte[] content) throws Exception {
// 执行写入
String destPath = config.getFtp().getBasePath() + path.trim();
boolean success = ftp.upload(destPath, fileName, new ByteArrayInputStream(content));
if (!success) {
throw new FtpException(StrUtil.format("上传文件到目标目录 ({}) 失败", path));
}
logger.info("FTP-文件写入操作:入参path->{} , 文件名->{} ", path, fileName);
return path + File.separator + fileName;
}
@Override
public byte[] getFileContent(String path, String fileName) {
String filePath = config.getFtp().getBasePath() + path.trim();
ByteArrayOutputStream out = new ByteArrayOutputStream();
ftp.download(filePath, fileName, out);
logger.info("FTP-文件读取操作:入参path->{} , 绝对路径->{}", path, filePath);
return out.toByteArray();
}
@Override
public void deleteFile(String path, String fileName) throws Exception {
// TODO fix me when publish 测试 临时使用 "/"
String filePath = config.getFtp().getBasePath() + path.trim() + "/" + fileName;
boolean success = ftp.delFile(filePath);
if (!success) {
throw new FtpException(StrUtil.format("删除文件 ({}) 失败", filePath));
}
logger.info("FTP-文件删除操作:入参path->{} , 绝对路径->{}", path, filePath);
}
SFTP存储实现
public class SftpFileStorage extends AbstractFileStorage {
private Logger logger = LoggerFactory.getLogger(SftpFileStorage.class);
private Sftp sftp;
private ZhongFuFileUploadProperties config;
public SftpFileStorage(ZhongFuFileUploadProperties config) {
this.config = config;
ZhongFuFileUploadProperties.SftpStorageProperties sftpStorageProperties = this.config.getSftp();
Assert.notNull(sftpStorageProperties, "Sftp客户端不能为空");
// 补全风格。例如说 Linux 是 /,Windows 是 TODO
if (!sftpStorageProperties.getBasePath().endsWith(File.separator)) {
sftpStorageProperties.setBasePath(sftpStorageProperties.getBasePath() + "/");
}
// 初始化 Ftp 对象
this.sftp = new Sftp(sftpStorageProperties.getHost(), sftpStorageProperties.getPort(), sftpStorageProperties.getUsername(), sftpStorageProperties.getPassword());
// 创建目录
sftp.mkdir(sftpStorageProperties.getBasePath());
logger.info("初次调用, 实例化SftpFileStorage");
}
@Override
protected void doExt() {
}
@Override
public String createFile(String path, String fileName, byte[] content) throws Exception {
// 创建目录
sftp.mkdir(getDir(path));
// 获取文件存储路径 TODO
String destPath = getDestPath(path, fileName);
// 根据文件名,创建文件
File file = createFileByFileName(content, fileName);
// 执行写入
boolean success = sftp.upload(destPath, file);
if (!success) {
throw new SftpException(500, StrUtil.format("SFTP上传文件到目标目录 ({}) 失败", destPath));
}
logger.info("SFTP-文件写入操作:入参path->{} , 文件名->{} ", path, fileName);
// 拼接返回路径
return destPath;
}
@Override
public void deleteFile(String path, String fileName) throws Exception {
String destPath = getDestPath(path, fileName);
sftp.delFile(destPath);
logger.info("Sftp-文件删除操作:入参path->{} , 文件名->{} , 文件存储路径->{}", path, fileName, destPath);
}
@Override
public byte[] getFileContent(String path, String fileName) throws Exception {
String filePath = getDestPath(path,fileName);
File destFile = new File(fileName);
sftp.download(filePath, destFile);
return FileUtil.readBytes(destFile);
}
private String getDestPath(String path, String fileName) {
String destPath = config.getSftp().getBasePath() + path.trim() + "/" + fileName;
return destPath;
}
private String getDir(String path) {
return config.getSftp().getBasePath() + path.trim();
}
@SneakyThrows
public static File createFileByFileName(byte[] data ,String fileName) {
File file = new File(fileName);
// 标记 JVM 退出时,自动删除
file.deleteOnExit();
// 写入内容
FileUtil.writeBytes(data, file);
return file;
}
}
S3存储实现(MINIO)
public class S3FileStorage extends AbstractFileStorage {
private Logger logger = LoggerFactory.getLogger(LocalFileStorage.class);
private MinioClient client;
private ZhongFuFileUploadProperties config;
public S3FileStorage(ZhongFuFileUploadProperties config) {
this.config = config;
ZhongFuFileUploadProperties.S3StorageProperties s3StorageProperties = this.config.getS3();
Assert.notNull(s3StorageProperties, "S3协议客户端不能为空");
validate(Validation.buildDefaultValidatorFactory().getValidator(), s3StorageProperties);
// 初始化客户端
client = MinioClient.builder()
// Endpoint URL
.endpoint(buildEndpointURL(s3StorageProperties))
// 认证密钥
.credentials(s3StorageProperties.getAccessKey(), s3StorageProperties.getAccessSecret())
.build();
// 创建Bucket
checkBucket(s3StorageProperties.getBucket());
logger.info("初次调用, 实例化S3FileStorage");
}
private void checkBucket(String bucketName) {
try {
if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build())) {
MakeBucketArgs makeArgs = MakeBucketArgs.builder().bucket(bucketName).build();
client.makeBucket(makeArgs);
logger.info("bucket {} 不存在, 自动创建该bucket", bucketName);
}
} catch (Exception e) {
logger.error(" 自动创建bucket {} 异常", bucketName, e.getMessage());
}
}
private String buildEndpointURL(ZhongFuFileUploadProperties.S3StorageProperties s3StorageProperties) {
return s3StorageProperties.getEndpoint();
}
@Override
protected void doExt() {
}
@Override
public String createFile(String path, String fileName, byte[] content) throws Exception {
// TODO
String filePath = path + "/" + fileName;
// 执行上传
client.putObject(PutObjectArgs.builder()
// bucket 必须传递
.bucket(config.getS3().getBucket())
// 相对路径作为 key
.object(filePath)
// 文件内容
.stream(new ByteArrayInputStream(content), content.length, -1)
.build());
String url = config.getS3().getEndpoint() + "/" + config.getS3().getBucket() + "/" + filePath;
logger.info("S3-文件写入操作:入参path->{} , 文件名->{} ,文件路径->{}", path, fileName, url);
// 拼接返回路径
return url;
}
@Override
public void deleteFile(String path, String fileName) throws Exception {
// TODO
String filePath = path + "/" + fileName;
client.removeObject(RemoveObjectArgs.builder()
// bucket 必须传递
.bucket(config.getS3().getBucket())
// 相对路径作为 key
.object(filePath)
.build());
logger.info("S3-文件删除操作:入参path->{} , 文件名->{} , 文件存储路径->{}", path, fileName);
}
@Override
public byte[] getFileContent(String path, String fileName) throws Exception {
// TODO
String filePath = path + "/" + fileName;
GetObjectResponse response = client.getObject(GetObjectArgs.builder()
// bucket 必须传递
.bucket(config.getS3().getBucket())
// 相对路径作为 key
.object(filePath)
.build());
return IoUtil.readBytes(response);
}
private void validate(Validator validator, Object object, Class>... groups) {
Set> constraintViolations = validator.validate(object, groups);
if (CollUtil.isNotEmpty(constraintViolations)) {
throw new ConstraintViolationException(constraintViolations);
}
}
}
spring.factories
# Auto Configure org.springframework.boot.autoconfigure.EnableAutoConfiguration= com.artisan.frame.file.config.ArtisanFileUploadAutoConfiguration
pom
org.springframework.boot spring-boot-starter org.springframework.boot spring-boot-starter-validation org.slf4j slf4j-api com.fasterxml.jackson.core jackson-databind com.fasterxml.jackson.core jackson-core commons-net commons-net com.jcraft jsch io.minio minio cn.hutool hutool-all org.projectlombok lombok true com.fasterxml.jackson.core jackson-annotations com.fasterxml.jackson.core jackson-databind org.slf4j slf4j-api org.junit.jupiter junit-jupiter-api test org.mockito mockito-all test
别忘了 spring-boot-configuration-processor 哦
org.springframework.boot spring-boot-configuration-processor



