Dubbo源码 之 SPI动态适配源码分析

目录

一、前言:

二、Java SPI和Dubbo SPI 

三、源码分析

四、小结


一、前言

以下简介摘自官方文档——

SPI 全称为 Service Provider Interface,是一种服务发现机制。SPI 的本质是将接口实现类的全限定名配置在文件中,并由服务加载器读取配置文件,加载实现类。这样可以在运行时,动态为接口替换实现类。正因此特性,我们可以很容易的通过 SPI 机制为我们的程序提供拓展功能。SPI 机制在第三方框架中也有所应用,比如 Dubbo 就是通过 SPI 机制加载所有的组件。不过,Dubbo 并未使用 Java 原生的 SPI 机制,而是对其进行了增强,使其能够更好的满足需求。                   

二、Java SPI和Dubbo SPI 

①、Java SPI示例:

首先定义一个接口

然后是其两个实现类

然后在META-INF/services/下新建一个普通文件,文件名是定义接口的全限定名称,文件内容是该接口的实现类全限定类名,如下所示:

然后新建main方法进行测试

/**
 * @author: wenyixicodedog
 * @create: 2020-07-02
 * @description:
 */
public class JavaSPITest {

    public static void main(String[] args) {
        ServiceLoader<Robot> serviceLoader = ServiceLoader.load(Robot.class);
        System.out.println("Java SPI Test");
        serviceLoader.forEach(Robot::sayHello);
    }

}

输出结果显示,我们定义的两个接口的两个实现类全部被加载,实现方法全部被执行

Connected to the target VM, address: '127.0.0.1:54223', transport: 'socket'
Java SPI Test
Hello, I am Optimus Prime.
Hello, I am Bumblebee.

②、Dubbo SPI 示例

情景1、

接口和实现类和刚才jdk spi的结构完全一样,只不过在接口上添加@SPI注解 ,配置文件需放置在 META-INF/dubbo 路径下,配置内容如下。

main方法测试及结果如下:

这里是两个实现类都加载进来了

情景2、

如果我们在其中一个实现类上添加@Adaptive注解,main方法再稍微修改下,则只会加载我们添加@Adaptive注解的类

情景3、

我们把@Adaptive注解加在接口的方法上面,但是这里有个前提:

  • @Adaptive 的value属性如果我们自己指定用的就是我们指定的,如果我们忽略不写,则value为接口名的小写形式(比如这里的tobot),value也可以是一个数组
  • 接口的被@Adaptive注解修饰的方法参数列表必须有URl参数或者存在参数有公共且非静态的返回URL的无参get方法

这两点是源码里面处理逻辑决定的,下面在详细看为什么

新的接口和实现类、测试main方法及结果如下:

@SPI
public interface Robot {

    @Adaptive
    void sayHello(URL url);

}

public class Bumblebee implements Robot {

    @Override
    public void sayHello(URL url) {
        System.out.println("Hello, I am Bumblebee.");
    }

}

public class OptimusPrime implements Robot {

    @Override
    public void sayHello(URL url) {
        System.out.println("Hello, I am Optimus Prime.");
    }

}

public static void main(String[] args) {       
        Robot robot = 
        ExtensionLoader.getExtensionLoader(Robot.class).getAdaptiveExtension();
        URL url = URL.valueOf("test://localhost/test?robot=optimusPrime");
        robot.sayHello(url);
    }

三、源码分析

(我的dubbo版本是2.5.x,可能和新版本有些许出入,不过流程大同小异)

首先我们从这行代码看起

Robot robot=ExtensionLoader.getExtensionLoader(Robot.class).getAdaptiveExtension();

ExtensionLoader.getExtensionLoader其实就是返回个ExtensionLoader

首先进行type是否为空,然后检查是否为接口类型,第三次检查是否有Adaptive注解修饰,三次检查任意一次检查不通过都会抛出异常,然后从EXTENSION_LOADERS缓存中取ExtensionLoader,如果缓存未命中,则生成ExtensionLoader并以type为key,新生成的ExtensionLoader为value放入缓存中,然后返回loader,这个方法没有什么复杂的逻辑处理。
然后是getAdaptiveExtension的处理

利用DCL检查从cachedAdaptiveInstance中获取instance,最终调用createAdaptiveExtension生成instance,进入到createAdaptiveExtension这个方法

从里往外看,调用getAdaptiveExtensionClass方法获取一个class对象,然后创建实例,最后为instance进行属性setter注入,这里先说一下injectExtension这个方法,官方文档上介绍说Dubbo IOC 是通过 setter 方法注入依赖。Dubbo 首先会通过反射获取到实例的所有方法, 然后再遍历方法列表,检测方法名是否具有 setter 方法特征。若有,则通过 ObjectFactory 获取依赖对象,最后通过反射调用 setter 方法将依赖设置到目标对象中,Dubbo IOC 目前仅支持 setter 方式注入。至于ObjectFactory,Dubbo 目前提供了两种 ExtensionFactory,分别是 SpiExtensionFactory 和 SpringExtensionFactory。 前者用于创建自适应的拓展,后者是用于从 Spring 的 IOC 容器中获取所需的拓展。

然后继续回到上面getAdaptiveExtensionClass这个方法,这个方法进行抉择扩展类上有@Adaptive注解的直接返回该类,如果没有则动态创建实现类。

方法代码很简洁,就是大概三个部分

  • 进行一些ExtensionLoader类里面的属性进行初始化,赋值。
  • 判断cachedAdaptiveClass是否为空,如果非空,则直接返回。
  • 如果cachedAdaptiveClass为空,则进行动态生成实现类

我们一一进入看其执行流程。

①、getExtensionClass

进入到getExtensionClass这个方法里面

逻辑同样并不复杂,首先从cachedClasses里面获取classes,利用DCL获取最终如果仍然为空则调用loadExtensionClasses

方法进行生成classes,然后放入cachedClasses,直接进入loadExtensionClasses方法

这个方法中首先判断ExtensionLoader类中的传入的type接口是否标注了SPI注解,如果标记了SPI注解,就获取SPI注解的值,这个值为接口的默认实现标记,然后就是loadFile方法的调用,loadFile方法用来加载配置路径下的接口的实现类,直接进入loadFile方法

private void loadFile(Map<String, Class<?>> extensionClasses, String dir) {
        String fileName = dir + type.getName();
        try {
            Enumeration<java.net.URL> urls;
            ClassLoader classLoader = findClassLoader();
            if (classLoader != null) {
                urls = classLoader.getResources(fileName);
            } else {
                urls = ClassLoader.getSystemResources(fileName);
            }
            if (urls != null) {
                while (urls.hasMoreElements()) {
                    java.net.URL url = urls.nextElement();
                    try {
                        //建立流
                        BufferedReader reader = new BufferedReader(new InputStreamReader(url.openStream(), "utf-8"));
                        try {
                            String line;
                            //开始读取数据
                            while ((line = reader.readLine()) != null) {
                                //截取#之前的内容是我们需要的数据,后面是注释之类的
                                final int ci = line.indexOf('#');
                                if (ci >= 0) line = line.substring(0, ci);
                                line = line.trim();
                                if (line.length() > 0) {
                                    try {
                                        String name = null;
                                        // =前面的数据是名称,后面的要实现的类的全路径名称
                                        int i = line.indexOf('=');
                                        if (i > 0) {
                                            name = line.substring(0, i).trim();
                                            line = line.substring(i + 1).trim();
                                        }
                                        if (line.length() > 0) {
                                            //利用反射加载解析出来的类的全限定名称
                                            Class<?> clazz = Class.forName(line, true, classLoader);
                                            //新加载clazz一定是type的子类
                                            if (!type.isAssignableFrom(clazz)) {
                                                throw new IllegalStateException("Error when load extension class(interface: " +
                                                        type + ", class line: " + clazz.getName() + "), class "
                                                        + clazz.getName() + "is not subtype of interface.");
                                            }
                                            // TODO 判断新加载的类上,是否有Adaptive的注解,如果有,直接赋值给cachedAdaptiveClass缓存
                                            if (clazz.isAnnotationPresent(Adaptive.class)) {
                                                if (cachedAdaptiveClass == null) {
                                                    cachedAdaptiveClass = clazz;
                                                } else if (!cachedAdaptiveClass.equals(clazz)) {
                                                    //不能存在多个类被Adaptive注解修饰
                                                    throw new IllegalStateException("More than 1 adaptive class found: "
                                                            + cachedAdaptiveClass.getClass().getName()
                                                            + ", " + clazz.getClass().getName());
                                                }
                                            } else {
                                                // TODO 以下都是不被Adaptive注解修饰的类解析过程
                                                try {
                                                    // TODO 构造函数一定有type类型的参数,否则不会被放进cachedWrapperClasses
                                                    clazz.getConstructor(type);
                                                    Set<Class<?>> wrappers = cachedWrapperClasses;
                                                    if (wrappers == null) {
                                                        cachedWrapperClasses = new ConcurrentHashSet<Class<?>>(4);
                                                        wrappers = cachedWrapperClasses;
                                                    }
                                                    wrappers.add(clazz);
                                                } catch (NoSuchMethodException e) {
                                                    // TODO 构造函数没有type类型的参数 比如正常一般的类
                                                    clazz.getConstructor();
                                                    if (name == null || name.length() == 0) {
                                                        name = findAnnotationName(clazz);
                                                        if (name == null || name.length() == 0) {
                                                            if (clazz.getSimpleName().length() > type.getSimpleName().length()
                                                                    && clazz.getSimpleName().endsWith(type.getSimpleName())) {
                                                                //生成一个name
                                                                name = clazz.getSimpleName().substring(0, clazz.getSimpleName().length() - type.getSimpleName().length()).toLowerCase();
                                                            } else {
                                                                throw new IllegalStateException("No such extension name for the class " + clazz.getName() + " in the config " + url);
                                                            }
                                                        }
                                                    }
                                                    String[] names = NAME_SEPARATOR.split(name);
                                                    if (names != null && names.length > 0) {
                                                        //类上有没有Activate注解
                                                        Activate activate = clazz.getAnnotation(Activate.class);
                                                        if (activate != null) {
                                                            //如果有,就放入cachedActivates缓存
                                                            cachedActivates.put(names[0], activate);
                                                        }
                                                        for (String n : names) {
                                                            if (!cachedNames.containsKey(clazz)) {
                                                                //将当前类型的实例与name放入cachedNames缓存
                                                                cachedNames.put(clazz, n);
                                                            }
                                                            Class<?> c = extensionClasses.get(n);
                                                            if (c == null) {
                                                                // TODO 最终将name与当前类型的实例放入extensionClasses(作为参数传进来的map)缓存
                                                                extensionClasses.put(n, clazz);
                                                            } else if (c != clazz) {
                                                                throw new IllegalStateException("Duplicate extension " + type.getName() + " name " + n + " on " + c.getName() + " and " + clazz.getName());
                                                            }
                                                        }
                                                    }
                                                }
                                            }
                                        }
                                    } catch (Throwable t) {
                                        IllegalStateException e = new IllegalStateException("Failed to load extension class(interface: " + type + ", class line: " + line + ") in " + url + ", cause: " + t.getMessage(), t);
                                        exceptions.put(line, e);
                                    }
                                }
                            } // end of while read lines
                        } finally {
                            reader.close();
                        }
                    } catch (Throwable t) {
                        logger.error("Exception when load extension class(interface: " +
                                type + ", class file: " + url + ") in " + url, t);
                    }
                } // end of while urls
            }
        } catch (Throwable t) {
            logger.error("Exception when load extension class(interface: " +
                    type + ", description file: " + fileName + ").", t);
        }
    }

乍一看,一个弯月形一大堆的的判断if处理,执行的东西还是比较多的,不过重要的地方都已经写了注释,配合注释结构看起来似乎挺清晰的,如果urls非空,建立BufferedReader流进行读取数据,然后是#和= 的处理,截取#之前的内容是我们需要的数据,后面是注释之类的,=前面的数据是名称,后面的要实现的类的全路径名称,然后利用反射加载解析出来的类进入内存当中,如果新加载clazz不是type的子类,则会抛出异常,然后是判断新加载的类上,是否有Adaptive的注解,如果有,直接赋值给cachedAdaptiveClass缓存,进行cachedAdaptiveClass的初始化赋值,如果当前这个类不被Adaptive注解修饰,然后看构造函数是否有type类型的参数,如果有会被放进cachedWrapperClasses中,进行cachedWrapperClasses的初始化赋值,如果判断构造函数有type类型的参数失败,就会抛出异常,被下面catch捕获,意味着构造函数没有type类型的参数 比如正常一般的类,然后下面就是一些正常的类的处理,处理完了最终将name与当前类型的实例放入extensionClasses(作为参数传进来的map)缓存,这就是loadFile的大致执行流程。

然后回到调用loadFile方法的地方

发现直接把extensionClasses这个map返回,返回到哪里呢,继续往回走

没错,又放进了cachedClasses当中,进行cachedClasses初始化赋值。

至此,getExtensionClass方法才算彻底执行完成。

②、@Adaptive注解修饰的类

扩展类上有@Adaptive注解的直接返回该类 优先级最高,意思就是如果一个类上面被@Adaptive进行修饰,那么直接返回这个类的实例作为借口的实现类,而不会再去动态的生成类加载到内存当中。

如果非空,直接返回。

③、动态生成实现类,经过编译加载到虚拟机中,动态生成的实现类赋值给cachedAdaptiveClass

直接进入createAdaptiveExtensionClass这个方法,大致执行流程注释均已标明。

主要看createAdaptiveExtensionClassCode这个方法,直接进入到这个方法当中。

private String createAdaptiveExtensionClassCode() {
        StringBuilder codeBuilder = new StringBuilder();
        // 获取所有的公共方法
        Method[] methods = type.getMethods();
        boolean hasAdaptiveAnnotation = false;
        for (Method m : methods) {
            //遍历所有方法,至少有一个方法有Adaptive的注解,否则报错
            if (m.isAnnotationPresent(Adaptive.class)) {
                hasAdaptiveAnnotation = true;
                break;
            }
        }
        // no need to generate adaptive class since there's no adaptive method found.
        if (!hasAdaptiveAnnotation)
            throw new IllegalStateException("No adaptive method on extension " + type.getName() + ", refuse to create the adaptive class!");

        codeBuilder.append("package " + type.getPackage().getName() + ";");
        codeBuilder.append("\nimport " + ExtensionLoader.class.getName() + ";");
        codeBuilder.append("\npublic class " + type.getSimpleName() + "$Adaptive" + " implements " + type.getCanonicalName() + " {");

        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);
            if (adaptiveAnnotation == null) {
                code.append("throw new UnsupportedOperationException(\"method ")
                        .append(method.toString()).append(" of interface ")
                        .append(type.getName()).append(" is not adaptive method!\");");
            } else {
                int urlTypeIndex = -1;
                for (int i = 0; i < pts.length; ++i) {
                    if (pts[i].equals(URL.class)) {
                        urlTypeIndex = i;
                        break;
                    }
                }
                // found parameter in URL type
                if (urlTypeIndex != -1) {
                    // Null Point check
                    String s = String.format("\nif (arg%d == null) throw new IllegalArgumentException(\"url == null\");",
                            urlTypeIndex);
                    code.append(s);

                    s = String.format("\n%s url = arg%d;", URL.class.getName(), urlTypeIndex);
                    code.append(s);
                }
                // did not find parameter in URL type
                else {
                    String attribMethod = null;

                    // find URL getter method
                    LBL_PTS:
                    for (int i = 0; i < pts.length; ++i) {
                        Method[] ms = pts[i].getMethods();
                        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;
                                break LBL_PTS;
                            }
                        }
                    }
                    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());
                    }

                    // Null point check
                    String s = String.format("\nif (arg%d == null) throw new IllegalArgumentException(\"%s argument == null\");",
                            urlTypeIndex, pts[urlTypeIndex].getName());
                    code.append(s);
                    s = String.format("\nif (arg%d.%s() == null) throw new IllegalArgumentException(\"%s argument %s() == null\");",
                            urlTypeIndex, attribMethod, pts[urlTypeIndex].getName(), attribMethod);
                    code.append(s);

                    s = String.format("%s url = arg%d.%s();", URL.class.getName(), urlTypeIndex, attribMethod);
                    code.append(s);
                }

                //获取adaptive注解的value
                String[] value = adaptiveAnnotation.value();
                // value is not set, use the value generated from class name as the key
                if (value.length == 0) {
                    //adaptive注解的value如果没有指定,则用类名为其生成一个
                    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()};
                }

                boolean hasInvocation = false;
                for (int i = 0; i < pts.length; ++i) {
                    if (pts[i].getName().equals("com.alibaba.dubbo.rpc.Invocation")) {
                        // Null Point check
                        String s = String.format("\nif (arg%d == null) throw new IllegalArgumentException(\"invocation == null\");", i);
                        code.append(s);
                        s = String.format("\nString methodName = arg%d.getMethodName();", i);
                        code.append(s);
                        hasInvocation = true;
                        break;
                    }
                }

                //defaultExtName为spi注解中的value
                String defaultExtName = cachedDefaultName;
                String getNameCode = null;
                for (int i = value.length - 1; i >= 0; --i) {
                    if (i == value.length - 1) {
                        //如果defaultExtName存在
                        if (null != defaultExtName) {
                            if (!"protocol".equals(value[i]))
                                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]))
                                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]))
                            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(";");
                // check extName == null?
                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);

                s = String.format("\n%s extension = (%<s)%s.getExtensionLoader(%s.class).getExtension(extName);",
                        type.getName(), ExtensionLoader.class.getSimpleName(), type.getName());
                code.append(s);

                // return statement
                if (!rt.equals(void.class)) {
                    code.append("\nreturn ");
                }

                s = String.format("extension.%s(", method.getName());
                code.append(s);
                for (int i = 0; i < pts.length; i++) {
                    if (i != 0)
                        code.append(", ");
                    code.append("arg").append(i);
                }
                code.append(");");
            }

            codeBuilder.append("\npublic " + rt.getCanonicalName() + " " + method.getName() + "(");
            for (int i = 0; i < pts.length; i++) {
                if (i > 0) {
                    codeBuilder.append(", ");
                }
                codeBuilder.append(pts[i].getCanonicalName());
                codeBuilder.append(" ");
                codeBuilder.append("arg" + 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()) {
            // TODO 将最终拼接好的字符串打印出来
            logger.debug(codeBuilder.toString());
        }
        return codeBuilder.toString();
    }

又是一大堆的处理,不过大致流程并不难懂,因为自上而下会发现都是StringBuilder类型的codeBuilder进行拼接,然后最后toString转成字符串进行返回。其实就是获取type接口的所有公共方法,遍历所有方法,至少有一个方法有Adaptive的注解,否则报错,然后根据Adaptive修饰的方法的方法签名进行重新生成动态类的方法或者其他的校验类的。看在最后有这么一段代码。

        if (logger.isDebugEnabled()) {
            // TODO 将最终拼接好的字符串打印出来
            logger.debug(codeBuilder.toString());
        }

就是会打印到控制台,但是前提是日志级别是debug,源码默认是info级别,这个需要手动改成debug级别,然后在控制台就可以看到生成的动态类信息。

程序执行的时候会执行到这些动态生成的类中去,对于我们调试代码来说,控制台输出的这些东西调试起来极为不便,庆幸我们可以将这些东西放在一个新建的文件里面,然后就可以像调试普通代码那样进行debug断点了。按照控制台输出的信息的包路径,我们新建文件,我这里已经新建好了一个动态实现类。

这个动态类完整内容如下:

public class Robot$Adaptive implements com.alibaba.dubbo.demo.provider.javaspi.Robot {

    public void sayHello(com.alibaba.dubbo.common.URL arg0) {
        if (arg0 == null) throw new IllegalArgumentException("url == null");
        com.alibaba.dubbo.common.URL url = arg0;
        String extName = url.getParameter("robot");
        if (extName == null)
            throw new IllegalStateException("Fail to get extension(com.alibaba.dubbo.demo.provider.javaspi.Robot) name from url(" + url.toString() + ") use keys([robot])");
        com.alibaba.dubbo.demo.provider.javaspi.Robot extension =
                ExtensionLoader.getExtensionLoader(com.alibaba.dubbo.demo.provider.javaspi.Robot.class).getExtension(extName);
        extension.sayHello(arg0);
    }
 
}

然后返回到之前getAdaptiveExtensionClass这个方法,最终进行赋值给cachedAdaptiveClass

然后这个动态生成的实现类可以调用实现类方法处理相关逻辑了。

顺便最后总结下@Adaptive生成动态实现类的优先级顺序:

四、小结

①、@Adaptive如果放在配置文件某个类上面,则会直接生成这个类的实例作为借口的实现类,优先级最高。

②、如果URL里面利用get方法取出来的非空,则用这个指定的extName借助SPI机制生成接口实现类,优先级次之。

③、最后如果没有找到@Adaptive修饰的类并且我们也没有指定何种实现方式,则会使用默认的实现(@SPI的value值)。

附:当 Adaptive 注解在类上时,Dubbo 不会为该类生成代理类。注解在方法(接口方法)上时,Dubbo 则会为该方法生成代理逻辑。Adaptive 注解在类上的情况很少,在 Dubbo 中,仅有两个类被 Adaptive 注解了,分别是 AdaptiveCompiler 和 AdaptiveExtensionFactory。此种情况,表示拓展的加载逻辑由人工编码完成。更多时候,Adaptive 是注解在接口方法上的,表示拓展的加载逻辑需由框架自动生成。Adaptive 注解的地方不同,相应的处理逻辑也是不同的。

 

个人才疏学浅,信手涂鸦,dubbo框架更多模块解读相关源码持续更新中,感兴趣的朋友请移步至个人公众号,谢谢支持😜😜......

公众号:wenyixicodedog

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值