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>
相关推荐
billy_gisboy3 天前
01_MinIO部署(Windows单节点部署/Docker化部署)
minio
vvw&16 天前
使用Ubuntu快速部署MinIO对象存储
linux·运维·服务器·ubuntu·minio·cos·oss
码见愁17 天前
Minio中出现Non-XML response from server异常
java·docker·minio·异常
xbronze21 天前
Minio文件服务器:SpringBoot实现文件上传
spring boot·minio
遇见你真好。21 天前
SpringBoot项目集成MinIO
springboot·minio
问道飞鱼1 个月前
【分布式知识】分布式对象存储组件-Minio
大数据·分布式·minio
识途老码1 个月前
Centos7搭建minio对象存储服务器
minio
鱼干~1 个月前
【.net core使用minio大文件分片上传】.net core使用minio大文件分片上传以及断点续传、秒传思路
c#·.netcore·minio·大文件分片上传·minio分片上传
敲代码不忘补水1 个月前
Docker 环境下 MinIO 监控实战:通过 Prometheus 实现集群与桶级别性能监控
docker·容器·prometheus·minio·exporter
lldhsds2 个月前
linux下的分布式Minio部署实践
linux·minio·分布式对象存储