“王大锤の非诚勿扰” —— Spring IoC / DI 思想详述

26 篇文章 0 订阅
10 篇文章 0 订阅

本文参考地址:

《spring Ioc/DI的理解》
《关于Spring IOC (DI-依赖注入)你需要知道的一切》
《一、IOC和DI的概念》
《深入理解IoC/DI》
《spring IOC篇二:xml的核心逻辑处理》

**温馨提示:**前方内容会引起认真怪和女权者些许不适,请出门左手边右拐。


一. 王大锤的相亲市场

我叫王大锤,是个码农,我们这个行业号称“人傻钱多速来”,不信?呵呵呵呵呵呵呵……

我的职业是码农,工作内容是 new 一个对象,日常聊天是如何找一个对象,睡觉是做梦如何 new 一个白富美对象陪我走上人生巅峰。总之,我没有对象。

公司的同事连顺看我工作繁忙无暇撩妹,同时又日渐饥渴难耐,最后还是建议我去婚介公司碰碰运气,也许有个好运气,或者找个盘接一下,再不济也能遇见一群饥渴男一起回家组队打 Dota。于是我走到了春天婚介公司,踏上了登上人生巅峰之路。

我叫王大锤,是个单身狗

大锤进入了春天婚介公司之后,主要办了三件事:

  1. 进入春天婚介公司
  2. 按照婚介公司要求,填写个人用户简历
  3. 婚介公司告诉大锤,等待我们下一次的联谊事宜:到时候会用很多本公司用户参加,每个用户都有自己的个人条件,届时可进行配对或组队;

大锤想了想还有点小激动,然后就回了公司,打开了自己的 Markdown,开始写起了一篇控制反转 (IoC)依赖注入 (DI) 相关的教程。

王大锤的人生巅峰之路

二. Spring IoC / DI 的简单理解

《spring Ioc/DI的理解》 一文中,作者用人力资源局的例子方便读者的理解。所以笔者按照自己对 IoC / DI 的理解,也编了一个王大锤婚介公司之旅的故事,用段子的方式写出来,以期加深对自己和读者的印象。

2.1 IoC 与 DI 的定义

首先,笔者需要明确说明 IoC 与 DI 的定义。

控制反转 (Inversion of Control,即 IoC) 是一种设计思想。在 Java 开发中,IoC 意味着将你设计好的对象交给容器控制,而不是传统的在你的对象内部直接控制。同时 IoC 也是面向对象编程中的一种设计原则,可以用来减低计算机代码之间的耦合度。
对于 IoC,它最常用的一种手段叫做依赖注入 (Dependency Injection,即 DI)。DI 通过控制反转,对象在被创建的时候,由一个调控系统内所有对象的外界实体,将其所依赖的对象的引用传递给它。

上述定义也许比较难懂,所以笔者讲了开头的故事。故事中的春天婚介公司,就是我们经常使用的经典的 Spring IoC 容器
将上面段子的名词与 Spring 内容一一对应,则如下所示:

  • 婚介公司 —— Spring IoC 容器
  • 用户征婚简历 —— Spring beans
  • 一次联谊活动 —— xml 配置文件
  • 月老 —— 开发者(我)

2.2 IoC 的三个经典问题

对于 IoC 有三个经典问题:**谁控制谁?控制了什么?怎么实现了反转?**很多博客里都进行了一个回答,笔者也按照自己的故事模式进行一个回答。

  1. “谁控制谁?”IoC / DI 容器控制应用程序
    • 其实可以把 IoC 当做一个存储对象的容器,我们在开发中形成的对象都可以交给 Spring IoC 容器做一个统一的规范管理;
    • 我们在开发中形成的对象可以用一个 Spring bean 来表示,所以可以联想一下,Spring IoC 容器就是婚介公司,每个对象都向这个婚介公司投递了征婚简历,所有用户简历全部由婚介公司调配。所以 IoC 容器就充当了一个婚姻介绍的角色;
  2. 控制了什么?:IoC / DI 容器控制对象本身的创建、实例化,以及控制对象之间的依赖关系
    • 开发之中的对象已经全部交由 IoC 容器来管理了,那我们在获取对象的时候,就得由 IoC 容器来给我们提供;
    • 例:我们想要和一个妹子配对:
      • 平常情况下,需要主动自己上去撩妹自己要(在实际工程中,即调用目标对象的 get API );
      • 现在既然我和妹子都是婚介公司的用户(都注册了 bean),婚介公司控制了我们对于对象的获取,那么就得通过婚介公司来把妹子给你(在实际工程中,即调用 Spring 的 getBean 方法,中间的 BeanDefinition 等细节内容暂且不表);
  3. 怎么实现了反转?:主要体现在控制权的反转。因为现在应用程序不能主动去获取外部资源了,而是被动等待 IoC / DI 容器给它注入它所需要的资源,所以称之为反转。
    • 例:依旧用上面的例子:我们想要和一个妹子配对:
      • 平常情况下,我们直接去找妹子要过来,这种事情是我们自己去做的,控制权在我们手里(实际工程中,就是在 classA 中需要一个 classB 的实例,所以就在 classA 中直接 new 了一个 classB 的实例来使用);
      • 现在既然我和妹子都是婚介公司的用户,那么向婚介公司要求介绍这个妹子,让婚介公司把妹子交给我们(实际工程中,就是通知 Spring IoC 容器“我需要 classB 的实例,你需要给我弄一个,然后把这个实例传给 classA”);
    • 这样一对比,就发现创建权与控制权都从开发者身上转移到了 Spring IoC 容器上,即实现了控制的反转;

2.3 DI 的三个经典问题

同样,DI 也存在三个经典问题:谁依赖谁?谁注入了谁?注入了什么?

  1. “谁依赖谁?”应用程序依赖于 IoC 容器
    • 上面也提到了我们找婚介公司介绍妹子配对的流程,可以看出我们用户是依赖于婚介公司的,也就是应用程序依赖于 IoC 容器;
  2. “谁注入了谁?”:IoC 容器把对象注入于应用程序
    • 依旧是我们找婚介公司介绍妹子配对的流程,婚介公司把同为用户的妹子给了我们,就相当于 IoC 容器将对象注入到了应用程序之中;
    • 这种我们需要了对象,IoC 将对象给我们的过程,就是依赖注入。
  3. “注入了什么?”:注入应用程序需要的外部资源,比如有依赖关系的对象;
    • 婚介公司把同为用户的妹子给了我们,就相当于 IoC 容器将对象注入到了应用程序之中;

此时,笔者可以通过一个类比,来把依赖注入的关系进行说明:

  1. 一个 xml 配置文件中,定义了若干 Spring beans
    • 即在一次联谊活动中,会有很多用户参加;
  2. 对于这些 Spring beans,就是定义 bean 时各种各样的属性定义;
    • 对应于这些用户,就是说每一个用户都有自己的个人条件;
    • 所谓个人条件,就是身长八尺,容貌甚伟,有房有车,Q大H好,医卜星象门门会,钢琴摄影样样通之类的;
  3. 根据开发者在 xml 配置文件中的定义,形成依赖关系。对于一个 bean 的依赖,可以依赖于一个 bean,也可以依赖于多个 bean;例如 bean 定义的 xml 配置文件中,会有类似于 p 命名空间的属性注入 (p:name=“qixiaoxia”),或者是 ref 依赖关系注入 (pcbrand-ref=MacBookPro) 之类的配置关系,通过这些配置形成了依赖关系;
    • 对于这次联谊活动,月老牵线,一金童一玉女成功配对,喜结连理,从今以后过上没羞没臊的生活;当然也可能某个老司机勾搭上了多个用户,形成了一个只属于自己的小团伙,从此开启了它的 S8 征战之旅 (RNG IG 加油冲鸭!!!);

:在《深入理解IoC/DI》中作者用一问一答的形式阐述了控制、依赖、注入等关系,以及与 IoC/DI 相关内容。本文有类似借鉴。

三. DI 的实现原理解析

控制反转 IoC 与依赖注入 DI 之间的关系,控制反转是目的,依赖注入是实现控制反转的手段。前面也提到,如果在传统模式中,A 类依赖于 B 类,就是在 A 类中 new 一个 B 类,或者是用 A 类的 set 方法将 B 类实例的引用注入 A 类。
但是 IoC 将生成类的方式把传统模式反了过来,即开发人员不需要调用 new,而是在需要类的时候,由框架注入,由 DI 实现。即控制对象生成的权利,从自己转移给了框架(即 Spring),或者比较浅显的理解为转移给了 Spring 的 xml 配置文件。

笔者为了模拟 DI 依赖注入的实现过程,按照文中的相亲市场写了一个简单的 demo,用来测试依赖注入在源码层面的实现。

测试源码笔者已经上传到笔者的 Github 上,地址:spring/DI

在 demo 中笔者设置了两个类:

  • 用户 User
  • 兴趣爱好 Hobby

其中的 User 类中有三个属性:

  • String name: 姓名
  • Hobby hobby: 兴趣爱好
  • User partner: 伴侣

每个用户都有自己的姓名,一个爱好,还有配对完毕的伴侣。笔者的 bean xml 配置文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
              http://www.springframework.org/schema/beans/spring-beans.xsd"
       xmlns:p="http://www.springframework.org/schema/p">

    <bean id="qixiaoxia" class="com.grq.spring.DI.User"
          p:name="qixiaoxia"
          p:hobby-ref="qixiaoxiaHobby"
          p:partner-ref="girlFriend"/>
    <bean id="girlFriend" class="com.grq.spring.DI.User"
          p:name="nsy"
          p:partner-ref="qixiaoxia"/>
    <bean id="qixiaoxiaHobby" class="com.grq.spring.DI.Hobby"
          p:name="piano" p:level="Lv.8"/>
</beans>

Hobby 类定义源码:

public class Hobby {
    private String name;
    private String level;
    // get, set, 构造函数略
    // ...
    @Override
    public String toString() {
        return "Hobby{" +
                "name='" + name + '\'' +
                ", level='" + level + '\'' +
                '}';
    }
}

User 类定义源码:

public class User {
    private String name;
    private Hobby hobby;
    private User partner;
    // set, get, 构造函数略
    // ...
    @Override
    public String toString() {
        return "User{" +
                "name='" + name + '\'' +
                ", hobby=" + hobby +
                ", partner=\'" + partner.getName() + "\'}";
    }
}

测试用的 main 方法也很简单:

public class DITest {
    public static void main(String[] args) {
        BeanFactory factory = new XmlBeanFactory(new ClassPathResource("DITest.xml"));
        User user = (User) factory.getBean("qixiaoxia");
        System.out.println(user);
    }
}

从 bean xml 配置文件解析内容的方法入口是 XmlBeanDefinitionReader # loadBeanDefinitions 方法,源码及注释如下:

    public int loadBeanDefinitions(EncodedResource encodedResource) throws BeanDefinitionStoreException {
        // 断言要解析的XML文件配置存在,不能为空
        Assert.notNull(encodedResource, "EncodedResource must not be null");
        // 向日志系统输出日志系统,输出 XML bean 的加载源
        if (logger.isInfoEnabled()) {
            logger.info("Loading XML bean definitions from " + encodedResource.getResource());
        }
        // 获取当前线程里的 ThreadLocal 里的变量集合
        Set<EncodedResource> currentResources = this.resourcesCurrentlyBeingLoaded.get();
        if (currentResources == null) {
            //如果为空的情况下,重新申请一下 HashSet 集合
            currentResources = new HashSet<EncodedResource>(4);
            this.resourcesCurrentlyBeingLoaded.set(currentResources);
        }
        // 将 encodeResource 填加到当前线程的局部变量集合中
        if (!currentResources.add(encodedResource)) {
            throw new BeanDefinitionStoreException(
                    "Detected cyclic loading of " + encodedResource + " - check your import definitions!");
        }
        try {
            InputStream inputStream = encodedResource.getResource().getInputStream();
            try {
                InputSource inputSource = new InputSource(inputStream);
                // 如果设置了编译方式,对输入流进行编码的设置
                if (encodedResource.getEncoding() != null) {
                    inputSource.setEncoding(encodedResource.getEncoding());
                }
                //========================================
                // 真正的从指定的 XML 文件中加载 Bean 的定义的关键方法
                //========================================
                return doLoadBeanDefinitions(inputSource, encodedResource.getResource());
            }
            finally {
                inputStream.close();
            }
        }
        catch (IOException ex) {
            throw new BeanDefinitionStoreException(
                    "IOException parsing XML document from " + encodedResource.getResource(), ex);
        }
        finally {
            // 释放内存空间
            currentResources.remove(encodedResource);
            if (currentResources.isEmpty()) {
                this.resourcesCurrentlyBeingLoaded.remove();
            }
        }
    }

该方法中,最关键的方法是 doLoadBeanDefinitions 方法,它真正的从指定的 XML 文件中加载了 Bean 的定义。源码及注释如下:

    protected int doLoadBeanDefinitions(InputSource inputSource, Resource resource)
            throws BeanDefinitionStoreException {
        try {
            // 获取 XML 的验证方式,加载 XML 文件得到对应的 Document
            Document doc = doLoadDocument(inputSource, resource);
            // 根据返回的 Dcoument 注册 Bean 信息
            return registerBeanDefinitions(doc, resource);
        }
        // 若干 catch 方法省略
        // .........................................
    }

doLoadBeanDefinitions 方法由两个方法组成,一个是 doLoadDocument() 方法,其中获取 XML 的验证方式(如确定文件为 DTD 或者 XSD 文件格式等相关信息),并将 xml 文档信息放入 Document 实例对象中。该方法可在《spring IOC篇二:xml的核心逻辑处理》中查阅。

registerBeanDefinitions 是注册 bean 的内容,其中依赖注入的过程就是在该部分进行的。源码及注释如下:

    public int registerBeanDefinitions(Document doc, Resource resource) throws BeanDefinitionStoreException {
        // 使用 DefaultBeanDefinitionDocumentReader 实例化 BeanDefinitionDocumentReader 对象
        BeanDefinitionDocumentReader documentReader = createBeanDefinitionDocumentReader();
        // 记录统计前 BeanDefinition 的加载个数
        int countBefore = getRegistry().getBeanDefinitionCount();
        // 加载以及注册 Bean
        // 这里使用到了单一职责原则,将逻辑处理委托给单一的类进行处理,这个逻辑处理类就是 BeanDefinitionDocumentReader 对象
        documentReader.registerBeanDefinitions(doc, createReaderContext(resource));
        // 统计本次加载 Beanfinition 的个数
        return getRegistry().getBeanDefinitionCount() - countBefore;
    }

registerBeanDefinitions 是一个接口方法,它的具体实现是在 DefaultBeanDefinitionDocumentReader 中实现的:

    @Override
    public void registerBeanDefinitions(Document doc, XmlReaderContext readerContext) {
        this.readerContext = readerContext;
        this.logger.debug("Loading bean definitions");
        Element root = doc.getDocumentElement();
        // 核心方法
        this.doRegisterBeanDefinitions(root);
    }

最后 doRegisterBeanDefinitions 方法才是实际解析 xml 文件内容的核心方法。

    protected void doRegisterBeanDefinitions(Element root) {
        BeanDefinitionParserDelegate parent = this.delegate;
        this.delegate = createDelegate(getReaderContext(), root, parent);
        //======================
        // 处理 profile 属性
        //======================
        if (this.delegate.isDefaultNamespace(root)) {
            String profileSpec = root.getAttribute(PROFILE_ATTRIBUTE);
            if (StringUtils.hasText(profileSpec)) {
                String[] specifiedProfiles = StringUtils.tokenizeToStringArray(
                        profileSpec, BeanDefinitionParserDelegate.MULTI_VALUE_ATTRIBUTE_DELIMITERS);
                if (!getReaderContext().getEnvironment().acceptsProfiles(specifiedProfiles)) {
                    return;
                }
            }
        }
        // 空代码留给子类去实现模板设计模式
        // 继承 DefaultBeanDefinitionDocumentReader 的子类在 XML 解析前做一些处理,可以实现此方法
        preProcessXml(root);
        
        //==============================
        // 解析除了 profile 以外的默认属性
        //==============================
        parseBeanDefinitions(root, this.delegate);
        
        // 空代码留给子类去实现模板设计模式
        // 继承 DefaultBeanDefinitionDocumentReader 的子类在 XML 解析后做一些处理,可以实现此方法
        postProcessXml(root);
        this.delegate = parent;
    }

在 doRegisterBeanDefinitions 方法中,主要作用有三个:

  • 处理了根节点 root 的 profile 属性;
    • 在该例程中,并没有使用到 profile 属性。
  • 核心方法:调用 parseBeanDefinitions 方法,解析 bean 的基础属性。
  • 在解析 bean 基础属性的上下文处进行预处理 preProcessXml, 后处理 postProcessXml,但两个方法在该类中并没有实际实现,而是采用了模板设计模式,留给继承的子类,实现覆盖该方法;

核心方法 parseBeanDefinitions 源码如下:

    // 从 XML 文件解析 Bean 的定义
    protected void parseBeanDefinitions(Element root, BeanDefinitionParserDelegate delegate) {
        if (delegate.isDefaultNamespace(root)) {
            // 获取根节点的子节点列表
            NodeList nl = root.getChildNodes();
            // 遍历子节点列表
            for(int i = 0; i < nl.getLength(); ++i) {
                Node node = nl.item(i);
                if (node instanceof Element) {
                    Element ele = (Element)node;
                    if (delegate.isDefaultNamespace(ele)) {
                        // 解析当前节点
                        this.parseDefaultElement(ele, delegate);
                    } else {
                        delegate.parseCustomElement(ele);
                    }
                }
            }
        } else {
            delegate.parseCustomElement(root);
        }
    }

    // 解析 Bean 默认元素
    private void parseDefaultElement(Element ele, BeanDefinitionParserDelegate delegate) {
        if (delegate.nodeNameEquals(ele, "import")) {
            this.importBeanDefinitionResource(ele);
        } else if (delegate.nodeNameEquals(ele, "alias")) {
            this.processAliasRegistration(ele);
        } else if (delegate.nodeNameEquals(ele, "bean")) {
            this.processBeanDefinition(ele, delegate);
        } else if (delegate.nodeNameEquals(ele, "beans")) {
            this.doRegisterBeanDefinitions(ele);
        }
    }

在核心方法 parseBeanDefinitions 中,解析了 import, alias, bean, beans 四种标签。我们的 bean xml 文件基本都是 <bean> 标签,所以其中最核心的方法就是 processBeanDefinition 方法。processBeanDefinition 源码如下:

    protected void processBeanDefinition(Element ele, BeanDefinitionParserDelegate delegate) {
        // Bean 定义持有者
        BeanDefinitionHolder bdHolder = delegate.parseBeanDefinitionElement(ele);
        if (bdHolder != null) {
            // 装饰 Bean 定义,为各个 bean 添加属性信息,其中包含依赖关系的添加
            bdHolder = delegate.decorateBeanDefinitionIfRequired(ele, bdHolder);

            try {
                BeanDefinitionReaderUtils.registerBeanDefinition(bdHolder, this.getReaderContext().getRegistry());
            } catch (BeanDefinitionStoreException var5) {
                this.getReaderContext().error("Failed to register bean definition with name '" + bdHolder.getBeanName() + "'", ele, var5);
            }

            this.getReaderContext().fireComponentRegistered(new BeanComponentDefinition(bdHolder));
        }
    }

processBeanDefinition 方法源码中,decorateBeanDefinitionIfRequired 方法将 xml 配置文件中各个 bean 的属性“装饰”到该 bean 的定义中,在该方法中实现了 bean 之间的依赖注入。源码如下:

    public BeanDefinitionHolder decorateBeanDefinitionIfRequired(Element ele, BeanDefinitionHolder definitionHolder, BeanDefinition containingBd) {
        BeanDefinitionHolder finalDefinition = definitionHolder;
        // 获取当前 bean 的所有属性值
        NamedNodeMap attributes = ele.getAttributes();

        // 遍历所有属性值
        for(int i = 0; i < attributes.getLength(); ++i) {
            Node node = attributes.item(i);
            //===============================================
            // 关键方法,对当前属性值进行装饰(包含依赖关系注入的步骤)
            //===============================================
            finalDefinition = this.decorateIfRequired(node, finalDefinition, containingBd);
        }

        NodeList children = ele.getChildNodes();

        for(int i = 0; i < children.getLength(); ++i) {
            Node node = children.item(i);
            if (node.getNodeType() == 1) {
                finalDefinition = this.decorateIfRequired(node, finalDefinition, containingBd);
            }
        }

        return finalDefinition;
    }

decorateBeanDefinitionIfRequired 方法中遍历部分的关键方法中,再向下可进入到装饰属性的内容实现方法 decorate。decorate 方法将一个 bean 在 xml 文件中的属性定义赋值进入 BeanDefinition 中,该过程中当然也包含了 DI 依赖注入。decorate 方法是在 SimplePropertyNamespaceHandler 中实现的,源码如下所示:

    public BeanDefinitionHolder decorate(Node node, BeanDefinitionHolder definition, ParserContext parserContext) {
        if (node instanceof Attr) {
            Attr attr = (Attr)node;
            // 属性名
            String propertyName = parserContext.getDelegate().getLocalName(attr);
            // 属性值
            String propertyValue = attr.getValue();
            // 属性值集合
            MutablePropertyValues pvs = definition.getBeanDefinition().getPropertyValues();
            // 如果属性值集合中已经包含了当前属性名,则报出错误
            if (pvs.contains(propertyName)) {
                parserContext.getReaderContext().error("Property '" + propertyName + "' is already defined using both <property> and inline syntax. Only one approach may be used per property.", attr);
            }

            //===================================
            // 依赖注入实现:
            //   如果属性名是以 "-ref" 结尾的,则将该属性设置为被依赖的 bean,即 RuntimeBeanReference
            //===================================
            if (propertyName.endsWith("-ref")) {
                propertyName = propertyName.substring(0, propertyName.length() - "-ref".length());
                // 将该属性设置为被依赖的 bean,即 RuntimeBeanReference,添加进入属性值集合中
                pvs.add(Conventions.attributeNameToPropertyName(propertyName), new RuntimeBeanReference(propertyValue));
            } else {
                pvs.add(Conventions.attributeNameToPropertyName(propertyName), propertyValue);
            }
        }

        return definition;
    }

:关于 RuntimeBeanReference 的内容,可以在文章《Spring Bean 的解析 RuntimeBeanReference》一文中进行查阅了解。

对于例程中名为 “qixiaoxia” 的 bean 进行调试,在 decorateBeanDefinitionIfRequired 方法中循环遍历 decorate 方法之前的 beandefinition 变量的值如下图所示:

decorateBeanDefinitionIfRequired 遍历之前的属性值

经过 decorateBeanDefinitionIfRequired 方法循环赋值之前,如上图所示,propertyValueList 为空集。但在循环赋值后,结果如下图所示:

decorateBeanDefinitionIfRequired 遍历之后的属性值
propertyValueList 加入了三个值,这三个值与 xml 配置文件中 “qixiaoxia” bean 的定义相同,而且包含了其中以 “-ref” 为属性名的两个属性。可以对比 bean 的定义,以及上图中循环后的 beandefinition 值的结果:

<bean id="qixiaoxia" class="com.grq.spring.DI.User"
      p:name="qixiaoxia"
      p:hobby-ref="qixiaoxiaHobby"
      p:partner-ref="girlFriend"/>

这样就将 bean 之间的依赖关系编辑完毕。往后将各个 beanDefinition 存入 Map 中并注册,继续运行 registerBeanDefinition 方法,即可完成 bean 从 xml 配置文件加载的操作。

至此,DI 依赖注入的源码分析完毕。

四. 后记

第一次当标题党,心里还有点小激动呢 ~ 笔者用相亲为主题讲了如何理解 IoC 和 DI,但标题党不能白当,亲还是要相的,万一哪个有趣又美丽小姐姐看上我了呢?

笔者之自恋,有诗为证:

钢琴吉他 KTV,
摄影健身吹牛逼。
JAVA Python 还有 C,
有趣灵魂颜值帝。

最后献上自拍一张,拜个晚年,各位中秋快乐 ~

自拍(手动滑稽)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值