设计模式 Proxy

本文详细解析了Java动态代理的实现原理,包括自定义动态代理、JDK动态代理及CGLIB动态代理的机制,展示了如何通过接口反射生成字节码并将其转换为Class对象,从而实现对象的动态增强。

什么是代理

# 增强一个对象的功能
# 买火车票,App 就是一个代理,他代理了火车站,小区当中的代售窗口
# Java 当中如何实现代理

Java 实现的代理的两种办法

代理的名词

代理对象 ===> 增强后的对象
目标对象 ===> 被增强的对象
他们不是绝对的,会根据情况发生变化

静态代理

1、继承
代理对象继承目标对象,重写需要增强的方法;
缺点:会代理类过多,非常复杂
​
2、聚合
目标对象和代理对象实现同一个接口,代理对象当中要包含目标对象。
缺点:也会产生类爆炸,只不过比继承少一点点
​
# 总结:如果在不确定的情况下,尽量不要去使用静态代理。因为一旦你写代码,就会产生类,一旦产生类就爆炸。

动态代理

自己模拟 JDK 动态代理

# 不需要订阅专栏手动创建类文件(因为一旦手动创建类文件,就会产生类爆炸),通过接口反射生成一个类文件,然后调用第三方的编译技术,动态编译这个产生的类文件成class文件,继而利用UrlclassLoader(因为这个动态产生的class不在工程当中所以需要使用UrlclassLoader)把这个动态编译的类加载到jvm当中,最后通过反射把这个类实例化。
​
缺点:首先要生成文件
缺点:动态编译class文件
缺点:需要一个 URLclassloader
软件性能的最终体现在 IO 操作

接口:FutureDao

/**
 * @description: 接口
 * @author: Mr.Li
 * @date: Created in 2020/7/1 15:42
 * @version: 1.0
 * @modified By:
 */
public interface FutureDao {
​
    public void query();
    
    public String returnString();
}

实现类:FutureDaoImpl

/**
 * @description: 实现类
 * @author: Mr.Li
 * @date: Created in 2020/7/1 15:43
 * @version: 1.0
 * @modified By:
 */
public class FutureDaoImpl implements FutureDao {
​
    @Override
    public void query(){
        System.out.println("FutureLL");
​
    }
    
    @Override
    public String returnString() {
        return "Return String";
    }
}

自己实现动态代理:ProxyUtil

import javax.tools.JavaCompiler;
import javax.tools.StandardJavaFileManager;
import javax.tools.ToolProvider;
import java.io.File;
import java.io.FileWriter;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.net.URL;
import java.net.URLClassLoader;
​
/**
 * @description: 自己实现动态代理
 * @author: Mr.Li
 * @date: Created in 2020/7/1 13:19
 * @version: 1.0
 * @modified By:
 */
public class ProxyUtil {
​
    /**
     * content --->string
     * .java  io
     * .class
     * <p>
     * .new   反射----》class
     *
     * @return
     */
    public static Object newInstance(Object target) {
​
        // 定义代理对象
        Object proxy = null;
        // getClass(): Returns the runtime class of this {@code Object}.
        // getInterfaces(): Determines the interfaces implemented by the class or interface represented by this object.
        Class targetInf = target.getClass().getInterfaces()[0];
        Method methods[] = targetInf.getDeclaredMethods();
        String line = "\n";
        String tab = "\t";
        // getSimpleName(): Returns the simple name of the underlying class as given in the source code.
        // infName = "FutureDao";
        String infName = targetInf.getSimpleName();
        String content = "";
        String packageContent = "package com.google;" + line;
        // Returns the name of the entity represented by this {@code Class} object, as a {@code String}.
        // targetInf.getName() = "com.futurell.dao.FutureDao"
        String importContent = "import " + targetInf.getName() + ";" + line;
        String clazzFirstLineContent = "public class $Proxy implements " + infName + "{" + line;
        String filedContent = tab + "private " + infName + " target;" + line;
        String constructorContent = tab + "public $Proxy (" + infName + " target){" + line
                + tab + tab + "this.target = target;"
                + line + tab + "}" + line;
        String methodContent = "";
        for (Method method : methods) {
            // getReturnType(): Returns a Class object that represents the formal return type of the method represented by this {@code Method} object.
            String returnTypeName = method.getReturnType().getSimpleName();
            // getName(): Returns the name of the method represented by this {@code Method} object, as a {@code String}.
            String methodName = method.getName();
            // Sting.class String.class
            Class args[] = method.getParameterTypes();
            String argsContent = "";
            String paramsContent = "";
            int flag = 0;
            for (Class arg : args) {
                //getSimpleName(): Returns the simple name of the underlying class as given in the source code.
                String temp = arg.getSimpleName();
                // String
                // String p0,Sting p1,
                argsContent += temp + " p" + flag + ",";
                paramsContent += "p" + flag + ",";
                flag ++;
            }
            if (argsContent.length() > 0) {
                // lastIndexOf(): Returns the index within this string of the last occurrence of the specified substring.
                argsContent = argsContent.substring(0, argsContent.lastIndexOf(",") - 1);
                paramsContent = paramsContent.substring(0, paramsContent.lastIndexOf(",") - 1);
            }
​
            methodContent += tab + "public " + returnTypeName + " " + methodName + "(" + argsContent + ") {" + line
                    + tab + tab + "System.out.println(\" Log \");" + line;
​
            if (returnTypeName.equals("void")) {
                methodContent += tab + tab + "target." + methodName + "(" + paramsContent + ");" + line
                        + tab + "}" + line;
            } else {
                methodContent += tab + tab + "return target." + methodName + "(" + paramsContent + ");" + line
                        + tab + "}" + line;
            }
        }
​
        content = packageContent + importContent + clazzFirstLineContent + filedContent + constructorContent + methodContent + "}";
​
        File file = new File("e:\\com\\google\\$Proxy.java");
        try {
            if (!file.exists()) {
                file.createNewFile();
            }
​
            FileWriter fw = new FileWriter(file);
            fw.write(content);
            fw.flush();
            fw.close();
​
            /**
             * 编译过程如下:
             */
            // getSystemJavaCompiler(): Gets the Java; programming language compiler provided with this platform.
            // 得到编译类,可以动态的编译一些文件
            JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
​
            // getStandardFileManager(): Gets a new instance of the standard file manager implementation for this tool.
            // 编译文件需要文件管理器
            StandardJavaFileManager fileMgr = compiler.getStandardFileManager(null, null, null);
            // getJavaFileObjects(): Gets file objects representing the given files.
            // 把文件放到文件管理器中
            Iterable units = fileMgr.getJavaFileObjects(file);
​
            // getTask(): Creates a future for a compilation task with the given components and arguments.
            // JavaCompiler.CompilationTask: Interface representing a future for a compilation task.
            // 把文件管理器当成一个任务来执行
            JavaCompiler.CompilationTask t = compiler.getTask(null, fileMgr, null, null, null, units);
            // call(): Performs this compilation task.
            t.call();
            fileMgr.close();
​
            // URL[]: Class {@code URL} represents a Uniform Resource Locator, a pointer to a "resource" on the World Wide Web.
            URL[] urls = new URL[]{new URL("file:E:\\\\")};
            // URLClassLoader: This class loader(类加载器) is used to load classes and resources from a search path of URLs
            //                 referring(引用) to both JAR files(Java Archive, Java归档) and directories(目录).
            // JAR: 通常用于聚合大量的Java类文件、相关的元数据和资源(文本、图片等)文件到一个文件,以便开发Java平台应用软件或库。
            URLClassLoader urlClassLoader = new URLClassLoader(urls);
            // loadClass(): Loads the class with the specified <a href="#name">binary name</a>.
            Class clazz = urlClassLoader.loadClass("com.google.$Proxy");
            // getConstructor(): Returns a {@code Constructor} object that reflects(反应) the specified
            //                   public constructor of the class represented by this {@code Class} object.
            // clazz这个类它有构造方法,只能以构造方法来new这个类的实例,所以需要先得到该类的构造方法
            Constructor constructor = clazz.getConstructor(targetInf);
​
            // newInstance(): Uses the constructor represented by this {@code Constructor} object to
            //                create and initialize a new instance of the constructor's
            //                declaring class, with the specified initialization parameters.
            // 通过构造方法来创建实例
            proxy = constructor.newInstance(target);
            // clazz.newInstance();
            // Class.forName()
        } catch (Exception e) {
            e.printStackTrace();
        }
​
        /**
         * public UserDaoLog(UserDao target){
         *     this.target = target;
         * }
         */
        return proxy;
    }
}

测试类:Test

import com.futurell.dao.FutureDao;
import com.futurell.dao.FutureDaoImpl;
import com.futurell.proxy.ProxyUtil;
​
/**
 * @description: 测试
 * @author: Mr.Li
 * @date: Created in 2020/7/1 10:56
 * @version: 1.0
 * @modified By:
 */
public class Test {
​
    public static void main(String[] args) {
​
        FutureDao fProxy = (FutureDao) ProxyUtil.newInstance(new FutureDaoImpl());
        fProxy.query();
​
        System.out.println("------------------");
​
        FutureDao fProxyString = (FutureDao) ProxyUtil.newInstance(new FutureDaoImpl());
        System.out.println(fProxyString.returnString());
​
    }
}
/**
 *  输出: 
 * ------------------
 *  Log 
 * FutureLL
 * ------------------
 *  Log 
 * Return String
 */

JDK 动态代理

接口和实现类与上述自定义相同

代理类:FutureInvocationHandler

import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
​
/**
 * @description:
 * @author: Mr.Li
 * @date: Created in 2020/7/2 12:24
 * @version: 1.0
 * @modified By:
 */
public class FutureInvocationHandler implements InvocationHandler {
​
    Object target;
​
    public FutureInvocationHandler(Object target) {
        this.target = target;
    }
​
    /**
     *
     * @param proxy 代理对象
     * @param method 代理对象包含目标对象,目标对象中的方法,就是method
     * @param args 目标方法的参数
     * @return
     * @throws Throwable
     */
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("proxy");
        return method.invoke(target, args);
    }
}

测试类:Test

import com.futurell.JDK.dao.FutureDao;
import com.futurell.JDK.dao.FutureDaoImpl;
import com.futurell.JDK.proxy.FutureInvocationHandler;
​
import java.lang.reflect.Proxy;
​
/**
 * @description: 测试
 * @author: Mr.Li
 * @date: Created in 2020/7/1 10:56
 * @version: 1.0
 * @modified By:
 */
public class Test {
​
    public static void main(String[] args) {
​
        FutureDao jdkProxy = (FutureDao) Proxy.newProxyInstance(
                // getClassLoader(): Returns the class loader for the class.
                // 判断一个类是否相同,需要根据类加载器是否相同来判断,为了保证加载器可用,那么传入当前所在类的ClassLoader
                // 在自定义动态代理的代码中,使用了URLClassLoader,因为自定义的类不在工程当中
                Test.class.getClassLoader(),
                // Class targetInf = target.getClass().getInterfaces()[0];
                // 自定义使用了上边的代码,我们需要得到接口,用来得到接口中的方法,然后对方法进行代理
                new Class[]{FutureDao.class},
                new FutureInvocationHandler(new FutureDaoImpl()));
​
        jdkProxy.query();
        System.out.println("--------------");
        System.out.println(jdkProxy.returnString());
    }
}

CGLIB

如果是要实现 CGLIB,需要先添加 cglib 依赖,然后实现一个接口 MethodInterceptor,重写 intercept() 方法

依赖

<!-- 使用CGLIB代理需要导入jar包 -->
<dependency>
  <groupId>cglib</groupId>
  <artifactId>cglib</artifactId>
  <version>3.3.0</version>
  <scope>compile</scope>
</dependency>

代理类:MyMethodInterceptor

import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
 
import java.lang.reflect.Method;
 
/**
 * 自定义MethodInterceptor
 */
public class MyMethodInterceptor implements MethodInterceptor{
 
    /**
     * sub:cglib生成的代理对象
     * method:被代理对象方法
     * objects:方法入参
     * methodProxy: 代理方法
     */
    @Override
    public Object intercept(Object sub, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        System.out.println("======插入前置通知======");
        Object object = methodProxy.invokeSuper(sub, objects);
        System.out.println("======插入后者通知======");
        return object;
    }
}

JDK 底层原理

测试类:Test

FutureDao jdkProxy = (FutureDao) Proxy.newProxyInstance(
    // getClassLoader(): Returns the class loader for the class.
    // 判断一个类是否相同,需要根据类加载器是否相同来判断,为了保证加载器可用,那么传入当前所在类的ClassLoader
    // 在自定义动态代理的代码中,使用了URLClassLoader,因为自定义的类不在工程当中
    Test.class.getClassLoader(),
    // Class targetInf = target.getClass().getInterfaces()[0];
    // 自定义使用了上边的代码,我们需要得到接口,用来得到接口中的方法,然后对方法进行代理
    new Class[]{FutureDao.class},
    new FutureInvocationHandler(new FutureDaoImpl())
);

测试类调用 Proxy 类的 newProxyInstance() 方法

/**
 * Returns an instance of a proxy class for the specified interfaces
 * that dispatches method invocations to the specified invocation
 * handler.
 */
@CallerSensitive
public static Object newProxyInstance(ClassLoader loader,
                                      Class<?>[] interfaces,
                                      InvocationHandler h) throws IllegalArgumentException {
    
    Objects.requireNonNull(h);
​
    // 拿到实现类的接口,使用接口可以对接口中的方法进行处理
    final Class<?>[] intfs = interfaces.clone();
    // 系统做的安全验证,这个不需要我们去管
    // getSecurityManager(): Gets the system security interface.
    final SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
        checkProxyAccess(Reflection.getCallerClass(), loader, intfs);
    }
​
    /*
     * Look up or generate the designated proxy class.
     * 找到或生成指定的代理类
     */
    Class<?> cl = getProxyClass0(loader, intfs);
​
    /*
     * Invoke its constructor with the designated invocation handler.
     */
    try {
        if (sm != null) {
            checkNewProxyPermission(Reflection.getCallerClass(), cl);
        }
​
        // 通过 cl.getConstructor() 得到一个构造方法
        final Constructor<?> cons = cl.getConstructor(constructorParams);
        final InvocationHandler ih = h;
        if (!Modifier.isPublic(cl.getModifiers())) {
            AccessController.doPrivileged(new PrivilegedAction<Void>() {
                public Void run() {
                    cons.setAccessible(true);
                    return null;
                }
            });
        }
        // 这里将得到的构造方法 new 成一个对象
        // 所以我们需要看这里 new 出来的对象是不是我们需要的代理对象
        // 首先复制 cons.newInstance(new Object[]{h}),鼠标右键点击 Evaluate Expression,将复制的代码粘贴回车,看到 result 的值就是我们的代理对象
        // 得到 cl 代理类,代理对象就轻易被 new 出来了,在我们的传入的是 new FutureInvocationHandler(new FutureDaoImpl())
        // 所以最重要的是我们怎么得到的 cl 代理类,进入 getProxyClass0() 这个方法
        return cons.newInstance(new Object[]{h});
    } catch (IllegalAccessException|InstantiationException e) {
        throw new InternalError(e.toString(), e);
    } catch (InvocationTargetException e) {
        Throwable t = e.getCause();
        if (t instanceof RuntimeException) {
            throw (RuntimeException) t;
        } else {
            throw new InternalError(t.toString(), t);
        }
    } catch (NoSuchMethodException e) {
        throw new InternalError(e.toString(), e);
    }
}

进入 getProxyClass0() 我们要知道如何得到的 cl 代理类

/**
 * Generate a proxy class.  Must call the checkProxyAccess method
 * to perform permission checks before calling this.
 */
private static Class<?> getProxyClass0(ClassLoader loader,
                                       Class<?>... interfaces) {
    if (interfaces.length > 65535) {
        throw new IllegalArgumentException("interface limit exceeded");
    }
​
    // If the proxy class defined by the given loader implementing
    // the given interfaces exists, this will simply return the cached copy;
    // otherwise, it will create the proxy class via the ProxyClassFactory
    // 如果实现给定接口的给定加载器定义的代理类存在,这将简单地返回缓存的副本;否则,它将通过 ProxyClassFactory 创建代理类
    // 这个方法只有一行代码,我们进入 get() 方法
    return proxyClassCache.get(loader, interfaces);
}

进入 get() 方法

/**
 * Look-up the value through the cache. This always evaluates the
 * {@code subKeyFactory} function and optionally evaluates
 * {@code valueFactory} function if there is no entry in the cache for given
 * pair of (key, subKey) or the entry has already been cleared.
 */
public V get(K key, P parameter) {
    Objects.requireNonNull(parameter);
​
    expungeStaleEntries();
​
    Object cacheKey = CacheKey.valueOf(key, refQueue);
​
    // lazily install the 2nd level valuesMap for the particular cacheKey
    ConcurrentMap<Object, Supplier<V>> valuesMap = map.get(cacheKey);
    if (valuesMap == null) {
        ConcurrentMap<Object, Supplier<V>> oldValuesMap
            = map.putIfAbsent(cacheKey,
                              valuesMap = new ConcurrentHashMap<>());
        if (oldValuesMap != null) {
            valuesMap = oldValuesMap;
        }
    }
​
    // create subKey and retrieve the possible Supplier<V> stored by that
    // subKey from valuesMap
    Object subKey = Objects.requireNonNull(subKeyFactory.apply(key, parameter));
    Supplier<V> supplier = valuesMap.get(subKey);
    Factory factory = null;
​
    while (true) {
        if (supplier != null) {
            // supplier might be a Factory or a CacheValue<V> instance
            // 进入这个 get() 方法
            V value = supplier.get();
            if (value != null) {
                // 最终返回的是这个 value,我们不知道从哪里看的时候,就找到结果从下往上反推即可
                // 向上看有一个 supplier.get() 它将返回值赋给了 value
                return value;
            }
        }
        // else no supplier in cache
        // or a supplier that returned null (could be a cleared CacheValue
        // or a Factory that wasn't successful in installing the CacheValue)
​
        // lazily construct a Factory
        if (factory == null) {
            factory = new Factory(key, parameter, subKey, valuesMap);
        }
​
        if (supplier == null) {
            supplier = valuesMap.putIfAbsent(subKey, factory);
            if (supplier == null) {
                // successfully installed Factory
                supplier = factory;
            }
            // else retry with winning supplier
        } else {
            if (valuesMap.replace(subKey, supplier, factory)) {
                // successfully replaced
                // cleared CacheEntry / unsuccessful Factory
                // with our Factory
                supplier = factory;
            } else {
                // retry with current supplier
                supplier = valuesMap.get(subKey);
            }
        }
    }
}

进入另一个 get() 方法

@Override
public synchronized V get() { // serialize access
    // re-check
    Supplier<V> supplier = valuesMap.get(subKey);
    if (supplier != this) {
        // something changed while we were waiting:
        // might be that we were replaced by a CacheValue
        // or were removed because of failure ->
        // return null to signal WeakCache.get() to retry
        // the loop
        return null;
    }
    // else still us (supplier == this)
​
    // create new value
    // 这个方法我们可以看出,结果需要返回 value,而 value 最终的取值是在下边的 try-finally 中取值
    V value = null;
    try {
        // 首先我们进入 apply() 方法
        value = Objects.requireNonNull(valueFactory.apply(key, parameter));
    } finally {
        if (value == null) { // remove us on failure
            valuesMap.remove(subKey, this);
        }
    }
    // the only path to reach here is with non-null value
    assert value != null;
​
    // wrap value with CacheValue (WeakReference)
    CacheValue<V> cacheValue = new CacheValue<>(value);
​
    // put into reverseMap
    reverseMap.put(cacheValue, Boolean.TRUE);
​
    // try replacing us with CacheValue (this should always succeed)
    if (!valuesMap.replace(subKey, this, cacheValue)) {
        throw new AssertionError("Should not reach here");
    }
​
    // successfully replaced us with new CacheValue -> return the value
    // wrapped by it
    // 返回 value
    return value;
}

进入 apply() 方法

@Override
public Class<?> apply(ClassLoader loader, Class<?>[] interfaces) {
​
    Map<Class<?>, Boolean> interfaceSet = new IdentityHashMap<>(interfaces.length);
    // 这里的 interfaces 的所有接口只有一个 FutureDao,循环所有接口,将接口赋给 intf
    for (Class<?> intf : interfaces) {
        /*
         * Verify that the class loader resolves the name of this
         * interface to the same Class object.
         */
        Class<?> interfaceClass = null;
        try {
            // 这个有个点需要注意, intf 本身就是一个 Class,为什么这里还要再次得到一个 Class
            // 判断对象是否相同的一个前提,是否是同一个类加载器
            // 原因: 这里要判断两个接口是不是同一个接口,
            interfaceClass = Class.forName(intf.getName(), false, loader);
        } catch (ClassNotFoundException e) {
            
        }
        // 如果两个不相同那么抛出异常: IllegalArgumentException,相等继续执行后面的代码
        if (interfaceClass != intf) {
            throw new IllegalArgumentException(
                intf + " is not visible from class loader");
        }
        /*
         * Verify that the Class object actually represents an
         * interface.
         */
        if (!interfaceClass.isInterface()) {
            throw new IllegalArgumentException(
                interfaceClass.getName() + " is not an interface");
        }
        /*
         * Verify that this interface is not a duplicate.
         */
        if (interfaceSet.put(interfaceClass, Boolean.TRUE) != null) {
            throw new IllegalArgumentException(
                "repeated interface: " + interfaceClass.getName());
        }
    }
​
    String proxyPkg = null;     // package to define proxy class in
    int accessFlags = Modifier.PUBLIC | Modifier.FINAL;
​
    /*
     * Record the package of a non-public proxy interface so that the
     * proxy class will be defined in the same package.  Verify that
     * all non-public proxy interfaces are in the same package.
     */
    for (Class<?> intf : interfaces) {
        int flags = intf.getModifiers();
        // 如果这个接口不是 public,那么引用的时候会有问题,这里需要做一个转换
        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) {
        // if no non-public proxy interfaces, use com.sun.proxy package
        proxyPkg = ReflectUtil.PROXY_PACKAGE + ".";
    }
​
    /*
     * Choose a name for the proxy class to generate.
     */
    long num = nextUniqueNumber.getAndIncrement();
    // 这里给 proxyPkg 加一个标识,防止并发的情况下类名相同
    String proxyName = proxyPkg + proxyClassNamePrefix + num;
​
    /*
     * Generate the specified proxy class.
     * 生成指定的代理类的二进制文件,
     */
    byte[] proxyClassFile = ProxyGenerator.generateProxyClass(proxyName, interfaces, accessFlags);
    
    try {
        // 我们知道一个对象的产生顺序: 
        // .java ---> .class ---ClassLoader---> JVM ---byte[]---> object
        // 而这里需要返回一个 Class,那么将二进制文件转换成对象就在 defineClass0() 方法中进行
        return defineClass0(loader, proxyName, proxyClassFile, 0, proxyClassFile.length);
    } catch (ClassFormatError e) {
        /*
         * A ClassFormatError here means that (barring bugs in the
         * proxy class generation code) there was some other
         * invalid aspect of the arguments supplied to the proxy
         * class creation (such as virtual machine limitations
         * exceeded).
         */
        throw new IllegalArgumentException(e.toString());
    }
}

进入 defineClass0() 方法

// 这个方法是一个被 native 修饰的方法是一个本地方法,也就是一个 Java 调用非 Java 代码的接口,再开发 JDK 的时候底层使用了 C 或 C++ 开发(可以去看一下 OpenJDK),也就是说 C 和 C++ 负责把 byte[] 变成 Class 对象
private static native Class<?> defineClass0(ClassLoader loader, String name, byte[] b, int off, int len);

总结:

# 通过接口反射得到字节码,然后把字节码转换成 Class,就得到了我们想要的对象

有兴趣的同学可以关注我的个人公众号,期待我们共同进步!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值