Dubbo的Reference注解必须先启动provider的问题

63 篇文章 11 订阅

目录

现象

看源码分析原因

注解Reference第一步:用Reference注解里的参数初始化ReferenceConfig

注解Reference第二步:从配置文件里获取参数,写入ReferenceConfig

注解Reference第三步:生成Consumer代理

解决方案


如果只想知道怎么解决,请翻到文章最后一句。

 

现象

当使用Dubbo的Reference注解时,必须先启动provider,再启动consumer,否则被Reference注解的service就是null,且后来启动了provider之后,service也依然是null,不能使用。

被Reference注解的service是null,说明Dubbo的代理没有生成。

在Reference注解中配置check=false,没有用。

在springboot的配置文件application.properties中各种花式配置check=false,没有用。

 

看源码分析原因

Spring处理Reference注解一共分三步。

 

注解Reference第一步:用Reference注解里的参数初始化ReferenceConfig

当Spring处理Controller注解时,使用Annotation类作为Controller的处理类,Spring会调用处理类的postProcessBeforeInitialization方法,当发现Controller类里有Reference注解的service时,会调用Annotation的refer方法来初始化被Reference注解的service,代码如下:

private Object refer(Reference reference, Class<?> referenceClass) {
    String interfaceName;
    if (!"".equals(reference.interfaceName())) {
        interfaceName = reference.interfaceName();
    } else if (!Void.TYPE.equals(reference.interfaceClass())) {
        interfaceName = reference.interfaceClass().getName();
    } else {
        if (!referenceClass.isInterface()) {
            throw new IllegalStateException("The @Reference undefined interfaceClass or interfaceName, and the property type " + referenceClass.getName() + " is not a interface.");
        }

        interfaceName = referenceClass.getName();
    }

    String key = reference.group() + "/" + interfaceName + ":" + reference.version();
    ReferenceBean<?> referenceConfig = (ReferenceBean)this.referenceConfigs.get(key);
    if (referenceConfig == null) {
        referenceConfig = new ReferenceBean(reference);
        if (Void.TYPE.equals(reference.interfaceClass()) && "".equals(reference.interfaceName()) && referenceClass.isInterface()) {
            referenceConfig.setInterface(referenceClass);
        }

        if (this.applicationContext != null) {
            referenceConfig.setApplicationContext(this.applicationContext);
            if (reference.registry() != null && reference.registry().length > 0) {
                List<RegistryConfig> registryConfigs = new ArrayList();
                String[] arr$ = reference.registry();
                int len$ = arr$.length;

                for(int i$ = 0; i$ < len$; ++i$) {
                    String registryId = arr$[i$];
                    if (registryId != null && registryId.length() > 0) {
                        registryConfigs.add((RegistryConfig)this.applicationContext.getBean(registryId, RegistryConfig.class));
                    }
                }

                referenceConfig.setRegistries(registryConfigs);
            }

            if (reference.consumer() != null && reference.consumer().length() > 0) {
                referenceConfig.setConsumer((ConsumerConfig)this.applicationContext.getBean(reference.consumer(), ConsumerConfig.class));
            }

            if (reference.monitor() != null && reference.monitor().length() > 0) {
                referenceConfig.setMonitor((MonitorConfig)this.applicationContext.getBean(reference.monitor(), MonitorConfig.class));
            }

            if (reference.application() != null && reference.application().length() > 0) {
                referenceConfig.setApplication((ApplicationConfig)this.applicationContext.getBean(reference.application(), ApplicationConfig.class));
            }

            if (reference.module() != null && reference.module().length() > 0) {
                referenceConfig.setModule((ModuleConfig)this.applicationContext.getBean(reference.module(), ModuleConfig.class));
            }

            if (reference.consumer() != null && reference.consumer().length() > 0) {
                referenceConfig.setConsumer((ConsumerConfig)this.applicationContext.getBean(reference.consumer(), ConsumerConfig.class));
            }

            try {
                referenceConfig.afterPropertiesSet();
            } catch (RuntimeException var11) {
                throw var11;
            } catch (Exception var12) {
                throw new IllegalStateException(var12.getMessage(), var12);
            }
        }

        this.referenceConfigs.putIfAbsent(key, referenceConfig);
        referenceConfig = (ReferenceBean)this.referenceConfigs.get(key);
    }

    return referenceConfig.get();
}

其中

referenceConfig = new ReferenceBean(reference);

这行表示用Reference注解生成ReferenceConfig代理,注解中配置的参数都会赋值给ReferenceConfig,实际上该方法调用的是ReferenceConfig的父类AbstractConfig类的

appendAnnotation(Class<?> annotationClass, Object annotation)

方法,其中的annotationClass参数就是com.alibaba.dubbo.config.annotation.Reference的class,annotation参数就是本次初始化的Reference对象的代理。

appendAnnotation方法的代码如下:

protected void appendAnnotation(Class<?> annotationClass, Object annotation) {
    Method[] methods = annotationClass.getMethods();
    Method[] arr$ = methods;
    int len$ = methods.length;

    for(int i$ = 0; i$ < len$; ++i$) {
        Method method = arr$[i$];
        if (method.getDeclaringClass() != Object.class && method.getReturnType() != Void.TYPE && method.getParameterTypes().length == 0 && Modifier.isPublic(method.getModifiers()) && !Modifier.isStatic(method.getModifiers())) {
            try {
                String property = method.getName();
                if ("interfaceClass".equals(property) || "interfaceName".equals(property)) {
                    property = "interface";
                }

                String setter = "set" + property.substring(0, 1).toUpperCase() + property.substring(1);
                Object value = method.invoke(annotation);
                if (value != null && !value.equals(method.getDefaultValue())) {
                    Class<?> parameterType = ReflectUtils.getBoxedClass(method.getReturnType());
                    if (!"filter".equals(property) && !"listener".equals(property)) {
                        if ("parameters".equals(property)) {
                            parameterType = Map.class;
                            value = CollectionUtils.toStringMap((String[])((String[])value));
                        }
                    } else {
                        parameterType = String.class;
                        value = StringUtils.join((String[])((String[])value), ",");
                    }

                    try {
                        Method setterMethod = this.getClass().getMethod(setter, parameterType);
                        setterMethod.invoke(this, value);
                    } catch (NoSuchMethodException var13) {
                        ;
                    }
                }
            } catch (Throwable var14) {
                logger.error(var14.getMessage(), var14);
            }
        }
    }

}

注意其中的

if (value != null && !value.equals(method.getDefaultValue())) {

这一行,意思是如果Reference注解的参数等于默认值,则不会把设置的参数值写入ReferenceConfg。

也就是说,如果我这样使用Reference注解:

@Reference(check = false)
TestService testService;

那么check参数不会被写入ReferenceConfig,生成的ReferenceConfig依然是:

<dubbo:reference singleton="true" generic="false" />

里面没有check参数。

这个规则可能是为了以后在生成Consumer代理的时候少循环几次,但是会引发一些问题。

此时是调用栈大概是这样的:

appendAnnotation:101, AbstractConfig (com.alibaba.dubbo.config)

<init>:122, ReferenceConfig (com.alibaba.dubbo.config)

<init>:56, ReferenceBean (com.alibaba.dubbo.config.spring)

refer:259, AnnotationBean (com.alibaba.dubbo.config.spring)

postProcessBeforeInitialization:233, AnnotationBean (com.alibaba.dubbo.config.spring)

applyBeanPostProcessorsBeforeInitialization:422, AbstractAutowireCapableBeanFactory (org.springframework.beans.factory.support)

initializeBean:1694, AbstractAutowireCapableBeanFactory (org.springframework.beans.factory.support)

doCreateBean:579, AbstractAutowireCapableBeanFactory (org.springframework.beans.factory.support)

createBean:501, AbstractAutowireCapableBeanFactory (org.springframework.beans.factory.support)

lambda$doGetBean$0:317, AbstractBeanFactory (org.springframework.beans.factory.support)

getObject:-1, 530696881 (org.springframework.beans.factory.support.AbstractBeanFactory$$Lambda$97)

getSingleton:228, DefaultSingletonBeanRegistry (org.springframework.beans.factory.support)

doGetBean:315, AbstractBeanFactory (org.springframework.beans.factory.support)

getBean:199, AbstractBeanFactory (org.springframework.beans.factory.support)

preInstantiateSingletons:760, DefaultListableBeanFactory (org.springframework.beans.factory.support)

finishBeanFactoryInitialization:869, AbstractApplicationContext (org.springframework.context.support)

refresh:550, AbstractApplicationContext (org.springframework.context.support)

refresh:140, ServletWebServerApplicationContext (org.springframework.boot.web.servlet.context)

refresh:759, SpringApplication (org.springframework.boot)

refreshContext:395, SpringApplication (org.springframework.boot)

run:327, SpringApplication (org.springframework.boot)

run:1255, SpringApplication (org.springframework.boot)

run:1243, SpringApplication (org.springframework.boot)

main:11,TestApplication 

 

注解Reference第二步:从配置文件里获取参数,写入ReferenceConfig

把注解的参数写入ReferenceConfig参数后,在refer方法最后一行,

return referenceConfig.get();

正式开始初始化ReferenceConfig。

get方法代码:

public synchronized T get() {
    if (this.destroyed) {
        throw new IllegalStateException("Already destroyed!");
    } else {
        if (this.ref == null) {
            this.init();
        }

        return this.ref;
    }
} 

init()方法很长,配置了很多ReferenceConfig的参数,其中单独调用了一下appendProperties方法,这个方法负责把配置文件中的属性写入ReferenceConfig,代码如下:

protected static void appendProperties(AbstractConfig config) {
    if (config != null) {
        String prefix = "dubbo." + getTagName(config.getClass()) + ".";
        Method[] methods = config.getClass().getMethods();
        Method[] arr$ = methods;
        int len$ = methods.length;

        for(int i$ = 0; i$ < len$; ++i$) {
            Method method = arr$[i$];

            try {
                String name = method.getName();
                if (name.length() > 3 && name.startsWith("set") && Modifier.isPublic(method.getModifiers()) && method.getParameterTypes().length == 1 && isPrimitive(method.getParameterTypes()[0])) {
                    String property = StringUtils.camelToSplitName(name.substring(3, 4).toLowerCase() + name.substring(4), "-");
                    String value = null;
                    String pn;
                    if (config.getId() != null && config.getId().length() > 0) {
                        pn = prefix + config.getId() + "." + property;
                        value = System.getProperty(pn);                //注释一
                        if (!StringUtils.isBlank(value)) {
                            logger.info("Use System Property " + pn + " to config dubbo");
                        }
                    }

                    if (value == null || value.length() == 0) {
                        pn = prefix + property;
                        value = System.getProperty(pn);                //注释二
                        if (!StringUtils.isBlank(value)) {
                            logger.info("Use System Property " + pn + " to config dubbo");
                        }
                    }

                    if (value == null || value.length() == 0) {
                        Method getter;
                        try {
                            getter = config.getClass().getMethod("get" + name.substring(3));
                        } catch (NoSuchMethodException var14) {
                            try {
                                getter = config.getClass().getMethod("is" + name.substring(3));
                            } catch (NoSuchMethodException var13) {
                                getter = null;
                            }
                        }

                        if (getter != null && getter.invoke(config) == null) {
                            if (config.getId() != null && config.getId().length() > 0) {
                                value = ConfigUtils.getProperty(prefix + config.getId() + "." + property);        //注释三
                            }

                            if (value == null || value.length() == 0) {
                                value = ConfigUtils.getProperty(prefix + property);      //注释四
                            }

                            if (value == null || value.length() == 0) {
                                String legacyKey = (String)legacyProperties.get(prefix + property);
                                if (legacyKey != null && legacyKey.length() > 0) {
                                    value = convertLegacyValue(legacyKey, ConfigUtils.getProperty(legacyKey));      //注释五
                                }
                            }
                        }
                    }

                    if (value != null && value.length() > 0) {
                        method.invoke(config, convertPrimitive(method.getParameterTypes()[0], value));
                    }
                }
            } catch (Exception var15) {
                logger.error(var15.getMessage(), var15);
            }
        }

    }
}

方法中设置了几个参数,假设我要把check参数赋值给Reference,那这几个参数如下:

prefix:dubbo.reference

property:check

config.getId():注解的service带路径的全名,我测试时用的service是test.TestService

根据这个方法的代码流程,赋值check有以下几次机会:

1,注释一

pn = prefix + config.getId() + "." + property;

value = System.getProperty(pn);                //注释一

此时的pn的值是dubbo.reference. test.TestService.check,只要系统参数里有这个配置,比如

-Ddubbo.reference. test.TestService.check=false

就可以向ReferenceConfig赋值。

2,注释二

pn = prefix + property;

value = System.getProperty(pn);                //注释二

此时的pn的值是dubbo.reference.check,只要系统参数里有这个配置,比如

-Ddubbo.reference.check=false

就可以向ReferenceConfig赋值。

可见在此处的配置中,单个service配置的优先级高于全局配置的优先级。

3,注释三

value = ConfigUtils.getProperty(prefix + config.getId() + "." + property);   //注释三

用ConfigUtils获取参数,key值为:

dubbo.reference. test.TestService.check

ConfigUtils获取参数的流程是:

①,先检查系统参数也就是System.getProperty(pn),同注释一,此场景中显然没有值,要不也不会来到注释三了。

②,查找配置文件,配置文件的路径是用以下方法定义的:

public static Properties getProperties() {
    if (PROPERTIES == null) {
        Class var0 = ConfigUtils.class;
        synchronized(ConfigUtils.class) {
            if (PROPERTIES == null) {
                String path = System.getProperty("dubbo.properties.file");
                if (path == null || path.length() == 0) {
                    path = System.getenv("dubbo.properties.file");
                    if (path == null || path.length() == 0) {
                        path = "dubbo.properties";
                    }
                }

                PROPERTIES = loadProperties(path, false, true);
            }
        }
    }

    return PROPERTIES;
}

也就是按如下顺序查找配置文件是否存在

System.getProperty("dubbo.properties.file");

System.getenv("dubbo.properties.file");

dubbo.properties

前两个是查看是否存在dubbo.properties.file配置,最后是查看是否存在dubbo.properties文件。

③,从配置文件中获取dubbo.reference. test.TestService.check参数。

4,注释四

value = ConfigUtils.getProperty(prefix + property);      //注释四

ConfigUtils中获取dubbo.reference.check参数。

可见此处的配置中,单个service配置的优先级高于全局配置的优先级。

5,注释五

String legacyKey = (String)legacyProperties.get(prefix + property);
if (legacyKey != null && legacyKey.length() > 0) {
    value = convertLegacyValue(legacyKey, ConfigUtils.getProperty(legacyKey));      //注释五
}

legacyProperties是AbstractConfig中定义的一个map,而且在该类中用static代码块初始化:

static {
    legacyProperties.put("dubbo.protocol.name", "dubbo.service.protocol");
    legacyProperties.put("dubbo.protocol.host", "dubbo.service.server.host");
    legacyProperties.put("dubbo.protocol.port", "dubbo.service.server.port");
    legacyProperties.put("dubbo.protocol.threads", "dubbo.service.max.thread.pool.size");
    legacyProperties.put("dubbo.consumer.timeout", "dubbo.service.invoke.timeout");
    legacyProperties.put("dubbo.consumer.retries", "dubbo.service.max.retry.providers");
    legacyProperties.put("dubbo.consumer.check", "dubbo.service.allow.no.provider");
    legacyProperties.put("dubbo.service.url", "dubbo.service.address");
    Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
        public void run() {
            if (AbstractConfig.logger.isInfoEnabled()) {
                AbstractConfig.logger.info("Run shutdown hook now.");
            }

            ProtocolConfig.destroyAll();
        }
    }, "DubboShutdownHook"));
    SUFFIXS = new String[]{"Config", "Bean"};
}

legacyProperties是用来设置一些默认值的,处理Reference注解的时候用不上,但是很多默认值的时候会用到,比如设置Consumer的check属性默认值时,此时的prefix + property是dubbo.consumer.check,所以可以在ConfigUtils可用的配置文件中,配置dubbo.service.allow.no.provider参数,作为所有Consumer的默认check属性。

另外,在这里使用legacyProperties时只能令dubbo.service.allow.no.provider和dubbo.service.max.retry.providers两个参数生效,因为注释五的convertLegacyValue方法是这么写的:

private static String convertLegacyValue(String key, String value) {
    if (value != null && value.length() > 0) {
        if ("dubbo.service.max.retry.providers".equals(key)) {
            return String.valueOf(Integer.parseInt(value) - 1);
        }

        if ("dubbo.service.allow.no.provider".equals(key)) {
            return String.valueOf(!Boolean.parseBoolean(value));
        }
    }

    return value;
}

可见,只用了两个参数,不明白这么设计的初衷是什么,可能别的参数在其他的方法和代码逻辑中会用到。

以上五处注释,就是Dubbo读取配置文件并且装配ReferenceConfig的节点。

 

注解Reference第三步:生成Consumer代理

参数配置完之后,回到init()方法的最后一行,

this.ref = this.createProxy(map);

map就是之前装配的所有参数的集合,createProxy方法的作用就是生成代理,代码如下:

private T createProxy(Map<String, String> map) {
    URL tmpUrl = new URL("temp", "localhost", 0, map);
    boolean isJvmRefer;
    if (this.isInjvm() == null) {
        if (this.url != null && this.url.length() > 0) {
            isJvmRefer = false;
        } else if (InjvmProtocol.getInjvmProtocol().isInjvmRefer(tmpUrl)) {
            isJvmRefer = true;
        } else {
            isJvmRefer = false;
        }
    } else {
        isJvmRefer = this.isInjvm();
    }

    if (isJvmRefer) {
        URL url = (new URL("injvm", "127.0.0.1", 0, this.interfaceClass.getName())).addParameters(map);
        this.invoker = refprotocol.refer(this.interfaceClass, url);
        if (logger.isInfoEnabled()) {
            logger.info("Using injvm service " + this.interfaceClass.getName());
        }
    } else {
        URL u;
        URL url;
        if (this.url != null && this.url.length() > 0) {
            String[] us = Constants.SEMICOLON_SPLIT_PATTERN.split(this.url);
            if (us != null && us.length > 0) {
                String[] arr$ = us;
                int len$ = us.length;

                for(int i$ = 0; i$ < len$; ++i$) {
                    String u = arr$[i$];
                    URL url = URL.valueOf(u);
                    if (url.getPath() == null || url.getPath().length() == 0) {
                        url = url.setPath(this.interfaceName);
                    }

                    if ("registry".equals(url.getProtocol())) {
                        this.urls.add(url.addParameterAndEncoded("refer", StringUtils.toQueryString(map)));
                    } else {
                        this.urls.add(ClusterUtils.mergeUrl(url, map));
                    }
                }
            }
        } else {
            List<URL> us = this.loadRegistries(false);
            if (us != null && us.size() > 0) {
                for(Iterator i$ = us.iterator(); i$.hasNext(); this.urls.add(u.addParameterAndEncoded("refer", StringUtils.toQueryString(map)))) {
                    u = (URL)i$.next();
                    url = this.loadMonitor(u);
                    if (url != null) {
                        map.put("monitor", URL.encode(url.toFullString()));
                    }
                }
            }

            if (this.urls == null || this.urls.size() == 0) {
                throw new IllegalStateException("No such any registry to reference " + this.interfaceName + " on the consumer " + NetUtils.getLocalHost() + " use dubbo version " + Version.getVersion() + ", please config <dubbo:registry address=\"...\" /> to your spring config.");
            }
        }

        if (this.urls.size() == 1) {
            this.invoker = refprotocol.refer(this.interfaceClass, (URL)this.urls.get(0));
        } else {
            List<Invoker<?>> invokers = new ArrayList();
            URL registryURL = null;
            Iterator i$ = this.urls.iterator();

            while(i$.hasNext()) {
                url = (URL)i$.next();
                invokers.add(refprotocol.refer(this.interfaceClass, url));
                if ("registry".equals(url.getProtocol())) {
                    registryURL = url;
                }
            }

            if (registryURL != null) {
                u = registryURL.addParameter("cluster", "available");
                this.invoker = cluster.join(new StaticDirectory(u, invokers));
            } else {
                this.invoker = cluster.join(new StaticDirectory(invokers));
            }
        }
    }

    Boolean c = this.check;
    if (c == null && this.consumer != null) {
        c = this.consumer.isCheck();
    }

    if (c == null) {
        c = true;
    }

    if (c && !this.invoker.isAvailable()) {
        throw new IllegalStateException("Failed to check the status of the service " + this.interfaceName + ". No provider available for the service " + (this.group == null ? "" : this.group + "/") + this.interfaceName + (this.version == null ? "" : ":" + this.version) + " from the url " + this.invoker.getUrl() + " to the consumer " + NetUtils.getLocalHost() + " use dubbo version " + Version.getVersion());
    } else {
        if (logger.isInfoEnabled()) {
            logger.info("Refer dubbo service " + this.interfaceClass.getName() + " from url " + this.invoker.getUrl());
        }

        return proxyFactory.getProxy(this.invoker);
    }
}

代码主要分两部分,前面大部分代码判断了要生成的consumer在本地有没有provider,如果有就直接使用本地的provider,否则就进行一系列装配,组装用于远端消费的consumer。如果远端的provider地址只有一个,那么这个consumer就直接指定地址并生成一个invoker,如果provider地址有多个,则生成invoker列表。invoker是用来生成代理的。

然后是第二部分,对provider是否存在的判断,也就是这一部分代码:

Boolean c = this.check;
if (c == null && this.consumer != null) {
    c = this.consumer.isCheck();
}

if (c == null) {
    c = true;
}

if (c && !this.invoker.isAvailable()) {
    throw new IllegalStateException("Failed to check the status of the service " + this.interfaceName + ". No provider available for the service " + (this.group == null ? "" : this.group + "/") + this.interfaceName + (this.version == null ? "" : ":" + this.version) + " from the url " + this.invoker.getUrl() + " to the consumer " + NetUtils.getLocalHost() + " use dubbo version " + Version.getVersion());
} else {
    if (logger.isInfoEnabled()) {
        logger.info("Refer dubbo service " + this.interfaceClass.getName() + " from url " + this.invoker.getUrl());
    }

    return proxyFactory.getProxy(this.invoker);
}

代码获取了之前配置的check属性,如果没有check属性,则默认check为true,需要参与后面的provider是否存在判断,如果没有provider,则抛异常,并且不会生成代理。

那么问题就来了,在之前处理Reference参数的时候,如果设置的参数和默认值相同,则不写入Reference,而且 Reference注解中的check默认值就是false,如前文所说,如果代码里写的是

@Reference(check = false)

那么这个check就不会被写到Reference里,如果配置文件里又没有对此进行配置,那么这里的check就会是null,然后在这里被设置为true,然后不得不参与provider是否是null的判断,此时如果没有启动任何provider,那么代理类就再也生成不了了,即使后面启动了provider也没有用。

 

汇总check参数相关代码逻辑,如下:

注解写法1:

@Reference(check = true)

生成代理的时候check为true。

注解写法2:

@Reference(check = false)

生成Reference时check被忽略,生成代理之前check被设置为true。

注解写法3:

@Reference()

成代理之前check被设置为true。

结果就是无论注解怎么写,最终的check都是true,都必须先起provider。

 

解决方案

想要先起consumer,就必须保证consumer代理能生成。

要保证consumer代理能生成,就必须配置check=false。

要配置check=false,不能在Reference注解里配置,而是应该在配置文件中配置。

 

汇总Spring从配置中获取check参数的方式,有以下几种:

1,System.getProperty("dubbo.reference.{id}.check")

其中{id}是被注解的类的全名。

System.getProperty()是从系统变量中获取参数,来自java启动命令的vm options参数。

人工定义此参数的方法是在启动java的命令中加入:-Dcheck=false参数。

可以在IDEA中的Run-->Edit Configurations-->VM options,添加-Dcheck=false参数。

可以在Eclipse中的Run Configurations—>Arguments-->VM arguments,添加-Dcheck=false参数。

2,System.getProperty("dubbo.reference.check")

系统变量中,配置所有Reference注解通用的配置方式。

3,System.getProperty("dubbo.properties.file")

系统变量中,指定配置文件路径,然后在配置文件中配置dubbo.reference.{id}.check或者dubbo.reference.check。

4,System.getenv("dubbo.properties.file")

环境变量中,指定配置文件路径,然后在配置文件中配置dubbo.reference.{id}.check或者dubbo.reference.check。

System.getenv()得到的变量是操作系统级的。

5,dubbo.properies

在resources目录中添加dubbo.properies配置文件,在此文件中添加dubbo.reference.{id}.check或者dubbo.reference.check配置。

 

以上就是Reference注解必须先启动provider的解决方案。

我选择添加dubbo.properies文件,并且在里面写上,dubbo.reference.check=false。

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值