Dubbo源码分析 - 自适应扩展

前言

上一篇文章中我们分析了dubbo spi机制,但是遗留了自适应扩展并没有展开说明,这篇文章就是来填坑的。上篇文章中也介绍了固定的自适应扩展类以及加载的流程,这篇文章主要专注于自动生成的自适应扩展类以及自适应扩展对象的创建,就不再过多介绍固定的自适应扩展。自适应扩展整体上需要讨论三部分内容:自适应扩展原理自适应扩展类串的生成动态编译 。 该篇文章将讨论前两个部分,动态编译会单独写一篇文章详细说明。

自适应扩展原理

扩展点的扩展类一般会在框架启动时被加载,但我们这次的主角并不会在框架启动时被加载,只可能在获取自适应实现的时候被创建、编译和实例化。这里之所以说可能,是当一个扩展接口既有固定的自适应扩展类,又想实现自动生成自适应扩展类的情况下,只会以固定的自适应扩展类为准,不会去创建动态的自适应扩展类,在框架启动时就会加载固定扩展类并放入缓存。当缓存中不存在自适应扩展类时,dubbo没有直接使用代理模式实现自适应扩展,而是为扩展接口生成具有代理功能的代码,然后通过动态编译得到自适应类,整个过程最终的目的是为扩展点生成代理对象,而代理对象主要任务就是从URL中获取扩展名对应的扩展实。接下来我们通过对官网的例子稍加改动来说明自动生成的自适应扩展的原理。

车轮制造接口 WheelMaker

public interface WheelMaker {
    void makeWheel(URL url);
}

WheelMaker 接口的普通实现类

// CommonWheelMaker对应的扩展名设置为 commonWheelMaker
public class CommonWheelMaker implements WheelMaker {
    public void makeWheel(URL url) {
       System.out.println("打印url,制造全宇宙最好的车轮..." + url);
    }
}

WheelMaker 接口的自适应实现类

public class AdaptiveWheelMaker implements WheelMaker {
    public void makeWheel(URL url) {
        if (url == null) {
            throw new IllegalArgumentException("url == null");
        }
        
    	// 1.从 URL 中获取 WheelMaker 名称
        String wheelMakerName = url.getParameter("wheel.maker");
        if (wheelMakerName == null) {
            throw new IllegalArgumentException("wheelMakerName == null");
        }
        
        // 2.通过 SPI 加载 WheelMaker 名称 对应WheelMaker具体实现。这里获取扩展实现还是使用getExtension方法。
        WheelMaker wheelMaker = ExtensionLoader.getExtensionLoader(WheelMaker.class).getExtension(wheelMakerName);
        
        // 3.调用目标方法
        wheelMaker.makeWheel(url);
    }
}

AdaptiveWheelMaker 是一个代理类[在dubbo框架中该类型的类是自动生成的,并发手动实现],与传统的代理逻辑不同,AdaptiveWheelMaker 所代理的对象是在 makeWheel 方法中通过 SPI 加载得到的。makeWheel 方法主要做了三件事情:

  1. 从 URL 中获取 WheelMaker 扩展名
  2. 通过 SPI 加载具体的 WheelMaker 实现类
  3. 调用目标方法

程序运行时,假设我们获取到了AdaptiveWheelMaker对象,然后调用它的makeWheel方法,然后有这样一个 url 参数传入:

dubbo://192.168.0.101:20880/XxxService?wheel.maker=commonWheelMaker

AdaptiveWheelMaker 的 makeWheel 方法从 url 中提取 wheel.maker 参数,得到扩展名 commonWheelMaker,之后再通过 SPI 加载扩展名为 commonWheelMaker 的实现类,最终得到具体的 WheelMaker 实例。

原理小结
这个例子展示了自动生成的自适应扩展类的核心实现,即在扩展接口的方法被调用(dubbo中是使用自适应扩展对象调用的)时,通过SPI加载具体的扩展对象,并调用该扩展对象的同名方法。

自适应扩展类串的生成

通过上面的例子,我们直观的认识了自适应扩展类的工作原理。通过上一篇文章我们知道@Adaptive 可注解在类或方法上,注解在类上时,Dubbo 不会为该类生成代理类。注解在扩展接口的方法上时,Dubbo 会为为该接口生成代理逻辑。接下来我们从上一篇文章提到的getAdaptiveExtension方法入口继续分析。

getAdaptiveExtension 方法
    public T getAdaptiveExtension() {

        // 从缓存中获取扩展点对应的自适应扩展对象
        Object instance = cachedAdaptiveInstance.get();

        // 如果缓存未命中,则通过双重检锁获取/创建
        if (instance == null) {
            //  若之前创建的时候没有报错,即之前创建了并且没有抛出异常
            if (createAdaptiveInstanceError == null) {
                synchronized (cachedAdaptiveInstance) {
                    // 再次尝试从缓存中获取
                    instance = cachedAdaptiveInstance.get();

                    if (instance == null) {
                        try {

                            // 创建自适应拓展对象
                            instance = createAdaptiveExtension();

                            // 放入缓存中
                            cachedAdaptiveInstance.set(instance);

                        } catch (Throwable t) {
                            createAdaptiveInstanceError = t;
                            throw new IllegalStateException("fail to create adaptive instance: " + t.toString(), t);
                        }
                    }
                }

                //  若之前创建的时候报错,则抛出异常
            } else {
                throw new IllegalStateException("fail to create adaptive instance: " + createAdaptiveInstanceError.toString(), createAdaptiveInstanceError);
            }
        }

        return (T) instance;
    }

上面的代码用来获取扩展点的自适应对象,该方法先检查缓存,缓存中没有则调用 createAdaptiveExtension 方法尝试创建自适应对象。我们继续跟进 createAdaptiveExtension 方法。

 private T createAdaptiveExtension() {
        try {
            /**
             *  1 getAdaptiveExtensionClass方法用来获得自适应扩展类【注意,获得的自适应扩展类可能是配置文件中的类,也可能是通过字节码创建的】
             *  2 通过反射创建自适应扩展对象
             *  3 调用injectExtension方法,向创建的自适应拓展对象注入依赖
             */
            return injectExtension((T) getAdaptiveExtensionClass().newInstance());

        } catch (Exception e) {
            throw new IllegalStateException("Can not create adaptive extension " + type + ", cause: " + e.getMessage(), e);
        }
    }

上面的方法先获取自适应扩展类,然后利用反射创建自适应对象,接着会向创建的自适应对象注入依赖。现在,我们已经知道了自适应扩展类分为两类,固定的自适应扩展类中可能存在一些依赖,这时需要使用扩展工厂进行setter注入,自动生成的扩展实现一般不会依赖其它属性。接下来我们分析下自适应扩展类怎么获取的。

   private Class<?> getAdaptiveExtensionClass() {
        // 刷新扩展点实现类集合
        getExtensionClasses();

        // 缓存中有扩展点的自适应类就直接返回
        if (cachedAdaptiveClass != null) {
            return cachedAdaptiveClass;
        }

        // 没有就自动生成自适应拓展类的代码,编译后返回该类
        return cachedAdaptiveClass = createAdaptiveExtensionClass();
    }

上面的代码先是刷新扩展点实现类集合,注意如果扩展接口的实现类中有标注@Adaptive注解的类,那么cachedAdaptiveClass缓存属性中保存的就是该类,即固定的自适应扩展类。如果没有的话,说明当前扩展接口的实现类中不存在固定的自适应扩展类,那么只能尝试创建该接口的自适应扩展类,代码逻辑如下:

private Class<?> createAdaptiveExtensionClass() {

        // 生成自适应拓展实现类的代码字符串
        String code = createAdaptiveExtensionClassCode();
        // 获取类加载器
        ClassLoader classLoader = findClassLoader();
        // 获取Compiler自适应扩展对象
        com.alibaba.dubbo.common.compiler.Compiler compiler = ExtensionLoader.getExtensionLoader(com.alibaba.dubbo.common.compiler.Compiler.class).getAdaptiveExtension();
        // 动态编译,生成Class
        return compiler.compile(code, classLoader);
    }

createAdaptiveExtensionClass 方法包含三个步骤:

  1. 生成自适应扩展实现类的代码字符串
  2. 获取Compiler自适应扩展对象
  3. 动态编译 自适应拓展实现类的代码字符串 ,生成Class

后面两个步骤属于 动态编译 部分,不在本文范畴,我们主要关注 自适应扩展实现类的代码字符串 的生成逻辑。

自适应扩展类代码生成

createAdaptiveExtensionClassCode方法代码非常多,不过总的逻辑大致可以分为八个逻辑分支,已经进行详细的注释,下面就直接贴上代码。

private String createAdaptiveExtensionClassCode() {
        StringBuilder codeBuilder = new StringBuilder();

        //----------------- 1 检查扩展接口方法是否包含 Adaptive注解,要求至少有一个方法被 Adaptive 注解修饰 --------------------/

        // 反射获取扩展点所有方法
        Method[] methods = type.getMethods();
        boolean hasAdaptiveAnnotation = false;

        // 遍历方法列表,检测是否标注 Adaptive 注解
        for (Method m : methods) {
            if (m.isAnnotationPresent(Adaptive.class)) {
                hasAdaptiveAnnotation = true;
                break;
            }
        }

        // 若所有方法上都没有Adaptive注解,就抛出异常
        if (!hasAdaptiveAnnotation) {
            throw new IllegalStateException("No adaptive method on extension " + type.getName() + ", refuse to create the adaptive class!");
        }

        //------------------ 2 生成自适应扩展类的代码字符串,代码生成的顺序与 Java 文件内容顺序一致 ---------------------------/

        // 生成package
        codeBuilder.append("package ").append(type.getPackage().getName()).append(";");
        // 生成import,注意自适应类只依赖ExtensionLoader,其它的都不会依赖,因为使用的都是全路径名,不需要再导入包了
        codeBuilder.append("\nimport ").append(ExtensionLoader.class.getName()).append(";");
        // 开始生成 class
        codeBuilder.append("\npublic class ").append(type.getSimpleName()).append("$Adaptive").append(" implements ").append(type.getCanonicalName()).append(" {");

        //------------------ 3 生成自适应扩展类中的方法,接口中方法可以被 Adaptive 注解修饰,也可以不被修饰,但处理方式也不同 -------/

        // 遍历方法列表,为类中填充方法
        for (Method method : methods) {

            // 方法返回类型
            Class<?> rt = method.getReturnType();
            // 方法参数类型
            Class<?>[] pts = method.getParameterTypes();
            // 方法异常类型
            Class<?>[] ets = method.getExceptionTypes();

            // 尝试获取方法的 Adaptive 注解,有无注解的区别体现在 生成方法字符串的差异上
            Adaptive adaptiveAnnotation = method.getAnnotation(Adaptive.class);

            // 类中的方法字符串集
            StringBuilder code = new StringBuilder(512);

            // 3.1 生成没有Adaptive注解的方法代码串。Dubbo不会为没有标注Adaptive注解的方法生成代理逻辑,仅仅生成一句抛出异常代码
            if (adaptiveAnnotation == null) {
                code.append("throw new UnsupportedOperationException(\"method ")
                        .append(method.toString()).append(" of interface ")
                        .append(type.getName()).append(" is not adaptive method!\");");

                // 3.2 生成有Adaptive注解的方法代码串。核心逻辑就是从方法的参数列表中直接或间接获取配置总线URL,然后结合Adaptive注解值及默认扩展名策略,从URL中得到扩展名,然后通过ExtensionLoader获取扩展名对应的扩展实现对象。
            } else {

                int urlTypeIndex = -1;
                // 遍历方法参数类型数组
                for (int i = 0; i < pts.length; ++i) {
                    // 判断参数类型是不是URL,确定URL参数位置
                    if (pts[i].equals(URL.class)) {
                        urlTypeIndex = i;
                        break;
                    }
                }

                // urlTypeIndex != -1,表示参数列表中存在 URL类型的参数,即直接获取配置总线URL。如:  <T> Invoker<T> refer(Class<T> type, URL url) throws RpcException 方法
                if (urlTypeIndex != -1) {

                    // 为 URL 类型参数生成判空代码,如:if (arg0 == null) throw new IllegalArgumentException("com.alibaba.dubbo.rpc.Invoker argument == null");
                    String s = String.format("\nif (arg%d == null) throw new IllegalArgumentException(\"url == null\");", urlTypeIndex);
                    code.append(s);

                    // 为 URL 类型参数生成赋值代码,形如 URL url = arg0
                    s = String.format("\n%s url = arg%d;", URL.class.getName(), urlTypeIndex);
                    code.append(s);
                }

                // 参数列表中不存在 URL 类型参数,只能间接尝试获取配置总线URL。如:<T> Exporter<T> export(Invoker<T> invoker) throws RpcException 方法,配置总线URL是从invoker中获取。
                else {
                    // 目标方法名,这里如果存在就是 getUrl
                    String attribMethod = null;

                    // find URL getter method
                    LBL_PTS:
                    // 遍历方法的参数类型列表
                    for (int i = 0; i < pts.length; ++i) {

                        // 获取当前方法的参数类型 的 全部方法
                        Method[] ms = pts[i].getMethods();

                        // 判断方法参数对象中是否有 public URL getUrl() 方法
                        for (Method m : ms) {
                            String name = m.getName();
                            if ((name.startsWith("get") || name.length() > 3)
                                    && Modifier.isPublic(m.getModifiers())
                                    && !Modifier.isStatic(m.getModifiers())
                                    && m.getParameterTypes().length == 0
                                    && m.getReturnType() == URL.class) {
                                urlTypeIndex = i;
                                attribMethod = name;

                                // 找到方法参数列表中间接存在URL的参数,则结束寻找逻辑
                                break LBL_PTS;
                            }
                        }
                    }

                    // 如果参数列表中没有一个参数有getUrl方法,则抛出异常
                    if (attribMethod == null) {
                        throw new IllegalStateException("fail to create adaptive class for interface " + type.getName()
                                + ": not found url parameter or url attribute in parameters of method " + method.getName());
                    }

                    // 为可返回URL的参数生成判空代码,如:if (arg0 == null) throw new IllegalArgumentException("com.alibaba.dubbo.rpc.Invoker argument == null");
                    String s = String.format("\nif (arg%d == null) throw new IllegalArgumentException(\"%s argument == null\");", urlTypeIndex, pts[urlTypeIndex].getName());
                    code.append(s);

                    // 为可返回URL的参数 的getUrl方法返回 的URL生成判空代码,如:if (arg0.getUrl() == null) throw new IllegalArgumentException("com.alibaba.dubbo.rpc.Invoker argument getUrl() == null");
                    s = String.format("\nif (arg%d.%s() == null) throw new IllegalArgumentException(\"%s argument %s() == null\");",
                            urlTypeIndex, attribMethod, pts[urlTypeIndex].getName(), attribMethod);
                    code.append(s);

                    // 生成赋值语句,形如:URL url = argN.getUrl();
                    s = String.format("%s url = arg%d.%s();", URL.class.getName(), urlTypeIndex, attribMethod);
                    code.append(s);
                }

                //----------------- 4 获取 Adaptive 注解值 ,Adaptive 注解值 value 类型为 String[],可填写多个值,默认情况下为空数组 -------------/

                /**
                 *  获取@Adaptive注解的值,如果有值,这些值将作为获取扩展名的key,需要注意,Protocol扩展和其它扩展点是不同的,前者获取扩展名是取协议,后者获取扩展名是取参数的值
                 *  1 普通扩展点,如ProxyFactor: String extName = url.getParameter("proxy", "javassist");
                 *  2 Protocol扩展点: String extName = ( url.getProtocol() == null ? "dubbo" : url.getProtocol() );
                 */
                String[] value = adaptiveAnnotation.value();

                // 如果@Adaptive注解没有指定值,则根据扩展接口名生成。如:SimpleExt -> simple.ext,即将扩展接口名中的大写转小写,并使用'.'把它们连接起来
                if (value.length == 0) {
                    // 获取扩展接口简单名称的字符数组
                    char[] charArray = type.getSimpleName().toCharArray();
                    StringBuilder sb = new StringBuilder(128);

                    for (int i = 0; i < charArray.length; i++) {
                        // 判断是否大写字母,如果是就使用 '.' 连接,并大写转小写
                        if (Character.isUpperCase(charArray[i])) {
                            if (i != 0) {
                                sb.append(".");
                            }
                            sb.append(Character.toLowerCase(charArray[i]));
                        } else {
                            sb.append(charArray[i]);
                        }
                    }
                    value = new String[]{sb.toString()};
                }

                //--------------------- 5 检测方法参数列表中是否存在 Invocation 类型的参数,有则表示是调用方法 --------------------/

                boolean hasInvocation = false;
                for (int i = 0; i < pts.length; ++i) {

                    // 参数类型是Invocation
                    if (pts[i].getName().equals("com.alibaba.dubbo.rpc.Invocation")) {

                        // 为 Invocation 类型参数生成判空代码
                        String s = String.format("\nif (arg%d == null) throw new IllegalArgumentException(\"invocation == null\");", i);
                        code.append(s);

                        // 生成 String methodName = argN.getMethodName(); 代码,Invocation是调用信息,里面包含调用方法
                        s = String.format("\nString methodName = arg%d.getMethodName();", i);
                        code.append(s);
                        hasInvocation = true;
                        break;
                    }
                }

                //----------------------- 6 扩展名决策逻辑,@SPI、@Adaptive以及方法含有Invocation类型参数都会影响最终的扩展名 -------------------------/

                // 设置默认拓展名,SPI注解值,默认情况下 SPI注解值为空串,此时cachedDefaultName = null
                String defaultExtName = cachedDefaultName;

                String getNameCode = null;

                /**
                 * 遍历Adaptive 的注解值,用于生成从URL中获取拓展名的代码,最终的扩展名会赋值给 getNameCode 变量。
                 * 注意:
                 * 1 这个循环的遍历顺序是由后向前遍历的,因为Adaptive注解可能配置了多个扩展名,而dubbo获取扩展名的策略是从前往后依次获取,找到即结束,以下代码拼接的时候也是从后往前拼接。
                 * 2 生成的扩展名代码大致有3大类,Adaptive的注解中属性值的数目决定了内嵌层级:
                 *(1) String extName = (url.getProtocol() == null ? defaultExtName : url.getProtocol()); 获取协议扩展点的扩展名
                 *(2) String extName = url.getMethodParameter(methodName, Adaptive的注解值, defaultExtName); 获取方法级别的参数值作为扩展名,因为方法的参数列表中含有Invocation调用信息。
                 *(3) String extName = url.getParameter(Adaptive的注解值, defaultExtName); 获取参数值作为扩展名
                 *(4) 如果Adaptive的注解中属性值有多个,就进行嵌套获取。如配置两个,以(3)为例:String extName = url.getParameter(Adaptive的注解值[0],url.getParameter(Adaptive的注解值[1], defaultExtName));
                 * 3  参数如果是protocol,protocol是url主要部分,可通过getProtocol方法直接获取。如果是其他的需要是从URL参数部分获取。两者获取方法不一样。其中参数获取又可分为方法级别参数和非方法级别参数
                 */
                for (int i = value.length - 1; i >= 0; --i) {
                    // 第一次遍历分支
                    if (i == value.length - 1) {
                        if (null != defaultExtName) {
                            if (!"protocol".equals(value[i])) {

                                // 方法参数列表中有调用信息Invocation参数
                                if (hasInvocation) {
                                    getNameCode = String.format("url.getMethodParameter(methodName, \"%s\", \"%s\")", value[i], defaultExtName);
                                } else {
                                    getNameCode = String.format("url.getParameter(\"%s\", \"%s\")", value[i], defaultExtName);
                                }
                            } else {
                                getNameCode = String.format("( url.getProtocol() == null ? \"%s\" : url.getProtocol() )", defaultExtName);
                            }
                        } else {
                            if (!"protocol".equals(value[i])) {

                                // 方法参数列表中有调用信息Invocation参数
                                if (hasInvocation) {
                                    getNameCode = String.format("url.getMethodParameter(methodName, \"%s\", \"%s\")", value[i], defaultExtName);
                                } else {
                                    getNameCode = String.format("url.getParameter(\"%s\")", value[i]);
                                }
                            } else {
                                getNameCode = "url.getProtocol()";
                            }
                        }
                        // 第二次开始都走该分支,用于嵌套获取扩展名
                    } else {
                        if (!"protocol".equals(value[i])) {

                            // 方法参数列表中有调用信息Invocation参数
                            if (hasInvocation) {
                                getNameCode = String.format("url.getMethodParameter(methodName, \"%s\", \"%s\")", value[i], defaultExtName);
                            } else {
                                getNameCode = String.format("url.getParameter(\"%s\", %s)", value[i], getNameCode);
                            }
                        } else {
                            getNameCode = String.format("url.getProtocol() == null ? (%s) : url.getProtocol()", getNameCode);
                        }
                    }
                }

                // 生成扩展明 赋值代码
                code.append("\nString extName = ").append(getNameCode).append(";");

                // 生成 扩展明 判空代码
                String s = String.format("\nif(extName == null) " +
                                "throw new IllegalStateException(\"Fail to get extension(%s) name from url(\" + url.toString() + \") use keys(%s)\");",
                        type.getName(), Arrays.toString(value));
                code.append(s);


                //---------------------------------------- 7 生成 获取扩展对象代码 以及 调用扩展对象的目标方法代码 ---------------------------------/

                // 生成 extension扩展对象 赋值 代码
                s = String.format("\n%s extension = (%<s)%s.getExtensionLoader(%s.class).getExtension(extName);", type.getName(), ExtensionLoader.class.getSimpleName(), type.getName());

                code.append(s);

                // 如果方法返回值类型非void,则生成return语句
                if (!rt.equals(void.class)) {
                    code.append("\nreturn ");
                }

                // 生成 extension扩展对象 调用目标方法逻辑,形如: extension.方法名(arg0, arg2, ..., argN);
                s = String.format("extension.%s(", method.getName());
                code.append(s);

                // 生成extension调用方法中的参数 拼接,注意和生成方法签名的参数名保持一直
                for (int i = 0; i < pts.length; i++) {
                    if (i != 0) {
                        code.append(", ");
                    }
                    code.append("arg").append(i);
                }
                code.append(");");
            }


            //------------------------------------------- 8  生成方法签名,包裹方法体内容 ---------------------------------------/

            // 生成方法签名,格式:public + 返回值全限定名 + 方法名 +(
            codeBuilder.append("\npublic ").append(rt.getCanonicalName()).append(" ").append(method.getName()).append("(");

            // 生成方法签名的参数列表
            for (int i = 0; i < pts.length; i++) {
                if (i > 0) {
                    codeBuilder.append(", ");
                }
                codeBuilder.append(pts[i].getCanonicalName());
                codeBuilder.append(" ");
                codeBuilder.append("arg").append(i);
            }
            codeBuilder.append(")");

            // 生成异常抛出代码
            if (ets.length > 0) {
                codeBuilder.append(" throws ");
                for (int i = 0; i < ets.length; i++) {
                    if (i > 0) {
                        codeBuilder.append(", ");
                    }
                    codeBuilder.append(ets[i].getCanonicalName());
                }
            }
            
            // 方法开始符号
            codeBuilder.append(" {");

            // 包括方法体内容
            codeBuilder.append(code.toString());

            // 追加方法结束符号
            codeBuilder.append("\n}");
        }
        
        // 追加类的结束符号,生成自适应扩展类结束
        codeBuilder.append("\n}");
        if (logger.isDebugEnabled()) {
            logger.debug(codeBuilder.toString());
        }
        return codeBuilder.toString();
    }

上面的代码逻辑就一个任务,使用字符串拼接一个自适应扩展类串,梳理出来后并没有那么复杂,其实就是按照编写一个类的步骤进行拼接的。如果非要说复杂的话,那么就提体现在拼接扩展名的逻辑代码中,因为情况非常多,胖友们可以多调试多归类。想要观察生成的自适应扩展类有两种办法,日志级别设置成debug是一种简单粗暴的方式,还可以使用反编译工具进行查看。

总结

本篇文章主要分析了自动生成的自适应扩展类的原理,以及详细分析了生成一个自适应扩展类的过程,总体来说还是很复杂的。至于为什么不直接使用代理的方式生成自适应扩展类,主要的原因是代理方式效率太低,更详细的可以参考梁飞大佬的博客 动态代理方案性能对比。自适应扩展还没结束,我们虽然有了一个自适应扩展类的字符串,但是还需要对这个字符串进行编译处理成Class,这样才能创建一个对象自适应扩展对象,下一篇文章中我们就来分析dubbo的动态编译原理。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值