minio文件存储+ckplayer视频播放(minio分片上传合并&视频播放)

文章目录

参考

来源:MInIO入门-04 基于minio+ckplayer视频点播 实现minio-demo-video - Gitee代码地址

视频分片上传Minio和播放

简述

文件在前端经过分片,将分片上传到后台服务器,后台服务器传到minio。所有分片上传完成后,前端根据bucketName和objectName从后台服务器获取资源,而后台读取请求的range范围响应流给前端播放。

(优化点:1. 文件分片上传合并操作直接让前端和minio之间交互,而后台只生成每个分片的上传凭证 2. 视频播放不需要经过后台,而是由后台生成该objectName对应的签名url给前端,前端直接找minio获取流)

效果

启动minio

cmd 复制代码
minio.exe server D:\software\work_software\minio\data --console-address :18001 --address :18000 > D:\software\work_software\minio\minio.log

代码

配置类

RedisConfig

java 复制代码
@Configuration
public class RedisConfig {
    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private String redisPort;

    /**
     * 通过配置RedisStandaloneConfiguration实例来
     * 创建Redis Standolone模式的客户端连接创建工厂
     * 配置hostname和port
     *
     * @return LettuceConnectionFactory
     */
    @Bean
    public JedisConnectionFactory redisConnectionFactory() {
        return new JedisConnectionFactory(new RedisStandaloneConfiguration(redisHost, Integer.parseInt(redisPort)));
    }

    /**
     * 保证序列化之后不会乱码的配置
     *
     * @param connectionFactory connectionFactory
     * @return RedisTemplate
     */
    @Bean(name = "jsonRedisTemplate")
    public RedisTemplate<String, Serializable> redisTemplate(JedisConnectionFactory connectionFactory) {
        return getRedisTemplate(connectionFactory, genericJackson2JsonRedisSerializer());
    }

    /**
     * 解决:
     * org.springframework.data.redis.serializer.SerializationException:
     * Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported
     *
     * @return GenericJackson2JsonRedisSerializer
     */
    @Bean
    @Primary // 当存在多个Bean时,此bean优先级最高
    public GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer() {
        ObjectMapper objectMapper = new ObjectMapper();
        // 解决查询缓存转换异常的问题
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.WRAPPER_ARRAY);
        // 支持 jdk 1.8 日期   ---- start ---
        objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
        objectMapper.registerModule(new Jdk8Module())
                .registerModule(new JavaTimeModule())
                .registerModule(new ParameterNamesModule());
        // --end --
        return new GenericJackson2JsonRedisSerializer(objectMapper);
    }

    /**
     * 注入redis分布式锁实现方案redisson
     *
     * @return RedissonClient
     */
    @Bean
    public RedissonClient redisson() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort).setDatabase(0);
        return Redisson.create(config);
    }

    /**
     * 采用jdk序列化的方式
     *
     * @param connectionFactory connectionFactory
     * @return RedisTemplate
     */
    @Bean(name = "jdkRedisTemplate")
    public RedisTemplate<String, Serializable> redisTemplateByJdkSerialization(JedisConnectionFactory connectionFactory) {
        return getRedisTemplate(connectionFactory, new JdkSerializationRedisSerializer());
    }

    private RedisTemplate<String, Serializable> getRedisTemplate(JedisConnectionFactory connectionFactory,
                                                                 RedisSerializer<?> redisSerializer) {
        RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(redisSerializer);

        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(redisSerializer);
        connectionFactory.afterPropertiesSet();
        redisTemplate.setConnectionFactory(connectionFactory);
        return redisTemplate;
    }
}

WebConfig

java 复制代码
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
            "classpath:/META-INF/resources/", "classpath:/resources/",
            "classpath:/static/", "classpath:/public/"};


    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**")
                .addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS);
    }
}

MinioClientAutoConfiguration

java 复制代码
@Slf4j
@Configuration
@EnableConfigurationProperties(OSSProperties.class)
public class MinioClientAutoConfiguration {
    /**
     * 初始化MinioTemplate,封装了一些MinIOClient的基本操作
     *
     * @return MinioTemplate
     */
    @ConditionalOnMissingBean(MinioTemplate.class)
    @Bean(name = "minioTemplate")
    public MinioTemplate minioTemplate() {
        return new MinioTemplate();
    }
}

OSSProperties

java 复制代码
@ConfigurationProperties(value = "oss.minio")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OSSProperties {
    /**
     * 对象存储服务的URL
     */
    private String endpoint;

    /**
     * Access key就像用户ID,可以唯一标识你的账户。
     */
    private String accessKey;

    /**
     * Secret key是你账户的密码。
     */
    private String secretKey;

    /**
     * bucketName是你设置的桶的名称
     */
    private String bucketName;
}

application.yml

yml 复制代码
server:
  port: 18002
spring:
  application:
    name: minio-application
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB
  redis:
    database: 0
    host: 127.0.0.1
    port: 6379
    jedis:
      pool:
        max-active: 200
        max-wait: -1
        max-idle: 10
        min-idle: 0
    timeout: 2000
  thymeleaf:
    #模板的模式,支持 HTML, XML TEXT JAVASCRIPT
    mode: HTML5
    #编码 可不用配置
    encoding: UTF-8
    #开发配置为false,避免修改模板还要重启服务器
    cache: false
    #配置模板路径,默认是templates,可以不用配置
    prefix: classpath:/templates/
    suffix: .html
    servlet:
      content-type: text/html
oss:
  minio:
    endpoint: http://127.0.0.1:18000
    accessKey: qwiVxtzgeYbGSEZuV9ki
    secretKey: UeM1Rj6kkrpB5LSHf4xSPOBXwu34CmUmEt9sAcnm
    bucketName: minio-demo

实体类

MinioObject

java 复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MinioObject {
    private String bucket;
    private String region;
    private String object;
    private String etag;
    private long size;
    private boolean deleteMarker;
    private Map<String, String> userMetadata;
}

Result

java 复制代码
@Slf4j
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {
    private String message;
    private Integer code;
    private T data;


    /**
     * 成功 并不返回数据
     * @param <T>
     * @return
     */
    public static <T> Result<T> ok() {
        return new Result<>(StatusCode.SUCCESS.getMessage(), StatusCode.SUCCESS.getCode(), null);
    }

    /**
     * 成功 并返回数据
     * @param data
     * @param <T>
     * @return
     */
    public static <T> Result<T> ok(T data) {
        return new Result<>(StatusCode.SUCCESS.getMessage(), StatusCode.SUCCESS.getCode(), data);
    }

    /**
     * 系统错误 不返回数据
     * @param <T>
     * @return
     */
    public static <T> Result<T> error() {
        return new Result<>(StatusCode.FAILURE.getMessage(), StatusCode.FAILURE.getCode(), null);
    }

    /**
     * 系统错误 并返回逻辑数据
     * @param data
     * @param <T>
     * @return
     */
    public static <T> Result<T> error(T data) {
        return new Result<>(StatusCode.FAILURE.getMessage(), StatusCode.FAILURE.getCode(), data);
    }

    /**
     * 错误并返回指定错误信息和状态码以及逻辑数据
     * @param statusCode
     * @param data
     * @param <T>
     * @return
     */
    public static <T> Result<T> error(StatusCode statusCode, T data) {
        return new Result<>(statusCode.getMessage(), statusCode.getCode(), data);
    }

    /**
     * 错误并返回指定错误信息和状态码 不返回数据
     * @param statusCode
     * @param <T>
     * @return
     */
    public static <T> Result<T> error(StatusCode statusCode) {
        return new Result<>(statusCode.getMessage(), statusCode.getCode(), null);
    }

    /**
     * 自定义错误和状态返回
     * @param message
     * @param code
     * @param data
     * @param <T>
     * @return
     */
    public static <T> Result<T> errorMessage(String message, Integer code, T data) {
        return new Result<>(message, code, data);
    }

    /**
     * 自定义错误信息 状态码固定
     * @param message
     * @param <T>
     * @return
     */
    public static <T> Result<T> errorMessage(String message) {
        return new Result<>(message, StatusCode.CUSTOM_FAILURE.getCode(), null);
    }
}

StatusCode

java 复制代码
public enum StatusCode {
    SUCCESS(20000, "操作成功"),
    PARAM_ERROR(40000, "参数异常"),
    NOT_FOUND(40004, "资源不存在"),
    FAILURE(50000, "系统异常"),
    CUSTOM_FAILURE(50001, "自定义异常错误"),
    ALONE_CHUNK_UPLOAD_SUCCESS(20001, "分片上传成功的标识"),
    ALL_CHUNK_UPLOAD_SUCCESS(20002, "所有的分片均上传成功");

    @Getter
    private final Integer code;
    @Getter
    private final String message;

    StatusCode(Integer code, String message) {
        this.code = code;
        this.message = message;
    }
}

OssFile

java 复制代码
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OssFile {
    /**
     * OSS 存储时文件路径
     */
    private String ossFilePath;
    /**
     * 原始文件名
     */
    private String originalFileName;
}

OssPolicy

java 复制代码
/**
 * | 参数      | 说明                                                         |
 * | --------- | ------------------------------------------------------------ |
 * | Version   | 标识策略的版本号,Minio中一般为"**2012-10-17**"              |
 * | Statement | 策略授权语句,描述策略的详细信息,包含Effect(效果)、Action(动作)、Principal(用户)、Resource(资源)和Condition(条件)。其中Condition为可选 |
 * | Effect    | Effect(效果)作用包含两种:Allow(允许)和Deny(拒绝),系统预置策略仅包含允许的授权语句,自定义策略中可以同时包含允许和拒绝的授权语句,当策略中既有允许又有拒绝的授权语句时,遵循Deny优先的原则。 |
 * | Action    | Action(动作)对资源的具体操作权限,格式为:服务名:资源类型:操作,支持单个或多个操作权限,支持通配符号*,通配符号表示所有。例如 s3:GetObject ,表示获取对象 |
 * | Resource  | Resource(资源)策略所作用的资源,支持通配符号*,通配符号表示所有。在JSON视图中,不带Resource表示对所有资源生效。Resource支持以下字符:-_0-9a-zA-Z*./\,如果Resource中包含不支持的字符,请采用通配符号*。例如:arn:aws:s3:::my-bucketname/myobject*\,表示minio中my-bucketname/myobject目录下所有对象文件。 |
 * | Condition | Condition(条件)您可以在创建自定义策略时,通过Condition元素来控制策略何时生效。Condition包括条件键和运算符,条件键表示策略语句的Condition元素,分为全局级条件键和服务级条件键。全局级条件键(前缀为g:)适用于所有操作,服务级条件键(前缀为服务缩写,如obs:)仅适用于对应服务的操作。运算符与条件键一起使用,构成完整的条件判断语句。 |
 * @since 2023/3/16 15:28
 */
@Slf4j
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class OssPolicy {
    /**
     * 标识策略的版本号,Minio中一般为"**2012-10-17**"
     */
    @JsonProperty("Version")
    private String version = "2012-10-17";

    /**
     * 策略授权语句,描述策略的详细信息,包含
     * Effect(效果)
     * Action(动作)
     * Principal(用户)
     * Resource(资源)
     * 和Condition(条件)。
     * 其中Condition为可选
     */
    @JsonProperty("Statement")
    private Statement[] statement;

    /**
     * 获取公共读的权限json字符串
     *
     * @param bucketName 桶名称
     * @return 公共读的权限json字符串
     */
    public static String getReadOnlyJsonPolicy(String bucketName) {
        return "{\n" +
                "  \"Version\": \"2012-10-17\",\n" +
                "  \"Statement\": [\n" +
                "    {\n" +
                "      \"Effect\": \"Allow\",\n" +
                "      \"Principal\": {\n" +
                "        \"AWS\": [\n" +
                "          \"*\"\n" +
                "        ]\n" +
                "      },\n" +
                "      \"Action\": [\n" +
                "        \"s3:GetBucketLocation\",\n" +
                "        \"s3:ListBucket\"\n" +
                "      ],\n" +
                "      \"Resource\": [\n" +
                "        \"arn:aws:s3:::" + bucketName + "\"\n" +
                "      ]\n" +
                "    },\n" +
                "    {\n" +
                "      \"Effect\": \"Allow\",\n" +
                "      \"Principal\": {\n" +
                "        \"AWS\": [\n" +
                "          \"*\"\n" +
                "        ]\n" +
                "      },\n" +
                "      \"Action\": [\n" +
                "        \"s3:GetObject\"\n" +
                "      ],\n" +
                "      \"Resource\": [\n" +
                "        \"arn:aws:s3:::" + bucketName + "/*\"\n" +
                "      ]\n" +
                "    }\n" +
                "  ]\n" +
                "}";
    }

    /**
     * 获取公共写的权限json字符串
     *
     * @param bucketName 桶名称
     * @return 公共写的权限json字符串
     */
    public static String getWriteOnlyJsonPolicy(String bucketName) {
        return "{\n" +
                "  \"Version\": \"2012-10-17\",\n" +
                "  \"Statement\": [\n" +
                "    {\n" +
                "      \"Effect\": \"Allow\",\n" +
                "      \"Principal\": {\n" +
                "        \"AWS\": [\n" +
                "          \"*\"\n" +
                "        ]\n" +
                "      },\n" +
                "      \"Action\": [\n" +
                "        \"s3:GetBucketLocation\",\n" +
                "        \"s3:ListBucketMultipartUploads\"\n" +
                "      ],\n" +
                "      \"Resource\": [\n" +
                "        \"arn:aws:s3:::" + bucketName + "\"\n" +
                "      ]\n" +
                "    },\n" +
                "    {\n" +
                "      \"Effect\": \"Allow\",\n" +
                "      \"Principal\": {\n" +
                "        \"AWS\": [\n" +
                "          \"*\"\n" +
                "        ]\n" +
                "      },\n" +
                "      \"Action\": [\n" +
                "        \"s3:AbortMultipartUpload\",\n" +
                "        \"s3:DeleteObject\",\n" +
                "        \"s3:ListMultipartUploadParts\",\n" +
                "        \"s3:PutObject\"\n" +
                "      ],\n" +
                "      \"Resource\": [\n" +
                "        \"arn:aws:s3:::" + bucketName + "/*\"\n" +
                "      ]\n" +
                "    }\n" +
                "  ]\n" +
                "}";
    }

    /**
     * 获取公共读写的权限json字符串
     *
     * @param bucketName 桶名称
     * @return 公共读写的权限json字符串
     */
    public static String getReadWriteJsonPolicy(String bucketName) {
        return "{\n" +
                "  \"Version\": \"2012-10-17\",\n" +
                "  \"Statement\": [\n" +
                "    {\n" +
                "      \"Effect\": \"Allow\",\n" +
                "      \"Principal\": {\n" +
                "        \"AWS\": [\n" +
                "          \"*\"\n" +
                "        ]\n" +
                "      },\n" +
                "      \"Action\": [\n" +
                "        \"s3:GetBucketLocation\",\n" +
                "        \"s3:ListBucket\",\n" +
                "        \"s3:ListBucketMultipartUploads\"\n" +
                "      ],\n" +
                "      \"Resource\": [\n" +
                "        \"arn:aws:s3:::" + bucketName + "\"\n" +
                "      ]\n" +
                "    },\n" +
                "    {\n" +
                "      \"Effect\": \"Allow\",\n" +
                "      \"Principal\": {\n" +
                "        \"AWS\": [\n" +
                "          \"*\"\n" +
                "        ]\n" +
                "      },\n" +
                "      \"Action\": [\n" +
                "        \"s3:ListMultipartUploadParts\",\n" +
                "        \"s3:PutObject\",\n" +
                "        \"s3:AbortMultipartUpload\",\n" +
                "        \"s3:DeleteObject\",\n" +
                "        \"s3:GetObject\"\n" +
                "      ],\n" +
                "      \"Resource\": [\n" +
                "        \"arn:aws:s3:::" + bucketName + "/*\"\n" +
                "      ]\n" +
                "    }\n" +
                "  ]\n" +
                "}";
    }


    /**
     * 需要对返回值判空
     *
     * @param inputStream 输入流
     * @return 策略文件
     */
    public static String getOssPolicyByReadJsonFile(InputStream inputStream) {
        try (BufferedInputStream bis = new BufferedInputStream(inputStream)) {
            return IoUtil.readUtf8(bis);
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }


    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private static class Statement {
        /**
         * Effect(效果)作用包含两种:Allow(允许)和Deny(拒绝),
         * 系统预置策略仅包含允许的授权语句,
         * 自定义策略中可以同时包含允许和拒绝的授权语句,
         * 当策略中既有允许又有拒绝的授权语句时,
         * 遵循Deny优先的原则。
         */
        @JsonProperty("Effect")
        private String effect = "Allow";

        @JsonProperty("Principal")
        private Principal principal;

        /**
         * Action(动作)对资源的具体操作权限,
         * 格式为:服务名:资源类型:操作,支持单个或多个操作权限,支持通配符号*,通配符号表示所有。
         * 例如 s3:GetObject ,表示获取对象
         */
        @JsonProperty("Action")
        private String[] actions;

        /**
         * Resource(资源)策略所作用的资源,支持通配符号*,通配符号表示所有。
         * 在JSON视图中,不带Resource表示对所有资源生效。
         * Resource支持以下字符:-_0-9a-zA-Z*./\,如果Resource中包含不支持的字符,请采用通配符号*。
         * 例如:arn:aws:s3:::my-bucketname/myobject*\,表示minio中my-bucketname/myobject目录下所有对象文件。
         */
        @JsonProperty("Resource")
        private String[] resources;

        /**
         * Condition(条件)您可以在创建自定义策略时,通过Condition元素来控制策略何时生效。
         * Condition包括条件键和运算符,条件键表示策略语句的Condition元素,分为全局级条件键和服务级条件键。
         * 全局级条件键(前缀为g:)适用于所有操作,服务级条件键(前缀为服务缩写,如obs:)仅适用于对应服务的操作。
         * 运算符与条件键一起使用,构成完整的条件判断语句。
         */
        @JsonProperty("Condition")
        private String condition;
    }

    @NoArgsConstructor
    @AllArgsConstructor
    @Data
    @JsonInclude(JsonInclude.Include.NON_EMPTY)
    private static class Principal {
        @JsonProperty("AWS")
        private String[] aws;
    }


    public static void main(String[] args) throws JsonProcessingException {
        //System.out.println(DefaultPolicy.READ_ONLY.getPolicyJson());


        /*ObjectMapper objectMapper = new ObjectMapper();
        OssPolicy ossPolicy = new OssPolicy();
        ossPolicy.setVersion("2012-10-17");
        Statement statement = new Statement();
        statement.setEffect("Allow");
        String[] actions1 = {"admin:*"};
        statement.setActions(actions1);

        Statement statement2 = new Statement();
        statement2.setEffect("Allow");
        String[] actions2 = {"s3:*"};
        String[] resource2 = {"arn:aws:s3:::*"};
        statement2.setActions(actions2);
        statement2.setResources(resource2);

        Statement[] statements = {statement, statement2};
        ossPolicy.setStatement(statements);


        String jsonStr = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(ossPolicy);
        System.out.println(jsonStr);*/
    }
}

工具类

FileTypeUtil

java 复制代码
@Slf4j
public final class FileTypeUtil {

    private static final Map<String, List<String>> MIME_TYPE_MAP;

    static {
        MIME_TYPE_MAP = new HashMap<>();
        try {
            SAXReader saxReader = new SAXReader();
            Document document = saxReader.read(Thread.currentThread().getContextClassLoader().getResourceAsStream(
                    "mime/mime-types.xml"));

            Element rootElement = document.getRootElement();
            List<Element> mimeTypeElements = rootElement.elements("mime-type");
            for (Element mimeTypeElement : mimeTypeElements) {
                String type = mimeTypeElement.attributeValue("type");
                List<Element> globElements = mimeTypeElement.elements("glob");
                List<String> fileTypeList = new ArrayList<>(globElements.size());
                for (Element globElement : globElements) {
                    String fileType = globElement.getTextTrim();
                    fileTypeList.add(fileType);
                }
                MIME_TYPE_MAP.put(type, fileTypeList);
            }
        } catch (DocumentException e) {
            log.error("", e);
        }

    }

    private FileTypeUtil() {
    }


    /**
     * 获取文件的MimeType
     *
     * @param inputStream 文件流
     * @param fileName    文件名
     * @param fileSize    文件字节大小
     * @return 文件的MimeType
     */
    public static String getFileMimeType(InputStream inputStream, String fileName, Long fileSize) {
        AutoDetectParser parser = new AutoDetectParser();
        parser.setParsers(new HashMap<>());
        Metadata metadata = new Metadata();

        // 设置资源名称
        if (!ObjectUtils.isEmpty(fileName)) {
            metadata.set(TikaCoreProperties.RESOURCE_NAME_KEY, fileName);
        }

        // 设置资源大小
        if (!ObjectUtils.isEmpty(fileSize)) {
            metadata.set(Metadata.CONTENT_LENGTH, Long.toString(fileSize));
        }
        try (InputStream stream = inputStream) {
            parser.parse(stream, new DefaultHandler(), metadata, new ParseContext());
        } catch (IOException | SAXException | TikaException e) {
            log.error("", e);
            throw new IllegalArgumentException("文件的MimeType类型解析失败,原因:" + e.getMessage());
        }
        return metadata.get(HttpHeaders.CONTENT_TYPE);
    }

    /**
     * 获取文件的MimeType
     *
     * @param inputStream inputStream
     * @return 文件的MimeType
     */
    public static String getFileMimeType(InputStream inputStream) throws IllegalArgumentException {
        return getFileMimeType(inputStream, null, null);
    }

    /**
     * 获取文件的真实类型, 全为小写
     *
     * @param inputStream inputStream
     * @return String
     */
    public static List<String> getFileRealTypeList(InputStream inputStream, String fileName, Long fileSize) {
        String fileMimeType = getFileMimeType(inputStream, fileName, fileSize);
        log.info("fileMimeType:{}", fileMimeType);
        return getFileRealTypeList(fileMimeType);
    }


    /**
     * 获取文件的真实类型, 全为小写
     *
     * @param inputStream inputStream
     * @return String
     * @throws IOException IOException
     */
    public static List<String> getFileRealTypeList(InputStream inputStream) throws IOException {
        return getFileRealTypeList(inputStream, null, null);
    }


    /**
     * 根据文件的mime类型获取文件的真实扩展名集合
     *
     * @param mimeType 文件的mime 类型
     * @return 文件的扩展名集合
     */
    public static List<String> getFileRealTypeList(String mimeType) {
        if (ObjectUtils.isEmpty(mimeType)) {
            return Collections.emptyList();
        }

        List<String> fileTypeList = MIME_TYPE_MAP.get(mimeType.replace(" ", ""));
        if (fileTypeList == null) {
            log.info("mimeType:{}, FileTypeList is null", mimeType);
            return Collections.emptyList();
        }
        return fileTypeList;
    }
}

Md5Util

java 复制代码
@Slf4j
public final class Md5Util {
    private static final int BUFFER_SIZE = 8 * 1024;

    private static final char[] HEX_CHARS =
            {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'};

    private Md5Util() {
    }

    /**
     * 计算字节数组的md5
     *
     * @param bytes bytes
     * @return 文件流的md5
     */
    public static String calculateMd5(byte[] bytes) {
        try {
            MessageDigest md5MessageDigest = MessageDigest.getInstance("MD5");
            return encodeHex(md5MessageDigest.digest(bytes));
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalArgumentException("no md5 found");
        }
    }

    /**
     * 计算文件的输入流
     *
     * @param inputStream inputStream
     * @return 文件流的md5
     */
    public static String calculateMd5(InputStream inputStream) {
        try {
            MessageDigest md5MessageDigest = MessageDigest.getInstance("MD5");
            try (BufferedInputStream bis = new BufferedInputStream(inputStream);
                 DigestInputStream digestInputStream = new DigestInputStream(bis, md5MessageDigest)) {

                final byte[] buffer = new byte[BUFFER_SIZE];

                while (digestInputStream.read(buffer) > 0) {
                    // 获取最终的MessageDigest
                    md5MessageDigest = digestInputStream.getMessageDigest();
                }

                return encodeHex(md5MessageDigest.digest());
            } catch (IOException ioException) {
                log.error("", ioException);
                throw new IllegalArgumentException(ioException.getMessage());
            }
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalArgumentException("no md5 found");
        }
    }

    /**
     * 获取字符串的MD5值
     *
     * @param input 输入的字符串
     * @return md5
     */
    public static String calculateMd5(String input) {
        try {
            // 拿到一个MD5转换器(如果想要SHA1参数,可以换成SHA1)
            MessageDigest md5MessageDigest = MessageDigest.getInstance("MD5");
            byte[] inputByteArray = input.getBytes(StandardCharsets.UTF_8);
            md5MessageDigest.update(inputByteArray);

            // 转换并返回结果,也是字节数组,包含16个元素
            byte[] resultByteArray = md5MessageDigest.digest();
            // 将字符数组转成字符串返回
            return encodeHex(resultByteArray);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalArgumentException("md5 not found");
        }
    }

    /**
     * 转成的md5值为全小写
     *
     * @param bytes bytes
     * @return 全小写的md5值
     */
    private static String encodeHex(byte[] bytes) {
        char[] chars = new char[32];
        for (int i = 0; i < chars.length; i = i + 2) {
            byte b = bytes[i / 2];
            chars[i] = HEX_CHARS[(b >>> 0x4) & 0xf];
            chars[i + 1] = HEX_CHARS[b & 0xf];
        }
        return new String(chars);
    }
}
	

MediaType

java 复制代码
public class MediaType implements Serializable {
    private static final long serialVersionUID = 560696828359220276L;
    public static final String ALL_VALUE = "*/*";
}

MinioTemplate

java 复制代码
@Slf4j
public class MinioTemplate {
    /**
     * MinIO 客户端
     */
    private MinioClient minioClient;


    /**
     * MinIO 配置类
     */
    @Autowired
    private OSSProperties ossProperties;


    /**
     * 初始化操作
     * 初始化MinioClient 客户端
     * 并初始化默认桶
     */
    @PostConstruct
    public void init() {
        minioClient = MinioClient.builder()
                .endpoint(ossProperties.getEndpoint())
                .credentials(ossProperties.getAccessKey(), ossProperties.getSecretKey())
                .build();

        String defaultBucketName = ossProperties.getBucketName();
        if (bucketExists(defaultBucketName)) {
            log.info("默认存储桶:{} 已存在", defaultBucketName);
        } else {
            log.info("创建默认存储桶:{}", defaultBucketName);
            makeBucket(ossProperties.getBucketName());
        }
    }

    /**
     * 获取默认的桶
     *
     * @return default BucketName
     */
    public String getDefaultBucketName() {
        return ossProperties.getBucketName();
    }

    /**
     * 查询所有存储桶
     *
     * @return Bucket 集合
     */
    @SneakyThrows
    public List<Bucket> listBuckets() {
        return minioClient.listBuckets();
    }

    /**
     * 桶是否存在
     *
     * @param bucketName 桶名
     * @return 是否存在
     */
    @SneakyThrows
    public boolean bucketExists(String bucketName) {
        return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
    }

    /**
     * 创建存储桶
     *
     * @param bucketName 桶名
     */
    @SneakyThrows
    public synchronized void makeBucket(String bucketName) {
        if (!bucketExists(bucketName)) {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
        }
    }

    /**
     * 设置桶的存储权限
     *
     * @param bucketName 桶的名称
     * @param config     桶的权限配置,有四种,一是私有,一个是公共读,一个是公共读写,一个是公共写
     */
    @SneakyThrows
    public void setBucketPolicy(String bucketName, String config) {
        minioClient.setBucketPolicy(SetBucketPolicyArgs.builder()
                .config(config)
                .bucket(bucketName)
                .build());
    }

    /**
     * 删除一个空桶 如果存储桶存在对象不为空时,删除会报错。
     *
     * @param bucketName 桶名
     */
    @SneakyThrows
    public void removeBucket(String bucketName) {
        removeBucket(bucketName, false);
        minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
    }

    /**
     * 删除一个桶 根据桶是否存在数据进行不同的删除
     * 桶为空时直接删除
     * 桶不为空时先删除桶中的数据,然后再删除桶
     *
     * @param bucketName 桶名
     */
    @SneakyThrows
    public void removeBucket(String bucketName, boolean bucketNotNull) {
        if (bucketNotNull) {
            deleteBucketAllObject(bucketName);
        }
        minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
    }

    /**
     * 上传文件
     *
     * @param inputStream      流
     * @param originalFileName 原始文件名
     * @param bucketName       桶名
     * @return ObjectWriteResponse
     */
    @SneakyThrows
    public OssFile putObject(InputStream inputStream, String bucketName, String originalFileName) {
        String uuidFileName = generateFileInMinioName(originalFileName);
        try {
            if (ObjectUtils.isEmpty(bucketName)) {
                bucketName = ossProperties.getBucketName();
            }
            minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucketName)
                            .object(uuidFileName)
                            .stream(inputStream, inputStream.available(), -1)
                            .build());
            return new OssFile(uuidFileName, originalFileName);
        } finally {
            if (inputStream != null) {
                inputStream.close();
            }
        }
    }

    @SneakyThrows
    public void uploadObject(String bucketName, String objectName, String filePath) {
        minioClient.uploadObject(UploadObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .filename(filePath)
                .build());
    }

    /**
     * 删除桶中所有的对象
     *
     * @param bucketName 桶对象
     */
    @SneakyThrows
    public void deleteBucketAllObject(String bucketName) {
        List<String> list = listObjectNames(bucketName);
        if (!list.isEmpty()) {
            for (String objectName : list) {
                deleteObject(bucketName, objectName);
            }
        }
    }

    @SneakyThrows
    public void deleteFolder(String bucketName, String folder) {
        Iterable<Result<Item>> results = listObjects(bucketName, folder, true);
        // 先删除子目录,最后再删除父目录
        for (Result<Item> result : results) {
            deleteObject(bucketName, result.get().objectName());
        }
        deleteObject(bucketName, folder);
    }

    /**
     * 查询桶中所有的对象名
     *
     * @param bucketName 桶名
     * @return objectNames
     */
    @SneakyThrows
    public List<String> listObjectNames(String bucketName) {
        List<String> objectNameList = new ArrayList<>();
        if (bucketExists(bucketName)) {
            Iterable<Result<Item>> results = listObjects(bucketName, true);
            for (Result<Item> result : results) {
                String objectName = result.get().objectName();
                objectNameList.add(objectName);
            }
        }
        return objectNameList;
    }


    /**
     * 删除一个对象
     *
     * @param bucketName 桶名
     * @param objectName 对象名
     */
    @SneakyThrows
    public void deleteObject(String bucketName, String objectName) {
        minioClient.removeObject(RemoveObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .build());
    }

    /**
     * 上传分片文件
     *
     * @param inputStream 流
     * @param objectName  存入桶中的对象名
     * @param bucketName  桶名
     * @return ObjectWriteResponse
     */
    @SneakyThrows
    public OssFile putChunkObject(InputStream inputStream, String bucketName, String objectName) {
        try {
            minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .stream(inputStream, inputStream.available(), -1)
                            .build());
            return new OssFile(objectName, objectName);
        } finally {
            if (inputStream != null) {
                inputStream.close();
            }
        }
    }

    /**
     * 返回临时带签名、Get请求方式的访问URL
     *
     * @param bucketName 桶名
     * @param filePath   Oss文件路径
     * @return 临时带签名、Get请求方式的访问URL
     */
    @SneakyThrows
    public String getPresignedObjectUrl(String bucketName, String filePath) {
        return minioClient.getPresignedObjectUrl(
                GetPresignedObjectUrlArgs.builder()
                        .method(Method.GET)
                        .bucket(bucketName)
                        .object(filePath)
                        .build());
    }

    /**
     * 返回临时带签名、过期时间为1天的PUT请求方式的访问URL
     *
     * @param bucketName  桶名
     * @param filePath    Oss文件路径
     * @param queryParams 查询参数
     * @return 临时带签名、过期时间为1天的PUT请求方式的访问URL
     */
    @SneakyThrows
    public String getPresignedObjectUrl(String bucketName, String filePath, Map<String, String> queryParams) {
        return minioClient.getPresignedObjectUrl(
                GetPresignedObjectUrlArgs.builder()
                        .method(Method.PUT)
                        .bucket(bucketName)
                        .object(filePath)
                        .expiry(1, TimeUnit.DAYS)
                        .extraQueryParams(queryParams)
                        .build());
    }


    /**
     * GetObject接口用于获取某个文件(Object)。此操作需要对此Object具有读权限。
     *
     * @param bucketName 桶名
     * @param objectName 文件路径
     */
    @SneakyThrows
    public InputStream getObject(String bucketName, String objectName) {
        return minioClient.getObject(
                GetObjectArgs.builder().bucket(bucketName).object(objectName).build());
    }


    /**
     * GetObject接口用于获取某个文件(Object)。此操作需要对此Object具有读权限。
     *
     * @param bucketName 桶名
     * @param objectName 文件路径
     */
    @SneakyThrows
    public StatObjectResponse getObjectInfo(String bucketName, String objectName) {
        return minioClient.statObject(StatObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .build());
    }


    /**
     * GetObject接口用于获取某个文件(Object)。此操作需要对此Object具有读权限。
     *
     * @param bucketName 桶名
     * @param objectName 文件路径
     * @param offset     截取流的开始位置
     * @param length     截取长度
     */
    @SneakyThrows
    public InputStream getObject(String bucketName, String objectName, Long offset, Long length) {
        return minioClient.getObject(
                GetObjectArgs.builder().bucket(bucketName).object(objectName).offset(offset).length(length).build());
    }


    /**
     * 查询桶的对象信息
     *
     * @param bucketName 桶名
     * @param recursive  是否递归查询
     * @return 桶的对象信息
     */
    @SneakyThrows
    public Iterable<Result<Item>> listObjects(String bucketName, boolean recursive) {
        return minioClient.listObjects(
                ListObjectsArgs.builder().bucket(bucketName).recursive(recursive).build());
    }

    /**
     * 查询桶的对象信息
     *
     * @param bucketName 桶名
     * @param prefix     指定的前缀名称
     * @param recursive  是否递归查询
     * @return 桶的对象信息
     */
    @SneakyThrows
    public Iterable<Result<Item>> listObjects(String bucketName, String prefix, boolean recursive) {
        return minioClient.listObjects(ListObjectsArgs.builder()
                .bucket(bucketName)
                .prefix(prefix)
                .recursive(recursive)
                .build());
    }

    /**
     * 获取带签名的临时上传元数据对象,前端可获取后,直接上传到Minio
     *
     * @param bucketName 桶名称
     * @param fileName   文件名
     * @return Map<String, String>
     */
    @SneakyThrows
    public Map<String, String> getPresignedPostFormData(String bucketName, String fileName) {
        // 为存储桶创建一个上传策略,过期时间为7天
        PostPolicy policy = new PostPolicy(bucketName, ZonedDateTime.now().plusDays(1));
        // 设置一个参数key,值为上传对象的名称
        policy.addEqualsCondition("key", fileName);
        // 添加Content-Type,例如以"image/"开头,表示只能上传照片,这里吃吃所有
        policy.addStartsWithCondition("Content-Type", MediaType.ALL_VALUE);
        // 设置上传文件的大小 64kiB to 10MiB.
        //policy.addContentLengthRangeCondition(64 * 1024, 10 * 1024 * 1024);
        return minioClient.getPresignedPostFormData(policy);
    }


    public String generateFileInMinioName(String originalFilename) {
        return "files" + StrUtil.SLASH + DateUtil.format(new Date(), "yyyy-MM-dd") + StrUtil.SLASH + UUID.randomUUID() + StrUtil.UNDERLINE + originalFilename;
    }

    /**
     * 文件合并,将分块文件组成一个新的文件
     *
     * @param bucketName       合并文件生成文件所在的桶
     * @param fileName         原始文件名
     * @param sourceObjectList 分块文件集合
     * @return OssFile
     */
    @SneakyThrows
    public OssFile composeObject(String bucketName, String fileName, List<ComposeSource> sourceObjectList) {
        String filenameExtension = StringUtils.getFilenameExtension(fileName);
        String objectName = UUID.randomUUID() + "." + filenameExtension;
        minioClient.composeObject(ComposeObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .sources(sourceObjectList)
                .build());

        String presignedObjectUrl = getPresignedObjectUrl(bucketName, fileName);
        return new OssFile(presignedObjectUrl, fileName);
    }


    /**
     * 文件合并,将分块文件组成一个新的文件
     *
     * @param bucketName       合并文件生成文件所在的桶
     * @param objectName       原始文件名
     * @param sourceObjectList 分块文件集合
     * @return OssFile
     */
    @SneakyThrows
    public OssFile composeObject(List<ComposeSource> sourceObjectList, String bucketName, String objectName) {
        minioClient.composeObject(ComposeObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .sources(sourceObjectList)
                .build());
        String presignedObjectUrl = getPresignedObjectUrl(bucketName, objectName);
        return new OssFile(presignedObjectUrl, objectName);
    }

    /**
     * 文件合并,将分块文件组成一个新的文件
     *
     * @param originBucketName 分块文件所在的桶
     * @param targetBucketName 合并文件生成文件所在的桶
     * @param objectName       存储于桶中的对象名
     * @return OssFile
     */
    @SneakyThrows
    public OssFile composeObject(String originBucketName, String targetBucketName, String objectName) {

        Iterable<Result<Item>> results = listObjects(originBucketName, true);
        List<String> objectNameList = new ArrayList<>();
        for (Result<Item> result : results) {
            Item item = result.get();
            objectNameList.add(item.objectName());
        }


        if (ObjectUtils.isEmpty(objectNameList)) {
            throw new IllegalArgumentException(originBucketName + "桶中没有文件,请检查");
        }

        List<ComposeSource> composeSourceList = new ArrayList<>(objectNameList.size());
        // 对文件名集合进行升序排序
        objectNameList.sort((o1, o2) -> Integer.parseInt(o2) > Integer.parseInt(o1) ? -1 : 1);
        for (String object : objectNameList) {
            composeSourceList.add(ComposeSource.builder()
                    .bucket(originBucketName)
                    .object(object)
                    .build());
        }

        return composeObject(composeSourceList, targetBucketName, objectName);
    }

    /**
     * 将Bucket指定目录下的文件合并,将分块文件组成一个新的文件
     *
     * @param bucketName 分块文件所在的桶
     * @param folder     对象的前缀名
     * @param objectName 存储于桶中的对象名
     * @return OssFile
     */
    @SneakyThrows
    public OssFile composeObjectByObjectFolder(String bucketName, String folder, String objectName) {

        Iterable<Result<Item>> results = listObjects(bucketName, folder, true);
        List<String> objectNameList = new ArrayList<>();
        for (Result<Item> result : results) {
            Item item = result.get();
            objectNameList.add(item.objectName());
        }


        if (ObjectUtils.isEmpty(objectNameList)) {
            throw new IllegalArgumentException(bucketName + "/" + folder + "文件夹中没有文件,请检查");
        }

        List<ComposeSource> composeSourceList = new ArrayList<>(objectNameList.size());
        objectNameList = objectNameList.stream().map(objectNameHandler -> objectNameHandler.replace(folder, "").replace("/", "")).collect(Collectors.toList());
        // 对文件名集合进行升序排序
        objectNameList.sort((o1, o2) -> Integer.parseInt(o2) > Integer.parseInt(o1) ? -1 : 1);

        objectNameList = objectNameList.stream().map(objectNameHandler -> folder + objectNameHandler).collect(Collectors.toList());
        for (String object : objectNameList) {
            composeSourceList.add(ComposeSource.builder()
                    .bucket(bucketName)
                    .object(object)
                    .build());
        }

        return composeObject(composeSourceList, bucketName, objectName);
    }

    /**
     * 获取桶的存储策略
     *
     * @param bucket bucket
     * @return 桶的存储策略
     */
    @SneakyThrows
    public String getBucketPolicy(String bucket) {
        return minioClient.getBucketPolicy(GetBucketPolicyArgs.builder().bucket(bucket).build());
    }
}

文件分片上传与合并

MinioFileController

java 复制代码
@RestController
@RequestMapping(value = "/file")
@Slf4j
@CrossOrigin // 允许跨域
public class MinioFileController {
   @Autowired
   private MinioService minioService;

    @RequestMapping(value = "/home")
    public ModelAndView homeUpload() {
        ModelAndView modelAndView = new ModelAndView();
        modelAndView.setViewName("upload");
        return modelAndView;
    }

    /**
     * 根据文件大小和文件的md5校验文件是否存在
     * 暂时使用Redis实现,后续需要存入数据库
     * 实现秒传接口
     *
     * @param md5 文件的md5
     * @return 操作是否成功
     */
    @GetMapping(value = "/check")
    public Map<String, Object> checkFileExists(String md5) {
       return minioService.uploadCheck(md5);
    }


    /**
     * 文件上传,适合大文件,集成了分片上传
     */
    @PostMapping(value = "/upload")
    public Map<String, Object> upload(HttpServletRequest req) {
       return minioService.upload(req);

    }

    /**
     * 文件合并
     *
     * @param shardCount 分片总数
     * @param fileName   文件名
     * @param md5        文件的md5
     * @param fileType   文件类型
     * @param fileSize   文件大小
     * @return 分片合并的状态
     */
    @GetMapping(value = "/merge")
    public Map<String, Object> merge(Integer shardCount, String fileName, String md5, String fileType,
                                     Long fileSize) {
       return minioService.merge(shardCount, fileName, md5, fileType, fileSize);
    }
}

MinioService

java 复制代码
public interface MinioService {

    /**
     * 文件上传前的检查,这是为了实现秒传接口
     *
     * @param md5 文件的md5
     * @return 文件是否上传过的元数据
     */
    Map<String, Object> uploadCheck(String md5);

    /**
     * 文件上传的核心功能
     *
     * @param req 请求
     * @return 上传结果的元数据
     */
    Map<String, Object> upload(HttpServletRequest req);

    /**
     * 分片文件合并的核心方法
     *
     * @param shardCount 分片数
     * @param fileName   文件名
     * @param md5        文件的md5值
     * @param fileType   文件类型
     * @param fileSize   文件大小
     * @return 合并成功的元数据
     */
    Map<String, Object> merge(Integer shardCount, String fileName, String md5, String fileType,
                              Long fileSize);

    /**
     * 视频播放的核心功能
     *
     * @param request    request
     * @param response   response
     * @param bucketName 视频文件所在的桶
     * @param objectName 视频文件名
     */
    void videoPlay(HttpServletRequest request, HttpServletResponse response,
                   String bucketName,
                   String objectName);
}
MinioServiceImpl
java 复制代码
@Slf4j
@Service
public class MinioServiceImpl implements MinioService {

    /**
     * 存储视频的元数据列表
     */
    private static final String OBJECT_INFO_LIST = "com:minio:media:objectList";

    /**
     * 已上传文件的md5列表
     */
    private static final String MD5_KEY = "com:minio:file:md5List";

    @Autowired
    private MinioTemplate minioTemplate;

    @Autowired
    private ObjectMapper objectMapper;

    @Resource(name = "jsonRedisTemplate")
    private RedisTemplate<String, Serializable> redisTemplate;

    /**
     * 文件上传前的检查,这是为了实现秒传接口
     *
     * @param md5 文件的md5
     * @return 文件是否上传过的元数据
     */
    @Override
    public Map<String, Object> uploadCheck(String md5) {
        Map<String, Object> resultMap = new HashMap<>();
        if (ObjectUtils.isEmpty(md5)) {
            resultMap.put("status", StatusCode.PARAM_ERROR.getCode());
            return resultMap;
        }
        // 先从Redis中查询
        String url = (String) redisTemplate.boundHashOps(MD5_KEY).get(md5);

        // 文件不存在
        if (ObjectUtils.isEmpty(url)) {
            resultMap.put("status", StatusCode.NOT_FOUND.getCode());
            return resultMap;
        }

        resultMap.put("status", StatusCode.SUCCESS.getCode());
        resultMap.put("url", url);
        // 文件已经存在了
        return resultMap;
    }

    /**
     * 文件上传的核心功能
     *
     * @param req 请求
     * @return 上传结果的元数据
     */
    @Override
    public Map<String, Object> upload(HttpServletRequest req) {
        Map<String, Object> map = new HashMap<>();

        MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) req;

        // 获得文件分片数据
        MultipartFile file = multipartRequest.getFile("data");

        // 上传过程中出现异常,状态码设置为50000
        if (file == null) {
            map.put("status", StatusCode.FAILURE.getCode());
            return map;
        }
        // 分片第几片
        int index = Integer.parseInt(multipartRequest.getParameter("index"));
        // 总片数
        int total = Integer.parseInt(multipartRequest.getParameter("total"));
        // 获取文件名
        String fileName = multipartRequest.getParameter("name");

        String md5 = multipartRequest.getParameter("md5");

        // 创建文件桶
        minioTemplate.makeBucket(md5);
        String objectName = String.valueOf(index);

        log.info("index: {}, total:{}, fileName:{}, md5:{}, objectName:{}", index, total, fileName, md5, objectName);

        // 当不是最后一片时,上传返回的状态码为20001
        if (index < total) {
            try {
                // 上传文件
                OssFile ossFile = minioTemplate.putChunkObject(file.getInputStream(), md5, objectName);
                log.info("{} upload success {}", objectName, ossFile);

                // 设置上传分片的状态
                map.put("status", StatusCode.ALONE_CHUNK_UPLOAD_SUCCESS.getCode());
                return map;
            } catch (Exception e) {
                e.printStackTrace();
                map.put("status", StatusCode.FAILURE.getCode());
                return map;
            }
        } else {
            // 为最后一片时状态码为20002
            try {
                // 上传文件
                minioTemplate.putChunkObject(file.getInputStream(), md5, objectName);

                // 设置上传分片的状态
                map.put("status", StatusCode.ALL_CHUNK_UPLOAD_SUCCESS.getCode());
                return map;
            } catch (Exception e) {
                e.printStackTrace();
                map.put("status", StatusCode.FAILURE.getCode());
                return map;
            }
        }
    }

    /**
     * 分片文件合并的核心方法
     *
     * @param shardCount 分片数
     * @param fileName   文件名
     * @param md5        文件的md5值
     * @param fileType   文件类型
     * @param fileSize   文件大小
     * @return 合并成功的元数据
     */
    @Override
    public Map<String, Object> merge(Integer shardCount, String fileName, String md5, String fileType, Long fileSize) {
        Map<String, Object> retMap = new HashMap<>();

        try {
            // 查询片数据
            List<String> objectNameList = minioTemplate.listObjectNames(md5);
            if (shardCount != objectNameList.size()) {
                // 失败
                retMap.put("status", StatusCode.FAILURE.getCode());
            } else {
                // 开始合并请求
                String targetBucketName = minioTemplate.getDefaultBucketName();
                String filenameExtension = StringUtils.getFilenameExtension(fileName);
                String fileNameWithoutExtension = UUID.randomUUID().toString();
                String objectName = fileNameWithoutExtension + "." + filenameExtension;
                minioTemplate.composeObject(md5, targetBucketName, objectName);

                log.info("桶:{} 中的分片文件,已经在桶:{},文件 {} 合并成功", md5, targetBucketName, objectName);

                // 合并成功之后删除对应的临时桶
                minioTemplate.removeBucket(md5, true);
                log.info("删除桶 {} 成功", md5);

                // 计算文件的md5
                String fileMd5 = null;
                try (InputStream inputStream = minioTemplate.getObject(targetBucketName, objectName)) {
                    fileMd5 = Md5Util.calculateMd5(inputStream);
                } catch (IOException e) {
                    log.error("", e);
                }

                // 计算文件真实的类型
                String type = null;
                List<String> typeList = new ArrayList<>();
                try (InputStream inputStreamCopy = minioTemplate.getObject(targetBucketName, objectName)) {
                    typeList.addAll(FileTypeUtil.getFileRealTypeList(inputStreamCopy, fileName, fileSize));
                } catch (IOException e) {
                    log.error("", e);
                }

                // 并和前台的md5进行对比
                if (!ObjectUtils.isEmpty(fileMd5) && !ObjectUtils.isEmpty(typeList) && fileMd5.equalsIgnoreCase(md5) && typeList.contains(fileType.toLowerCase(Locale.ENGLISH))) {
                    // 表示是同一个文件, 且文件后缀名没有被修改过
                    String url = minioTemplate.getPresignedObjectUrl(targetBucketName, objectName);

                    // 存入redis中
                    redisTemplate.boundHashOps(MD5_KEY).put(fileMd5, url);

                    // 成功
                    retMap.put("status", StatusCode.SUCCESS.getCode());
                } else {
                    log.info("非法的文件信息: 分片数量:{}, 文件名称:{}, 文件fileMd5:{}, 文件真实类型:{}, 文件大小:{}",
                            shardCount, fileName, fileMd5, typeList, fileSize);
                    log.info("非法的文件信息: 分片数量:{}, 文件名称:{}, 文件md5:{}, 文件类型:{}, 文件大小:{}",
                            shardCount, fileName, md5, fileType, fileSize);

                    // 并需要删除对象
                    minioTemplate.deleteObject(targetBucketName, objectName);
                    retMap.put("status", StatusCode.FAILURE.getCode());
                }
            }
        } catch (Exception e) {
            log.error("", e);
            // 失败
            retMap.put("status", StatusCode.FAILURE.getCode());
        }
        return retMap;
    }

    /**
     * 视频播放的核心功能
     *
     * @param request    request
     * @param response   response
     * @param bucketName 视频文件所在的桶
     * @param objectName 视频文件名
     */
    @Override
    public void videoPlay(HttpServletRequest request, HttpServletResponse response, String bucketName, String objectName) {
        // 设置响应报头
        // 需要查询redis
        String key = bucketName + ":" + objectName;
        Object obj = redisTemplate.boundHashOps(OBJECT_INFO_LIST).get(key);

        // 用于记录视频文件的元数据
        // 这里使用Redis的缓存作为优化
        MinioObject minioObject;
        if (obj == null) {
            StatObjectResponse objectInfo = null;
            try {
                objectInfo = minioTemplate.getObjectInfo(bucketName, objectName);
            } catch (Exception e) {
                log.error("{}中{}不存在: {}", bucketName, objectName, e.getMessage());
                response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
                response.setContentType("application/json;charset=utf-8");
                response.setStatus(HttpServletResponse.SC_NOT_FOUND);
                try {
                    response.getWriter().write(objectMapper.writeValueAsString(Result.error(StatusCode.NOT_FOUND)));
                } catch (IOException ex) {
                    throw new RuntimeException(ex);
                }
                return;
            }
            // 判断是否是视频,是否为mp4格式
            String filenameExtension = StringUtils.getFilenameExtension(objectName);
            if (ObjectUtils.isEmpty(filenameExtension) ||
                    !"mp4".equalsIgnoreCase(filenameExtension.toLowerCase(Locale.ENGLISH))) {
                throw new IllegalArgumentException("不支持的媒体类型, 文件名: " + objectName);
            }

            minioObject = new MinioObject();
            BeanUtils.copyProperties(objectInfo, minioObject);


            redisTemplate.boundHashOps(OBJECT_INFO_LIST).put(key, minioObject);
        } else {
            minioObject = (MinioObject) obj;
        }


        // 获取文件的长度
        long fileSize = minioObject.getSize();
        // Accept-Ranges: bytes
        response.setHeader("Accept-Ranges", "bytes");
        //pos开始读取位置;  last最后读取位置
        long startPos = 0;
        long endPos = fileSize - 1;
        String rangeHeader = request.getHeader("Range");
        if (!ObjectUtils.isEmpty(rangeHeader) && rangeHeader.startsWith("bytes=")) {

            try {
                // 情景一:RANGE: bytes=2000070- 情景二:RANGE: bytes=2000070-2000970
                String numRang = request.getHeader("Range").replaceAll("bytes=", "");
                if (numRang.startsWith("-")) {
                    endPos = fileSize - 1;
                    startPos = endPos - Long.parseLong(new String(numRang.getBytes(StandardCharsets.UTF_8), 1,
                            numRang.length() - 1)) + 1;
                } else if (numRang.endsWith("-")) {
                    endPos = fileSize - 1;
                    startPos = Long.parseLong(new String(numRang.getBytes(StandardCharsets.UTF_8), 0,
                            numRang.length() - 1));
                } else {
                    String[] strRange = numRang.split("-");
                    if (strRange.length == 2) {
                        startPos = Long.parseLong(strRange[0].trim());
                        endPos = Long.parseLong(strRange[1].trim());
                    } else {
                        startPos = Long.parseLong(numRang.replaceAll("-", "").trim());
                    }
                }

                if (startPos < 0 || endPos < 0 || endPos >= fileSize || startPos > endPos) {
                    // SC 要求的范围不满足
                    response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
                    return;
                }

                // 断点续传 状态码206
                response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
            } catch (NumberFormatException e) {
                log.error(request.getHeader("Range") + " is not Number!");
                startPos = 0;
            }
        }

        // 总共需要读取的字节
        long rangLength = endPos - startPos + 1;
        response.setHeader("Content-Range", String.format("bytes %d-%d/%d", startPos, endPos, fileSize));
        response.addHeader("Content-Length", String.valueOf(rangLength));
        //response.setHeader("Connection", "keep-alive");
        response.addHeader("Content-Type", "video/mp4");

        try (BufferedOutputStream bos = new BufferedOutputStream(response.getOutputStream());
             BufferedInputStream bis = new BufferedInputStream(
                     minioTemplate.getObject(bucketName, objectName, startPos, rangLength))) {
            IOUtils.copy(bis, bos);
        } catch (
                IOException e) {
            if (e instanceof ClientAbortException) {
                // ignore 这里就不要打日志,这里的异常原因是用户在拖拽视频进度造成的
            } else {
                log.error(e.getMessage());
            }
        }
    }
}

upload.html

java 复制代码
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>

</head>
<body>
<script type="text/javascript" src="/js/jquery.js" th:src="@{/js/jquery.js}"></script>
<script type="text/javascript" src="/js/spark-md5.min.js" th:src="@{/js/spark-md5.min.js}"></script>
<script type="text/javascript" src="/js/base.js" th:src="@{/js/base.js}"></script>
<input type="file" name="file" id="file">
<script>
    /**
     * 分块计算文件的md5值
     * @param file 文件
     * @param chunkSize 分片大小
     * @returns Promise
     */
    function calculateFileMd5(file, chunkSize) {
        return new Promise((resolve, reject) => {
            let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
            let chunks = Math.ceil(file.size / chunkSize);
            let currentChunk = 0;
            let spark = new SparkMD5.ArrayBuffer();
            let fileReader = new FileReader();

            fileReader.onload = function (e) {
                spark.append(e.target.result);
                currentChunk++;
                if (currentChunk < chunks) {
                    loadNext();
                } else {
                    let md5 = spark.end();
                    resolve(md5);
                }
            };

            fileReader.onerror = function (e) {
                reject(e);
            };

            function loadNext() {
                let start = currentChunk * chunkSize;
                let end = start + chunkSize;
                if (end > file.size) {
                    end = file.size;
                }
                fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
            }

            loadNext();
        });
    }

    /**
     * 分块计算文件的md5值,默认分片大小为2097152(2M)
     * @param file 文件
     * @returns Promise
     */
    function calculateFileMd5ByDefaultChunkSize(file) {
        return calculateFileMd5(file, 2097152);
    }

    /**
     * 获取文件的后缀名
     */
    function getFileType(fileName) {
        return fileName.substr(fileName.lastIndexOf(".") + 1).toLowerCase();
    }

    // 文件选择之后就计算文件的md5值
    document.getElementById("file").addEventListener("change", function () {
        let file = this.files[0];
        calculateFileMd5ByDefaultChunkSize(file).then(e => {
            // 获取到文件的md5
            let md5 = e;
            checkMd5(md5, file)
        }).catch(e => {
            // 处理异常
            console.error(e);
        });
    });

    /**
     * 根据文件的md5值判断文件是否已经上传过了
     *
     * @param md5 文件的md5
     * @param file 准备上传的文件
     */
    function checkMd5(md5, file) {
        // 请求数据库,查询md5是否存在
        $.ajax({
            url: baseUrl + "/file/check",
            type: "GET",
            data: {
                md5: md5
            },
            async: true, //异步
            dataType: "json",
            success: function (msg) {
                console.log(msg);
                // 文件已经存在了,无需上传
                if (msg.status === 20000) {
                    console.log("文件已经存在了,无需上传")
                } else if (msg.status === 40004) {
                    // 文件不存在需要上传
                    console.log("文件不存在需要上传")
                    PostFile(file, 0, md5);
                } else {
                    console.log('未知错误');
                }
            }
        })
    }

    /**
     * 执行分片上传
     * @param file 上传的文件
     * @param i 第几分片,从0开始
     * @param md5 文件的md5值
     */
    function PostFile(file, i, md5) {
        let name = file.name,                           //文件名
            size = file.size,                           //总大小shardSize = 2 * 1024 * 1024,
            shardSize = 5 * 1024 * 1024,                //以5MB为一个分片,每个分片的大小
            shardCount = Math.ceil(size / shardSize);   //总片数
        if (i >= shardCount) {
            return;
        }

        let start = i * shardSize;
        let end = start + shardSize;
        let packet = file.slice(start, end);  //将文件进行切片
        /*  构建form表单进行提交  */
        let form = new FormData();
        form.append("md5", md5);// 前端生成uuid作为标识符传个后台每个文件都是一个uuid防止文件串了
        form.append("data", packet); //slice方法用于切出文件的一部分
        form.append("name", name);
        form.append("totalSize", size);
        form.append("total", shardCount); //总片数
        form.append("index", i + 1); //当前是第几片
        $.ajax({
            url: baseUrl + "/file/upload",
            type: "POST",
            data: form,
            //timeout:"10000",  //超时10秒
            async: true, //异步
            dataType: "json",
            processData: false, //很重要,告诉jquery不要对form进行处理
            contentType: false, //很重要,指定为false才能形成正确的Content-Type
            success: function (msg) {
                console.log(msg);
                /*  表示上一块文件上传成功,继续下一次  */
                if (msg.status === 20001) {
                    form = '';
                    i++;
                    PostFile(file, i, md5);
                } else if (msg.status === 50000) {
                    form = '';
                    /*  失败后,每2秒继续传一次分片文件  */
                    setInterval(function () {
                        PostFile(file, i, md5)
                    }, 2000);
                } else if (msg.status === 20002) {
                    merge(shardCount, name, md5, getFileType(file.name), file.size)
                    console.log("上传成功");
                } else {
                    console.log('未知错误');
                }
            }
        })
    }

    /**
     * 合并文件
     * @param shardCount 分片数
     * @param fileName 文件名
     * @param md5 文件md值
     * @param fileType 文件类型
     * @param fileSize 文件大小
     */
    function merge(shardCount, fileName, md5, fileType, fileSize) {
        $.ajax({
            url: baseUrl + "/file/merge",
            type: "GET",
            data: {
                shardCount: shardCount,
                fileName: fileName,
                md5: md5,
                fileType: fileType,
                fileSize: fileSize
            },
            // timeout:"10000",  //超时10秒
            async: true, //异步
            dataType: "json",
            success: function (msg) {
                console.log(msg);
            }
        })
    }
</script>

</body>
</html>

视频播放

VideoController

调用minio的播放方法

java 复制代码
@RestController
@Slf4j
@RequestMapping(value = "/video")
@CrossOrigin
public class VideoController {

    @Autowired
    private MinioService minioService;

    /**
     * 支持分段读取视频流
     *
     * @param request    请求对象
     * @param response   响应对象
     * @param bucketName 视频所在桶的位置
     * @param objectName 视频的文件名
     */
    @GetMapping(value = "/play/{bucketName}/{objectName}")
    public void videoPlay(HttpServletRequest request, HttpServletResponse response,
                                  @PathVariable(value = "bucketName") String bucketName,
                                  @PathVariable(value = "objectName") String objectName) {
        minioService.videoPlay(request, response, bucketName, objectName);
    }

    @RequestMapping(value = "/home/{bucketName}/{objectName}")
    public ModelAndView videoHome( @PathVariable(value = "bucketName") String bucketName,
                                   @PathVariable(value = "objectName") String objectName) {
        ModelAndView modelAndView = new ModelAndView();

        modelAndView.addObject("bucketName", bucketName);
        modelAndView.addObject("objectName", objectName);
        modelAndView.setViewName("video");
        return modelAndView;
    }
}

video.html

html 复制代码
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>ckplayer</title>
    <link rel="shortcut icon" href="#"/>
    <link type="text/css" rel="stylesheet" href="/ckplayer/css/ckplayer.css" th:href="@{/ckplayer/css/ckplayer.css}"/>

    <script type="text/javascript" src="/js/jquery.js" th:src="@{/js/jquery.js}"></script>

    <!--
        如果需要使用其它语言,请在此处引入相应的js,比如:<script type="text/javascript" src="ckplayer/language/en.js" charset="UTF-8"></script>
    -->
    <script type="text/javascript" src="/ckplayer/js/ckplayer.min.js" th:src="@{/ckplayer/js/ckplayer.min.js}"
            charset="UTF-8"></script>

    <script type="text/javascript" src="/js/base.js" th:src="@{/js/base.js}"></script>


</head>
<body>

<div class="video" style="width: 100%; height: 500px;max-width: 800px;">播放容器</div>


<p>官网:<a href="https://www.ckplayer.com" target="_blank">www.ckplayer.com</a></p>
<p>手册:<a href="https://www.ckplayer.com/manual/" target="_blank">www.ckplayer.com/manual/</a></p>
<p>社区:<a href="https://bbs.ckplayer.com/" target="_blank">bbs.ckplayer.com</a></p>
<p>全功能演示:<a href="https://www.ckplayer.com/demo.html" target="_blank">www.ckplayer.com/demo.html</a></p>
<p>控制示例:</p>
<p>
    <button type="button" onclick="player.play()">播放</button>
    <button type="button" onclick="player.pause()">暂停</button>
    <button type="button" onclick="player.seek(20)">跳转</button>
    <button type="button" onclick="player.volume(0.6)">修改音量</button>
    <button type="button" onclick="player.muted()">静音</button>
    <button type="button" onclick="player.exitMuted()">恢复音量</button>
    <button type="button" onclick="player.full()">全屏</button>
    <button type="button" onclick="player.webFull()">页面全屏</button>
    <button type="button" onclick="player.theatre()">剧场模式</button>
    <button type="button" onclick="player.exitTheatre()">退出剧场模式</button>
</p>
<p id="state"></p>
<p id="state2"></p>

</body>

<!--JS获取-->
<script type="text/javascript" th:inline="javascript">
    const bucketName = [[${bucketName}]];
    const objectName = [[${objectName}]];
</script>

<script>

    //调用开始
    let videoObject = {
        container: '.video',//视频容器的ID
        volume: 0.8,//默认音量,范围0-1
        video: 'http://localhost:18002/video/play/'+ bucketName + '/' + objectName,//视频地址
    };
    let player = new ckplayer(videoObject)//调用播放器并赋值给变量player
    /*
     * ===============================================================================================
     * 以上代码已完成调用演示,下方的代码是演示监听动作和外部控制的部分
     * ===============================================================================================
     * ===============================================================================================
     */
    player.play(function () {
        document.getElementById('state').innerHTML = '监听到播放';
    });
    player.pause(function () {
        document.getElementById('state').innerHTML = '监听到暂停';
    });
    player.volume(function (vol) {
        document.getElementById('state').innerHTML = '监听到音量改变:' + vol;
    });
    player.muted(function (b) {
        document.getElementById('state2').innerHTML = '监听到静音状态:' + b;
    });
    player.full(function (b) {
        document.getElementById('state').innerHTML = '监听到全屏状态:' + b;
    });
    player.ended(function () {
        document.getElementById('state').innerHTML = '监听到播放结束';
    });
</script>
</html>

测试

上传

访问:http://localhost:18002/file/home来上传文件

注意到文件的objectName是:9cabffc5-1812-43c1-bcd3-d97a96b0282b.mp4

播放1

访问路径:http://localhost:18002/video/home/minio-demo/9cabffc5-1812-43c1-bcd3-d97a96b0282b.mp4

播放2

但是上面是通过后端直接拿的流,应该直接向mino获取文件流数据,由于这个bucket是private不能直接访问,因此这里可以直接向后端拿到签名的url访问地址,前端可以直接使用这个地址播放

首先生成可访问的签名url

java 复制代码
@Test
void contextLoads() throws Exception {
    String bucketName = "minio-demo";
    String filePath = "9cabffc5-1812-43c1-bcd3-d97a96b0282b.mp4";

    MinioClient minioClient = MinioClient.builder()
            .endpoint(ossProperties.getEndpoint())
            .credentials(ossProperties.getAccessKey(), ossProperties.getSecretKey())
            .build();
    String presignedObjectUrl = minioClient.getPresignedObjectUrl(
            GetPresignedObjectUrlArgs.builder()
                    .method(Method.GET)
                    .bucket(bucketName)
                    .object(filePath)
                    .expiry()
                    .build());
    System.out.println(presignedObjectUrl);
}

然后将url给到video标签(给到ckplayer也可以播放,已测试)

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <video src="http://127.0.0.1:18000/minio-demo/9cabffc5-1812-43c1-bcd3-d97a96b0282b.mp4?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=qwiVxtzgeYbGSEZuV9ki%2F20240828%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20240828T044350Z&X-Amz-Expires=604800&X-Amz-SignedHeaders=host&X-Amz-Signature=f83bdffdbc101e9973754545c110ffbe3a46c4920c418eb182fcc18914a0ea4c" controls>
</body>
</html>
相关推荐
分布式存储与RustFS2 天前
告别复杂配置:用Milvus、RustFS和Vibe Coding,60分钟DIY专属Chatbot
wpf·文件系统·milvus·对象存储·minio·rustfs·vibe
分布式存储与RustFS5 天前
告别手动配置:用 Terraform 定义你的 RustFS 存储帝国
云原生·wpf·文件系统·terraform·对象存储·minio·rustfs
SirLancelot111 天前
MinIO-基本介绍(一)基本概念、特点、适用场景
后端·云原生·中间件·容器·aws·对象存储·minio
爱刘温柔的小猪15 天前
Python 基于 MinIO 的文件上传服务与图像处理核心实践
python·minio
分布式存储与RustFS19 天前
RustFS与其他新兴存储系统(如SeaweedFS)相比有哪些优势和劣势?
开源软件·文件系统·对象存储·minio·aws s3·seaweedfs·rustfs
休息一下接着来1 个月前
MinIO 分布式模式与纠删码
分布式·minio
wL魔法师1 个月前
minio大文件断点续传
minio
wL魔法师1 个月前
minio 文件批量下载
minio
Kookoos1 个月前
多模联邦查询网关:ABP + Trino/Presto 聚合跨源数据
minio·presto·trino·数据网关·abp vnext·join优化
冷冷的菜哥1 个月前
ASP.NET Core上传文件到minio
后端·asp.net·上传·asp.net core·minio