Tomcat、JDBC 如何打破双亲委派机制的?

首先我们说 之前,还要从类加载器说起。

类加载器

类加载器是干什么的?

我们在初学 Java 的时候,用命令行编译过 Java 文件,Java 代码通过j avac 编译成 class 文件,而类加载器的作用就是把 class 文件装进虚拟机。

JVM 预定义有三种类加载器,当一个 JVM 启动的时候,Java 开始使用如下三种类加载器:

  • 启动类加载器
  • 扩展类加载器
  • 系统类加载器

启动类加载器(Bootstrap ClassLoade)

  • 这个类加载使用 C/C++ 语言实现的,嵌套在 JVM 内部
  • 它用来加载 Java 的核心库(JAVAHOME/jre/1ib/rt.jar、resources.jar或sun.boot.class.path 路径下的内容),用于提供 JVM 自身需要的类
  • 并不继承自 ava.lang.ClassLoader,没有父加载器
  • 加载扩展类和应用程序类加载器,并指定为他们的父类加载器
  • 出于安全考虑,Bootstrap 启动类加载器只加载包名为 java、javax、sun 等开头的类

扩展类加载器(Extension ClassLoader)

  • Java 语言编写,由 sun.misc.Launcher$ExtClassLoader 实现
  • 派生于 ClassLoader 类
  • 父类加载器为启动类加载器
  • 从 java.ext.dirs 系统属性所指定的目录中加载类库,或从 JDK 的安装目录的 jre/1ib/ext 子目录(扩展目录)下加载类库。如果用户创建的 JAR 放在此目录下,也会自动由扩展类加载器加载

系统类加载器(Application ClassLoader)

  • Java 语言编写,由 sun.misc.LaunchersAppClassLoader 实现
  • 派生于 ClassLoader 类
  • 父类加载器为扩展类加载器
  • 它负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类库
  • 该类加载是程序中默认的类加载器,一般来说,Java 应用的类都是由它来完成加载
  • 通过 classLoader#getSystemclassLoader()方法可以获取到该类加载器

扩展类加载器与应用类加载器继承结构如图所示:

在这里插入图片描述

ClassLoader 类是一个抽象类,其后所有的类加载器都继承自 ClassLoader(不包括启动类加载器)。

值得注意的是,启动类、扩展类与系统类加载器之间的父子关系,并不是通过继承来实现的,而是通过组合,即使用 parent 变量来保存父加载器的引用。

双亲委派机制

JVM 在加载类时默认采用的是双亲委派机制。通俗的讲,就是某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归 (本质上就是 loadClass 函数的递归调用),因此所有的加载请求最终都应该传送到顶层的启动类加载器中。如果父类加载器可以完成这个类加载请求,就成功返回;只有当父类加载器无法完成此加载请求时,子加载器才会尝试自己去加载。

可以看出双亲委派机制的意思就是优先父类加载器加载!

我们可以从 java.lang.ClassLoader 中的 loadClass(String name)方法的代码中分析出虚拟机默认采用的双亲委派机制到底是什么模样:

public Class<?> loadClass(String name) throws ClassNotFoundException {  
    return loadClass(name, false);  
}  
  
protected synchronized Class<?> loadClass(String name, boolean resolve)  
        throws ClassNotFoundException {  
  
    // 首先判断该类型是否已经被加载  
    Class c = findLoadedClass(name);  
    if (c == null) {  
        //如果没有被加载,就委托给父类加载或者委派给启动类加载器加载  
        try {  
            if (parent != null) {  
                //如果存在父类加载器,就委派给父类加载器加载  
                c = parent.loadClass(name, false);  
            } else {    // 递归终止条件
                // 由于启动类加载器无法被Java程序直接引用,因此默认用 null 替代
                // parent == null就意味着由启动类加载器尝试加载该类,  
                // 即通过调用 native方法 findBootstrapClass0(String name)加载  
                c = findBootstrapClass0(name);  
            }  
        } catch (ClassNotFoundException e) {  
            // 如果父类加载器不能完成加载请求时,再调用自身的findClass方法进行类加载,若加载成功,findClass方法返回的是defineClass方法的返回值
            // 注意,若自身也加载不了,会产生ClassNotFoundException异常并向上抛出
            c = findClass(name);  
        }  
    }  
    //是否需要连接该类
    if (resolve) {  
        resolveClass(c);  
    }  
    return c;  
}

双亲委派的意义

  • 防止加载同一个.class。通过委托去询问上级是否已经加载过该.class,如果加载过了,则不需要重新加载。保证了数据安全
  • 保证核心.class不被篡改。通过委托的方式,保证核心.class不被篡改,即使被篡改也不会被加载,即使被加载也不会是同一个class对象,因为不同的加载器加载同一个.class也不是同一个Class对象。这样则保证了Class的执行安全

在这里插入图片描述

那么如何主动破坏双亲委派机制?

知道了双亲委派模型的实现,那么想要破坏双亲委派机制就很简单了。

因为他的双亲委派过程都是在 loadClass 方法中实现的,那么想要破坏这种机制,那么就自定义一个类加载器,重写其中的 loadClass 方法,使其不进行双亲委派即可。

loadClass()、findClass()、defineClass()区别

lassLoader 中和类加载有关的方法有很多,前面提到了 loadClass ,除此之外,还有 findClass 和 defineClass 等,那么这几个方法有什么区别呢?

  • loadClass() 就是主要进行类加载的方法,默认的双亲委派机制就实现在这个方法中
  • findClass() 根据名称或位置加载.class字节码
  • definclass() 把字节码转化为Class

    这里主要讲一下 loadClass 和 findClass,我们前面说过,当我们想要自定义一个类加载器的时候,并且想破坏双亲委派原则时,我们可以重写 loadClass 方法。

    那么我们想定义一个类加载器,但是又不想破坏双亲委派模型的时候呢?这时候就可以继承 ClassLoader,并且重写 findClass 方法。findClass() 方法是 JDK1.2 之后的 ClassLoader 新添加的一个方法。

    protected Class<?> findClass(String name) throws ClassNotFoundException {
            throw new ClassNotFoundException(name);
        }
    

    该方法直接抛出异常,没有默认实现。

    JDK1.2 之后已不再提倡用户直接覆盖 loadClass() 方法,而是建议把自己的类加载逻辑实现到 findClass() 方法中,因为在 loadClass() 方法的逻辑里,如果父类加载器加载失败,则会调用自己的 findClass() 方法来完成加载。

    所以,如果想定义一个自己的类加载器,并且要遵守双亲委派模型,那么可以继承 ClassLoader,并且在 findClass 中实现我们自己的加载逻辑即可。

    那么双亲委派机制能被破坏?

    从上面我们已经知道,破坏双亲委派直接重写 loadClass 方法就可以了,毕竟这个方法没有被 final 修饰。双亲委派既然有好处,那么为什么 JDK 对 loadClass 开放重写呢?这得从双亲委派引入的时间来看:

    双亲委派模型是在 JDK1.2 之后才被引入的,而类加载器和抽象类 java.lang.ClassLoader 则在 JDK1.0 时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java 设计者引入双亲委派模型时不得不做出一些妥协。在此之前,用户去继承java.lang.ClassLoader 的唯一目的就是为了重写 loadClass() 方法,JDK为了向前兼容,不得已开放对 loadClass 的重写操作。

    当然,破坏也不止这一次,到现在为止,双亲委派模型出现过 3 次较大规模的破坏, Tomcat 以及 JDBC 也破坏了双亲委派机制。

    Tomcat 对双亲委派的破坏

    首先,我们需要思考一个问题:Tomcat 为什么要打破双亲委派机制?

    Tomcat 是个 web 容器, 那么它要解决什么问题呢:

    • 一个 web 容器可能需要部署两个应用程序,不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离
    • 部署在同一个 web 容器中相同的类库相同的版本可以共享。否则,如果服务器有 5 个应用程序,那么要有 5 份相同的类库加载进虚拟机,这肯定不行的。
    • web 容器也有自己依赖的类库,不能于应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来
    • web 容器要支持 jsp 的修改,jsp 文件最终也是要编译成 class 文件才能在虚拟机中运行,但程序运行后修改 jsp 常见的事情, web 容器需要支持 jsp 修改后不用重启

    Tomcat 如何实现自己独特的类加载机制,Tomcat 团队已经设计好了。设计图如下所示:

    在这里插入图片描述
    Tomcat的几个主要类加载器:

    • CommonClassLoader:Tomcat最基本的类加载器,加载路径中的 class 可以被 Tomcat 容器本身以及各个 Webapp 访问,主要加载 /commo/* 目录下的资源
    • CatalinaClassLoader:Tomcat 容器私有的类加载器,加载路径中的 class 对于 Webapp 不可见,主要加载 /server/* 目录下的资源
    • sharedLoader:各个 Webapp 共享的类加载器,加载路径中的 class 对于所有 Webapp 可见,但是对于 Tomcat 容器不可见,主要加载 /shared/* 目录下的资源
    • WebappClassLoader:各个 Webapp 私有的类加载器,加载路径中的 class 只对当前 Webapp 可见,主要加载该应用下的 WEB-INF 下的资源

    前三个类加载器在 Tomcat 6 之后已经合并到根目录下的 lib目录下和/ WebApp/WEB-INF/*中的 Java 类库目录下的资源。

    从图中的委派关系中可以看出:

    CommonClassLoade r能加载的类都可以被 Catalina ClassLoader 和 SharedClassLoader 使用,从而实现了公有类库的共用,而 CatalinaClassLoader 和 Shared ClassLoader 自己能加载的类则与对方相互隔离。

    WebAppClassLoader 可以使用 SharedClassLoader 加载到的类,但各个 WebAppClassLoader 实例之间相互隔离。

    而 JasperLoader 的加载范围仅仅是这个 jsp 文件所编译出来的那一个.class 文件,它出现的目的就是为了被丢弃:当 web 容器检测到 jsp 文件被修改时,会替换掉目前的 JasperLoader 的实例,并通过再建立一个新的 jsp 类加载器来实现 jsp 文件的 HotSwap 功能。

    至此,我们已经知道了 Tomcat 为什么要这么设计,以及是如何设计的,很显然,Tomcat 违背了双亲委派机制,我们前面又说过,双亲委派模型要求除了顶层的启动类加载器之外,其余的类加载器都应当由自己的父类加载器加载。

    但是 Tomcat不是这么实现的,Tomcat 为了实现隔离性,没有遵守这个约定,每个 webappClassLoader 自行去加载自己目录下的 class 文件,不会传递给父类加载器。

    Tomca t中的 WebappClassLoader 就是自定义类加载器,它的 loadClass 方法为:

    public Class loadClass(String name) throws ClassNotFoundException {
            return (loadClass(name, false));
        }
        
        public Class loadClass(String name, boolean resolve)
            throws ClassNotFoundException {
     
            if (log.isDebugEnabled())
                log.debug("loadClass(" + name + ", " + resolve + ")");
            Class clazz = null;
     
            // Log access to stopped classloader
            if (!started) {
                try {
                    throw new IllegalStateException();
                } catch (IllegalStateException e) {
                    log.info(sm.getString("webappClassLoader.stopped", name), e);
                }
            }
     
            //先在本地 cache 查找该类是否已经加载过,本地缓存的数据结构为 ResourceEntry
            clazz = findLoadedClass0(name);
            if (clazz != null) {
                if (log.isDebugEnabled())
                    log.debug("  Returning class from cache");
                if (resolve)
                    resolveClass(clazz);
                return (clazz);
            }
     
            //从系统类加载器的 cache 中查找是否加载过
            clazz = findLoadedClass(name);
            if (clazz != null) {
                if (log.isDebugEnabled())
                    log.debug("  Returning class from cache");
                if (resolve)
                    resolveClass(clazz);
                return (clazz);
            }
     
            //如果缓存中都找不到,则利用系统类加载器加载
            try {
                clazz = system.loadClass(name);
                if (clazz != null) {
                    if (resolve)
                        resolveClass(clazz);
                    return (clazz);
                }
            } catch (ClassNotFoundException e) {
                // Ignore
            }
     
            if (securityManager != null) {
                int i = name.lastIndexOf('.');
                if (i >= 0) {
                    try {
                        securityManager.checkPackageAccess(name.substring(0,i));
                    } catch (SecurityException se) {
                        String error = "Security Violation, attempt to use " +
                            "Restricted Class: " + name;
                        log.info(error, se);
                        throw new ClassNotFoundException(error, se);
                    }
                }
            }
     
            boolean delegateLoad = delegate || filter(name);
     
            //开启代理的话,则使用父加载器加载
            if (delegateLoad) {
                if (log.isDebugEnabled())
                    log.debug("  Delegating to parent classloader1 " + parent);
                ClassLoader loader = parent;
                if (loader == null)
                    loader = system;
                try {
                    clazz = loader.loadClass(name);
                    if (clazz != null) {
                        if (log.isDebugEnabled())
                            log.debug("  Loading class from parent");
                        if (resolve)
                            resolveClass(clazz);
                        return (clazz);
                    }
                } catch (ClassNotFoundException e) {
                    ;
                }
            }
     
            //自行加载
            if (log.isDebugEnabled())
                log.debug("  Searching local repositories");
            try {
                clazz = findClass(name);
                if (clazz != null) {
                    if (log.isDebugEnabled())
                        log.debug("  Loading class from local repository");
                    if (resolve)
                        resolveClass(clazz);
                    return (clazz);
                }
            } catch (ClassNotFoundException e) {
                ;
            }
     
            //如果自己也加载不了,那就只能让父加载器加载了
            if (!delegateLoad) {
                if (log.isDebugEnabled())
                    log.debug("  Delegating to parent classloader at end: " + parent);
                ClassLoader loader = parent;
                if (loader == null)
                    loader = system;
                try {
                    clazz = loader.loadClass(name);
                    if (clazz != null) {
                        if (log.isDebugEnabled())
                            log.debug("  Loading class from parent");
                        if (resolve)
                            resolveClass(clazz);
                        return (clazz);
                    }
                } catch (ClassNotFoundException e) {
                    ;
                }
            }
            //上述过程都加载失败,抛出异常
            throw new ClassNotFoundException(name);
        }
    

    loadClass 内部执行流程如下所示:

    • 首先,在本地缓存中查找是否已经加载过该类(对于一些已经加载了的类,会被缓存在 resourceEntries 这个数据结构中),如果已经加载到则返回,否则继续下一步
    • 让系统类加载器尝试加载该类,主要是为了防止一些基础类会被 web 中的类覆盖,如果加载到则返回,返回继续
    • 前两步都没有加载到目标类,那么 web 应用的类加载器将自行加载,如果加载到则返回,否则继续下一步
    • 最后还是加载不到的话,则委托父类加载器去加载

    而第 3 和 第 4 两个步骤的顺序已经违反了双亲委托机制。

    JDBC对双亲委派的破坏

    JDBC 为什么要破坏双亲委派机制?

    因为 JDBC 的 DriverManager 是由 Bootstrap 类加载器去加载的,调用的方法也是 JDBC 的方法,但是真正的驱动 JDBC 里根本没有,所以在这样的机制下 Bootstrap 类加载器不会向下委托,自然也就加载不到了,所以要让程序类加载器来加载真正的驱动就需要打破这种机制。

    未破坏双亲委派机制的情况

    下面是我们连接数据库常用的代码;

    String url = "jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL";
    String username = "root";
    String password = "123456";
    String driverClassName = "com.mysql.cj.jdbc.Driver";
    // 注册驱动
    Class.forName(driverClassName);
    // 获取连接
    Connection connection = DriverManager.getConnection(url, username, password);
    

    Java 给数据库操作提供了一个 Driver 接口:

    public interface Driver {
    
        Connection connect(String url, java.util.Properties info)
            throws SQLException;
    
        boolean acceptsURL(String url) throws SQLException;
    
        DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties info)
                             throws SQLException;
        int getMajorVersion();
    
        int getMinorVersion();
    
        boolean jdbcCompliant();
    
        public Logger getParentLogger() throws SQLFeatureNotSupportedException;
    }
    

    原生的 JDBC 中 Driver 驱动本身只是一个接口,并没有具体的实现,所以上面的方法都需要厂商来实现。

    然后 Java 有一个 DriverManager 类来管理所有的加载的 Driver 驱动,点进看源码:

    public class DriverManager {
    
        // 存储已经注册的驱动,这是一个由 JUC 下 ReentrantLock 实现的可以高并发的 List ,
        // 如果想要注册驱动,就需要 调用 addIfAbsent 方法添加进 List。
        private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
    
        private final static  Object logSync = new Object();
    
        private DriverManager(){}
        
        // 注册驱动
        public static synchronized void registerDriver(java.sql.Driver driver)
            throws SQLException {
    
            registerDriver(driver, null);
        }
        public static synchronized void registerDriver(java.sql.Driver driver,
                                                       DriverAction da)
            throws SQLException {
    
            // 注册驱动
            /* Register the driver if it has not already been added to our list */
            if(driver != null) {
                registeredDrivers.addIfAbsent(new DriverInfo(driver, da));
            } else {
                // This is for compatibility with the original DriverManager
                throw new NullPointerException();
            }
    
            println("registerDriver: " + driver);
    
        }
    }
    

    这里省略部分代码,从代码中我们可以看出,我们获取连接的时候,是到 registeredDrivers 中去查找对应的 Driver 来获取连接,Driver 是需要先向 DriverManager 中进行注册的,,然后我们才能正常使用。

    不破坏双亲委派模型的情况下,我们看下 mysql 的驱动是如何被加载的:

      //1.加载数据访问驱动
      Class.forName("com.mysql.jdbc.Driver");
      //2.连接到数据库上
      Connection conn= DriverManager.getConnection("jdbc:mysql://localhost:3306/test?characterEncoding=GBK", "root", "123456");
    

    核心就是这句 Class.forName() 触发了 mysq l驱动的加载,我们看下 mysql 对 Driver 接口的实现:

    public class Driver extends NonRegisteringDriver implements java.sql.Driver {
        public Driver() throws SQLException {
        }
    
        static {
            try {
                DriverManager.registerDriver(new Driver());
            } catch (SQLException var1) {
                throw new RuntimeException("Can't register driver!");
            }
        }
    }
    

    可以看到,Class.forName() 其实触发了静态代码块,然后向 DriverManager 中注册了一个 mysql 的 Driver 实现,这时候我们通过 DriverManager 去获取 connection 的时候只要遍历当前所有 Driver 实现,然后选择一个建立连接就可以了。

    Class.forName() 加载类的时候使用的类加载器是调用者的类加载器,在自己的项目代码中调用 Class.forName() 这个方法,因为自己编写的项目代码是在 ClassPath 路径下的,而这个路径的类的加载器就是应用类加载器(系统类加载器),所以直接就可以用这个系统类加载器去加载 com.mysql.jdbc.Driver 这个类。

    破坏双亲委派模型的情况

    在 JDBC4.0 以后,开始支持使用 spi 的方式来注册这个 Driver,具体做法就是在 MyS QL 的 jar 包中的 META-INF/services/java.sql.Driver 文件中指明当前使用的 Driver 是哪个,如下图:

    在这里插入图片描述

    然后使用的时候就不需要我们手动的去加载驱动了,我们只需要直接获取连接就可以了,连接的过程变为了:

    String url = "jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=CONVERT_TO_NULL";
    String username = "root";
    String password = "123456";
    
    Connection connection = DriverManager.getConnection(url, username, password);
    

    然而, DriverManager 是位于 rt.jar 包中的,类加载器是启动类加载器,com.mysql.jdbc.Driver 肯定不在 <JAVA_HOME>/lib,那就无法加载 MySQL的Driver,我们可以用应用程序类加载器来加载。所以 Java 开发者的设计是,添加一个线程上下文类加载器(Thread Context ClassLoader),在启动类加载器中获取应用程序类加载器。 Thread.setContextClassLoaser() 设置线程上下文类加载器,如果创建线程的时候没有设置,会从父类继承一个,默认应用程序类加载器。

    线程上下文类加载器是让父类加载器请求子类加载器完成类的加载,打破了双亲委派机制。

    现在我们看下 DriverManager 是如何使用线程上下文类加载器去加载第三方 jar 包中的 Driver 类的。

    public class DriverManager {
        static {
            loadInitialDrivers();
            println("JDBC DriverManager initialized");
        }
        private static void loadInitialDrivers() {
            
            //..
            
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                public Void run() {
                    //这里就是查找各个 sql 厂商在自己的 jar 包中通过 spi 注册的驱动
                    ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
                    Iterator<Driver> driversIterator = loadedDrivers.iterator();
                    
                    try{
                        while(driversIterator.hasNext()) {
                            driversIterator.next();
                        }
                    } catch(Throwable t) {
                    // Do nothing
                    }
                    return null;
                }
            });
    
            //..
        }
    }
    

    使用时,我们直接调用 DriverManager.getConn() 方法自然会触发静态代码块的执行,开始加载驱动。

    我们接着点进 ServiceLoader.load() 方法查看源码:

      public static <S> ServiceLoader<S> load(Class<S> service) {
            ClassLoader cl = Thread.currentThread().getContextClassLoader();
            return ServiceLoader.load(service, cl);
        }
    
        public static <S> ServiceLoader<S> loadInstalled(Class<S> service) {
            ClassLoader cl = ClassLoader.getSystemClassLoader();
            ClassLoader prev = null;
            // 如果创建线程的时候没有设置,会使用父类加载器,上面已经设置,那么这里的类加载器就是线程上下文类加载器
            while (cl != null) {
                prev = cl;
                cl = cl.getParent();
            }
            return ServiceLoader.load(service, prev);
        }
    

    可以看到核心就是拿到线程上下文类加载器,然后构造了一个 ServiceLoader,说明已经不符合双亲委派机制了。

    接下来,DriverManager 的 loadInitialDrivers() 方法中有一句 driversIterator.next(),它的具体实现源码如下:

    private class LazyIterator
            implements Iterator<S>
    {
        private boolean hasNextService() {
        }
    
        private S nextService() {
            if (!hasNextService())
                throw new NoSuchElementException();
            String cn = nextName;
            nextName = null;
            Class<?> c = null;
            try {
                //cn:是产商在 META-INF/services/java.sql.Driver 文件中注册的 Driver 具体实现类的名称
                //loader:是之前构造 ServiceLoader 时传进去的线程上下文类加载器
                c = Class.forName(cn, false, loader);
            } catch (ClassNotFoundException x) {
                fail(service,
                     "Provider " + cn + " not found");
            }
        }
    
    }
    

    从上面的代码中我们知道,通过线程上下文类加载器拿到了应用程序类加载器,同时我们也查找到了厂商在子级的 jar 包中注册的驱动具体实现类名,这样我们就可以在 rt.jar 包中的 DriverManager 中成功加载了放在第三方应用程序包中的类了。

    我们总结一下这个过程:

    • 获取线程上下文类加载器,从而也就获得了应用程序类加载器
    • 从 META-INF/services/java.sql.Driver 文件中获取具体的实现类名 “com.mysql.jdbc.Driver”
    • 通过线程上下文类加载器去加载这个 Driver 类,从而避开了双亲委派模型的弊端

    所以, 在 JDBC 4.0 以后 ,开始支持使用 SPI 的方式来注册 Driver , 扫描 META-INF/services/java.sql.Driver 文件,使用线程上下文类加载器,破坏了双亲委派机制。

    • 1
      点赞
    • 7
      收藏
      觉得还不错? 一键收藏
    • 1
      评论

    “相关推荐”对你有帮助么?

    • 非常没帮助
    • 没帮助
    • 一般
    • 有帮助
    • 非常有帮助
    提交
    评论 1
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

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

    抵扣说明:

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

    余额充值