文章目录
- 参考
- 简述
- 效果
- 启动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
*/
@Bean
public JedisConnectionFactory redisConnectionFactory() {
return new JedisConnectionFactory(new RedisStandaloneConfiguration(redisHost, Integer.parseInt(redisPort)));
}
/**
* 保证序列化之后不会乱码的配置
*
* @param connectionFactory connectionFactory
* @return RedisTemplate
*/
@Bean(name = "jsonRedisTemplate")
public RedisTemplate<String, Serializable> redisTemplate(JedisConnectionFactory connectionFactory) {
return getRedisTemplate(connectionFactory, genericJackson2JsonRedisSerializer());
}
/**
* 解决:
* org.springframework.data.redis.serializer.SerializationException:
* Could not write JSON: Java 8 date/time type `java.time.LocalDateTime` not supported
*
* @return GenericJackson2JsonRedisSerializer
*/
@Bean
@Primary // 当存在多个Bean时,此bean优先级最高
public GenericJackson2JsonRedisSerializer genericJackson2JsonRedisSerializer() {
ObjectMapper objectMapper = new ObjectMapper();
// 解决查询缓存转换异常的问题
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.WRAPPER_ARRAY);
// 支持 jdk 1.8 日期 ---- start ---
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
objectMapper.registerModule(new Jdk8Module())
.registerModule(new JavaTimeModule())
.registerModule(new ParameterNamesModule());
// --end --
return new GenericJackson2JsonRedisSerializer(objectMapper);
}
/**
* 注入redis分布式锁实现方案redisson
*
* @return RedissonClient
*/
@Bean
public RedissonClient redisson() {
Config config = new Config();
config.useSingleServer().setAddress("redis://" + redisHost + ":" + redisPort).setDatabase(0);
return Redisson.create(config);
}
/**
* 采用jdk序列化的方式
*
* @param connectionFactory connectionFactory
* @return RedisTemplate
*/
@Bean(name = "jdkRedisTemplate")
public RedisTemplate<String, Serializable> redisTemplateByJdkSerialization(JedisConnectionFactory connectionFactory) {
return getRedisTemplate(connectionFactory, new JdkSerializationRedisSerializer());
}
private RedisTemplate<String, Serializable> getRedisTemplate(JedisConnectionFactory connectionFactory,
RedisSerializer<?> redisSerializer) {
RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(redisSerializer);
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(redisSerializer);
connectionFactory.afterPropertiesSet();
redisTemplate.setConnectionFactory(connectionFactory);
return redisTemplate;
}
}
WebConfig
@Configuration
public class WebConfig implements WebMvcConfigurer {
private static final String[] CLASSPATH_RESOURCE_LOCATIONS = {
"classpath:/META-INF/resources/", "classpath:/resources/",
"classpath:/static/", "classpath:/public/"};
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations(CLASSPATH_RESOURCE_LOCATIONS);
}
}
MinioClientAutoConfiguration
@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-application
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
redis:
database: 0
host: 127.0.0.1
port: 6379
jedis:
pool:
max-active: 200
max-wait: -1
max-idle: 10
min-idle: 0
timeout: 2000
thymeleaf:
#模板的模式,支持 HTML, XML TEXT JAVASCRIPT
mode: HTML5
#编码 可不用配置
encoding: UTF-8
#开发配置为false,避免修改模板还要重启服务器
cache: false
#配置模板路径,默认是templates,可以不用配置
prefix: classpath:/templates/
suffix: .html
servlet:
content-type: text/html
oss:
minio:
endpoint: http://127.0.0.1:18000
accessKey: qwiVxtzgeYbGSEZuV9ki
secretKey: UeM1Rj6kkrpB5LSHf4xSPOBXwu34CmUmEt9sAcnm
bucketName: minio-demo
实体类
MinioObject
@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, "所有的分片均上传成功");
@Getter
private final Integer code;
@Getter
private 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) {
// 获取最终的MessageDigest
md5MessageDigest = digestInputStream.getMessageDigest();
}
return encodeHex(md5MessageDigest.digest());
} catch (IOException ioException) {
log.error("", ioException);
throw new IllegalArgumentException(ioException.getMessage());
}
} catch (NoSuchAlgorithmException e) {
throw new IllegalArgumentException("no md5 found");
}
}
/**
* 获取字符串的MD5值
*
* @param input 输入的字符串
* @return md5
*/
public static String calculateMd5(String input) {
try {
// 拿到一个MD5转换器(如果想要SHA1参数,可以换成SHA1)
MessageDigest md5MessageDigest = MessageDigest.getInstance("MD5");
byte[] inputByteArray = input.getBytes(StandardCharsets.UTF_8);
md5MessageDigest.update(inputByteArray);
// 转换并返回结果,也是字节数组,包含16个元素
byte[] resultByteArray = md5MessageDigest.digest();
// 将字符数组转成字符串返回
return encodeHex(resultByteArray);
} catch (NoSuchAlgorithmException e) {
throw new IllegalArgumentException("md5 not found");
}
}
/**
* 转成的md5值为全小写
*
* @param bytes bytes
* @return 全小写的md5值
*/
private static String encodeHex(byte[] bytes) {
char[] chars = new char[32];
for (int i = 0; i < chars.length; i = i + 2) {
byte b = bytes[i / 2];
chars[i] = HEX_CHARS[(b >>> 0x4) & 0xf];
chars[i + 1] = HEX_CHARS[b & 0xf];
}
return new String(chars);
}
}
MediaType
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 配置类
*/
@Autowired
private OSSProperties ossProperties;
/**
* 初始化操作
* 初始化MinioClient 客户端
* 并初始化默认桶
*/
@PostConstruct
public void init() {
minioClient = MinioClient.builder()
.endpoint(ossProperties.getEndpoint())
.credentials(ossProperties.getAccessKey(), ossProperties.getSecretKey())
.build();
String defaultBucketName = ossProperties.getBucketName();
if (bucketExists(defaultBucketName)) {
log.info("默认存储桶:{} 已存在", defaultBucketName);
} else {
log.info("创建默认存储桶:{}", defaultBucketName);
makeBucket(ossProperties.getBucketName());
}
}
/**
* 获取默认的桶
*
* @return default BucketName
*/
public String getDefaultBucketName() {
return ossProperties.getBucketName();
}
/**
* 查询所有存储桶
*
* @return Bucket 集合
*/
@SneakyThrows
public List<Bucket> listBuckets() {
return minioClient.listBuckets();
}
/**
* 桶是否存在
*
* @param bucketName 桶名
* @return 是否存在
*/
@SneakyThrows
public boolean bucketExists(String bucketName) {
return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
}
/**
* 创建存储桶
*
* @param bucketName 桶名
*/
@SneakyThrows
public synchronized void makeBucket(String bucketName) {
if (!bucketExists(bucketName)) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
}
}
/**
* 设置桶的存储权限
*
* @param bucketName 桶的名称
* @param config 桶的权限配置,有四种,一是私有,一个是公共读,一个是公共读写,一个是公共写
*/
@SneakyThrows
public void setBucketPolicy(String bucketName, String config) {
minioClient.setBucketPolicy(SetBucketPolicyArgs.builder()
.config(config)
.bucket(bucketName)
.build());
}
/**
* 删除一个空桶 如果存储桶存在对象不为空时,删除会报错。
*
* @param bucketName 桶名
*/
@SneakyThrows
public void removeBucket(String bucketName) {
removeBucket(bucketName, false);
minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
}
/**
* 删除一个桶 根据桶是否存在数据进行不同的删除
* 桶为空时直接删除
* 桶不为空时先删除桶中的数据,然后再删除桶
*
* @param bucketName 桶名
*/
@SneakyThrows
public void removeBucket(String bucketName, boolean bucketNotNull) {
if (bucketNotNull) {
deleteBucketAllObject(bucketName);
}
minioClient.removeBucket(RemoveBucketArgs.builder().bucket(bucketName).build());
}
/**
* 上传文件
*
* @param inputStream 流
* @param originalFileName 原始文件名
* @param bucketName 桶名
* @return ObjectWriteResponse
*/
@SneakyThrows
public OssFile putObject(InputStream inputStream, String bucketName, String originalFileName) {
String uuidFileName = generateFileInMinioName(originalFileName);
try {
if (ObjectUtils.isEmpty(bucketName)) {
bucketName = ossProperties.getBucketName();
}
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(uuidFileName)
.stream(inputStream, inputStream.available(), -1)
.build());
return new OssFile(uuidFileName, originalFileName);
} finally {
if (inputStream != null) {
inputStream.close();
}
}
}
@SneakyThrows
public void uploadObject(String bucketName, String objectName, String filePath) {
minioClient.uploadObject(UploadObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.filename(filePath)
.build());
}
/**
* 删除桶中所有的对象
*
* @param bucketName 桶对象
*/
@SneakyThrows
public void deleteBucketAllObject(String bucketName) {
List<String> list = listObjectNames(bucketName);
if (!list.isEmpty()) {
for (String objectName : list) {
deleteObject(bucketName, objectName);
}
}
}
@SneakyThrows
public void deleteFolder(String bucketName, String folder) {
Iterable<Result<Item>> results = listObjects(bucketName, folder, true);
// 先删除子目录,最后再删除父目录
for (Result<Item> result : results) {
deleteObject(bucketName, result.get().objectName());
}
deleteObject(bucketName, folder);
}
/**
* 查询桶中所有的对象名
*
* @param bucketName 桶名
* @return objectNames
*/
@SneakyThrows
public List<String> listObjectNames(String bucketName) {
List<String> objectNameList = new ArrayList<>();
if (bucketExists(bucketName)) {
Iterable<Result<Item>> results = listObjects(bucketName, true);
for (Result<Item> result : results) {
String objectName = result.get().objectName();
objectNameList.add(objectName);
}
}
return objectNameList;
}
/**
* 删除一个对象
*
* @param bucketName 桶名
* @param objectName 对象名
*/
@SneakyThrows
public void deleteObject(String bucketName, String objectName) {
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
}
/**
* 上传分片文件
*
* @param inputStream 流
* @param objectName 存入桶中的对象名
* @param bucketName 桶名
* @return ObjectWriteResponse
*/
@SneakyThrows
public OssFile putChunkObject(InputStream inputStream, String bucketName, String objectName) {
try {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.stream(inputStream, inputStream.available(), -1)
.build());
return new OssFile(objectName, objectName);
} finally {
if (inputStream != null) {
inputStream.close();
}
}
}
/**
* 返回临时带签名、Get请求方式的访问URL
*
* @param bucketName 桶名
* @param filePath Oss文件路径
* @return 临时带签名、Get请求方式的访问URL
*/
@SneakyThrows
public String getPresignedObjectUrl(String bucketName, String filePath) {
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(bucketName)
.object(filePath)
.build());
}
/**
* 返回临时带签名、过期时间为1天的PUT请求方式的访问URL
*
* @param bucketName 桶名
* @param filePath Oss文件路径
* @param queryParams 查询参数
* @return 临时带签名、过期时间为1天的PUT请求方式的访问URL
*/
@SneakyThrows
public String getPresignedObjectUrl(String bucketName, String filePath, Map<String, String> queryParams) {
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket(bucketName)
.object(filePath)
.expiry(1, TimeUnit.DAYS)
.extraQueryParams(queryParams)
.build());
}
/**
* GetObject接口用于获取某个文件(Object)。此操作需要对此Object具有读权限。
*
* @param bucketName 桶名
* @param objectName 文件路径
*/
@SneakyThrows
public InputStream getObject(String bucketName, String objectName) {
return minioClient.getObject(
GetObjectArgs.builder().bucket(bucketName).object(objectName).build());
}
/**
* GetObject接口用于获取某个文件(Object)。此操作需要对此Object具有读权限。
*
* @param bucketName 桶名
* @param objectName 文件路径
*/
@SneakyThrows
public StatObjectResponse getObjectInfo(String bucketName, String objectName) {
return minioClient.statObject(StatObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.build());
}
/**
* GetObject接口用于获取某个文件(Object)。此操作需要对此Object具有读权限。
*
* @param bucketName 桶名
* @param objectName 文件路径
* @param offset 截取流的开始位置
* @param length 截取长度
*/
@SneakyThrows
public InputStream getObject(String bucketName, String objectName, Long offset, Long length) {
return minioClient.getObject(
GetObjectArgs.builder().bucket(bucketName).object(objectName).offset(offset).length(length).build());
}
/**
* 查询桶的对象信息
*
* @param bucketName 桶名
* @param recursive 是否递归查询
* @return 桶的对象信息
*/
@SneakyThrows
public Iterable<Result<Item>> listObjects(String bucketName, boolean recursive) {
return minioClient.listObjects(
ListObjectsArgs.builder().bucket(bucketName).recursive(recursive).build());
}
/**
* 查询桶的对象信息
*
* @param bucketName 桶名
* @param prefix 指定的前缀名称
* @param recursive 是否递归查询
* @return 桶的对象信息
*/
@SneakyThrows
public Iterable<Result<Item>> listObjects(String bucketName, String prefix, boolean recursive) {
return minioClient.listObjects(ListObjectsArgs.builder()
.bucket(bucketName)
.prefix(prefix)
.recursive(recursive)
.build());
}
/**
* 获取带签名的临时上传元数据对象,前端可获取后,直接上传到Minio
*
* @param bucketName 桶名称
* @param fileName 文件名
* @return Map<String, String>
*/
@SneakyThrows
public Map<String, String> getPresignedPostFormData(String bucketName, String fileName) {
// 为存储桶创建一个上传策略,过期时间为7天
PostPolicy policy = new PostPolicy(bucketName, ZonedDateTime.now().plusDays(1));
// 设置一个参数key,值为上传对象的名称
policy.addEqualsCondition("key", fileName);
// 添加Content-Type,例如以"image/"开头,表示只能上传照片,这里吃吃所有
policy.addStartsWithCondition("Content-Type", MediaType.ALL_VALUE);
// 设置上传文件的大小 64kiB to 10MiB.
//policy.addContentLengthRangeCondition(64 * 1024, 10 * 1024 * 1024);
return minioClient.getPresignedPostFormData(policy);
}
public String generateFileInMinioName(String originalFilename) {
return "files" + StrUtil.SLASH + DateUtil.format(new Date(), "yyyy-MM-dd") + StrUtil.SLASH + UUID.randomUUID() + StrUtil.UNDERLINE + originalFilename;
}
/**
* 文件合并,将分块文件组成一个新的文件
*
* @param bucketName 合并文件生成文件所在的桶
* @param fileName 原始文件名
* @param sourceObjectList 分块文件集合
* @return OssFile
*/
@SneakyThrows
public OssFile composeObject(String bucketName, String fileName, List<ComposeSource> sourceObjectList) {
String filenameExtension = StringUtils.getFilenameExtension(fileName);
String objectName = UUID.randomUUID() + "." + filenameExtension;
minioClient.composeObject(ComposeObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.sources(sourceObjectList)
.build());
String presignedObjectUrl = getPresignedObjectUrl(bucketName, fileName);
return new OssFile(presignedObjectUrl, fileName);
}
/**
* 文件合并,将分块文件组成一个新的文件
*
* @param bucketName 合并文件生成文件所在的桶
* @param objectName 原始文件名
* @param sourceObjectList 分块文件集合
* @return OssFile
*/
@SneakyThrows
public OssFile composeObject(List<ComposeSource> sourceObjectList, String bucketName, String objectName) {
minioClient.composeObject(ComposeObjectArgs.builder()
.bucket(bucketName)
.object(objectName)
.sources(sourceObjectList)
.build());
String presignedObjectUrl = getPresignedObjectUrl(bucketName, objectName);
return new OssFile(presignedObjectUrl, objectName);
}
/**
* 文件合并,将分块文件组成一个新的文件
*
* @param originBucketName 分块文件所在的桶
* @param targetBucketName 合并文件生成文件所在的桶
* @param objectName 存储于桶中的对象名
* @return OssFile
*/
@SneakyThrows
public OssFile composeObject(String originBucketName, String targetBucketName, String objectName) {
Iterable<Result<Item>> results = listObjects(originBucketName, true);
List<String> objectNameList = new ArrayList<>();
for (Result<Item> result : results) {
Item item = result.get();
objectNameList.add(item.objectName());
}
if (ObjectUtils.isEmpty(objectNameList)) {
throw new IllegalArgumentException(originBucketName + "桶中没有文件,请检查");
}
List<ComposeSource> composeSourceList = new ArrayList<>(objectNameList.size());
// 对文件名集合进行升序排序
objectNameList.sort((o1, o2) -> Integer.parseInt(o2) > Integer.parseInt(o1) ? -1 : 1);
for (String object : objectNameList) {
composeSourceList.add(ComposeSource.builder()
.bucket(originBucketName)
.object(object)
.build());
}
return composeObject(composeSourceList, targetBucketName, objectName);
}
/**
* 将Bucket指定目录下的文件合并,将分块文件组成一个新的文件
*
* @param bucketName 分块文件所在的桶
* @param folder 对象的前缀名
* @param objectName 存储于桶中的对象名
* @return OssFile
*/
@SneakyThrows
public OssFile composeObjectByObjectFolder(String bucketName, String folder, String objectName) {
Iterable<Result<Item>> results = listObjects(bucketName, folder, true);
List<String> objectNameList = new ArrayList<>();
for (Result<Item> result : results) {
Item item = result.get();
objectNameList.add(item.objectName());
}
if (ObjectUtils.isEmpty(objectNameList)) {
throw new IllegalArgumentException(bucketName + "/" + folder + "文件夹中没有文件,请检查");
}
List<ComposeSource> composeSourceList = new ArrayList<>(objectNameList.size());
objectNameList = objectNameList.stream().map(objectNameHandler -> objectNameHandler.replace(folder, "").replace("/", "")).collect(Collectors.toList());
// 对文件名集合进行升序排序
objectNameList.sort((o1, o2) -> Integer.parseInt(o2) > Integer.parseInt(o1) ? -1 : 1);
objectNameList = objectNameList.stream().map(objectNameHandler -> folder + objectNameHandler).collect(Collectors.toList());
for (String object : objectNameList) {
composeSourceList.add(ComposeSource.builder()
.bucket(bucketName)
.object(object)
.build());
}
return composeObject(composeSourceList, bucketName, objectName);
}
/**
* 获取桶的存储策略
*
* @param bucket bucket
* @return 桶的存储策略
*/
@SneakyThrows
public String getBucketPolicy(String bucket) {
return minioClient.getBucketPolicy(GetBucketPolicyArgs.builder().bucket(bucket).build());
}
}
文件分片上传与合并
MinioFileController
@RestController
@RequestMapping(value = "/file")
@Slf4j
@CrossOrigin // 允许跨域
public class MinioFileController {
@Autowired
private MinioService minioService;
@RequestMapping(value = "/home")
public ModelAndView homeUpload() {
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("upload");
return modelAndView;
}
/**
* 根据文件大小和文件的md5校验文件是否存在
* 暂时使用Redis实现,后续需要存入数据库
* 实现秒传接口
*
* @param md5 文件的md5
* @return 操作是否成功
*/
@GetMapping(value = "/check")
public Map<String, Object> checkFileExists(String md5) {
return minioService.uploadCheck(md5);
}
/**
* 文件上传,适合大文件,集成了分片上传
*/
@PostMapping(value = "/upload")
public Map<String, Object> upload(HttpServletRequest req) {
return minioService.upload(req);
}
/**
* 文件合并
*
* @param shardCount 分片总数
* @param fileName 文件名
* @param md5 文件的md5
* @param fileType 文件类型
* @param fileSize 文件大小
* @return 分片合并的状态
*/
@GetMapping(value = "/merge")
public Map<String, Object> merge(Integer shardCount, String fileName, String md5, String fileType,
Long fileSize) {
return minioService.merge(shardCount, fileName, md5, fileType, fileSize);
}
}
MinioService
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";
@Autowired
private MinioTemplate minioTemplate;
@Autowired
private ObjectMapper objectMapper;
@Resource(name = "jsonRedisTemplate")
private RedisTemplate<String, Serializable> redisTemplate;
/**
* 文件上传前的检查,这是为了实现秒传接口
*
* @param md5 文件的md5
* @return 文件是否上传过的元数据
*/
@Override
public Map<String, Object> uploadCheck(String md5) {
Map<String, Object> resultMap = new HashMap<>();
if (ObjectUtils.isEmpty(md5)) {
resultMap.put("status", StatusCode.PARAM_ERROR.getCode());
return resultMap;
}
// 先从Redis中查询
String url = (String) redisTemplate.boundHashOps(MD5_KEY).get(md5);
// 文件不存在
if (ObjectUtils.isEmpty(url)) {
resultMap.put("status", StatusCode.NOT_FOUND.getCode());
return resultMap;
}
resultMap.put("status", StatusCode.SUCCESS.getCode());
resultMap.put("url", url);
// 文件已经存在了
return resultMap;
}
/**
* 文件上传的核心功能
*
* @param req 请求
* @return 上传结果的元数据
*/
@Override
public Map<String, Object> upload(HttpServletRequest req) {
Map<String, Object> map = new HashMap<>();
MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) req;
// 获得文件分片数据
MultipartFile file = multipartRequest.getFile("data");
// 上传过程中出现异常,状态码设置为50000
if (file == null) {
map.put("status", StatusCode.FAILURE.getCode());
return map;
}
// 分片第几片
int index = Integer.parseInt(multipartRequest.getParameter("index"));
// 总片数
int total = Integer.parseInt(multipartRequest.getParameter("total"));
// 获取文件名
String fileName = multipartRequest.getParameter("name");
String md5 = multipartRequest.getParameter("md5");
// 创建文件桶
minioTemplate.makeBucket(md5);
String objectName = String.valueOf(index);
log.info("index: {}, total:{}, fileName:{}, md5:{}, objectName:{}", index, total, fileName, md5, objectName);
// 当不是最后一片时,上传返回的状态码为20001
if (index < total) {
try {
// 上传文件
OssFile ossFile = minioTemplate.putChunkObject(file.getInputStream(), md5, objectName);
log.info("{} upload success {}", objectName, ossFile);
// 设置上传分片的状态
map.put("status", StatusCode.ALONE_CHUNK_UPLOAD_SUCCESS.getCode());
return map;
} catch (Exception e) {
e.printStackTrace();
map.put("status", StatusCode.FAILURE.getCode());
return map;
}
} else {
// 为最后一片时状态码为20002
try {
// 上传文件
minioTemplate.putChunkObject(file.getInputStream(), md5, objectName);
// 设置上传分片的状态
map.put("status", StatusCode.ALL_CHUNK_UPLOAD_SUCCESS.getCode());
return map;
} catch (Exception e) {
e.printStackTrace();
map.put("status", StatusCode.FAILURE.getCode());
return map;
}
}
}
/**
* 分片文件合并的核心方法
*
* @param shardCount 分片数
* @param fileName 文件名
* @param md5 文件的md5值
* @param fileType 文件类型
* @param fileSize 文件大小
* @return 合并成功的元数据
*/
@Override
public Map<String, Object> merge(Integer shardCount, String fileName, String md5, String fileType, Long fileSize) {
Map<String, Object> retMap = new HashMap<>();
try {
// 查询片数据
List<String> objectNameList = minioTemplate.listObjectNames(md5);
if (shardCount != objectNameList.size()) {
// 失败
retMap.put("status", StatusCode.FAILURE.getCode());
} else {
// 开始合并请求
String targetBucketName = minioTemplate.getDefaultBucketName();
String filenameExtension = StringUtils.getFilenameExtension(fileName);
String fileNameWithoutExtension = UUID.randomUUID().toString();
String objectName = fileNameWithoutExtension + "." + filenameExtension;
minioTemplate.composeObject(md5, targetBucketName, objectName);
log.info("桶:{} 中的分片文件,已经在桶:{},文件 {} 合并成功", md5, targetBucketName, objectName);
// 合并成功之后删除对应的临时桶
minioTemplate.removeBucket(md5, true);
log.info("删除桶 {} 成功", md5);
// 计算文件的md5
String fileMd5 = null;
try (InputStream inputStream = minioTemplate.getObject(targetBucketName, objectName)) {
fileMd5 = Md5Util.calculateMd5(inputStream);
} catch (IOException e) {
log.error("", e);
}
// 计算文件真实的类型
String type = null;
List<String> typeList = new ArrayList<>();
try (InputStream inputStreamCopy = minioTemplate.getObject(targetBucketName, objectName)) {
typeList.addAll(FileTypeUtil.getFileRealTypeList(inputStreamCopy, fileName, fileSize));
} catch (IOException e) {
log.error("", e);
}
// 并和前台的md5进行对比
if (!ObjectUtils.isEmpty(fileMd5) && !ObjectUtils.isEmpty(typeList) && fileMd5.equalsIgnoreCase(md5) && typeList.contains(fileType.toLowerCase(Locale.ENGLISH))) {
// 表示是同一个文件, 且文件后缀名没有被修改过
String url = minioTemplate.getPresignedObjectUrl(targetBucketName, objectName);
// 存入redis中
redisTemplate.boundHashOps(MD5_KEY).put(fileMd5, url);
// 成功
retMap.put("status", StatusCode.SUCCESS.getCode());
} else {
log.info("非法的文件信息: 分片数量:{}, 文件名称:{}, 文件fileMd5:{}, 文件真实类型:{}, 文件大小:{}",
shardCount, fileName, fileMd5, typeList, fileSize);
log.info("非法的文件信息: 分片数量:{}, 文件名称:{}, 文件md5:{}, 文件类型:{}, 文件大小:{}",
shardCount, fileName, md5, fileType, fileSize);
// 并需要删除对象
minioTemplate.deleteObject(targetBucketName, objectName);
retMap.put("status", StatusCode.FAILURE.getCode());
}
}
} catch (Exception e) {
log.error("", e);
// 失败
retMap.put("status", StatusCode.FAILURE.getCode());
}
return retMap;
}
/**
* 视频播放的核心功能
*
* @param request request
* @param response response
* @param bucketName 视频文件所在的桶
* @param objectName 视频文件名
*/
@Override
public void videoPlay(HttpServletRequest request, HttpServletResponse response, String bucketName, String objectName) {
// 设置响应报头
// 需要查询redis
String key = bucketName + ":" + objectName;
Object obj = redisTemplate.boundHashOps(OBJECT_INFO_LIST).get(key);
// 用于记录视频文件的元数据
// 这里使用Redis的缓存作为优化
MinioObject minioObject;
if (obj == null) {
StatObjectResponse objectInfo = null;
try {
objectInfo = minioTemplate.getObjectInfo(bucketName, objectName);
} catch (Exception e) {
log.error("{}中{}不存在: {}", bucketName, objectName, e.getMessage());
response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
response.setContentType("application/json;charset=utf-8");
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
try {
response.getWriter().write(objectMapper.writeValueAsString(Result.error(StatusCode.NOT_FOUND)));
} catch (IOException ex) {
throw new RuntimeException(ex);
}
return;
}
// 判断是否是视频,是否为mp4格式
String filenameExtension = StringUtils.getFilenameExtension(objectName);
if (ObjectUtils.isEmpty(filenameExtension) ||
!"mp4".equalsIgnoreCase(filenameExtension.toLowerCase(Locale.ENGLISH))) {
throw new IllegalArgumentException("不支持的媒体类型, 文件名: " + objectName);
}
minioObject = new MinioObject();
BeanUtils.copyProperties(objectInfo, minioObject);
redisTemplate.boundHashOps(OBJECT_INFO_LIST).put(key, minioObject);
} else {
minioObject = (MinioObject) obj;
}
// 获取文件的长度
long fileSize = minioObject.getSize();
// Accept-Ranges: bytes
response.setHeader("Accept-Ranges", "bytes");
//pos开始读取位置; last最后读取位置
long startPos = 0;
long endPos = fileSize - 1;
String rangeHeader = request.getHeader("Range");
if (!ObjectUtils.isEmpty(rangeHeader) && rangeHeader.startsWith("bytes=")) {
try {
// 情景一:RANGE: bytes=2000070- 情景二:RANGE: bytes=2000070-2000970
String numRang = request.getHeader("Range").replaceAll("bytes=", "");
if (numRang.startsWith("-")) {
endPos = fileSize - 1;
startPos = endPos - Long.parseLong(new String(numRang.getBytes(StandardCharsets.UTF_8), 1,
numRang.length() - 1)) + 1;
} else if (numRang.endsWith("-")) {
endPos = fileSize - 1;
startPos = Long.parseLong(new String(numRang.getBytes(StandardCharsets.UTF_8), 0,
numRang.length() - 1));
} else {
String[] strRange = numRang.split("-");
if (strRange.length == 2) {
startPos = Long.parseLong(strRange[0].trim());
endPos = Long.parseLong(strRange[1].trim());
} else {
startPos = Long.parseLong(numRang.replaceAll("-", "").trim());
}
}
if (startPos < 0 || endPos < 0 || endPos >= fileSize || startPos > endPos) {
// SC 要求的范围不满足
response.setStatus(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
return;
}
// 断点续传 状态码206
response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
} catch (NumberFormatException e) {
log.error(request.getHeader("Range") + " is not Number!");
startPos = 0;
}
}
// 总共需要读取的字节
long rangLength = endPos - startPos + 1;
response.setHeader("Content-Range", String.format("bytes %d-%d/%d", startPos, endPos, fileSize));
response.addHeader("Content-Length", String.valueOf(rangLength));
//response.setHeader("Connection", "keep-alive");
response.addHeader("Content-Type", "video/mp4");
try (BufferedOutputStream bos = new BufferedOutputStream(response.getOutputStream());
BufferedInputStream bis = new BufferedInputStream(
minioTemplate.getObject(bucketName, objectName, startPos, rangLength))) {
IOUtils.copy(bis, bos);
} catch (
IOException e) {
if (e instanceof ClientAbortException) {
// ignore 这里就不要打日志,这里的异常原因是用户在拖拽视频进度造成的
} else {
log.error(e.getMessage());
}
}
}
}
upload.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script type="text/javascript" src="/js/jquery.js" th:src="@{/js/jquery.js}"></script>
<script type="text/javascript" src="/js/spark-md5.min.js" th:src="@{/js/spark-md5.min.js}"></script>
<script type="text/javascript" src="/js/base.js" th:src="@{/js/base.js}"></script>
<input type="file" name="file" id="file">
<script>
/**
* 分块计算文件的md5值
* @param file 文件
* @param chunkSize 分片大小
* @returns Promise
*/
function calculateFileMd5(file, chunkSize) {
return new Promise((resolve, reject) => {
let blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;
let chunks = Math.ceil(file.size / chunkSize);
let currentChunk = 0;
let spark = new SparkMD5.ArrayBuffer();
let fileReader = new FileReader();
fileReader.onload = function (e) {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < chunks) {
loadNext();
} else {
let md5 = spark.end();
resolve(md5);
}
};
fileReader.onerror = function (e) {
reject(e);
};
function loadNext() {
let start = currentChunk * chunkSize;
let end = start + chunkSize;
if (end > file.size) {
end = file.size;
}
fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
}
loadNext();
});
}
/**
* 分块计算文件的md5值,默认分片大小为2097152(2M)
* @param file 文件
* @returns Promise
*/
function calculateFileMd5ByDefaultChunkSize(file) {
return calculateFileMd5(file, 2097152);
}
/**
* 获取文件的后缀名
*/
function getFileType(fileName) {
return fileName.substr(fileName.lastIndexOf(".") + 1).toLowerCase();
}
// 文件选择之后就计算文件的md5值
document.getElementById("file").addEventListener("change", function () {
let file = this.files[0];
calculateFileMd5ByDefaultChunkSize(file).then(e => {
// 获取到文件的md5
let md5 = e;
checkMd5(md5, file)
}).catch(e => {
// 处理异常
console.error(e);
});
});
/**
* 根据文件的md5值判断文件是否已经上传过了
*
* @param md5 文件的md5
* @param file 准备上传的文件
*/
function checkMd5(md5, file) {
// 请求数据库,查询md5是否存在
$.ajax({
url: baseUrl + "/file/check",
type: "GET",
data: {
md5: md5
},
async: true, //异步
dataType: "json",
success: function (msg) {
console.log(msg);
// 文件已经存在了,无需上传
if (msg.status === 20000) {
console.log("文件已经存在了,无需上传")
} else if (msg.status === 40004) {
// 文件不存在需要上传
console.log("文件不存在需要上传")
PostFile(file, 0, md5);
} else {
console.log('未知错误');
}
}
})
}
/**
* 执行分片上传
* @param file 上传的文件
* @param i 第几分片,从0开始
* @param md5 文件的md5值
*/
function PostFile(file, i, md5) {
let name = file.name, //文件名
size = file.size, //总大小shardSize = 2 * 1024 * 1024,
shardSize = 5 * 1024 * 1024, //以5MB为一个分片,每个分片的大小
shardCount = Math.ceil(size / shardSize); //总片数
if (i >= shardCount) {
return;
}
let start = i * shardSize;
let end = start + shardSize;
let packet = file.slice(start, end); //将文件进行切片
/* 构建form表单进行提交 */
let form = new FormData();
form.append("md5", md5);// 前端生成uuid作为标识符传个后台每个文件都是一个uuid防止文件串了
form.append("data", packet); //slice方法用于切出文件的一部分
form.append("name", name);
form.append("totalSize", size);
form.append("total", shardCount); //总片数
form.append("index", i + 1); //当前是第几片
$.ajax({
url: baseUrl + "/file/upload",
type: "POST",
data: form,
//timeout:"10000", //超时10秒
async: true, //异步
dataType: "json",
processData: false, //很重要,告诉jquery不要对form进行处理
contentType: false, //很重要,指定为false才能形成正确的Content-Type
success: function (msg) {
console.log(msg);
/* 表示上一块文件上传成功,继续下一次 */
if (msg.status === 20001) {
form = '';
i++;
PostFile(file, i, md5);
} else if (msg.status === 50000) {
form = '';
/* 失败后,每2秒继续传一次分片文件 */
setInterval(function () {
PostFile(file, i, md5)
}, 2000);
} else if (msg.status === 20002) {
merge(shardCount, name, md5, getFileType(file.name), file.size)
console.log("上传成功");
} else {
console.log('未知错误');
}
}
})
}
/**
* 合并文件
* @param shardCount 分片数
* @param fileName 文件名
* @param md5 文件md值
* @param fileType 文件类型
* @param fileSize 文件大小
*/
function merge(shardCount, fileName, md5, fileType, fileSize) {
$.ajax({
url: baseUrl + "/file/merge",
type: "GET",
data: {
shardCount: shardCount,
fileName: fileName,
md5: md5,
fileType: fileType,
fileSize: fileSize
},
// timeout:"10000", //超时10秒
async: true, //异步
dataType: "json",
success: function (msg) {
console.log(msg);
}
})
}
</script>
</body>
</html>
视频播放
VideoController
调用minio的播放方法
@RestController
@Slf4j
@RequestMapping(value = "/video")
@CrossOrigin
public class VideoController {
@Autowired
private MinioService minioService;
/**
* 支持分段读取视频流
*
* @param request 请求对象
* @param response 响应对象
* @param bucketName 视频所在桶的位置
* @param objectName 视频的文件名
*/
@GetMapping(value = "/play/{bucketName}/{objectName}")
public void videoPlay(HttpServletRequest request, HttpServletResponse response,
@PathVariable(value = "bucketName") String bucketName,
@PathVariable(value = "objectName") String objectName) {
minioService.videoPlay(request, response, bucketName, objectName);
}
@RequestMapping(value = "/home/{bucketName}/{objectName}")
public ModelAndView videoHome( @PathVariable(value = "bucketName") String bucketName,
@PathVariable(value = "objectName") String objectName) {
ModelAndView modelAndView = new ModelAndView();
modelAndView.addObject("bucketName", bucketName);
modelAndView.addObject("objectName", objectName);
modelAndView.setViewName("video");
return modelAndView;
}
}
video.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ckplayer</title>
<link rel="shortcut icon" href="#"/>
<link type="text/css" rel="stylesheet" href="/ckplayer/css/ckplayer.css" th:href="@{/ckplayer/css/ckplayer.css}"/>
<script type="text/javascript" src="/js/jquery.js" th:src="@{/js/jquery.js}"></script>
<!--
如果需要使用其它语言,请在此处引入相应的js,比如:<script type="text/javascript" src="ckplayer/language/en.js" charset="UTF-8"></script>
-->
<script type="text/javascript" src="/ckplayer/js/ckplayer.min.js" th:src="@{/ckplayer/js/ckplayer.min.js}"
charset="UTF-8"></script>
<script type="text/javascript" src="/js/base.js" th:src="@{/js/base.js}"></script>
</head>
<body>
<div class="video" style="width: 100%; height: 500px;max-width: 800px;">播放容器</div>
<p>官网:<a href="https://www.ckplayer.com" target="_blank">www.ckplayer.com</a></p>
<p>手册:<a href="https://www.ckplayer.com/manual/" target="_blank">www.ckplayer.com/manual/</a></p>
<p>社区:<a href="https://bbs.ckplayer.com/" target="_blank">bbs.ckplayer.com</a></p>
<p>全功能演示:<a href="https://www.ckplayer.com/demo.html" target="_blank">www.ckplayer.com/demo.html</a></p>
<p>控制示例:</p>
<p>
<button type="button" onclick="player.play()">播放</button>
<button type="button" onclick="player.pause()">暂停</button>
<button type="button" onclick="player.seek(20)">跳转</button>
<button type="button" onclick="player.volume(0.6)">修改音量</button>
<button type="button" onclick="player.muted()">静音</button>
<button type="button" onclick="player.exitMuted()">恢复音量</button>
<button type="button" onclick="player.full()">全屏</button>
<button type="button" onclick="player.webFull()">页面全屏</button>
<button type="button" onclick="player.theatre()">剧场模式</button>
<button type="button" onclick="player.exitTheatre()">退出剧场模式</button>
</p>
<p id="state"></p>
<p id="state2"></p>
</body>
<!--JS获取-->
<script type="text/javascript" th:inline="javascript">
const bucketName = [[${bucketName}]];
const objectName = [[${objectName}]];
</script>
<script>
//调用开始
let videoObject = {
container: '.video',//视频容器的ID
volume: 0.8,//默认音量,范围0-1
video: 'http://localhost:18002/video/play/'+ bucketName + '/' + objectName,//视频地址
};
let player = new ckplayer(videoObject)//调用播放器并赋值给变量player
/*
* ===============================================================================================
* 以上代码已完成调用演示,下方的代码是演示监听动作和外部控制的部分
* ===============================================================================================
* ===============================================================================================
*/
player.play(function () {
document.getElementById('state').innerHTML = '监听到播放';
});
player.pause(function () {
document.getElementById('state').innerHTML = '监听到暂停';
});
player.volume(function (vol) {
document.getElementById('state').innerHTML = '监听到音量改变:' + vol;
});
player.muted(function (b) {
document.getElementById('state2').innerHTML = '监听到静音状态:' + b;
});
player.full(function (b) {
document.getElementById('state').innerHTML = '监听到全屏状态:' + b;
});
player.ended(function () {
document.getElementById('state').innerHTML = '监听到播放结束';
});
</script>
</html>
测试
上传
访问:http://localhost:18002/file/home
来上传文件
注意到文件的objectName是:9cabffc5-1812-43c1-bcd3-d97a96b0282b.mp4
播放1
访问路径:http://localhost:18002/video/home/minio-demo/9cabffc5-1812-43c1-bcd3-d97a96b0282b.mp4
播放2
但是上面是通过后端直接拿的流,应该直接向mino获取文件流数据,由于这个bucket是private不能直接访问,因此这里可以直接向后端拿到签名的url访问地址,前端可以直接使用这个地址播放
首先生成可访问的签名url
@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>