【设计模式】代理模式

代理模式是一种结构型设计模式,可以说是GOF23中应用最广泛的模式,尤其是其中的动态代理,功能强大,也较难理解。

代理

什么是代理?房屋中介就是一种代理,它是房东的代理。
中介

静态代理

代理模式,不管是静态还是动态,肯定有一个被代理的真实角色(Target),是最后真正执行业务逻辑的对象。比如房东,房子是他的,收钱的是他,办手续的也是他。
另外有一个代理角色(Proxy),比如中介,并且代理肯定有一个和真实角色相同的接口。为什么?因为虽然房子不是他的,但是他干的事情肯定都是房东想干的事情。另外,代理会持有一个真实角色的引用,因为它并不能真正处理业务逻辑(因为房子不是它的),他需要将逻辑委派给真实角色执行。代理的作用就是,对真实角色的能力做一个增强,比方跑腿、挑地段、谈价格。

//公共抽象
public interface Renter {
    String rent();
}

//真实角色,房东
public class Landlord implements Renter {
    public String rent() {
        return "我是房东,我要把房子租给你";
    }
}
//静态代理类
/**
 * 中介
 */
public class Proxy implements Renter {
    private Renter landlord;

    public Proxy(Renter renter) {
        this.landlord = renter;
    }

    public String rent() {
        System.out.println("我是中介,带你看房");
        System.out.println(landlord.rent());
        System.out.println("我是中介,房子漏水,帮你联系维修人员");
        return null;
    }
}
//测试类
public class TestStaticProxy {
    public static void main(String[] args) {
        Renter landlord = new Landlord();
        Renter proxy = new Proxy(landlord);
        proxy.rent();
    }
}

静态代理

缺点

  1. 真实角色与代理角色紧耦合了。
    如果一个中介代理很多个房东,一个房东有很多套房子,那就得每一个真实角色创建一个与之对应的代理角色,会有很多对象和重复代码。
  2. 违反开闭原则。
    因为抽象接口一旦修改,真实角色和代理角色必须全部做修改。
  3. 每次创建代理角色,都得手动传入一个已存在的真实角色。有些场景下,可能需要在并不知道真实角色的情况下,创建指定接口的代理。

动态代理

动态代理解决了静态代理所有的弊病。

JDK动态代理

JDK动态代理的关键在于java.lang.reflect.Proxy类,其
newProxyInstance(ClassLoader loader, Class<?>[] interface, InvocationHanlder h)
方法是整个JDK动态代理的核心,用于生成指定接口的代理对象。参数分别是:

  • ClassLoader 加载动态生成的代理类的类加载器;
  • Class<?>[] 代理类需要实现的接口;
  • InvocationHanlder 调用处理器。

用JDK的动态代理实现上面的场景:

public interface Renter {
    String rent();
}

public class Landlord implements Renter {
    public String rent() {
        return "我是房东,我要把房子租给你";
    }
}

/*
    自定义调用处理器
 */
public class RentHandler implements InvocationHandler {
    //房东
    private Renter Landlord;

    public RentHandler(Renter landlord) {
        Landlord = landlord;
    }

    //对代理对象发起的所有请求都会被委托给该方法
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        //前置处理
        System.out.println("我是中介,带你看房");
        //委托给真实角色处理业务逻辑
        System.out.println(method.invoke(Landlord, args));
        //后置处理
        System.out.println("我是中介,房子漏水,帮你联系维修人员");
        return null;
    }
}

public class TestDynamicProxy {
    public static void main(String[] args) {
        Renter landlord = new Landlord();
        Renter proxy = (Renter) Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),  //默认类加载器
                new Class[]{Renter.class},  //代理的接口
                new RentHandler(landlord)   //自定义调用处理器实现
                );
        proxy.rent();
    }
}

动态代理
与静态代理效果相同,且没有编写代理类。

Proxy.newProxyInstance()

动态代理的核心就是Proxy.newProxyInstance()。核心代码如下:

public static Object newProxyInstance(ClassLoader loader, Class<?>[] interfaces, InvocationHandler h) throws IllegalArgumentException {
				... //省略一些非空校验,权限校验的逻辑
        //返回一个代理类,这个是整个方法的核心,后续会做详细剖析
        Class<?> cl = getProxyClass0(loader, intfs);
				//使用反射获取其有参构造器,constructorParams是定义在Proxy类中的字段,值为{InvocationHandler.class}
        final Constructor<?> cons = cl.getConstructor(constructorParams);
				//使用返回创建代理对象
        return cons.newInstance(new Object[]{h});

}
  1. 使用getProxyClass0获取一个Class对象,这个就是最后生成返回的代理类的Class对象;
  2. 使用反射获取有参构造器;
  3. 传入自定义的InvocationHandler创建对象。由此可以猜测,这个动态生成的代理类有一个参数为InvocationHandler的构造器。

getProxyClass0方法:

private static Class<?> getProxyClass0(ClassLoader loader, Class<?>... interfaces) {
        if (interfaces.length > 65535) {
            throw new IllegalArgumentException("interface limit exceeded");
        }
        return proxyClassCache.get(loader, interfaces);
}

proxyClassCache是Proxy类的一个成员变量,可以将其理解为一个代理类的缓存。因为代理类的生成过程中伴随着大量的字节码操作、IO操作、反射操作,比较耗资源,所以Proxy提供了缓存机制,缓存生成的代理类,获取代理类时先从缓存拿,拿不到再生成,以提升效率。
proxyClassCache.get()的核心代码:

public V get(K key, P parameter){
  	... //省略大量的缓存操作
    while (true) {
      if (supplier != null) {
        V value = supplier.get();
        if (value != null) {
          return value;	
        }
      }
      if (factory == null) {
        factory = new WeakCache.Factory(key, parameter, subKey, valuesMap); 
      }

      if (supplier == null) {
        supplier = valuesMap.putIfAbsent(subKey, factory);
        if (supplier == null) {
          supplier = factory;
        }
      } else {
        if (valuesMap.replace(subKey, supplier, factory)) {
          supplier = factory;
        } else {
          supplier = valuesMap.get(subKey);
        }
      }
    }
}

这段代码是一个while(true)的死循环,只有第6行有个出口,通过supplier.get()获得一个代理类结果。Supplier是一个函数式接口,代表了一种数据的获取操作。而supplier是通过factory赋值来的,factory是在第11行创建的。WeakCache.Factory(key, parameter, subKey, valuesMap)恰好是supplier的实现类。进入WeakCache.Factory的get():

public synchronized V get() {
				... //省略一些缓存操作
        V value = null;
        value = Objects.requireNonNull(valueFactory.apply(key, parameter));
        ... //省略一些缓存操作
        return value;
}

apply是BiFunction的抽象方法,我们通过WeakCache的构造器知道,BiFunction实际上是一个ProxyClassFactory。
在这里插入图片描述
顾名思义,ProxyClassFactory就是专门用来创建类的工厂类。
ProxyClassFactory的apply代码如下:

Map<Class<?>, Boolean> interfaceSet = new IdentityHashMap<>(interfaces.length);
				//对每一个指定的Class校验其是否能被指定的类加载器加载以及校验是否是接口,动态代理只能对接口代理,至于原因,后面会说。
        for (Class<?> intf : interfaces) {
            Class<?> interfaceClass = null;
                interfaceClass = Class.forName(intf.getName(), false, loader);
            if (interfaceClass != intf) {
                throw new IllegalArgumentException(
                        intf + " is not visible from class loader");
            }	
            if (!interfaceClass.isInterface()) {
                throw new IllegalArgumentException(
                        interfaceClass.getName() + " is not an interface");
            }
            if (interfaceSet.put(interfaceClass, Boolean.TRUE) != null) {
                throw new IllegalArgumentException(
                        "repeated interface: " + interfaceClass.getName());
            }
        }
				//下面这一大段是用来指定生成的代理类的包信息
				//如果全是public的,就是用默认的com.sun.proxy,
				//如果有非public的,所有的非public接口必须处于同一级别包下面,而该包路径也会成为生成的代理类的包。
        String proxyPkg = null;
        int accessFlags = Modifier.PUBLIC | Modifier.FINAL;

        for (Class<?> intf : interfaces) {
            int flags = intf.getModifiers();
            if (!Modifier.isPublic(flags)) {
                accessFlags = Modifier.FINAL;
                String name = intf.getName();
                int n = name.lastIndexOf('.');
                String pkg = ((n == -1) ? "" : name.substring(0, n + 1));
                if (proxyPkg == null) {
                    proxyPkg = pkg;
                } else if (!pkg.equals(proxyPkg)) {
                    throw new IllegalArgumentException(
                            "non-public interfaces from different packages");
                }
            }
        }

        if (proxyPkg == null) {
            proxyPkg = ReflectUtil.PROXY_PACKAGE + ".";
        }

        long num = nextUniqueNumber.getAndIncrement();
				//代理类最后生成的名字是包名+$Proxy+一个数字
        String proxyName = proxyPkg + proxyClassNamePrefix + num;
				//生成代理类的核心
        byte[] proxyClassFile = ProxyGenerator.generateProxyClass(
                proxyName, interfaces, accessFlags);
            return defineClass0(loader, proxyName,
                    proxyClassFile, 0, proxyClassFile.length);
    }

生成代理类的核心代码在倒数第三行

byte[] proxyClassFile = ProxyGenerator.generateProxyClass(
proxyName, interfaces, accessFlags);

ProxyGenerator会生成一个字节数组形式的代理类,然后转为Class对象。
这个方法位于sun.misc包,不是开源包,只能通过反编译查看。

 public static byte[] generateProxyClass(final String name, Class<?>[] interfaces, int accessFlags) {
        ProxyGenerator gen = new ProxyGenerator(name, interfaces, accessFlags);
        final byte[] classFile = gen.generateClassFile();

        if (saveGeneratedFiles) {
            //省略一堆IO操作
        }
        return classFile;
 }

这里调用生成器的generateClassFile()方法返回代理类,后面的if是将生成的代理类以class文件形式保存在硬盘。

private final static boolean saveGeneratedFiles =
        java.security.AccessController.doPrivileged(
            new GetBooleanAction("sun.misc.ProxyGenerator.saveGeneratedFiles")).booleanValue();

可以看出系统属性sun.misc.ProxyGenerator.saveGeneratedFiles决定了是否持久化动态生成的class文件,默认是false,如果要持久化需设置成true。

System.setProperty(“sun.misc.ProxyGenerator.saveGeneratedFiles”, “true”);
在这里插入图片描述

反编译内容如下:

public final class $Proxy0 extends Proxy implements Renter {
    private static Method m1;
    private static Method m3;
    private static Method m2;
    private static Method m0;

    public $Proxy0(InvocationHandler var1) throws  {
        super(var1);
    }

    public final boolean equals(Object var1) throws  {
        try {
            return (Boolean)super.h.invoke(this, m1, new Object[]{var1});
        } catch (RuntimeException | Error var3) {
            throw var3;
        } catch (Throwable var4) {
            throw new UndeclaredThrowableException(var4);
        }
    }

    public final String rent() throws  {
        try {
            return (String)super.h.invoke(this, m3, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    public final String toString() throws  {
        try {
            return (String)super.h.invoke(this, m2, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    public final int hashCode() throws  {
        try {
            return (Integer)super.h.invoke(this, m0, (Object[])null);
        } catch (RuntimeException | Error var2) {
            throw var2;
        } catch (Throwable var3) {
            throw new UndeclaredThrowableException(var3);
        }
    }

    static {
        try {
            m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
            m3 = Class.forName("com.lt.dynamicProxy.bean.Renter").getMethod("rent");
            m2 = Class.forName("java.lang.Object").getMethod("toString");
            m0 = Class.forName("java.lang.Object").getMethod("hashCode");
        } catch (NoSuchMethodException var2) {
            throw new NoSuchMethodError(var2.getMessage());
        } catch (ClassNotFoundException var3) {
            throw new NoClassDefFoundError(var3.getMessage());
        }
    }
}

关注点:

  1. 静态代码块中的代码。当类一加载,就会用反射获取到代理接口中的方法,和Object对象equals(),toString(),hashCode()方法的Method对象,并将其保存在属性中,为后续请求分派做准备。
  2. 参数为InvocationHandle的构造器。
  3. 代理类继承了Proxy类。由此我们知道,Proxy是所有代理类的父类。而Java只能单继承,这就意味着代理类无法继承其他类拓展。所以,JDK动态代理无法对不实现任何接口的类进行代理,这是JDK动态代理为数不多的缺点之一。如果需要以继承的形式代理一个类,可以使用CGLIB等类库。
  4. 不管是继承自Object的equals(),toString(),hashCode()方法,还是实现接口的rent()方法,都会委托给通过构造器传入的InvocationHandler对象的invoke()方法处理。

ProxyGenerator.generateClassFile()源码:

private byte[] generateClassFile() {
        addProxyMethod(hashCodeMethod, Object.class);
        addProxyMethod(equalsMethod, Object.class);
        addProxyMethod(toStringMethod, Object.class);
        for (Class<?> intf : interfaces) {
            for (Method m : intf.getMethods()) {
                addProxyMethod(m, intf);
            }
        }
        for (List<ProxyGenerator.ProxyMethod> sigmethods : proxyMethods.values()) {
            checkReturnTypes(sigmethods);
        }
        methods.add(generateConstructor());

        for (List<ProxyGenerator.ProxyMethod> sigmethods : proxyMethods.values()) {
            for (ProxyGenerator.ProxyMethod pm : sigmethods) {

                fields.add(new ProxyGenerator.FieldInfo(pm.methodFieldName,
                        "Ljava/lang/reflect/Method;",
                        ACC_PRIVATE | ACC_STATIC));

                methods.add(pm.generateMethod());
            }
        }
        methods.add(generateStaticInitializer());
        if (methods.size() > 65535) {
            throw new IllegalArgumentException("method limit exceeded");
        }
        if (fields.size() > 65535) {
            throw new IllegalArgumentException("field limit exceeded");
        }

        cp.getClass(dotToSlash(className));
        cp.getClass(superclassName);
        for (Class<?> intf : interfaces) {
            cp.getClass(dotToSlash(intf.getName()));
        }

        cp.setReadOnly();

        ByteArrayOutputStream bout = new ByteArrayOutputStream();
        DataOutputStream dout = new DataOutputStream(bout);

        dout.writeInt(0xCAFEBABE);
        // u2 minor_version;
        dout.writeShort(CLASSFILE_MINOR_VERSION);
        // u2 major_version;
        dout.writeShort(CLASSFILE_MAJOR_VERSION);

        cp.write(dout);             // (write constant pool)

        // u2 access_flags;
        dout.writeShort(accessFlags);
        // u2 this_class;
        dout.writeShort(cp.getClass(dotToSlash(className)));
        // u2 super_class;
        dout.writeShort(cp.getClass(superclassName));

        // u2 interfaces_count;
        dout.writeShort(interfaces.length);
        // u2 interfaces[interfaces_count];
        for (Class<?> intf : interfaces) {
            dout.writeShort(cp.getClass(
                    dotToSlash(intf.getName())));
        }

        // u2 fields_count;
        dout.writeShort(fields.size());
        // field_info fields[fields_count];
        for (ProxyGenerator.FieldInfo f : fields) {
            f.write(dout);
        }

        // u2 methods_count;
        dout.writeShort(methods.size());
        // method_info methods[methods_count];
        for (ProxyGenerator.MethodInfo m : methods) {
            m.write(dout);
        }
        // u2 attributes_count;
        dout.writeShort(0);

        return bout.toByteArray();
    }

其实这个方法就做了一件事情,就是根据我们传入的这些个信息,再按照Java虚拟机规范的字节码结构,用IO流的方式写入到一个字节数组中,这个字节数组就是代理类的Class文件。
如果不了解Java虚拟机规范中关于字节码文件结构的话,这段代码是肯定看不懂的。

Class文件结构简述

在Java虚拟机规范中,Class文件是一组二进制流,每个Class文件会对应一个类或者接口的定义信息,当然,Class文件并不是一定以文件形式存在于硬盘,也有可能直接由类加载器加载到内存
每一个Class文件加载到内存后,经过一系列的加载、连接、初始化过程,然后会在方法区中形成一个Class对象,作为外部访问该类信息的的唯一入口。
按照Java虚拟机规范,Class文件是具有非常严格严谨的结构规范,由一系列数据项组成,各个数组项之间没有分隔符的结构紧凑排列。每个数据项会有相应的数据类型,如下表就是一个完整Class文件结构的表。
在这里插入图片描述
左边是其类型,主要分两类,像u2,u4这类是无符号数,分别表示2个字节和4个字节。以info结尾的是表结构,表结构又是一个复合类型,由其它的无符号数和其他的表结构组成。
我这边以相对结构简单的field_info结构举个例子,field_info结构用来描述接口或者类中的变量。它的结构如下:
在这里插入图片描述
其它的表结构method_info,attribute_info也都是类似,都会有自己特有的一套结构规范。

动态代理的应用

  1. Mybatis日志模块
    如果日志级别是DEBUG,Mybatis会打印执行的SQL,入参,结果等。
    在Mybatis底层的日志模块中,有一块专门用于打印JDBC相关信息日志的功能。这块功能是由一系列xxxLogger类构成。其中最顶层的是BaseJdbcLogger,他有4个子类,继承关系如下图:在这里插入图片描述
    可以看看ConnectionLogger的关键代码:
public final class ConnectionLogger extends BaseJdbcLogger implements InvocationHandler { ❶

    private final Connection connection;	

    private ConnectionLogger(Connection conn, Log statementLog, int queryStack) {
        super(statementLog, queryStack);
        this.connection = conn;	❷
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] params)	❸
            throws Throwable {
        if (Object.class.equals(method.getDeclaringClass())) {
            return method.invoke(this, params);
        }
        if ("prepareStatement".equals(method.getName())) {
            if (isDebugEnabled()) {
                debug(" Preparing: " + removeBreakingWhitespace((String) params[0]), true);
            }
            PreparedStatement stmt = (PreparedStatement) method.invoke(connection, params);
            stmt = PreparedStatementLogger.newInstance(stmt, statementLog, queryStack);
            return stmt;
        } else if ("prepareCall".equals(method.getName())) {
            if (isDebugEnabled()) {
                debug(" Preparing: " + removeBreakingWhitespace((String) params[0]), true);
            }
            PreparedStatement stmt = (PreparedStatement) method.invoke(connection, params);
            stmt = PreparedStatementLogger.newInstance(stmt, statementLog, queryStack);
            return stmt;
        } else if ("createStatement".equals(method.getName())) {
            Statement stmt = (Statement) method.invoke(connection, params);
            stmt = StatementLogger.newInstance(stmt, statementLog, queryStack);
            return stmt;
        } else {
            return method.invoke(connection, params);
        }
    }

    public static Connection newInstance(Connection conn, Log statementLog, int queryStack) {
        InvocationHandler handler = new ConnectionLogger(conn, statementLog, queryStack);
        ClassLoader cl = Connection.class.getClassLoader();
        return (Connection) Proxy.newProxyInstance(cl, new Class[]{Connection.class}, handler);
    }
}

可以看出:

  • ConnectionLogger实现了InvocationHandler,通过构造器传入真实Connection对象,这是一个真实对象,并将其保存在属性,后续请求会委托给它执行。其静态方法newInstance()内部就是通过Proxy.newProxyInstance()并传入类加载器等一系列参数返回一个Connection的代理对象给前端。该方法最终会在DEBUG日志级别下被org.apache.ibatis.executor.BaseExecutor.getConnection()方法调用返回一个Connection代理对象。
  • 前面说过,JDK动态代理会将客户端所有的请求全部派发给InvocationHandler的invoke()方法,即上面ConnectionLogger中的invoke()方法。invoke()方法当中,不难发现,Mybatis对于Object中定义的方法,统一不做代理处理,直接调用返回。对于prepareStatement(),prepareCall(),createStatement()这三个核心方法会统一委托给真实的Connection对象处理,并且在执行之前会以DEBUG方式打印日志信息。除了这三个方法,Connection其它方法也会被真实的Connection对象代理,但是并不会打印日志信息。我们以prepareStatement()方法为例,当真实的Connection对象调用prepareStatement()方法会返回PreparedStatement对象,这又是一个真实对象,但是Mybatis并不会将该真实对象直接返回,而且通过调用PreparedStatementLogger.newInstance()再次包装代理,看到这个方法名字,我相信聪明的您都能猜到这个方法的逻辑了。没错,PreparedStatementLogger类的套路和ConnectionLogger如出一辙。这边我再贴回PreparedStatementLogger的代码,
public final class PreparedStatementLogger extends BaseJdbcLogger implements InvocationHandler {

    private final PreparedStatement statement;

    private PreparedStatementLogger(PreparedStatement stmt, Log statementLog, int queryStack) {
        super(statementLog, queryStack);
        this.statement = stmt;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] params) throws Throwable {
        if (Object.class.equals(method.getDeclaringClass())) {
            return method.invoke(this, params);
        }
        if (EXECUTE_METHODS.contains(method.getName())) {
            if (isDebugEnabled()) {
                debug("Parameters: " + getParameterValueString(), true);
            }
            clearColumnInfo();
            if ("executeQuery".equals(method.getName())) {
                ResultSet rs = (ResultSet) method.invoke(statement, params);
                return rs == null ? null : ResultSetLogger.newInstance(rs, statementLog, queryStack);
            } else {
                return method.invoke(statement, params);
            }
        } else if (SET_METHODS.contains(method.getName())) {
            if ("setNull".equals(method.getName())) {
                setColumn(params[0], null);
            } else {
                setColumn(params[0], params[1]);
            }
            return method.invoke(statement, params);
        } else if ("getResultSet".equals(method.getName())) {
            ResultSet rs = (ResultSet) method.invoke(statement, params);
            return rs == null ? null : ResultSetLogger.newInstance(rs, statementLog, queryStack);
        } else if ("getUpdateCount".equals(method.getName())) {
            int updateCount = (Integer) method.invoke(statement, params);
            if (updateCount != -1) {
                debug("   Updates: " + updateCount, false);
            }
            return updateCount;
        } else {
            return method.invoke(statement, params);
        }
    }

    public static PreparedStatement newInstance(PreparedStatement stmt, Log statementLog, int queryStack) {
        InvocationHandler handler = new PreparedStatementLogger(stmt, statementLog, queryStack);
        ClassLoader cl = PreparedStatement.class.getClassLoader();
        return (PreparedStatement) Proxy.newProxyInstance(cl, new Class[]{PreparedStatement.class, CallableStatement.class}, handler);
    }
}

思路几乎和ConnectionLogger完全一致。无非是拦截的方法不同,因为这次被代理对象是PreparedStatement,所以这次会去拦截都是PreparedStatement的方法,比如setXXX()系列,executeXX()系列等方法。然后在指定方法执行前后添加需要的DEBUG日志信息。
以getResultSet()方法为例,PreparedStatement对象调用getResultSet()后,会返回真实的ResultSet对象,但是一样的套路,并不会直接将该真实对象返回,而是由调用ResultSetLogger.newInstance()再次将该ResultSet对象包装,ResultSetLogger的代码相信聪明的您不需要我再花篇幅讲了。
这个时候,再回过头思考一下,这个场景下,如果是采用静态代理是不是根本没法完成了?因为,每一个数据库连接都会产生一个新的Connection对象,而每一个Connection对象每次调用preparedStatement()方法都会产生一个新的PreparedStatement对象,而每一个PreparedStatement对象每次调用getResultSet()又都会产生一个新的ResultSet对象,跟上面的多个房东出租房子一个道理,就会产生不计其数处理逻辑极其相似的代理类,所以,这才是开源框架底层不采用静态代理的本质原因!

Proxy.getProxyClass()

在Proxy类中,还有一个getProxyClass()方法,这个只需要传入加载代理类的类加载器和指定接口就可以动态生成其代理类,一开始说到静态代理弊病的时候说过,静态代理创建代理时,真实角色必须要存在,否则这个模式没法进行下去,但是JDK动态代理可以做到在真实角色不存在的情况下就返回该接口的代理类。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值