我撸了一个 Spring 容器

01. Spring 如何根据注解创建容器?

我们都知道Spring提供了根据注解和xml文件两种方式来创建容器和管理bean的,而在此我们将使用Spring提供的注解创建出容器,并从容器中获取到bean对象。

1. 创建配置类MySpringContext.java,类上添加Spring提供的ComponentScan注解生命扫描包的路径

@ComponentScan({"com.it120"})
public class MySpringContext {
    public MySpringContext(){
        System.out.println("容器初始化中。。。。");
    }
}

注解中的“com.it120”,表示Spring应该把该路径下贴上@Component注解的类加载到容器中

2. 在需要被Spring容器加载的类上贴上@Component注解:
@Component
public class MyBean {
    public void  test(){
        System.out.println("执行test方法");
    }
}

以上代码中我们定义了一个MyBean类,并提供了test()方法,类上我们贴上了@Component注解,表示该类将会被加载到Spring容器中

3. 根据配置类创建一个容器,并根据名称获取某个bean:
public static void main(String[] args) {
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(MySpringContext.class);
        MyBean myBean = (MyBean) context.getBean("myBean");
        myBean.test();
    }

以上代码中我们使用了Spring提供的AnnotationConfigApplicationContext类创建了一个容器上下文对象,入参为配置类的Class对象,通过容器上下文getBean(String beanNaem)方法 获取到我们加载到Spring容器中的bean对象,强转之后再调用test()方法,运行结果如图示:

以上就是根据Spring提供的注解和方法创建的容器和从容器中获取Bean的简单案例,我们暂且不深究其中奥妙,因为我们将会通过自己的创建的注解来实现以上的案例。

02. 创建容器类和自定义组件

创建容器类和自定注解

上一篇中我们使用了Spring提供的AnnotationConfigApplicationContext类来创建了一个容器上下文对象,入参为配置类的Class文件对象。并且该容器上下文对象提供了一个getBean(String beanName)的方法

那么我们可以简化思考为 AnnotationConfigApplicationContext类其实就是一个拥有Class类型成员变量和一个参数的构造器再加上一个getBean()方法的类,我们可以依此创建出容器类如下:

public class MyApplicationContext {

    // Class类型的成员变量
    public  Class clazz;
   
    // 构造方法
    public MyApplicationContext(Class clazz){
        this.clazz=clazz;
     
    }
    
    // getBean方法,根据名称获取一个bean对象
    public Object getBean(String name){
    // 先返回null,后续代码补上
       return null;
    }
}

创建自定义@ComponentScan()和@Component注解,这两个注解里面都拥有一个String类型的属性,前者中的属性表示包扫描的路径,后者的属性代表某个bean在容器中的名称

@ComponentScan()实现:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ComponentScan {
    String value();
}

@Component实现:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Component {
    String value();
}

注解中的@Target(ElementType.TYPE)表示这个注解可以使用在 类、接口上,@Retention(RetentionPolicy.RUNTIME)表示注解不仅被保存到class文件中,jvm加载class文件之后,仍然存在

定义一个配置类,类上使用@ComponentScan()注解,并指定包扫描路径:

@ComponentScan("com.spring_container.service")
public class AppConfig {
}

再定义一个将被加载到容器中的类,类上使用@Component注解,指定该bean在容器中的名称:

@Component("myService")
public class MyService {
}

到此我们已经把基本的类结构和注解定义完成可以在main方法中进行一个“假容器”的创建了如下:

public static void main(String[] args) throws ClassNotFoundException {
        //手写实现Spring容器
        MyApplicationContext myApplicationContext= new MyApplicationContext(AppConfig.class);
        System.out.println(myApplicationContext.getBean("myService"));
       
    }

但是现在我们运行其实也不会返回什么,因为我们还没完成bean对象的创建,所以这是个“空壳容器”

03. 解析注解获取包扫描路径

解析@ComponentScan注解获取注解属性值,获取该路径下所有.class文件

在上文中我们通过自定义注解和自定义容器类搭建了一个“空壳容器”,在本篇内容我们将逐步完成包扫描的过程。包扫描的流程大致可分为如下步骤:

  1. 解析注解获取注解的属性值

  2. 根据注解属性值,获取该路径下所有文件

  3. 通过ClassLoader 加载.class文件

包扫描和bean对象的创建都是需要在容器类中的构造方法进行创建处理的,我们可以把包扫描的步骤定义在一个方法内 名为scan(Class clazz) 之后在构造器中调用此方法即可:构造方法如下:

// 构造方法
 public MyApplicationContext(Class aClass) throws ClassNotFoundException {
        this.aClass=aClass;
        // 扫描路径-
        scan(aClass);
    }

scan方法:

private void scan(Class aClass) {
            //扫描包的逻辑代码
    }

在Scan方法中我们第一步需要获取到注解中的扫描路径:在scan方法中添加如下代码:

//1.获取传入的配置类上的@ComponentScan里面的参数,包的扫描路径
        ComponentScan componentScan = (ComponentScan)aClass.getDeclaredAnnotation(ComponentScan.class);
        String path = componentScan.value();
        System.out.println(path);

输出的包扫描路径如图:

获取到包扫描路径后,需要根据该路径获取到该路径下所有的文件

//1.获取传入的配置类上的@ComponentScan里面的参数,包的扫描路径
ComponentScan componentScan = (ComponentScan)aClass.getDeclaredAnnotation(ComponentScan.class);
String path = componentScan.value();
ClassLoader classLoader = MyApplicationContext.class.getClassLoader();
// 获取path下所有资源
URL resource = classLoader.getResource(path.replace(".", "/"));
// 获取文件
File file = new File(resource.getFile());
if(file.isDirectory()){
// 如果是文件夹
File[] files = file.listFiles();
    for (File f: files) {
            // 输出每一个文件的地址
            System.out.println(f.getAbsolutePath());
        }
   }

运行结果如图所示:

三种类加载器

  1. 启动类加载器(Bootstrap classLoader),加载的是jre/lib下的文件

  2. 拓展类加载器(Extension classLoader),加载的是/jre/ext/lib下的文件

  3. 应用类加载器(appclassloader)这个加载器就是加载用户所自定义的类的,加载的是classpath路径下的文件,那classpath路经指的是哪?看下图

我们从idea的启动参数log中看到有一个Classpath对应的参数,而这里的classpath指的是相对于Target/classes/下的文件,所以我们的appclassloader将会加载classes/下面的所有文件

第三步根据包扫描路径下所有.class文件生成Class对象,这里分两个小步,第一步获取类的全限定类名,第二步通过全限定类名生成Class对象。

1. 通过字符串的切割和替换最终得到了包下全部类的全限定类名

for (File f: files) {
        if(f.getAbsolutePath().endsWith(".class")){
           String absolutePath = f.getAbsolutePath();
           String filePath = absolutePath.substring(absolutePath.indexOf("com"), absolutePath.indexOf(".class"));
           String className = filePath.replace("\\", ".");
            System.out.println(className);
         }

    }

2. 通过类加载器获取到Class对象:

// 通过类加载器,加载类
Class<?> clazz = classLoader.loadClass(className);
System.out.println(clazz);

在上文中我们通过获取注解属性值,并通过该值加载.calss文件,最终通过类加载器获取到了Class对象,获取到类的Class对象之后我们就可以通过反射来创建出类的对象了

04. 单例池和BeanDefinition对象

在上文中我们通过获取注解属性值,并通过该值加载.calss文件,最终通过类加载器获取到了Class对象,获取到类的Class对象之后我们就可以通过反射来创建出类的对象了

我们都知道Spirng中Bean的作用域有以下几种:

  1. 原型(prototype):每次通过 Spring 容器获取 prototype 定义的 bean 时,容器都将创建一个新的 Bean 实例,每个 Bean 实例都有自己的属性和状态

  2. 单例(singleton):Spring IoC 容器中只会存在一个共享的 Bean 实例,无论有多少个Bean 引用它,始终指向同一对象。该模式在多线程下是不安全的。Singleton 作用域是Spring 中的缺省作用域,也可以显示的将 Bean 定义为 singleton 模式

  3. request:在一次 Http 请求中,容器会返回该 Bean 的同一实例。而对不同的 Http 请求则会产生新的 Bean,而且该 bean 仅在当前 Http Request 内有效,当前 Http 请求结束,该 bean实例也将会被销毁

  4. session:在一次 Http Session 中,容器会返回该 Bean 的同一实例。而对不同的 Session 请求则会创建新的实例,该 bean 实例仅在当前 Session 内有效。同 Http 请求相同,每一次session 请求创建新的实例,而不同的实例之间不共享属性,且实例仅在自己的 session 请求内有效,请求结束,则实例将被销毁

  5. global Session:在一个全局的 Http Session 中,容器会返回该 Bean 的同一个实例,仅在使用 portlet context 时有效

在本篇文章中我们将讲解原型bean和单例bean的区别和实现,首先我们先来看以下代码:

/**
 * 单例模式
 */
public class Singleton {
    private  static Singleton singleton;

    // 声明为私有之后在其他内就无法使用 该构造器来创建新的对象
    private Singleton(){}
    //只提供唯一一个访问此对象的方法
    public static Singleton getSingleton(){
        if(singleton==null){
            return new Singleton();
        }
        return singleton;
    }
}

//调用
public static void main(String[] args){
    System.out.println(Singleton.getSingleton());
    System.out.println(Singleton.getSingleton());
    System.out.println(Singleton.getSingleton());
}

运行截图:

以上就是一个单例模式的小案例,通过运行我们发现多次调用getSingleton()方法返回的都是通一个对象,正是对应了作用域为单例的Spring bean 如果我们把代码改为以下:

public static Singleton getSingleton(){
    return new Singleton();
    }

则运行结果如图下所示:

我们修改代码之后每调用一个getSingleton(),就会创建出一个新的对象,所有打印出来的对象自然是不相同的,而这种对象在Spring中称为原型bean

通过以上代码我们基本了解了什么是单例bean和原型bean的区别了,那Spring容器是如何区分这两bean呢?是不是也像上面一样写了个单例模式呢?

单例池

单例池顾名思义 “池”中保存的bean都是单例bean,当你想要获取的bean是存在这个“池”中的,存在即可返回不用再创建,这样也就实现了每次获取都是同一个对象,那这个单例池的是何种数据结构呢? 在此我们使用了ConcurrentHashMap<String,Object> 来作为单例池的存储结构

BeanDefinition对象

BeanDefinition对象是bean的定义,而非bean的对象,在beanDefinition对象中我们仅提供了 Class 类型的成员变量和bean作用域的成员变量:如下

//bean定义对象
public class BeanDefinition {
    // 类型
    private Class clazz;
    // bean的作用域
    private String scope;
    }

我们接着上篇文章的进度,上篇文章中我们是通过类加载器加载了指定扫描包路径下的所有Class对象。本文我们将会完成以下内容:

  1. 判断某个bean是否为单例类型,如果为单例类型则将该bean对象存入到单例池中

  2. 在扫描包的过程中将生成bean的定义对象,将bean定义对象存入到一个ConcurrentHashMap中,以供后续创建bean和获取bean时通过bean名称获取Class对象

第一步:我们需要定义一个注解表示该bean为单例bean还是原型bean

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface Scope {
    String value();
}

注解的定义如上,在使用该注解时定义某个bean为单例时需要,传入bean的类型 如 :“@Scope("prototype")” 则表示该bean的类型为原型bean,如果不用此注解则默认为单例bean 在容器中声明两个map成员如下:

//单例池
private ConcurrentHashMap<String,Object> singletonMap =new ConcurrentHashMap<>();
// BeanDefinition
private ConcurrentHashMap<String,Object> beanDefinitionMap =new ConcurrentHashMap<>();

我们在scan方法中补充以下代码:

// 通过类加载器,加载类
    Class<?> clazz = classLoader.loadClass(className);
     if(clazz.isAnnotationPresent(Component.class)){
            // 如果该类上存在@Component注解
            Component component =clazz.getDeclaredAnnotation(Component.class);
            // 定义一个beanDefinition对象
            BeanDefinition beanDefinition = new BeanDefinition();
            beanDefinition.setClazz(clazz);

            if(clazz.isAnnotationPresent(Scope.class) ){
                //该类上有@Scope注解注释
                beanDefinition.setScope(clazz.getDeclaredAnnotation(Scope.class).value());
                 }else{
                //默认单例bean
                beanDefinition.setScope("singleton");
                      }
                String beanName = component.value();
                beanDefinitionMap.put(beanName,beanDefinition);
    }

再以上代码中我们已经生成好了每个bean的bean定义对象,并以bean的名称作为key,beanDefination对象为value存到了beanDefinitionMap中。

到此包扫描的过程已经完成了,但是我们还是需要把单例的bean存入到单例池的map中 在构造器中添加以下代码:

// 构造方法
    public MyApplicationContext(Class aClass) throws ClassNotFoundException {
        this.aClass=aClass;
        // 扫描路径----->beanDefinition---->beanDefinitionMap
        scan(aClass);
        // 单例bean处理
        for (Map.Entry<String, Object> entry : beanDefinitionMap.entrySet()) {
            String key = entry.getKey();
            BeanDefinition beanDefinition = (BeanDefinition)entry.getValue();
            if(beanDefinition.getScope().equals("singleton")){
             Object o= createBean(beanDefinition);
             singletonMap.put(key,o);
            }
        }
    }

以上代码块中,我们遍历了了beanDefinitionMap,把map中定义为单例bean的bean对象存到单例池中。

getBean()方法的改造:在getBean()方法中我们首先区判断该bean是否为单例bean如果为单例bean则从单例池中获取即可,如不是单例类型则需要进行bean的创建,补充getBean()方法如下:

// getBean方法,根据名称获取一个bean对象
    public Object getBean(String name){
        // 根据bean名称,获取bean定义
        if(beanDefinitionMap.containsKey(name)){
            BeanDefinition beanDefinition =(BeanDefinition) beanDefinitionMap.get(name);
            if(beanDefinition.getScope().equals("singleton")){
                // 从单例池中获取
                return  singletonMap.get(name);
            }else {
                // 创建bean
                return createBean(beanDefinition);
            }
        }else{
            // 不存在对应的bean
            throw new NullPointerException();
        }
    }

创建以上代码中createBean()方法,并补充createBean()代码,在createBean中我们暂且使用反射来创建一个简单的对象如下:

private Object createBean(BeanDefinition beanDefinition) { try { // 根据beanDefinition对象创建bean对象 Class clazz = beanDefinition.getClazz(); //通过反射生成对象 return clazz.newInstance(); } catch (Exception e) { e.printStackTrace(); return null; } }

测试单例池是否生效,此时我们的MyService类如下:

@Component("myService")@Scope("prototype")public class MyService {}

我们使用了@Scope("prototype")表示bean的类型为原型bean,每次调用都会创建一个新的bean

而另外一个xxxService则只使用了@Component注解,默认为单例bean:如下

@Component("xxxService")public class xxxService {}

测试结果如图所示:

看到上图的运行结果,确实是如我们代码所写的单例bean无论多次获取都是返回的是同一个对象,而原型bean则是每次都创建了一个新的对象

05. 简易版@Autowired依赖注入实现

在上文中我们主要讲解了bean的单例和原型作用域的区别以及单例池和BeanDefinition对象的作用和使用,将扫描路径下的bean的定义,存入到了map中,也将作用域为单例的bean存入了单例池中。

在本篇文章中我们主要讲解简易版@Autowired依赖注入的实现。

创建@Autowired注解实现:

@Target({ElementType.FIELD,ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface Autowired {
}

新增一个类并使用@Component注解,表示该类将会被加载到容器中

@Component("orderService")
public class OrderService {
}

改造MyService类,引入OrderService 成员变量如下:

@Component("myService")
@Scope("prototype")
public class MyService {

    @Autowired
    private OrderService orderService;

    public OrderService getOrderService() {
        return orderService;
    }
}

在以上代码中我们使用了@Autowired 注入了一个OrderService类,并提供了get方法,修改测试类如下:

public class MainTest {
    public static void main(String[] args) throws ClassNotFoundException {
        //手写实现Spring容器
        MyApplicationContext myApplicationContext= new MyApplicationContext(AppConfig.class);
        MyService myService = (MyService) myApplicationContext.getBean("myService");
        System.out.println(myService.getOrderService());
    }
}

此时我们运行main方法结果如下:

以上图示我们虽然使用了@Autowired把OrderService 注入到了MyService中,但是其真正本质的代码却还没有写,所有此时我们从MyService对象中取出OrderService自然为空的,接下来我们需要在createBean()方法中,构造其类于类的关系。

改造createBean()方法如下:

private Object createBean(BeanDefinition beanDefinition) {
    try {
        // 根据beanDefinition对象创建bean对象
        Class clazz = beanDefinition.getClazz();
        Object instance = clazz.newInstance();
        //获取类的所有成员变量
        Field[] fields = clazz.getDeclaredFields();
        for (Field field: fields) {
            // 如果该成员变量被@Autowired注解标识
            if(field.isAnnotationPresent(Autowired.class)){
                // 成员变量名称
                String name = field.getName();
                // 根据名称获取bean
                Object bean = getBean(name);
                field.setAccessible(true);
                // 将获取到的bean设置到类对象中
                field.set(instance,bean);
                }
            }
         return instance;
        } catch (Exception e) {
        e.printStackTrace();
        return null;
    }
}

在以上代码块中我们,通过反射创建了类对象,遍历类中的所有成员变量,如果成员变量被@Autowired注解标识,我们则通过成员变量的名称,创建bean 再把生成的bean设置到类对象中,这样就可以实现一个简单的依赖注入

此时的运行结果如下:

从运行结果来看我们自定义的简易版依赖注入是成功得到效果的!

BeanNameAware接口

在Spirng中提供了一个接口叫做BeanNameAware,这个接口中提供了一个setBeanName()的方法,用来实现让Bean获取自己在BeanFactory配置中的名字(根据情况是id或者name),在此我们创建一个BeanNameAware接口来实现这个功能,让生成的bean知道自己的bean

创建beanNameAware接口:

public interface BeanNameAware {
    void setBeanName(String name);
}

在Myservice类中添加 成员变量,并实现BeanNameAware接口,重写其方法如下:

@Component("myService")
@Scope("prototype")
public class MyService implements BeanNameAware {

    @Autowired
    private OrderService orderService;

    private String beanName;

    public String getBeanName() {
        return beanName;
    }

    public OrderService getOrderService() {
        return orderService;
    }

    @Override
    public void setBeanName(String name) {
        this.beanName=name;
    }
}

继续改造createBean()方法添加如下:

if(instance instanceof BeanNameAware){
        // 如果instance实现类BeanNameAware接口
        ((BeanNameAware) instance).setBeanName(beanName);
    }

完整的createBean方法为:

在此需要修改createBean方法,加入一个beanName参数

private Object createBean(String beanName,BeanDefinition beanDefinition) { try { // 根据beanDefinition对象创建bean对象 Class clazz = beanDefinition.getClazz(); Object instance = clazz.newInstance(); //获取类的所有成员变量 Field[] fields = clazz.getDeclaredFields(); for (Field field: fields) { // 如果该成员变量被@Autowired注解标识 if(field.isAnnotationPresent(Autowired.class)){ // 成员变量名称 String name = field.getName(); // 根据名称获取bean Object bean = getBean(name); field.setAccessible(true); // 将获取到的bean设置到类对象中 field.set(instance,bean); } } if(instance instanceof BeanNameAware){ // 如果instance实现类BeanNameAware接口 ((BeanNameAware) instance).setBeanName(beanName); } return instance; } catch (Exception e) { e.printStackTrace(); return null; } }

修改测试类,从bean中获取BeanName,运行结果如下:

从上图中我们可以看到,通过代码改造我们是可以获取出bean的名称的,本篇文章中我们简易实现了依赖注入和BeanNameAware接口的实现,虽然不及Spring源码级别深奥但是对于我们理解Spring源码还是很有帮助的

InitializingBean和BeanPostProcessor接口

在上文中我们通过bean实现beanAware接口实现了给类的成员变量回调赋值,在Spring中提供了一个名为InitializingBean的接口,通过实现该接口,可以在bean初始化的时候根据用户的需要实现InitializingBean接口中并重写其中的方法

如下是Spring中提供的InitializingBean接口:

public interface InitializingBean {

 void afterPropertiesSet() throws Exception;

}

第一步 同样我们也模拟实现这个接口:首先我们也自定义接口如上接口和抽象方法,修改MyService 实现InitializingBean 并从写其方法如下:

@Component("myService")
@Scope("prototype")
public class MyService implements BeanNameAware, InitializingBean {

    @Autowired
    private OrderService orderService;

    private String beanName;

    public String getBeanName() {
        return beanName;
    }

    public OrderService getOrderService() {
        return orderService;
    }

    @Override
    public void setBeanName(String name) {
        this.beanName=name;
    }

    // 重写 InitializingBean 抽象方法
    @Override
    public void afterPropertiesSet() throws Exception {
        System.out.println("调用了afterPropertiesSet方法");
    }
}

以上代码中我们重写InitializingBean接口中的方法时只是简单的打印输出一句话

第二步 在createBean方法中改造代码,如果当前bean实现了InitializingBean接口,则需要调用其方法:

// 初始化
    if(instance instanceof InitializingBean){
        //如果instance实现类InitializingBean接口,则调用其抽象方法
        ((InitializingBean) instance).afterPropertiesSet();
     }

运行结果如下:

通过以上运行截图来看,我们在createBean()代码中判断,如果当前bean是InitializingBean的子类时我们直接调用了接口中的方法,在此我们仅仅重写了其接口的方法并只是打印出了一句话,在Spirng中提供了一个可扩展性的接口BeanPostProcessor

BeanPostProcessor

BeanPostProcessor接口我们可以理解为Spring提供的一个扩展接口,在源码中该接口和抽象方法如下:

public interface BeanPostProcessor {
 @Nullable
 default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
  return bean;
 }
 
  @Nullable
 default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
  return bean;
 }
}

上文中我们自定义了InitializingBean接口,而在BeanPostProcessor源码我们可以看到其中两个方法,一个是初始化之前执行一个是初始化之后执行的,我们同样自定义一个BeanPostProcessor并提供类似的两个抽象方法,修改扫描包阶段的代码逻辑如果该类是实现了BeanPostProcessor接口则反射创建出该对象实例,放到一个集合里边,当在bean初始化之前调用,修改scan代码如下:

在容器类中声明一个List集合用于存放bean对象:

//BeanPostProcessor
    private List<BeanPostProcessor> beanPostProcessorList=new ArrayList<>();

修改createBean方法,在初始化之前,和初始化之后分别执行:

修改MyService实现BeanPostProcessor接口和重写其中方法

@Component("myService")
@Scope("prototype")
public class MyService implements BeanNameAware, InitializingBean,BeanPostProcessor {

    ...

    // 重写 InitializingBean 抽象方法
    @Override
    public void afterPropertiesSet() throws Exception {
        System.out.println("调用了afterPropertiesSet方法");
    }

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) {
        System.out.println("初始化之前运行"+beanName);
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        System.out.println("初始化之后运行"+beanName);
        return bean;
    }
}

启动项目运行测试:

从上图中我们可以看到并不是只有实现了BeanPostProcessor接口的类会执行执行之前和执行之后的方法,凡是所有的注入容器的bean都会执行初始化之前和初始化之后的方法。

总结:本文主要讲解了初始化InitializingBean接口和BeanPostProcessor接口,主要的功能就是我们可以在bean创建和扫描的过程加入自定义的处理逻辑,这也是Spring源码提供的扩展性接口。


推荐阅读

1. GitHub 上有什么好玩的项目?

2. 这个牛逼哄哄的数据库开源了

3. SpringSecurity + JWT 实现单点登录

4. 100 道 Linux 常见面试题

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值