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

文章目录

  • 参考
  • 简述
  • 效果
  • 启动minio
  • 代码
    • 配置类
      • RedisConfig
      • WebConfig
      • MinioClientAutoConfiguration
      • OSSProperties
      • application.yml
    • 实体类
      • MinioObject
      • Result
      • StatusCode
      • OssFile
      • OssPolicy
    • 工具类
      • FileTypeUtil
      • Md5Util
      • MediaType
      • MinioTemplate
    • 文件分片上传与合并
      • MinioFileController
      • MinioService
        • MinioServiceImpl
      • upload.html
    • 视频播放
      • VideoController
      • video.html
    • 测试
      • 上传
      • 播放1
      • 播放2

参考

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

视频分片上传Minio和播放

简述

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

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

效果

在这里插入图片描述

启动minio

minio.exe server D:\software\work_software\minio\data --console-address :18001 --address :18000 > D:\software\work_software\minio\minio.log

在这里插入图片描述

代码

配置类

RedisConfig

@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*/@Beanpublic 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*/@Beanpublic 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

@Configuration
public class WebConfig implements WebMvcConfigurer {private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {"classpath:/META-INF/resources/", "classpath:/resources/","classpath:/static/", "classpath:/public/"};@Overridepublic void addResourceHandlers(ResourceHandlerRegistry registry) {registry.addResourceHandler("/**").addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS);}
}

MinioClientAutoConfiguration

@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

@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

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

实体类

MinioObject

@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

@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

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, "所有的分片均上传成功");@Getterprivate final Integer code;@Getterprivate final String message;StatusCode(Integer code, String message) {this.code = code;this.message = message;}
}

OssFile

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

OssPolicy

/*** | 参数      | 说明                                                         |* | --------- | ------------------------------------------------------------ |* | 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

@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

@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) {// 获取最终的MessageDigestmd5MessageDigest = 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

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

MinioTemplate

@Slf4j
public class MinioTemplate {/*** MinIO 客户端*/private MinioClient minioClient;/*** MinIO 配置类*/@Autowiredprivate OSSProperties ossProperties;/*** 初始化操作* 初始化MinioClient 客户端* 并初始化默认桶*/@PostConstructpublic 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 集合*/@SneakyThrowspublic List<Bucket> listBuckets() {return minioClient.listBuckets();}/*** 桶是否存在** @param bucketName 桶名* @return 是否存在*/@SneakyThrowspublic boolean bucketExists(String bucketName) {return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());}/*** 创建存储桶** @param bucketName 桶名*/@SneakyThrowspublic synchronized void makeBucket(String bucketName) {if (!bucketExists(bucketName)) {minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());}}/*** 设置桶的存储权限** @param bucketName 桶的名称* @param config     桶的权限配置,有四种,一是私有,一个是公共读,一个是公共读写,一个是公共写*/@SneakyThrowspublic void setBucketPolicy(String bucketName, String config) {minioClient.setBucketPolicy(SetBucketPolicyArgs.builder().config(config).bucket(bucketName).build());}/*** 删除一个空桶 如果存储桶存在对象不为空时,删除会报错。** @param bucketName 桶名*/@SneakyThrowspublic void removeBucket(String bucketName) {removeBucket(bucketName, false);minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());}/*** 删除一个桶 根据桶是否存在数据进行不同的删除* 桶为空时直接删除* 桶不为空时先删除桶中的数据,然后再删除桶** @param bucketName 桶名*/@SneakyThrowspublic 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*/@SneakyThrowspublic 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();}}}@SneakyThrowspublic void uploadObject(String bucketName, String objectName, String filePath) {minioClient.uploadObject(UploadObjectArgs.builder().bucket(bucketName).object(objectName).filename(filePath).build());}/*** 删除桶中所有的对象** @param bucketName 桶对象*/@SneakyThrowspublic void deleteBucketAllObject(String bucketName) {List<String> list = listObjectNames(bucketName);if (!list.isEmpty()) {for (String objectName : list) {deleteObject(bucketName, objectName);}}}@SneakyThrowspublic 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*/@SneakyThrowspublic 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 对象名*/@SneakyThrowspublic void deleteObject(String bucketName, String objectName) {minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(objectName).build());}/*** 上传分片文件** @param inputStream 流* @param objectName  存入桶中的对象名* @param bucketName  桶名* @return ObjectWriteResponse*/@SneakyThrowspublic 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*/@SneakyThrowspublic 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*/@SneakyThrowspublic 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 文件路径*/@SneakyThrowspublic InputStream getObject(String bucketName, String objectName) {return minioClient.getObject(GetObjectArgs.builder().bucket(bucketName).object(objectName).build());}/*** GetObject接口用于获取某个文件(Object)。此操作需要对此Object具有读权限。** @param bucketName 桶名* @param objectName 文件路径*/@SneakyThrowspublic 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     截取长度*/@SneakyThrowspublic 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 桶的对象信息*/@SneakyThrowspublic 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 桶的对象信息*/@SneakyThrowspublic 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>*/@SneakyThrowspublic 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*/@SneakyThrowspublic 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*/@SneakyThrowspublic 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*/@SneakyThrowspublic 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*/@SneakyThrowspublic 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 桶的存储策略*/@SneakyThrowspublic String getBucketPolicy(String bucket) {return minioClient.getBucketPolicy(GetBucketPolicyArgs.builder().bucket(bucket).build());}
}

文件分片上传与合并

MinioFileController

@RestController
@RequestMapping(value = "/file")
@Slf4j
@CrossOrigin // 允许跨域
public class MinioFileController {@Autowiredprivate 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

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
@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";@Autowiredprivate MinioTemplate minioTemplate;@Autowiredprivate ObjectMapper objectMapper;@Resource(name = "jsonRedisTemplate")private RedisTemplate<String, Serializable> redisTemplate;/*** 文件上传前的检查,这是为了实现秒传接口** @param md5 文件的md5* @return 文件是否上传过的元数据*/@Overridepublic 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 上传结果的元数据*/@Overridepublic Map<String, Object> upload(HttpServletRequest req) {Map<String, Object> map = new HashMap<>();MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) req;// 获得文件分片数据MultipartFile file = multipartRequest.getFile("data");// 上传过程中出现异常,状态码设置为50000if (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);// 当不是最后一片时,上传返回的状态码为20001if (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 {// 为最后一片时状态码为20002try {// 上传文件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 合并成功的元数据*/@Overridepublic 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);// 计算文件的md5String 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 视频文件名*/@Overridepublic void videoPlay(HttpServletRequest request, HttpServletResponse response, String bucketName, String objectName) {// 设置响应报头// 需要查询redisString 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: bytesresponse.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-2000970String 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;}// 断点续传 状态码206response.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

<!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 => {// 获取到文件的md5let 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-Typesuccess: 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的播放方法

@RestController
@Slf4j
@RequestMapping(value = "/video")
@CrossOrigin
public class VideoController {@Autowiredprivate 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

<!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',//视频容器的IDvolume: 0.8,//默认音量,范围0-1video: '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

@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也可以播放,已测试)

<!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>

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/diannao/52949.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Webpack打包常见问题及优化策略

聚沙成塔每天进步一点点 本文回顾 ⭐ 专栏简介Webpack打包常见问题及优化策略1. 引言2. Webpack打包常见问题2.1 打包时间过长问题描述主要原因 2.2 打包体积过大问题描述主要原因 2.3 依赖包版本冲突问题描述主要原因 2.4 动态导入和代码拆分问题问题描述主要原因 2.5 文件路径…

Python+VScode 两个不同文件夹里的py文件相互调用|python的模块调用|绝对导入

第一次用VScode写python遇到了模块无法识别的问题&#xff0c;搞了一整天&#xff0c; 上网查&#xff0c;chatGPT都不行&#xff0c;现在时解决了。 首先项目结构如下&#xff0c;四个文件夹&#xff0c;四个py文件 代码&#xff1a; def f1fun():print("f1") de…

Code Practice Journal | Day59-60_Graph09 最短路径(待更)

1. Dijkstra 1.1 原理与步骤 步骤&#xff1a; 选取距离源点最近且未被访问过的节点标记该节点为已访问更新未访问节点到源点的距离 1.2 代码实现 以KamaCoder47题为例 题目&#xff1a;47. 参加科学大会&#xff08;第六期模拟笔试&#xff09; (kamacoder.com) class Progra…

Apache SeaTunnel Zeta 引擎源码解析(一)Server端的初始化

引入 本系列文章是基于 Apache SeaTunnel 2.3.6版本&#xff0c;围绕Zeta引擎给大家介绍其任务是如何从提交到运行的全流程&#xff0c;希望通过这篇文档&#xff0c;对刚刚上手SeaTunnel的朋友提供一些帮助。 我们整体的文章将会分成三篇&#xff0c;从以下方向给大家介绍&am…

指针5.回调函数与qsort

今天来学习回调函数与qsort 目录 1.回调函数实现模拟计算器代码的简化原代码运行结果简化代码运行结果 qsort函数排序整型数据代码运行结果 qsort排序结构数据代码 qsort函数的模拟实现代码运行结果 总结 1.回调函数 回调函数就是⼀个通过函数指针调用的函数。 如果你把函数的…

C++语法基础(一)

第一个C程序 1. <iostream>&#xff08;C&#xff09; <iostream> 是 C 标准库中的头文件&#xff0c;用于处理输入输出操作。它提供了基于流&#xff08;stream&#xff09;的输入输出机制。 特点&#xff1a; 面向对象&#xff1a;C 中的输入输出操作是基于流…

hyperf json-rpc

安装 安装docker hyperf 安装 hyperf-rpc-server-v8 &#xff08;服务端&#xff09; docker run --name hyperf-rpc-server-v8 \ -v /www/docker/hyperf-rpc-server:/data/project \ -w /data/project \ -p 9508:9501 -it \ --privileged -u root \ --entrypoint /bin/sh \…

Upload-LABS通关攻略【1-20关】

Pass-01 第一关是前端JS绕过 上传一个php文件显示只能上传特定后缀名的文件 这里将1.php改为1.jpg直接进行抓包&#xff0c;在数据包中将jpg改为php放行 文件上传成功&#xff0c;邮件图片新建页面打开 可以访问到1.php文件&#xff0c;则一句话密码上传成功 使用蚁剑 进行连接…

Redux的中间件原理分析

Redux的中间件原理分析 redux的中间件对于使用过redux的各位都不会感到陌生&#xff0c;通过应用上我们需要的所有要应用在redux流程上的中间件&#xff0c;我们可以加强dispatch的功能。最近抽了点时间把之前整理分析过的中间件有关的东西放在这里分享分享。本文只对中间件涉…

音视频开发之旅(90)-Vision Transformer论文解读与源码分析

目录 1.背景和问题 2.Vision Transformer(VIT)模型结构 3.Patch Embedding 4.实现效果 5.代码解析 6.资料 一、背景和问题 上一篇我们学习了Transformer的原理&#xff0c;主要介绍了在NLP领域上的应用&#xff0c;那么在CV(图像视频)领域该如何使用&#xff1f; 最直观…

算法复盘——LeetCode hot100:哈希

文章目录 哈希表哈希表的基本概念哈希表的使用1. 插入操作2. 查找操作3. 删除操作 哈希表的优点和缺点1.两数之和复盘 242.有效的字母异位词复盘 49.字母异位词分组复盘 128. 最长连续序列复盘HashSet 哈希表 先来搞清楚什么是哈希表吧~ 概念不清楚方法不清楚怎么做题捏 哈希表…

使用mysql保存密码

登录MySQL 这行命令告诉MySQL客户端程序用户root准备登录&#xff0c;-p表示告诉 MySQL 客户端程序提示输入密码。 mysql -u root -p创建数据库 create database wifi; use wifi;create table password(user_password CHAR(8),primary key(user_password));源码 代码编译 …

QT实战项目之音乐播放器

项目效果演示 myMusicShow 项目概述 在本QT音乐播放器实战项目中&#xff0c;开发环境使用的是QT Creator5.14版本。该项目实现了音乐播放器的基本功能&#xff0c;例如开始播放、停止播放、下一首播放、上一首播放、调节音量、调节倍速、设置音乐播放模式等。同时还具备搜索功…

Centos 下载和 VM 虚拟机安装

1. Centos 下载 阿里云下载地址 centos-7.9.2009-isos-x86_64安装包下载_开源镜像站-阿里云 2. VM 中创建 Centos 虚拟机 2.1 先打开 VM 虚拟机&#xff0c;点击首页的创建新的虚拟机 2.2 选择自定义&#xff0c;然后点击下一步。 2.3 这里默认就好&#xff0c;继续选择下一…

gitlab SSH的使用

一、 安装git bash https://git-scm.com/download/win 下载windows 版本&#xff0c;默认安装即可。 二、使用命令 打开本地git bash,使用如下命令生成ssh公钥和私钥对 ssh-keygen -t rsa -C ‘xxxxxx.com’ 然后一路回车 (-C 参数是你的邮箱地址) 若是想输入密码可以输入…

算法-最长连续序列

leetcode的题目链接 这道题的思路主要是要求在O&#xff08;n)的时间复杂度下&#xff0c;所以你暴力解决肯定不行&#xff0c;暴力至少两层for循环&#xff0c;所以要在O&#xff08;n)的时间复杂度下&#xff0c;你可以使用HashSet来存储数组&#xff0c;对于每个数字&#…

黑马JavaWeb开发笔记07——Ajax、Axios请求、前后端分离开发介绍、Yapi详细配置步骤

文章目录 前言一、Ajax1. 概述2. 作用3. 同步异步4. 原生Ajax请求&#xff08;了解即可&#xff09;5. Axios&#xff08;重点&#xff09;5.1 基本使用5.2 Axios别名&#xff08;简化书写&#xff09; 二、前后端分离开发1. 介绍1.1 前后台混合开发1.2 前后台分离开发方式&…

Docker续6:容器网络

1.bridge-utils 一个用于Linux系统的网络桥接工具集。它提供了一些命令行工具&#xff0c;帮助用户创建、管理和配置网络桥接。网络桥接是一种将多个网络接口连接在一起&#xff0c;以使它们能够作为单个网络段进行通信的技术。 bridge-utils 常用的命令包括&#xff1a; b…

【 OpenHarmony 系统应用源码魔改 】-- Launcher 之「桌面布局定制」

前言 阅读本篇文章之前&#xff0c;有几个需要说明一下&#xff1a; 调试设备&#xff1a;平板&#xff0c;如果你是开发者手机&#xff0c;一样可以加 Log 调试&#xff0c;源码仍然是手机和平板一起分析&#xff1b;文章中的 Log 信息所显示的数值可能跟你的设备不一样&…

单片机编程魔法师-并行多任务程序

程序架构 程序代码 小结 数码分离&#xff0c;本质上就是将数据和代码逻辑进行分离&#xff0c;跟第一章使用数据驱动程序一样的道理。 不过这里不同之处在于。这里使用通过任务线程&#xff0c;但是却有2个任务在运行&#xff0c;两个任务都通过先初始化任务数据参数&#x…