Pre
Spring Boot - 手把手教小师妹自定义Spring Boot Starter
需求
系统中,文件存储是个非常常规的需求,大家都需要重复开发,何不封装一个starter支持多协议文件存储的呢?
目前规划了如下的功能:
- 支持 多种存储, FTP , SFTP ,本地存储 , S3协议客户端(MINIO、 阿里云等)
- 支持自定义属性配置
- 开箱即用
使用步骤
各位看官,先看看符不符合你的需要,先演示下开发完成后的如何集成到自己的业务系统中。
1. 引入pom依赖
<dependency> <groupId>com.artisan</groupId> <artifactId>artisan-filestorage-spring-boot-starter</artifactId> <version>1.0</version> </dependency>
2. 配置
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; /** * @author 小工匠 * @version 1.0 * @mark: show me the code , change the world */ @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); } }
实现
自动装配类 和 属性文件
/** * @author 小工匠 * @version 1.0 * @date 2022/4/16 19:12 * @mark: show me the code , change the world */ @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; } /** * * @return 文件存储工厂对象 */ @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; /** * @author 小工匠 * @version 1.0 * @date 2022/4/18 22:55 * @mark: show me the code , change the world */ @ConfigurationProperties(ArtisanFileUploadProperties.PREFIX) @Data public class ArtisanFileUploadProperties { /** * 属性配置前缀 */ public static final String PREFIX = "zf.filestorage"; /** * 文件服务类型 * 1. file:本地磁盘 * 2. ftp:FTP 服务器 * 3. sftp:SFTP 服务器 * 4. s3:支持 S3 协议的云存储服务,比如 MinIO、阿里云、华为云、腾讯云、七牛云等等 */ private StorageType storageType; private LocalStorageProperties local; private FtpStorageProperties ftp; private SftpStorageProperties sftp; private S3StorageProperties s3; /** * Type of Storage to use. */ public enum StorageType { /** * 本地存储 */ local, /** * ftp存储 */ ftp, /** * sftp存储 */ sftp, /** * s3协议的存储,比如minio */ s3 } /** * Local */ @Data public static class LocalStorageProperties { /** * 基础路径 */ @NotEmpty(message = "基础路径不能为空") private String basePath; } /** * FTP */ @Data public static class FtpStorageProperties { /** * 基础路径 * <p> * 1. basePath为null或""上传到当前路径 * 2. basePath为相对路径则相对于当前路径的子路径 * 3. basePath为绝对路径则上传到此路径 */ @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; /** * 连接模式 * <p> * 使用 {@link cn.hutool.extra.ftp.FtpMode} 对应的字符串 * Active 主动模式 * Passive 被动模式 (推荐) */ @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; } /** * S3协议 */ @Data public static class S3StorageProperties { /** * 节点地址 MinIO:http://127.0.0.1:9000 */ @NotNull(message = "endpoint 不能为空") private String endpoint; /** * 存储 Bucket */ @NotNull(message = "bucket 不能为空") private String bucket; /** * 访问 Key */ @NotNull(message = "accessKey 不能为空") private String accessKey; /** * 访问 Secret */ @NotNull(message = "accessSecret 不能为空") private String accessSecret; } }
FileStorageFactory
/** * @author 小工匠 * @version 1.0 * @date 2022/4/19 18:40 * @mark: show me the code , change the world */ public class FileStorageFactory { private Logger logger = LoggerFactory.getLogger(FileStorageFactory.class); /** * 存储配置信息 */ private ZhongFuFileUploadProperties config; /** * 文件存储类型 和 实例化存储对象 映射关系 */ private Map<String, FileStorage> uploader = new ConcurrentHashMap<>(); /** * 构造函数 * * @param config */ public FileStorageFactory(ZhongFuFileUploadProperties config) { this.config = config; } /** * @return 文件存储对象 */ 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); } }
/** * 文件 Storage 接口 * @author artisan */ public interface FileStorage { /** * 保存文件 * * @param path 文件路径 * @param path 文件名称 * @param content 文件内容 * @return 文件路径 */ String createFile(String path, String fileName, byte[] content) throws Exception; /** * 删除文件 * * @param path 相对路径 * @throws Exception 删除文件时,抛出 Exception 异常 */ void deleteFile(String path, String fileName) throws Exception; /** * 获得文件内容 * * @param path 文件路径 * @param fileName 文件名 * @return 文件内容 */ byte[] getFileContent(String path, String fileName) throws Exception; }
/** * @author 小工匠 * @version 1.0 * @date 2022/4/19 10:42 * @mark: show me the code , change the world */ public abstract class AbstractFileStorage implements FileStorage { /** * 业务扩展 */ public final void ext() { doExt(); } /** * 自定义业务扩展 */ protected abstract void doExt();
本地存储实现
/** * 本地文件 Storage 实现类 * * @author artisan */ 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); } /** * @param path 相对路径 * @param fileName 文件名名称 * @return 文件绝对路径 */ 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存储实现
/** * Ftp Storage 实现类 * * @author artisan */ 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() { } /** * @param path 文件相对路径 * @param fileName 文件名称 * @param content 文件内容 * @return 文件存储路径 */ @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存储实现
/** * @author 小工匠 * @version 1.0 * @date 2022/4/20 10:21 * @mark: show me the code , change the world */ 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); } /** * @param path * @param fileName * @return 文件路径 */ private String getDestPath(String path, String fileName) { String destPath = config.getSftp().getBasePath() + path.trim() + "/" + fileName; return destPath; } /** * @param path * @return 文件目录 */ private String getDir(String path) { return config.getSftp().getBasePath() + path.trim(); } /** * 创建文件 * * @param fileName 文件名 * @return 文件 */ @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)
/** * @author 小工匠 * @version 1.0 * @date 2022/4/20 8:44 * @mark: show me the code , change the world */ 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"); } /** * 检查Bucket是否存在,不存在 创建 * * @param bucketName bucket 名称 */ 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()); } } /** * 基于 endpoint 构建调用云服务的 URL 地址 * * @return URI 地址 */ 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); } /** * * @param validator * @param object * @param groups */ private void validate(Validator validator, Object object, Class<?>... groups) { Set<ConstraintViolation<Object>> 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
<dependencies> <!-- Spring 核心 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <!-- 工具类相关 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-core</artifactId> </dependency> <!-- ftp 连接 --> <dependency> <groupId>commons-net</groupId> <artifactId>commons-net</artifactId> </dependency> <!-- sftp 连接 --> <dependency> <groupId>com.jcraft</groupId> <artifactId>jsch</artifactId> </dependency> <!-- 三方云服务相关 --> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-annotations</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> </dependency> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-all</artifactId> <scope>test</scope> </dependency> </dependencies>
别忘了 spring-boot-configuration-processor 哦
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-configuration-processor</artifactId> </dependency>