• Spring Security验证码配置化开发


    背景

    在前两篇文章中给大家介绍了Spring Security的认证流程,包含对项目的定制化处理,可以明白security的设计还是比较强大的,通过一系列的过滤器注册的过滤器链,对流程进行链式的处理。
    今天介绍一种以配置器的方式处理验证码生成、校验,流程可以参考Security中的FormLoginConfigurer表单登录配置器,在前两篇中提到的定制化UsernamePasswordAuthenticationFilter过滤器,就是表单登录配置器中的认证实现环节,而FormLoginConfigurer表单登录配置器的作用是定义了登录功能的入口、实现流程(从上文中可以看出我们所做的定制化是对于方法体,于配置器而言,我们的定制化是模板方法模式下的一种实现)。

    配置器设计——模板方法模式

    Security应用configurer配置器

    Security的核心是HttpSecurity对象的应用,HttpSecurity配置完成后会通过build()方法生成SecurityFilterChain过滤器链应用到系统中。
    SecurityFilterChain加入过滤器的两种方式

    • 通过HttpSecurity对象直接调用addFilterAt或者addFilterBefor方法,两种方法含义分别是:在某个过滤器的位置添加过滤器、在某个过滤器前面添加过滤器;
    • 通过配置器加入过滤器,HttpSecurity在build时会把所有的配置器应用到过滤器中

    下面展示下模型图
    在这里插入图片描述

    configurer配置器设计

    下面是对验证码配置器的设计,大致的骨架如图,自定义的验证码配置器的三个功能:加入自定义的验证码过滤器、校验码生成器对象、校验码处理器对象。骨架是模板,子类在不改变整体结构的情况,实现流程中的某些步骤细节。定义校验码接口,是为了方便扩展多种验证码校验例如:图片、短信、语音等。
    ![在这里插入图片描述](https://img-blog.csdnimg.cn/165b1f368fe94a28a956a48a8f637184.png

    代码详解

    ValidateCodeConfigurer配置器

    配置器中添加了验证码处理的过滤器,声明了两个集合字段属性,key是验证码的接口地址,value是接口子类对象,分别是生成器接口子类和处理器接口子类,目的是兼容项目中多中验证方式。

    public final class ValidateCodeConfigurer<H extends HttpSecurityBuilder<H>> extends AbstractHttpConfigurer<ValidateCodeConfigurer<H>, H> {
    
        private AuthenticationFailureHandler failureHandler;
    
        private final Map<String, ValidateCodeGenerator> validateCodeGenerators = new HashMap<>();
    
        private final Map<String, ValidateCodeProcessor> validateCodeProcessors = new HashMap<>();
    
        public ValidateCodeConfigurer() {
    
        }
    
        @Override
        public void configure(H http) throws Exception {
            ValidateCodeFilter validateCodeFilter = new ValidateCodeFilter(this.validateCodeGenerators, this.validateCodeProcessors);
            validateCodeFilter.setFailureHandler(this.failureHandler);
            http.addFilterBefore(validateCodeFilter, UsernamePasswordAuthenticationFilter.class);
        }
    
        /**
         * 
         * @param generateUrl 应用接口地址
         * @param validateCodeGenerator 生成器子类
         * @return
         */
        public ValidateCodeConfigurer<H> validateCodeGenerator(String generateUrl, ValidateCodeGenerator validateCodeGenerator) {
            Assert.isNull(this.validateCodeGenerators.get(generateUrl), StrUtil.format("{} has already exist", generateUrl));
            this.validateCodeGenerators.put(generateUrl, validateCodeGenerator);
            return ValidateCodeConfigurer.this;
        }
        
    	/**
         * 
         * @param generateUrl 应用接口地址
         * @param validateCodeProcessor 处理器子类
         * @return
         */
        public ValidateCodeConfigurer<H> validateCodeProcessor(String processingUrl, ValidateCodeProcessor validateCodeProcessor) {
            Assert.isNull(this.validateCodeProcessors.get(processingUrl), StrUtil.format("{} has already exist", processingUrl));
            this.validateCodeProcessors.put(processingUrl, validateCodeProcessor);
            return ValidateCodeConfigurer.this;
        }
    
        public ValidateCodeConfigurer<H> failureHandler(AuthenticationFailureHandler failureHandler) {
            Assert.notNull(failureHandler, "failureHandler cannot be null");
            this.failureHandler = failureHandler;
            return ValidateCodeConfigurer.this;
        }
    
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51

    ValidateCodeFilter过滤器

    验证码过滤器在doFilterInternal()方法中做了放行的校验,随即执行了验证码的生成或者处理。
    放行的校验:生成和处理的子类是以Map的形式存在Filter中,key是具体的接口地址,所以如果不为获取验证码地址或登录接口地址,Filter会直接放行

    @RequiredArgsConstructor
    public class ValidateCodeFilter extends OncePerRequestFilter implements InitializingBean {
    
        private final Map<String, ValidateCodeGenerator> validateCodeGenerators;
    
        private final Map<String, ValidateCodeProcessor> validateCodeProcessors;
    
        private AuthenticationFailureHandler failureHandler;
    
        @Override
        public void afterPropertiesSet() throws ServletException {
            super.afterPropertiesSet();
            // init
            Assert.isNull(this.failureHandler, "failureHandler cannot be null");
            // validateCodeGenerators 和 validateCodeProcessors 不能都为空
            Assert.isTrue(CollUtil.isEmpty(validateCodeGenerators) && CollUtil.isEmpty(validateCodeProcessors), "validateCodeGenerators and validateCodeProcessors must be init at least one");
        }
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
            if (!requiresValidateMethod(request, response)) {
                chain.doFilter(request, response);
                return;
            }
            ValidateCodeGenerator validateCodeGenerator;
            ValidateCodeProcessor validateCodeProcessor;
            // generate
            if (null != (validateCodeGenerator = getValidateGenerator(request, response))) {
                try {
                    // 判断认证状态
                    if (validateCodeGenerator.needAuthenticated()
                            && null == SecurityContextHolder.getContext().getAuthentication()) {
                        throw new BadCredentialsException("Failed to generate since no authentication found");
                    }
                    validateCodeGenerator.send(request, response, validateCodeGenerator.create(request));
                } catch (AuthenticationException e) {
                    if (e instanceof ValidateCodeException) {
                        this.failureHandler.onAuthenticationFailure(request, response, e);
                    } else if (e instanceof BadCredentialsException) {// 透出未登录异常
                        this.failureHandler.onAuthenticationFailure(request, response, e);
                    } else {
                        logger.error("validate code send error", e);
                        this.failureHandler.onAuthenticationFailure(request, response, new ValidateCodeException("验证码发送出错"));
                    }
                }
                return;
            }
            // validate
            if (null != (validateCodeProcessor = getValidateProcessor(request, response))) {
                try {
                    validateCodeProcessor.validate(request);
                } catch (AuthenticationException e) {
                    if (e instanceof ValidateCodeException) {
                        this.failureHandler.onAuthenticationFailure(request, response, e);
                    } else {
                        logger.error("validate code validate error", e);
                        this.failureHandler.onAuthenticationFailure(request, response, new ValidateCodeException("验证码校验出错"));
                    }
                    return;
                }
            }
            chain.doFilter(request, response);
        }
    
        private ValidateCodeGenerator getValidateGenerator(HttpServletRequest request, HttpServletResponse response) {
            return validateCodeGenerators.get(request.getRequestURI());// 简单直接获取,避免循环 match
        }
    
        private ValidateCodeProcessor getValidateProcessor(HttpServletRequest request, HttpServletResponse response) {
            return validateCodeProcessors.get(request.getRequestURI());// 简单直接获取,避免循环 match
        }
    
        private boolean requiresValidateMethod(HttpServletRequest request, HttpServletResponse response) {
            String method = request.getMethod();
            return HttpMethod.GET.matches(method) || HttpMethod.POST.matches(method);
        }
    
        public void setFailureHandler(AuthenticationFailureHandler failureHandler) {
            Assert.notNull(failureHandler, "failureHandler cannot be null");
            this.failureHandler = failureHandler;
        }
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83

    ValidateCodeRepository验证码缓存操作接口

    这个是将验证码的缓存拉出来做了设计,为了其它形式的缓存扩展。接口定义了保存、获取验证码方法。

    public interface ValidateCodeRepository {
    
        /**
         * 保存验证码
         *
         * @param validateCode the code
         * @param expireIn     失效时间,单位秒
         */
        void save(ValidateCode validateCode, int expireIn);
    
        /**
         * 获取验证码
         *
         * @param uid 验证码唯一 ID
         * @return validate code
         */
        ValidateCode get(String uid);
    
        /**
         * 获取验证码
         *
         * @param uid 验证码唯一 ID
         * @return validate code
         */
        default ValidateCode getAndRemove(String uid) {
            ValidateCode validateCode = get(uid);
            remove(uid);
            return validateCode;
        }
    
    
        /**
         * 移除验证码
         *
         * @param uid 验证码唯一 ID
         */
        void remove(String uid);
    
    }
    
    @Slf4j
    @RequiredArgsConstructor
    public class RedissonValidateCodeRepository implements ValidateCodeRepository {
    
        private String keyPrefix = "VALIDATE:CODE";
    
        private final RedissonClient redissonClient;
    
        @Override
        public void save(ValidateCode validateCode, int expireIn) {
            RBucket<ValidateCode> bucket = getBucket(validateCode.getUid());
            bucket.set(validateCode, expireIn, TimeUnit.SECONDS);
        }
    
        @Override
        public ValidateCode get(String uid) {
            return getBucket(uid).get();
        }
    
        @Override
        public ValidateCode getAndRemove(String uid) {
            return getBucket(uid).getAndDelete();
        }
    
        @Override
        public void remove(String uid) {
            getBucket(uid).delete();
        }
    
        private RBucket<ValidateCode> getBucket(String uid) {
            if (StringUtils.isBlank(uid)) {
                throw new ValidateCodeException("验证码的id不能为空");
            }
            String key = getKeyPrefix() + ":" + uid;
            return redissonClient.getBucket(key);
        }
    
        public String getKeyPrefix() {
            return keyPrefix;
        }
    
        public void setKeyPrefix(String keyPrefix) {
            this.keyPrefix = keyPrefix;
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86

    ValidateCode验证码对象封装

    对验证码的一些属性做一层封装,包含了uid、code、type、expireIn、expireTime等,其子类对自己相关特征再添加其余信息,比如图片验证码,将Base64的图片放在子类中。

    @Getter
    @Setter
    public class ValidateCode implements Serializable {
    
        /**
         * 验证码类型
         */
        private ValidateCodeType type;
    
        /**
         * 校验码唯一 ID
         */
        private final String uid;
    
        /**
         * 校验码
         */
        private String code;
    
        /**
         * 有效期,单位秒
         */
        private int expireIn;
    
        /**
         * 失效时间
         */
        private LocalDateTime expireTime;
    
        /**
         * @param type     类型
         * @param uid      校验码唯一 ID
         * @param code     校验码
         * @param expireIn 有效期, 单位秒
         */
        public ValidateCode(ValidateCodeType type, String uid, String code, int expireIn) {
            this.type = type;
            this.uid = uid;
            this.code = code;
            this.expireIn = expireIn;
            this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
        }
    
        /**
         * 设置校验码有效期
         *
         * @param expireIn 有效期,单位秒
         */
        public void setExpireTime(int expireIn) {
            this.expireTime = LocalDateTime.now().plusSeconds(expireIn);
        }
    
        /**
         * 是否过期
         *
         * @return true/false
         */
        public boolean isExpired() {
            return LocalDateTime.now().isAfter(expireTime);
        }
    
    }
    
    
    @Getter
    @Setter
    public class ImageCode extends ValidateCode {
    
        private String base64Str;
    
        public ImageCode(String uid, String code, int expireIn, String base64Str) {
            super(ValidateCodeType.IMAGE, uid, code, expireIn);
            this.base64Str = base64Str;
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76

    ValidateCodeGenerator校验码生成器接口

    定义了验证码生成的模板方法,方便子类以扩展和实现。目前使用的rediss做缓存操作。

    public interface ValidateCodeGenerator {
    
        /**
         * 生成校验码
         *
         * @param request the request
         * @return validate code
         */
        ValidateCode generate(HttpServletRequest request);
    
        /**
         * 生成并保存校验码
         *
         * @param request the request
         * @return validate code
         */
        ValidateCode create(HttpServletRequest request);
    
        /**
         * 发送校验码
         *
         * @param request      the request
         * @param response     the response
         * @param validateCode the validateCode
         */
        void send(HttpServletRequest request, HttpServletResponse response, ValidateCode validateCode);
    
        /**
         * 是否需要认证
         *
         * @return boolean
         */
        default boolean needAuthenticated() {
            return false;
        }
    }
    
    @Getter
    @Setter
    @Slf4j
    @Accessors(chain = true)
    public class ImageCodeGenerator implements ValidateCodeGenerator {
    
    /**
         * 基础字符,排除易混淆字符: o, O, i, I, l, L, p, P, q, Q
         */
        public static final String BASE_CHAR_WITHOUT = "abcdefghjkmnrstuvwxyz";
    
        public static final String BASE_CHAR = "abcdefghijklmnopqrstuvwxyz";
    
        public static final String BASE_NUMBER = "1234567890";
    
        private ValidateCodeRepository validateCodeRepository;
    	/**
         * 验证码类型
         */
        private ValidateCodeType type;
    
        /**
         * 有效期, 单位秒
         */
        private int expireIn = 60;
    
        /**
         * 验证码位数,默认 4 位
         */
        private int count = 4;
    
        /**
         * 是否仅数字,默认否
         */
        private boolean numberOnly = false;
    
        /**
         * 是否需要认证
         */
        private boolean needAuthenticated = false;
    
        public ImageCodeGenerator(ValidateCodeRepository validateCodeRepository) {
        	this.validateCodeRepository = validateCodeRepository;
        }
    
        @Override
        public ImageCode generate(HttpServletRequest request) {
            LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(75, 30, getCount(), 4);
            lineCaptcha.setGenerator(new RandomGenerator(isNumberOnly() ? BASE_NUMBER : BASE_NUMBER + BASE_CHAR_WITHOUT, getCount()));
            return new ImageCode(IdUtil.fastUUID(), lineCaptcha.getCode(), getExpireIn(), lineCaptcha.getImageBase64Data());
        }
    
        @Override
        public ValidateCode create(HttpServletRequest request) {
            ImageCode imageCode = generate(request);
            getValidateCodeRepository().save(imageCode, imageCode.getExpireIn());
            return imageCode;
        }
    
        @Override
        public void send(HttpServletRequest request, HttpServletResponse response, ValidateCode validateCode) {
            if (!(validateCode instanceof ImageCode)) {
                throw new ValidateCodeException("发送验证码错误, 未生成图形");
            }
            ImageCode imageCode = (ImageCode) validateCode;
            if (StrUtil.isBlank(imageCode.getBase64Str())) {
                throw new ValidateCodeException("发送验证码错误, 未生成图形");
            }
            ImageCodeResponse responseData = new ImageCodeResponse().setUid(imageCode.getUid())
                    .setCaptcha(imageCode.getBase64Str());
            log.info("发送图形验证码[{}]成功...", validateCode.getUid());
            HttpContextUtil.write(request, response, Rest.success(responseData));
        }
    
    }
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114

    ValidateCodeProcessor校验码处理器

    设计上和生成器类似,接口定义方法,不同类型去实现接口形成不同的子类。

    public interface ValidateCodeProcessor {
    
        /**
         * 校验验证码(验证后删除)
         *
         * @param servletWebRequest the servlet web request
         */
        void validate(HttpServletRequest servletWebRequest);
    
        /**
         * 校验验证码(验证后不删除)
         *
         * @param servletWebRequest the servlet web request
         */
        void check(HttpServletRequest servletWebRequest);
    
    }
    
    @Slf4j
    @RequiredArgsConstructor
    public class DefaultValidateCodeProcessor implements ValidateCodeProcessor {
    
        private String keyParameter = "uid";
    
        private String valueParameter = "captcha";
    
        private final ValidateCodeRepository validateCodeRepository;
    
        protected String obtainKey(HttpServletRequest request) {
            String key = request.getParameter(keyParameter);
            if (StringUtils.isBlank(key)) {
                throw new ValidateCodeException("验证码的id不能为空");
            }
            return key;
        }
    
        protected String obtainValue(HttpServletRequest request) {
            String value = request.getParameter(valueParameter);
            if (StringUtils.isBlank(value)) {
                throw new ValidateCodeException("验证码的id不能为空");
            }
            return value;
        }
    
        /**
         * 验证后删除
         *
         * @param request the request
         */
        @Override
        public void validate(HttpServletRequest request) {
            String key = obtainKey(request);
            String value = obtainValue(request);
            ValidateCode validateCode = validateCodeRepository.getAndRemove(key);
            if (validateCode == null || validateCode.isExpired()) {
                throw new ValidateCodeException("验证码已过期");
            }
            if (!StringUtils.equalsIgnoreCase(value, validateCode.getCode())) {
                throw new ValidateCodeException("验证码不匹配");
            }
            log.info("验证码[{}]校验通过...", key);
        }
    
        /**
         * 验证
         *
         * @param request the request
         */
        @Override
        public void check(HttpServletRequest request) {
            String key = obtainKey(request);
            String value = obtainValue(request);
            ValidateCode validateCode = validateCodeRepository.get(key);
            if (validateCode == null || validateCode.isExpired()) {
                throw new ValidateCodeException("验证码已过期");
            }
            if (!StringUtils.equalsIgnoreCase(value, validateCode.getCode())) {
                throw new ValidateCodeException("验证码不匹配");
            }
        }
    
        public String getKeyParameter() {
            return keyParameter;
        }
    
        public ValidateCodeProcessor setKeyParameter(String keyParameter) {
            this.keyParameter = keyParameter;
            return DefaultValidateCodeProcessor.this;
        }
    
        public String getValueParameter() {
            return valueParameter;
        }
    
        public ValidateCodeProcessor setValueParameter(String valueParameter) {
            this.valueParameter = valueParameter;
            return DefaultValidateCodeProcessor.this;
        }
    }
    
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
  • 相关阅读:
    【WinMTR】Windows上winmtr的安装使用方法
    MongoDB基础学习(五)之在Springboot项目使用MongoTemplate进行操作
    Gin+Vue全栈开发实战
    FFplay文档解读-11-协议一
    前后端跨域请求问题解决方法
    webm格式怎么转换成mp4?
    centos常用命令大全
    爬虫的介绍与使用
    springboot集成整合kafka
    JavaScript进阶之路(一)初学者的开始
  • 原文地址:https://blog.csdn.net/zzztimes/article/details/127994860