掌握@ControllerAdvice配合RequestBodyAdvice/ResponseBodyAdvice使用,让你的选择不仅仅只有拦截器【享学Spring MVC】

前言

要么出众,要么出局(stand out or get out)。

前言

我们在实际的项目开发中,肯定会有这样的需求:请求时记录请求日志,返回时记录返回日志;对所有的入参解密,对所有的返回值加密…。这些都是与业务没关系的花边但又不可缺少的功能,若你全都写在Controller的方法内部,那将造成大量的代码重复且严重干扰了业务代码的可读性。
怎么破?可能你第一反应想到的是使用Spring MVCHandlerInterceptor拦截器来做,没毛病,相信大部分公司的同学也都是这么来干的。那么本文就介绍一种更为优雅、更为简便的实现方案:使用@ControllerAdvice + RequestBodyAdvice/ResponseBodyAdvice不仅仅只有拦截器一种。

@ControllerAdvice / @RestControllerAdvice

对于这个注解你可能即熟悉,却又觉得陌生。熟悉是因为你看到很多项目都使用了@ControllerAdvice + @ExceptionHandler来实现全局异常捕获;陌生在于你除了copy代码时看到过外,自己似乎从来没有真正使用过它。
在前面关于@ModelAttribute@InitBinder 的相关文章中其实和这个注解是打过照面的:在此注解标注的类上使用@InitBinder等注解可以使得它对"全局"生效实现统一的控制。本文将把@ControllerAdvice此注解作为重点进一步的去了解它的使用以及工作机制。

此类的命名是很有信息量的:ControllerAdvice通知。关于Advice的含义,熟悉AOP相关概念的同学就不会陌生了,因此可以看到它整体上还是个AOP的设计思想,只是实现方式不太一样而已。

@ControllerAdvice使用AOP思想可以这么理解:此注解对目标Controller的通知是个环绕通知,织入的方式是注解方式,增强器是注解标注的方法。如此就很好理解@ControllerAdvice搭配@InitBinder/@ModelAttribute/@ExceptionHandler起到的效果喽~

使用示例

最简单的示例前文有过,这里摘抄出一小段:

@RestControllerAdvice
public class MyControllerAdvice {

    @InitBinder
    public void initBinder(WebDataBinder binder) {
        //binder.setDisallowedFields("name");
        binder.registerCustomEditor(String.class, new StringTrimmerEditor());
    }
}

这样我们的@InitBinder标注的方法对所有的Controller都是生效的。(@InitBinder写在Controller内部只对当前处理器生效)

原理分析

接下来就看看这个注解到底是怎么work的,做到知其然,知其所以然。

// @since 3.2
@Target(ElementType.TYPE) // 只能标注在类上
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component // 派生有组件注解的功能
public @interface ControllerAdvice {

	@AliasFor("basePackages")
	String[] value() default {};
	@AliasFor("value")
	String[] basePackages() default {};

	Class<?>[] basePackageClasses() default {};
	Class<?>[] assignableTypes() default {};
	Class<? extends Annotation>[] annotations() default {};
}

官方doc说它可以和如上我指出的三个注解的一起使用。关于它的使用我总结有如下注意事项:

  • @ControllerAdvice只需要标注上即可,Spring MVC会在容器里自动探测到它(请确保能被扫描到,否则无效哦~)
  • 若有多个@ControllerAdvice可以使用@Order或者Ordered接口来控制顺序
  • basePackageClasses属性最终也是转换为了basePackages拿去匹配的,相关代码如下:
HandlerTypePredicate:
		// 这是packages属性本文:有一个判空的过滤器
		public Builder basePackage(String... packages) {
			Arrays.stream(packages).filter(StringUtils::hasText).forEach(this::addBasePackage);
			return this;
		}

		// packageClasses最终都是转换为了addBasePackage
		// 只是它的pachage值是:ClassUtils.getPackageName(clazz)
		// 说明:ClassUtils.getPackageName(String.class) --> java.lang
		public Builder basePackageClass(Class<?>... packageClasses) {
			Arrays.stream(packageClasses).forEach(clazz -> addBasePackage(ClassUtils.getPackageName(clazz)));
			return this;
		}
		private void addBasePackage(String basePackage) {
			this.basePackages.add(basePackage.endsWith(".") ? basePackage : basePackage + ".");
		}
  • 它的basePackages扫包不支持占位符Ant形式的匹配。对于其他几个属性的匹配可参照下面这段匹配代码(我配上了文字说明):
HandlerTypePredicate:

	@Override
	public boolean test(Class<?> controllerType) {
		// 1、若所有属性一个都没有指定,那就是default情况-->作用于所有的Controller
		if (!hasSelectors()) {
			return true;
		} else if (controllerType != null) {
			// 2、注意此处的basePackage只是简单的startsWith前缀匹配而已~~~
			// 说明:basePackageClasses属性最终都是转为它来匹配的,
			// 如果写了一个Controller类匹配上了,那它所在的包下所有的都是匹配的(因为同包嘛)
			for (String basePackage : this.basePackages) {
				if (controllerType.getName().startsWith(basePackage)) {
					return true;
				}
			}
			// 3、指定具体的Class类型,只会匹配数组里面的这些类型,精确匹配。
			for (Class<?> clazz : this.assignableTypes) {
				if (ClassUtils.isAssignable(clazz, controllerType)) {
					return true;
				}
			}
			// 4、根据类上的注解类型来匹配(若你想个性化灵活配置,可以使用这种方式)
			for (Class<? extends Annotation> annotationClass : this.annotations) {
				if (AnnotationUtils.findAnnotation(controllerType, annotationClass) != null) {
					return true;
				}
			}
		}
		return false;
	}

这里做个说明:

  • 若注解的多个属性都给值,它们是取并集的关系(只要符合一个就成)
  • ControllerAdviceBean.findAnnotatedBeans()去找@ControllerAdvice类会被调用两次:
    - RequestMappingHandlerAdapter#afterPropertiesSet() -> initControllerAdviceCache()
    - ExceptionHandlerExceptionResolver#afterPropertiesSet() -> initExceptionHandlerAdviceCache()
    - 因为前面说了:@ControllerAdvice它即可用于正常的(和ResponseBodyAdvice等联用),也可使用在异常上(和@RestControllerAdvice联用)
  • 若注解的属性一个都没有指定值,那它将作用于所有的@Controller们(为何是所有的Controller呢?各位可参考ControllerAdviceBean#isApplicableToBeanType()方法的调用处:只在处理RequestMapping、@RequestBody相关的类里使用到,当然处理异常时会看这个异常我到底要不要处理等等…)
    - 虽然可以不用指定包名,但我个人标记比较喜欢使用basePackageClasses属性把它显示的指明出来~

针对于@RestControllerAdvice,它就类似于@RestController和@Controller之间的区别,在@ControllerAdvice的基础上带有@ResponseBody的效果。

@ControllerAdvice在容器初始化的时候被解析,伪代码如下:

所有的被标注有此注解的Bean最终都变成一个org.springframework.web.method.ControllerAdviceBean,它内部持有Bean本身,以及判断逻辑器(HandlerTypePredicate)的引用

RequestMappingHandlerAdapter:
	@Override
	public void afterPropertiesSet() {
		// Do this first, it may add ResponseBody advice beans
		initControllerAdviceCache();
		...
	}

	private void initControllerAdviceCache() {
		// 因为它需要通过它去容器内找到所有标注有@ControllerAdvice注解的Bean们
		if (getApplicationContext() == null) {
			return;
		}
		// 关键就是在findAnnotatedBeans方法里:传入了容器上下文
		List<ControllerAdviceBean> adviceBeans = ControllerAdviceBean.findAnnotatedBeans(getApplicationContext());
		// 注意此处是有个排序的~~~~
		AnnotationAwareOrderComparator.sort(adviceBeans);
		...
		
		// 注意:找到这些标注有@ControllerAdvice后并不需要保存下来。
		// 而是一个一个的找它们里面的@InitBinder/@ModelAttribute 以及 RequestBodyAdvice和ResponseBodyAdvice
		// 说明:异常注解不在这里解析,而是在`ExceptionHandlerMethodResolver`里~~~
		for (ControllerAdviceBean adviceBean : adviceBeans) {
			...
		}
	}

ControllerAdviceBean:
	// 找到容器内(包括父容器)所有的标注有@ControllerAdvice的Bean们~~~
	public static List<ControllerAdviceBean> findAnnotatedBeans(ApplicationContext context) {
		return Arrays.stream(BeanFactoryUtils.beanNamesForTypeIncludingAncestors(context, Object.class))
				.filter(name -> context.findAnnotationOnBean(name, ControllerAdvice.class) != null)
				.map(name -> new ControllerAdviceBean(name, context))
				.collect(Collectors.toList());
	}

这就是@ControllerAdvice被解析、初始化的原理。它提供一个书写Advice增强器的平台,在初始化的时候根据此类完成解析各种注解作用于各个功能上,从而在运行期直接运行即可。



RequestBodyAdvice/ResponseBodyAdvice

顾名思义,它们和@RequestBody@ResponseBody有关,ResponseBodyAdviceSpring4.1推出的,另外一个是4.2后才有。它哥俩和@ControllerAdvice一起使用会有很好的化学反应

说明:这哥俩是接口不是注解,实现类需要自己提供实现

RequestBodyAdvice

官方解释为:允许body体转换为对象之前进行自定义定制;也允许该对象作为实参传入方法之前对其处理

public interface RequestBodyAdvice {

	// 第一个调用的。判断当前的拦截器(advice是否支持) 
	// 注意它的入参有:方法参数、目标类型、所使用的消息转换器等等
	boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType);

	// 如果body体木有内容就执行这个方法(后面的就不会再执行喽)
	Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType);
	
	// 重点:它在body被read读/转换**之前**进行调用的
	HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException;

	// 它在body体已经转换为Object后执行。so此时都不抛出IOException了嘛~
	Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType);

}

它的内置实现有这些:
在这里插入图片描述
RequestResponseBodyAdviceChain比较特殊,放在后面重点说明。RequestBodyAdviceAdapter没啥说的,因此主要看看JsonViewRequestBodyAdvice这个实现。

JsonViewRequestBodyAdvice

Spring MVC的内置实现,它支持的是Jackson的com.fasterxml.jackson.annotation.@JsonView这个注解,@JsonView一般用于标注在HttpEntity/@RequestBody上,来决定处理入参的哪些key。
该注解指定的反序列视图将传递给MappingJackson2HttpMessageConverter,然后用它来反序列化请求体(从而做对应的过滤)。

// @since 4.2
public class JsonViewRequestBodyAdvice extends RequestBodyAdviceAdapter {

	// 处理使用的消息转换器是AbstractJackson2HttpMessageConverter类型
	// 并且入参上标注有@JsonView注解的
	@Override
	public boolean supports(MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) {
		return (AbstractJackson2HttpMessageConverter.class.isAssignableFrom(converterType) &&
				methodParameter.getParameterAnnotation(JsonView.class) != null);
	}

	// 显然这里实现的beforeBodyRead这个方法:
	// 它把body最终交给了MappingJacksonInputMessage来反序列处理消息体
	// 注意:@JsonView能处理这个注解。也就是说能指定把消息体转换成指定的类型,还是比较实用的
	// 可以看到当标注有@jsonView注解后 targetType就没啥卵用了
	@Override
	public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter methodParameter, Type targetType, Class<? extends HttpMessageConverter<?>> selectedConverterType) throws IOException {
		JsonView ann = methodParameter.getParameterAnnotation(JsonView.class);
		Assert.state(ann != null, "No JsonView annotation");

		Class<?>[] classes = ann.value();
		// 必须指定class类型,并且有且只能指定一个类型
		if (classes.length != 1) {
			throw new IllegalArgumentException("@JsonView only supported for request body advice with exactly 1 class argument: " + methodParameter);
		}
		// 它是一个InputMessage的实现
		return new MappingJacksonInputMessage(inputMessage.getBody(), inputMessage.getHeaders(), classes[0]);
	}

}

说明:这个类只要你导入了jackson的jar,默认就会被添加进去,so注解@JsonView属于天生就支持的。伪代码如下:

WebMvcConfigurationSupport:
	@Bean
	public RequestMappingHandlerAdapter requestMappingHandlerAdapter() {
		...
		if (jackson2Present) {
			adapter.setRequestBodyAdvice(Collections.singletonList(new JsonViewRequestBodyAdvice()));
			adapter.setResponseBodyAdvice(Collections.singletonList(new JsonViewResponseBodyAdvice()));
		}
		...
	}
使用示例
@Getter
@Setter
@ToString
public static class User {
    @JsonView({Simple.class, Complex.class})
    private Long id;
    @JsonView({Simple.class, Complex.class})
    private String name;
    @JsonView({Complex.class})
    private Integer age;
}

// 准备两个view类型(使用接口、类均可)
interface Simple {}
interface Complex {}

至于我为何这么准备示例,有兴趣的同学可以了解下@JsonView注解的用法和使用场景,你便会有所收获。

继续准备一个控制器,使用@JsonView来指定视图类型:

@ResponseBody
@PostMapping("/test/requestbody")
public String testRequestBodyAdvice(@JsonView(Simple.class) @RequestBody User user) {
    System.out.println(user);
    return "hello world";
}

这时候请求(发送的body里有age这个key哦):
在这里插入图片描述
控制台输出:

HelloController.User(id=1, name=fsx, age=null)

可以看到即使body体里有age这个key,服务端也是不会给与接收的(age仍然为null),就因为我要的是Simple类型的JsonView。这个时候若换成@JsonView(Complex.class)那最终的结果就为:

HelloController.User(id=1, name=fsx, age=18)

使用时需要注意如下几点:

  1. 若不标注@JsonView注解,默认是接收所有(这是我们绝大部分的使用场景)
  2. @JsonView的value有且只能写一个类型(必须写)
  3. @JsonView指定的类型,在POJO的所有属性(或者set方法)里都没有@JsonView对应的指定,那最终一个值都不会接收(因为一个都匹配不上)。
@JsonView执行原理简述

简单说说@JsonView在生效的原理。它主要是在AbstractJackson2HttpMessageConverter的这个方法里(这就是为何JsonViewRequestBodyAdvice只会处理这种消息转转器的原因):

AbstractJackson2HttpMessageConverter(实际为MappingJackson2HttpMessageConverter):

	@Override
	public Object read(Type type, @Nullable Class<?> contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
		JavaType javaType = getJavaType(type, contextClass);
		// 把body内的东西转换为java对象
		return readJavaType(javaType, inputMessage);
	}

	private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) throws IOException {
		if (inputMessage instanceof MappingJacksonInputMessage) {
			Class<?> deserializationView = ((MappingJacksonInputMessage) inputMessage).getDeserializationView();
			if (deserializationView != null) {
				return this.objectMapper.readerWithView(deserializationView).forType(javaType).readValue(inputMessage.getBody());
			}
		}
		return this.objectMapper.readValue(inputMessage.getBody(), javaType);
	}

因为标注了@JsonView注解就使用的是它MappingJacksonInputMessage。so可见最底层的原理就是readerWithViewreadValue的区别。

ResponseBodyAdvice

它允许在@ResponseBody/ResponseEntity标注的处理方法上在用HttpMessageConverter在写数据之前做些什么。

// @since 4.1 泛型T:body类型
public interface ResponseBodyAdvice<T> {
	boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType);
	@Nullable
	T beforeBodyWrite(@Nullable T body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response);
}

它的内置实现类们:
在这里插入图片描述

AbstractMappingJacksonResponseBodyAdvice

它做出了限定:body使用的消息转换器必须是AbstractJackson2HttpMessageConverter才会生效。

public abstract class AbstractMappingJacksonResponseBodyAdvice implements ResponseBodyAdvice<Object> {
	@Override
	public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
		return AbstractJackson2HttpMessageConverter.class.isAssignableFrom(converterType);
	}

	// 最终使用MappingJacksonValue来序列化body体
	@Override
	@Nullable
	public final Object beforeBodyWrite(@Nullable Object body, MethodParameter returnType, MediaType contentType, Class<? extends HttpMessageConverter<?>> converterType, ServerHttpRequest request, ServerHttpResponse response) {
		if (body == null) {
			return null;
		}
		MappingJacksonValue container = getOrCreateContainer(body);
		beforeBodyWriteInternal(container, contentType, returnType, request, response);
		return container;
	}
}
JsonViewResponseBodyAdvice

继承自父类,用法几乎同上面的@JsonView,只是它是标注在方法返回值上的。

它的源码此处忽略,没什么特别的需要说明的
使用示例

准备一个控制器如下(其它的同上):

@ResponseBody
@GetMapping("/test/responsebody")
@JsonView(Simple.class)
public User testResponseBodyAdvice() {
    User user = new User();
    user.setId(1L);
    user.setName("fsx");
    user.setAge(18);
    return user;
}

请求结果如下:
在这里插入图片描述
它的使用注意事项同上,基本原理同上(writerWithView/writer的区别)。


RequestResponseBodyAdviceChain

它是代理模式的实现,用于执行指定的RequestBodyAdvice/ResponseBodyAdvice们,实现方式基本同前面讲过多次的xxxComposite模式。

需要注意的是,两个advice的support()方法都只只只在这里被调用。所以很容易相想到Spring调用advice增强时最终调用的都是它,它就是一个门面。

// @since 4.2  请注意:它的访问权限是default哦
class RequestResponseBodyAdviceChain implements RequestBodyAdvice, ResponseBodyAdvice<Object> {
	//它持有所有的,记住是所有的advice们
	private final List<Object> requestBodyAdvice = new ArrayList<>(4);
	private final List<Object> responseBodyAdvice = new ArrayList<>(4);

	// 可以看到这是个通用的方法。内来进行区分存储的   getAdviceByType这个区分方法可以看一下
	// 兼容到了ControllerAdviceBean以及beanType本身
	public RequestResponseBodyAdviceChain(@Nullable List<Object> requestResponseBodyAdvice) {
		this.requestBodyAdvice.addAll(getAdviceByType(requestResponseBodyAdvice, RequestBodyAdvice.class));
		this.responseBodyAdvice.addAll(getAdviceByType(requestResponseBodyAdvice, ResponseBodyAdvice.class));
	}

	@Override
	public boolean supports(MethodParameter param, Type type, Class<? extends HttpMessageConverter<?>> converterType) {
		throw new UnsupportedOperationException("Not implemented");
	}
	@Override
	public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
		throw new UnsupportedOperationException("Not implemented");
	}

	// 可以看到最终都是委托给具体的Advice去执行的(supports方法)
	// 特点:符合条件的所有的`Advice`都会顺序的、依次的执行
	@Override
	public HttpInputMessage beforeBodyRead(HttpInputMessage request, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
		for (RequestBodyAdvice advice : getMatchingAdvice(parameter, RequestBodyAdvice.class)) {
			if (advice.supports(parameter, targetType, converterType)) {
				request = advice.beforeBodyRead(request, parameter, targetType, converterType);
			}
		}
		return request;
	}
	... // 其余方法略。处理逻辑同上顺序执行。
	// 最重要的是如下这个getMatchingAdvice()匹配方法


	private <A> List<A> getMatchingAdvice(MethodParameter parameter, Class<? extends A> adviceType) {
		// 简单的说你想要的是Request的还是Response的List呢?
		List<Object> availableAdvice = getAdvice(adviceType);
		if (CollectionUtils.isEmpty(availableAdvice)) {
			return Collections.emptyList();
		}
		List<A> result = new ArrayList<>(availableAdvice.size());
		for (Object advice : availableAdvice) {
			if (advice instanceof ControllerAdviceBean) {
				ControllerAdviceBean adviceBean = (ControllerAdviceBean) advice;


				// 这里面会调用beanTypePredicate.test(beanType)方法
				// 也就是根据basePackages等等判断此advice是否是否要作用在本类上
				if (!adviceBean.isApplicableToBeanType(parameter.getContainingClass())) {
					continue;
				}
				advice = adviceBean.resolveBean();
			}
			// 当前的advice若是满足类型要求的,那就添加进去  最终执行切面操作
			if (adviceType.isAssignableFrom(advice.getClass())) {
				result.add((A) advice);
			}
		}
		return result;
	}
}

这是批量代理模式的典型实现,Spring框架中不乏这种实现方式,对使用者非常友好,也很容易控制为链式执行或者短路执行。

初始化解析流程分析

我们知道所有的xxxBodyAdvice最终都是通过暴露的RequestResponseBodyAdviceChain来使用的,它内部持有容器内所有的Advice的引用。由于RequestResponseBodyAdviceChain的访问权限是default,所以这套机制完全由Spring内部控制。
他唯一设值处是AbstractMessageConverterMethodArgumentResolver

AbstractMessageConverterMethodArgumentResolver(一般实际为RequestResponseBodyMethodProcessor):
	// 唯一构造函数,指定所有的advices
	public AbstractMessageConverterMethodArgumentResolver(List<HttpMessageConverter<?>> converters, @Nullable List<Object> requestResponseBodyAdvice) {
		Assert.notEmpty(converters, "'messageConverters' must not be empty");
		this.messageConverters = converters;
		this.allSupportedMediaTypes = getAllSupportedMediaTypes(converters);
		this.advice = new RequestResponseBodyAdviceChain(requestResponseBodyAdvice);
	}

此构造函数在new RequestResponseBodyMethodProcessor(getMessageConverters(), this.requestResponseBodyAdvice)时候调用,传进来的requestResponseBodyAdvice就刚好是在初始化RequestMappingHandlerAdapter的时候全局扫描进来的所有的增强器们。

使用场景

本文介绍了@ControllerAdvice的使用以及它的解析原理,最重要的是结合RequestBodyAdvice/ResponseBodyAdvice来实现类似拦截器的效果。在现在前后端分离的开发模式下,大部分的情况下的请求是json格式,因此此种方式会有很大的用武之地,我举例几个经典使用场景供以参考:

  1. 打印请求、响应日志
  2. 对参数解密、对响应加密
  3. 对请求传入的非法字符做过滤/检测

总结

本文旨在介绍@ControllerAdviceRequestBodyAdvice/ResponseBodyAdvice的作用,为你解决在解决一些拦截问题时提供一个新的思路,希望能够对你的眼界、代码结构上的把控能有所帮助。
同时也着重介绍了@JsonView的使用:它可以放入参时接收指定的字段;也可以让返回值中敏感字段(如密码、盐值等)不予返回,可做到非常灵活的配置和管理,实现一套代码多处使用的目的,提高集成程度。

咀咒,需要注意的是:xxxBodyAdvice虽然使用方便,但是它的普适性还是没有HandlerInterceptor那么强的,下面我列出使用它的几点局限/限制:

  1. xxxAdvice必须被@ControllerAdvice注解标注了才会生效,起到拦截的效果
  2. 它只能作用于基于消息转换器的请求/响应(参考注解@RequestBody/@ResponseBody
  3. 当然,只能作用于@RequestMapping模式下的处理器模型上

关注A哥

Author A哥(YourBatman)
个人站点 www.yourbatman.cn
E-mail yourbatman@qq.com
微 信 fsx641385712
活跃平台
公众号 BAT的乌托邦(ID:BAT-utopia)
知识星球 BAT的乌托邦
每日文章推荐 每日文章推荐

BAT的乌托邦

YourBatman CSDN认证博客专家 博客专家 专栏创作者 BAT的乌托邦
也许当我老了,也一样写代码。不为别的,只为爱好
公众号:BAT的乌托邦
亦可在这里和我交流:https://www.yourbatman.cn
©️2020 CSDN 皮肤主题: 代码科技 设计师: Amelia_0503 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值