Spring MVC 教程-类容协商,颠覆你的认知

作者简介:大家好,我是smart哥,前中兴通讯、美团架构师,现某互联网公司CTO

联系qq:184480602,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬

学习必须往深处挖,挖的越深,基础越扎实!

阶段1、深入多线程

阶段2、深入多线程设计模式

阶段3、深入juc源码解析


阶段4、深入jdk其余源码解析


阶段5、深入jvm源码解析

码哥源码部分

码哥讲源码-原理源码篇【2024年最新大厂关于线程池使用的场景题】

码哥讲源码【炸雷啦!炸雷啦!黄光头他终于跑路啦!】

码哥讲源码-【jvm课程前置知识及c/c++调试环境搭建】

​​​​​​码哥讲源码-原理源码篇【揭秘join方法的唤醒本质上决定于jvm的底层析构函数】

码哥源码-原理源码篇【Doug Lea为什么要将成员变量赋值给局部变量后再操作?】

码哥讲源码【你水不是你的错,但是你胡说八道就是你不对了!】

码哥讲源码【谁再说Spring不支持多线程事务,你给我抽他!】

终结B站没人能讲清楚红黑树的历史,不服等你来踢馆!

打脸系列【020-3小时讲解MESI协议和volatile之间的关系,那些将x86下的验证结果当作最终结果的水货们请闭嘴】

本文将介绍SpringMVC中内容协商,可能有朋友听过,没听过的估计觉得很陌生,不管怎么样,先告诉你一点,这篇是非常重要的一个知识点,一定不要错误,坚持看完,一定会有大量收获, 末尾有pdf版本,需要的自行获取。

目录

1、预备知识

接口测试利器HTTP  Client

2、先来做一个测试

思考下,下面这个springmvc接口会输出什么?

    @RequestMapping(value = "/cn/test1")
    @ResponseBody
    public List<String> test1() {
        List<String> result = Arrays.asList(
            "刘德华",
            "张学友",
            "郭富城",
            "黎明");
        return result;
    }

代码很简单,方法上标识了ResponseBody注解,会输出如下json格式的数据。

浏览器中访问下这个接口,效果如下

2.1、测试场景1

大家在项目maven配置中加入下面内容,然后再试试会输出什么

    <!-- 添加jackson配置 -->
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-core</artifactId>
        <version>2.11.4</version>
    </dependency>
    
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.11.4</version>
    </dependency>
    
    <dependency>
        <groupId>com.fasterxml.jackson.dataformat</groupId>
        <artifactId>jackson-dataformat-xml</artifactId>
        <version>2.11.4</version>
    </dependency>

浏览器中再次访问,效果如下

我倒,这是什么玩意啊,有点奇怪啊,浏览器当前页面,点击右键,查看源码,如下, 怎么变成xml了啊???

2.2、结论1:返回值受服务器端的影响

从这个上面我们可以看出,我们只是在服务器端调整了一下maven的配置,此时接口的返回结果却发生了变化,由json格式变成xml格式了。

这里得到第1个结论:接口的返回值格式受服务器端的影响。

2.3、测试场景2

我们在idea中使用Http Client来访问一下上面这个接口,效果如下,返回的结果依然是xml格式的数据。

    ###
    GET http://localhost:8080/chat22/cn/test1

我们调整一下调用的代码,加一行代码,请求中加一个Accept: application/json,改成下面这样,然后再次看看效果如下图,这次结果怎么变成json了。

    ###
    GET http://localhost:8080/chat22/cn/test1
    Accept: application/json

这两次请求唯一不同的地方就是第二次多了Accept: application/json这部分代码,然后结果就变成json了,说明响应的结果收到了这个头的影响。

咱们再回过头去看一下浏览器的那次请求,它的请求头中的Accept是什么样的,如下图,内容我给提取出来了,如下代码,看起来好像很陌生啊,这玩意是啥?稍后我们会详细说明。

    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9

2.4、结论2:返回值受客户端Accept头的影响

场景2中,我们只是调整了一下http请求头中Accept,响应结果不一样了。

这里得到第2个结论:返回值受客户端Accept头的影响。

2.5、小结

从上面可以看出,响应结果的格式受服务器端和客户端的影响,由二者共同决定的。

3、为什么会这样?

3.1、这是由类容协商决定的

服务器端和请求端协商决定最终返回什么格式的内容。

客户端发送请求的时候可以告知服务器端,自己希望对方返回的数据格式列表,而服务器端的接口也有自己能够支持的响应格式列表,最终返回结果会根据这2个类型列表,找到一种两边都能够支持的类型返回,如果找不到合适,则会报错。

**比如:**服务器端可以响应json和xml格式的数据,而浏览器发送请求的时候告诉服务器说:我能够接收html和json格式的数据,那么最终会返回二者都能够支持的类型:json格式的数据。

**再比如:**服务器端可以相应json和html格式的数据,而客户端发送http请求的时候,说自己希望接受xml格式的数据,此时服务器端没有能力返回xml格式的数据,最终会报错。

如果还是不懂,更通俗的解释:

  • 小明找小王介绍女朋友,小明说能满足这些的就可以【有钱、漂亮、幽默】,小王收集了一下身边的资源,发现有钱的、漂亮的没有,幽默的倒是有,然后就将幽默的介绍给小明了,若小王这边没有满足这些条件的,那么就没法给小明介绍女友了。
  • 小明找小王介绍女朋友,小明说能满足这些的就可以【有钱、漂亮、幽默】,如果都能够满足,那么优先选择有钱的,然后漂亮的,然后幽默的,小王收集了一下身边的资源,发现有钱的、漂亮的、幽默的都有,然后根据小明的需求,将优先级最高的有钱的介绍给他了,小明乐呵呵,哈哈。

3.2、带来了2个问题

  • 客户端如何告诉服务器端自己能够接受的内容类型?
  • 服务器端开发的接口如何指定能够响应的类型?

4、客户端如何告诉服务器端自己能够接受的内容类型?

4.1、常见2种方式

  • 方式1:http请求头中使用Accept来指定客户端能够接收的类型(又叫:媒体类型)

  • 方式2:自定义的方式

    比如请求地址的后缀,test1.xml、test1.json,通过后缀来指定类容类型

    比如请求中可以添加一个参数,如format来指定能够接收的内容类型

这2种方式SpringMVC中都有实现,SpringMVC中默认开启了第1种方式,而SpringBoot中默认开启了这2种方式的支持,本文主要讲解第1种方式,后续在SpringBoot系列中,将详细介绍第2种方式。

4.2、又带来了2个问题

  • 问题1:什么是媒体类型
  • 问题2:http请求头Accept是什么样的?

5、什么是媒体类型(MimeType或MediaType)?

5.1、解释

简单点理解,媒体类型就是用来表示内容的格式,比如可以用来表示http请求体和响应体内容的格式。

英文称呼:MineType或者MediaType

5.2、MimeType格式

  • 格式:type/subtype;参数1=值1;参数2=值2;参数n=值n
  • type:表示主类型
  • subtype:表示子列类型
  • 类型和参数之间用英文分号隔开
  • 可以有很多参数,多个参数之间用英文分号隔开

5.3、常见的MimeType举例

MimeType说明
application/json表示json格式数据
application/json;charset=UTF-8表示json格式数据,后面跟了一个编码参数
text/plain表示纯文本格式内容
text/html表示html格式内容
text/html;charset=utf-8表示html,utf-8编码
application/json;q=1表示json格式数据,有个q参数,这个参数比较特殊,表示优先级

5.4、MimeType在http请求中的应用

(1)请求头Content-type:用来指定请求体中的内容的格式。

比如:Content-type: application/json用来告诉服务器端,客户端请求体的内容是json格式的,这样服务器端就可以以json格式解析请求体中的内容

(2)请求头Accept:用来告诉服务器,客户端能够接收的媒体类型。

Accpet由多个MimeType组成,之间用英文逗号隔开。

比如:Accept:text/html,text/xml,application/json,这个是告诉服务器,客户端可以接收3种格式的数据,服务器可以根据自己的能力选择一种格式进行响应

(3)响应头Content-type:用来告知客户端,响应体中的类容是什么格式。

比如:Content-type:text/html,表示响应的内容是html格式的,此时浏览器就会以html显示内容;浏览器会根据不同的格式做出不同的显示效果

(4)Http中的Content-Type详解

Http中的Content-Type是一个非常重要的东西,不了解的朋友建议先去这里了解下:http://itsoku.com/article/199

5.5、特殊参数q:指定MimeType优先级

当有多个媒体类型在一起的时候,可以在媒体中添加q参数用来指定媒体类型的优先级,q值的范围从0.0~1.0(1.0优先级最高)

比如Http请求头Accept可以指定多个媒体类型,那么可以在媒体类型中加上q参数,用来指定媒体类型的优先级,服务器端优先选择媒体类型高的格式进行响应。

如:Accept: text/html;q=0.8,text/xml;q=0.6,application/json;q=0.9,这个告知服务器端,客户端希望能够返回这3中类型的内容,若服务器端这3种都支持,则优先返回q值高的类型。

再回头来看看开头的案例,浏览器中看一下这个案例请求头中Accept的值

    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9

6、http请求头Accept是什么样的?

6.1、Accept作用

用来指定客户端能够接收的媒体类型,用来告诉服务器端,客户端希望服务器端返回什么格式的数据

6.2、Accept格式

  • 媒体类型1,媒体类型2,媒体类型3
  • 多个媒体之间用逗号隔开
  • 媒体类型中可以使用q参数来标注其优先级,q值的范围从0.0~1.0(1.0优先级最高)
  • 文章有点长,感谢大家的阅读,如果有收获,麻烦帮忙分享一下,路人在此感谢大家了。

咱们继续。

7、Spring中的类MediaType工具类

spring中为了更方便操作媒体类型,提供了一个工具类org.springframework.http.MediaTypeMediaType内部提供了很多常见的MediaType常量和常用的方法。

7.1、常见常量

    public static final String APPLICATION_JSON_VALUE = "application/json"; //json
    public static final String TEXT_PLAIN_VALUE = "text/plain"; //文本
    public static final String TEXT_HTML_VALUE = "text/html"; //html
    public static final String APPLICATION_XML_VALUE = "application/xml"; //xml
    public static final String IMAGE_GIF_VALUE = "image/gif"; //gif图片
    public static final String IMAGE_JPEG_VALUE = "image/jpeg"; //jpeg图片
    public static final String APPLICATION_PDF_VALUE = "application/pdf";//pdf格式
    public static final String APPLICATION_FORM_URLENCODED_VALUE = "application/x-www-form-urlencoded"; //普通表单提交内容的格式
    public static final String MULTIPART_FORM_DATA_VALUE = "multipart/form-data";//上传文件表单提交的内容格式

7.2、常用的方法

方法说明
staticMediaTypeparseMediaType(StringmediaType)将文本解析为MediaType
staticList&lt;MediaType&gt;parseMediaTypes(@NullableStringmediaTypes)将文本解析为MediaType列表
staticStringtoString(Collection&lt;MediaType&gt;mediaTypes)将MediaType列表解析为字符串
staticvoidsortBySpecificityAndQuality(List&lt;MediaType&gt;mediaTypes)将多个MediaType进行排序,内部会按照q参数排序
booleanincludes(@NullableMediaTypeother)判断当前的MediaType是否包含了参数中指定的other,比如当前的是:/,这是一个通配符类型的,那么可以匹配一切类型

7.3、排序规则

SpringMVC内部会根据客户端Accept指定的媒体类型列表以及服务器端接口能够支持的媒体类型列表,处理之后得到一个双方都支持的媒体类型列表,然后调用MediaType#sortBySpecificityAndQuality方法进行升序排序,最终会选择优先级最高的来返回。

我们来分析下开头案例为什么返回的是xml格式的数据

浏览器发送的Accept:

    Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9

服务器端的那个接口支持的媒体类型:

    application/xml;charset=UTF-8, text/xml;charset=UTF-8, application/*+xml;charset=UTF-8, application/json, application/*+json

二者取交集,最终得到双方都支持的媒体类型:

    application/xhtml+xml, application/xml;charset=UTF-8;q=0.9, application/xml;q=0.9, application/xml;charset=UTF-8;q=0.8, text/xml;charset=UTF-8;q=0.8, application/*+xml;charset=UTF-8;q=0.8, application/json;q=0.8, application/*+json;q=0.8

然后调用MediaType#sortBySpecificityAndQuality方法排序,得到

小提示 :排序规则:类型的在前面,通配符*的在后面,q大的在前,如果没有指定q,则表示q为1

    application/xhtml+xml, application/xml;charset=UTF-8;q=0.9, application/xml;q=0.9, application/xml;charset=UTF-8;q=0.8, text/xml;charset=UTF-8;q=0.8, application/json;q=0.8, application/*+xml;charset=UTF-8;q=0.8, application/*+json;q=0.8

然后取第一个作为最终返回的类型:

    Content-Type: application/xhtml+xml;charset=UTF-8

如下图,确实和浏览器中的结果一致

8、服务端可响应的媒体类型

8.1、服务端有3种方式可以指定响应的媒体类型

  • 方式1 :@RequestMapping注解的produces属性
  • 方式2 :response.setHeader("Content-Type","媒体类型");
  • 方式3 :如果上面2种方式都不指定,则由SpringMVC内部机制自动确定能够响应的媒体类型列表

8.2、方式1:@RequestMapping注解的produces属性

(1)解释

@RequestMapping注解有个produces属性,用来指定当前接口能够响应的媒体类型,也可以理解为此接口可以处理的媒体类型,其他的一些注解@PostMapping/@GettMapping/@PutMapping/@DeleteMapping/@PatchMapping/@PatchMapping也有这个属性,作用一样的;这里以@RequestMapping注解有个produces属性来做说明。

(2)案例

比如要求接口只能返回json格式的数据,那么可以这么写

    @RequestMapping(value = "/cn/test1", produces = {"application/json"})
    @ResponseBody
    public List<String> testProduct() {
        List<String> result = Arrays.asList(
                "刘德华",
                "张学友",
                "郭富城",
                "黎明");
        return result;
    }

测试场景1 :浏览器直接访问,返回的是json格式数据

测试场景2 :头Accept指定为applicaiton/xml,出现了406,服务器端无法处理,那是因为客户单希望服务器端返回application/xml格式数据,而服务器端接口只能返回application/json格式的数据,请求还没有到达接口内部,就被springmvc拦截了,给拒绝了

8.3、方式2:response.setHeader("Content-Type","媒体类型");

这种方式是直接忽略你客户端的要求,不管客户端的Accept是什么,服务器端都直接返回指定的类型,比如下面这段代码,不管客户端的Accept是什么值,最终都只会返回xml格式的数据。

    @RequestMapping(value = "/cn/contenttype")
    public void testContentType(HttpServletResponse response) throws IOException {
        //指定了响应的结果的类型
        response.setHeader("Content-Type", "application/xml");
        response.getWriter().write(
                "<List>" +
                        "<item>刘德华</item>" +
                        "<item>张学友</item>" +
                        "<item>郭富城</item>" +
                        "<item>黎明</item>" +
                        "</List>");
        response.getWriter().flush();
    }

8.4、方式3:由SpringMVC内部机制自动确定能够响应的媒体类型列表

如下代码,这段代码就由SpringMVC内部结合请求头中的Accpet协商得到最终返回的媒体类型。

    @RequestMapping(value = "/cn/auto")
    @ResponseBody
    public List<String> testAuto(HttpServletResponse response) throws IOException {
        List<String> result = Arrays.asList(
                "刘德华",
                "张学友",
                "郭富城",
                "黎明");
        return result;
    }

比如,你Accept传递的是application/xml,表示客户端希望返回xml格式的数据。

Aceept传递的是application/json,表示客户端希望返回json格式的数据,那么返回但就是json格式的数据。

这个代码带来了一个问题:这段代码能够响应的媒体类型有哪些呢?这个问题大家有没有思考过

方法或者类上标注有@ResponseBody注解,通常这个接口的返回值会被SpringMVC中的org.springframework.web.servlet.mvc.method.annotation.RequestResponseBodyMethodProcessor#handleReturnValue这个方法处理。

这个方法内部会找到当前SpringMVC容器中的所有消息转换器(org.springframework.http.converter.HttpMessageConverter),消息转换器中有个getSupportedMediaTypes方法。

    List<MediaType> getSupportedMediaTypes();

这个方法会返回当前转换器能够支持的媒体类型,表示这个转换器能能够将内容转换为这些媒体类型格式的数据然后响应到客户端,比如上面接口的返回值是一个List,然后丢给xml的HttpMessageConverter,就会被转换为xml格式的数据输出到客户端。

SpringMVC会调用这些HttpMessageConverter的getSupportedMediaTypes方法得到一个媒体类型列表,这个列表就是当前接口可以相应的媒体类型。

然后结合http头中的Accept,得到一个最终双方都可以接受的媒体类型。

然后就进行排序。

然后取最优的一个,通常是排序后的第一个,作为最终响应的媒体类型,这个媒体类型会对应一个HttpMessageConverter,然后使用HttpMessageConverter将接口的返回值转换为指定的媒体类型格式的数据,比如xml格式、json格式等等,然后输出到响应体中。

8.5、方式3源码解读

方式3是最常用的方式,其次是方式1,方式2用的比较少,这里我们来解读一下方式3的源码,以便加深理解。

方式3中会涉及到内容的协商,过程大致如下
  • step1:获取客户端能够接收的媒体类型列表:由请求头Accpet解析得到
  • step2:获取服务器端能够响应的媒体类型列表:遍历所有HttpMessageConverter的getSupportedMediaTypes方法得到一个媒体类型列表
  • step3:根据双方支持的媒体类型列表,得到双方都认可媒体类型列表
  • step4:对step中得到的双方都支持的媒体类型列表进行排序
  • step5:取一个合适的作为响应的媒体类型
  • step6:根据接口的返回值和step5得到的MediaType,匹配到合适HttpMessageConverter,然后调用HttpMessageConverter的write方法,其内部将内容转换为指定的格式输出

这个过程所在的代码在下面这个方法中,大家可以设置断点,然后去请求方式3中的/cn/auto接口,会进入到这个方法中。

    org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodProcessor#writeWithMessageConverters(T, org.springframework.core.MethodParameter, org.springframework.http.server.ServletServerHttpRequest, org.springframework.http.server.ServletServerHttpResponse)

step1:获取客户端支持的媒体类型列表

获取客户端能够接收的媒体类型列表:由请求头Accpet解析得到

step2:获取服务器端能够响应的媒体类型列表

对应的代码如下

    List<MediaType> producibleTypes = getProducibleMediaTypes(request, valueType, targetType);

下面进入getProducibleMediaTypes方法

    protected List<MediaType> getProducibleMediaTypes(
        HttpServletRequest request, Class<?> valueClass, @Nullable Type targetType) {
    	//这个是从@RquestMapping的produces属性中取值,如果有就直接取这个的值
        Set<MediaType> mediaTypes =
            (Set<MediaType>) request.getAttribute(HandlerMapping.PRODUCIBLE_MEDIA_TYPES_ATTRIBUTE);
        if (!CollectionUtils.isEmpty(mediaTypes)) {
            return new ArrayList<>(mediaTypes);
        }
        //遍历HttpMessageConverter,调用其canWrite方法判断是否能够处理当前接口方法的返回值,比如当前接口是List<String>
        //若可以处理,则调用其getSupportedMediaTypes方法,得到媒体类型列表
        List<MediaType> result = new ArrayList<>();
        for (HttpMessageConverter<?> converter : this.messageConverters) {
            if (converter instanceof GenericHttpMessageConverter && targetType != null) {
                if (((GenericHttpMessageConverter<?>) converter).canWrite(targetType, valueClass, null)) {
                    result.addAll(converter.getSupportedMediaTypes(valueClass));
                }
            }
            else if (converter.canWrite(valueClass, null)) {
                result.addAll(converter.getSupportedMediaTypes(valueClass));
            }
        }
        //如果上面的媒体类型为空,则返回*/*媒体类型,否则返回找到的媒体类型列表
        return (result.isEmpty() ? Collections.singletonList(MediaType.ALL) : result);
    }

HttpMessageConverter我们截图看一下,这里有8个

HttpMessageConverter支持的MediaType支持的接口返回值类型说明
StringHttpMessageConvertertext/plain,/String返回纯文本
ByteArrayHttpMessageConverterapplication/octet-streambyte[]返回字节流
FormHttpMessageConverterapplication/x-www-form-urlencodedmultipart/form-datamultipart/mixedMultiValueMap&lt;String,?&gt;将内容以表单提交的内容格式输出
ResourceHttpMessageConverter/org.springframework.core.io.ResourceResource用来表示各种资源,这种可以用来下载文件
MappingJackson2HttpMessageConverterapplication/jsonapplication/*+json能够被jackson工具转换为json格式的类型都行响应json用的就是这个
MappingJackson2XmlHttpMessageConverterapplication/xmltext/xmlapplication/*+xml能够被jacksonxml工具转换为xml格式的类型都行响应xml用的就是这个

上面列表中的最后2个Converter在下面这些包中,所以加了这些配置之后,SpringMVC才有了处理json和xml的能力,这里也算是解答了本文开头的问题。

getProducibleMediaTypes方法执行完毕之后,得到了服务器端能够响应的媒体类型列表

step3:根据双方支持的媒体类型列表,得到双方都认可媒体类型列表

step4:对step中得到的双方都支持的媒体类型列表进行排序

step5:取一个合适的作为响应的媒体类型

如下,会遍历排序好的列表,然后进行遍历,取第一个具体的媒体类型,mediaType.isConcrete()用来爬的南是不是具体的类型,具体的类型就是内部不包含通配符*的类型

step6:匹配到合适的HttpMessageConverter,将结果转换为指定的格式输出

代码如下,根据接口的返回值和step5得到的MediaType,匹配到合适HttpMessageConverter,然后调用HttpMessageConverter的write方法,其内部将内容转换为指定的格式输出

    if (selectedMediaType != null) {
        selectedMediaType = selectedMediaType.removeQualityValue();
        for (HttpMessageConverter<?> converter : this.messageConverters) {
            GenericHttpMessageConverter genericConverter = (converter instanceof GenericHttpMessageConverter ?
                                                            (GenericHttpMessageConverter<?>) converter : null);
            if (genericConverter != null ?
                ((GenericHttpMessageConverter) converter).canWrite(targetType, valueType, selectedMediaType) :
                converter.canWrite(valueType, selectedMediaType)) {
                body = getAdvice().beforeBodyWrite(body, returnType, selectedMediaType,
                                                   (Class<? extends HttpMessageConverter<?>>) converter.getClass(),
                                                   inputMessage, outputMessage);
                if (body != null) {
                    Object theBody = body;
                    LogFormatUtils.traceDebug(logger, traceOn ->
                                              "Writing [" + LogFormatUtils.formatValue(theBody, !traceOn) + "]");
                    addContentDispositionHeader(inputMessage, outputMessage);
                    if (genericConverter != null) {
                        genericConverter.write(body, targetType, selectedMediaType, outputMessage);
                    }
                    else {
                        ((HttpMessageConverter) converter).write(body, selectedMediaType, outputMessage);
                    }
                }
                else {
                    if (logger.isDebugEnabled()) {
                        logger.debug("Nothing to write: null body");
                    }
                }
                return;
            }
        }
    }

9、总结

本文的内容是非常非常重要的一个知识点,建议大家多看2遍,敲一敲+debug,测试测试,掌握就比较容易了;掌握这些之后才能更好的用好SpringMVC和SpringBoot。

10、案例代码git地址

10.1、git地址

    https://gitee.com/javacode2018/springmvc-series

10.2、本文案例代码结构说明

  • 29
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值