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.rhkb.cn/news/411261.html

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

相关文章

Avalonia与WPF开发时的差异总结

1.一个控件绑定到另外一个控件的属性 WPF: <TextBox Height"30" Width"100" x:Name"tb"></TextBox><TextBlock Text"{Binding ElementNametb,PathText}" ></TextBlock>Avalonia: <TextBox Height"3…

YarnClient发送和接收请求源码解析

YarnClient发送和接收请求流程 Yarn是通过RPC协议通信的&#xff0c;协议类型可以通过查看RpcKind类得知&#xff0c;总共有三种类型&#xff1a; RPC_BUILTIN ((short) 1), // Used for built in calls by tests RPC_WRITABLE ((short) 2), // Use WritableRp…

比特币的签名和验证(基于ECDSA)

比特币&#xff08;Bitcoin&#xff09;和以太坊&#xff08;Ethereum&#xff09;等区块链技术使用了加密算法来确保交易的安全性。私钥签名和公钥验证是这些算法的核心部分&#xff0c;主要用于证明交易的发起者拥有交易中使用的资金的控制权&#xff0c;而不需要暴露私钥本身…

浪潮服务器NVME 硬盘通过 Intel VROC 做RAID

INTEL VROC Configuration solution 1.VMD configuration in BIOS Processor > IIO Configuration> Intel(R) VDM Technology> Intel(R) VMD for volume Management Device on Socket 0 “CPU 0”, Intel VMD for volume management device for “PStack0” or “PSta…

【香橙派系列教程】(十七) 视觉垃圾桶-功能完善优化

【十七】视觉垃圾桶-功能完善优化 文章目录 【十七】视觉垃圾桶-功能完善优化一、增加垃圾桶开关盖1.引脚2.PWM 频率的公式3.PWM_APIsoftPwmCreatesoftPwmWrite附加说明softPwmStop 4.代码pwm.cpwm.hmain.c 二、项目代码优化编译运行 三、增加OLED 屏幕显示功能myoled.hmyoled.…

小白之 FastGPT Windows 本地化部署

目录 引言环境步骤1. 安装 docker2. 启动 docker3. 浏览器访问4. One API 配置语言模型、向量模型渠道和令牌5. 创建 FastGPT 知识库6. 创建 FastGPT 应用 官方文档 引言 部署之前可以先看一下 RAG 技术原理&#xff0c;也可以后面回过头来看&#xff0c;对一些概念有些了解&a…

Qt+FFmpeg开发视频播放器笔记(一):环境搭建

一、FFmpeg介绍 FFmpeg是一个开源的跨平台多媒体处理工具集&#xff0c;它可以用于处理音频、视频和其他多媒体数据。FFmpeg提供了一组功能强大的命令行工具&#xff0c;用于音频和视频的编解码、转换、处理、流媒体传输等任务。 FFmpeg支持多种音频和视频格式&#xff0c;包…

【自动化】考试答题自动化完成答案,如何实现100%正确呢

一、科目仿真考试不能自动答题 我的答案是可以的&#xff0c;电脑程序可以模拟人的操作完成所有的答题并提交结束考试 二、分析页面内容 完成一个题目&#xff0c;包括判断题&#xff0c;对与错2选1答案&#xff0c;单选题ABCD4选1答案&#xff0c;多选题大家想一想 F12查看按…

C语言 ——— 将动态版本的通讯录实现为文件存储联系人模式

目录 前言 在退出通讯录之前 在运行通讯录之前 前言 在这篇博客中&#xff0c;实现了动态版本的通讯录&#xff0c;接下来会增加函数&#xff0c;能用文件存储通讯录中的联系人 C语言 ——— 在控制台实现通讯录&#xff08;增删查改、动态开辟内存空间&#xff09;-CSDN…

#网络高级 笔记

modbus_tcp协议 modbus_rtu协议和modbus库 http协议和web服务器搭建 服务器原码分析和基于WebServer的工业数据采集项目 第H5&#xff0c;即网页制作&#xff0c;项目完善 一、modbus起源 1.起源 Modbus由Modicon公司于1979年开发&#xff0c;是一种工业现场总线协议标准 Mo…

python将字典数据保存为json文件

目录 一、json库介绍 二、字典生成json文件 1、导入 json 模块 2、将字典数据保存为 json 文件 (1) 创建一个python字典 (2) 指定要保存的 json 文件路径 (3) 将字典数据存为 json 文件 3、读取 json文件&#xff0c;并打印 一、json库介绍 方法作用json.dumps()将py…

对数据处理过程中,缺失值和异常值应该怎么处理?

创作不易&#xff0c;您的关注、点赞、收藏和转发是我坚持下去的动力&#xff01; 大家有技术交流指导、论文及技术文档写作指导、项目开发合作的需求可以私信联系我。 在数据处理过程中&#xff0c;缺失值和异常值的处理是非常重要的步骤&#xff0c;它们可能会对模型的性能…

Datawhale AI夏令营第五期学习!

学习日志 日期&#xff1a; 2024年8月27日 今日学习内容&#xff1a; 今天&#xff0c;我学习了如何在深度学习任务中使用卷积神经网络&#xff08;CNN&#xff09;进行图像分类的基本流程&#xff0c;并成功地在JupyterLab中运行了一个完整的项目。以下是我今天的学习和操作…

【扩散模型(六)】IP-Adapter 是如何训练的?2 源码篇(IP-Adapter Plus)

系列文章目录 【扩散模型&#xff08;二&#xff09;】IP-Adapter 从条件分支的视角&#xff0c;快速理解相关的可控生成研究【扩散模型&#xff08;三&#xff09;】IP-Adapter 源码详解1-训练输入 介绍了训练代码中的 image prompt 的输入部分&#xff0c;即 img projection…

【Verilog 数字系统设计教程】Verilog 基础:硬件描述语言入门指南

目录 摘要 1. 引言 2. Verilog 历史与发展 3. Verilog 基本语法 4. Verilog 模块与端口 5. 组合逻辑与时序逻辑 6. 时钟域与同步设计 7. 测试与仿真 8. Verilog 高级特性 任务&#xff08;Tasks&#xff09; 函数&#xff08;Functions&#xff09; 多维数组 结构体…

【二叉树】OJ题目

&#x1f31f;个人主页&#xff1a;落叶 目录 单值⼆叉树 【单值二叉树】代码 相同的树 【相同二叉树】代码 对称⼆叉树 【对称二叉树】代码 另一颗树的子树 【另一颗树的子树】代码 二叉树的前序遍历 【二叉树前序遍历】代码 二叉树的中序遍历 【二叉树中序遍历】…

【大模型】llama系列模型基础

前言&#xff1a;llama基于transformer架构&#xff0c;与GPT相似&#xff0c;只用了transformer的解码器部分。本文主要是关于llama&#xff0c;llama2和llama3的结构解读。 目录 1. llama1.1 整体结构1.2 RoPE1.3 SwiGLU 激活函数 2. llama22.2 GQA架构2.3 RLHF3. llama3 参考…

CAD中命令和系统变量

屏幕去除菜单全屏显示&#xff1a; ThisDrawing.SendCommand ("CLEANSCREENON ") 恢复原始&#xff1a;ThisDrawing.SendCommand ("CLEANSCREENOFF ") CAD中系统变量决定图形的基本设置。 第一个系统变量&#xff1a;uscicon vba代码如下&#xff1a; …

【Linux】——Rocky Linux配置静态IP

Rocky Linux配置静态IP Rocky Linux Rocky Linux 进入官网进行下载&#xff0c;下载版本自定义 官网link 获取ip地址 ip addr 获取服务器ip地址 进入网络配置文件目录&#xff1a; cd /etc/NetworkManager/system-connections/vi打开ens33.nmconnection 在IPv4下输入配置信…

Ubuntu美化为类Windows风格

博主的系统为 Ubuntu22.04 参考文献&#xff1a;How to Make Ubuntu Look Like Windows 11 | 22.04 GNOME 43 / 42 | Linux AF Tech 可能遇到的bug的解决方法&#xff1a;如何在 Linux 中安装和更改 GNOME 主题 先来一下视频演示&#xff1a; 下面正式开始安装。在主文件夹下打…