JavaEE 企业级分布式高级架构师(九)Dubbo(2.7)学习笔记(5)

Dubbo业务框架的源码解析

服务订阅

获取动态代理对象

  • 分析入口:context.getBean("demoService", DemoService.class);
public class ConsumerApplication {
    public static void main(String[] args) {
        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("spring/dubbo-consumer.xml");
        context.start();
        DemoService demoService = context.getBean("demoService", DemoService.class);
        String hello = demoService.sayHello("world");
    }
}
获取代理对象的Bean实例
  • 从 Spring 容器中一层一层 getBean 获取服务提供者代理对象的 Bean 实例,依次进过 AbstractApplicationContext、AbstractBeanFactory、FactoryBeanRegistrySupport、ReferenceBean、ReferenceConfig

在这里插入图片描述

  • 继续 Debug 跟一下 getObjectFromFactoryBean 这个方法:ReferenceBean 实现了 FactoryBean 接口,所以最终到 getObject() 这个重新方法中获取到了代理对象的 Bean 实例

在这里插入图片描述

创建代理对象
  • 首先做了一些校验检查,然后创建一个 Map 集合用于收集消费者的元数据,接着就创建并初始化消费者代理对象:分别处理本地调用和远程调用

在这里插入图片描述- 此时看一下 map 中的元数据:

在这里插入图片描述

处理本地调用
  • shouldJvmRefer 方法用于判断是否进行本地调用:
    • 若 <dubbo:reference/> 中设置了 injvm 属性,则按照设置的值;
    • 若没有设置 injvm 属性,则判断是否设置了 url 属性,设置了 url 属性则为直连式的远程调用;
    • 若没有设置 url 属性,则判断 scope 属性值,scope 属性值为 local 或者 injvm 为 true,则为 本地调用;
    • 若 scope 属性值为 remote,则为远程调用;
    • 若泛化引用 generic 属性值为 true,则为远程调用;
    • 若 exporterMap 缓存中存在与当前url相匹配的exporter,则为本地调用,即本地有该服务,则优先调用本地服务。

在这里插入图片描述

处理远程调用
  • 首先清空注册中心的 urls 集合,然后分别处理直连方式和具有注册中心的远程调用两种情况,标准化 URL 后加入到 urls 集合中。直连方式下会对 url 进行;拆分,然后封装成标准的注册中心 URL;有注册中心的则会获取到所有注册中心标准化的 URL。

在这里插入图片描述

  • 进过上面的处理,到这里就已经获取到了所有注册中心的URL,包含了消费者数据,此时 urls 集合肯定不为空。针对 urls 集合中 URL 的数量为1个的和多个的分别处理,最终是封装成一个具有容错与降级功能的invoker,用这一个 invoker 对象创建消费者代理对象。

在这里插入图片描述

创建消费者代理对象
  • 从代理工厂自适应扩展类创建消费者代理对象,getProxy() 方法依次进过 ReferenceConfig、ProxyFactory$Adaptive、StubProxyFactoryWrapper、AbstractProxyFactory、JavassistProxyFactory、Proxy

在这里插入图片描述

  • 至此,我们已经分析到了消费者的代理对象是通过 Javassist 的代理方法创建的,注意这里的 Proxy 是 Dubbo 自己定义的一个代理类,接着继续分析 getProxy 这个方法:

在这里插入图片描述

获取Invoker

  • 分析入口:ReferenceConfig 中的 createProxy 方法
// 将这个注册中心中的所有invoker抽象为一个具有容错与降级功能的invoker
invoker = REF_PROTOCOL.refer(interfaceClass, urls.get(0));
  • 进过一系列 refer 方法的处理:ReferenceConfig、Protocol$Adaptive、ProtocolListenerWrapper、ProtocolFilterWrapper、RegistryProtocol

在这里插入图片描述

  • 先整体分析一下核心方法 doRefer(),其主要完成四件事:
    • 创建一个动态的 directory ,并将消费者注册到 ZK;
    • 获取 RouterFactory 的所有激活类创建的 router 实例,添加到 directory;
    • 完成服务订阅:为三个分类节点添加watcher监听,并获取当前zk中三个分类节点下的所有子节点url;
    • 将动态列表 directory 中的 invoker 列表抽象(创建)出一个具有容错与降级功能的 invoker。

在这里插入图片描述

将消费者注册到ZK
  • 与提供者完成服务注册类似,最终是写入ZK临时节点,节点信息例如:
consumer://192.168.254.1/org.apache.dubbo.demo.DemoService?application=demo-consumer&category=consumers&check=false&cluster=failfast&dubbo=2.0.2&interface=org.apache.dubbo.demo.DemoService&lazy=false&methods=sayHello&pid=33440&qos.port=33333&side=consumer&sticky=false&timestamp=1625061598628

在这里插入图片描述

构建路由链
  • 获取到RouterFactory的所有激活类创建的router实例,添加到directory中。这里有一个核心方法 RouterChain.buildChain(url) 是后面分析服务路由的入口。

在这里插入图片描述

完成服务订阅
  • 为三个分类节点【providers, configurators, routers】添加watcher监听,并获取当前zk中三个分类节点下的所有子节点url。进过一系列 subscribe 方法,依次经过:RegistryProtocol、RegistryDirectory、FailbackRegistry、ZookeeperRegistry。
  • 最终是调用 ZookeeperRegistry 的 doSubscribe 方法完成订阅:遍历所有分类节点,创建指定分类节点,并为之添加watcher监听,然后再获取它们所有子节点形成的url;之后再主动调用各个分类节点的监听器,将zk中的提供者信息更新到本地。

在这里插入图片描述

toUrlsWithEmpty
  • 将当前遍历路径的所有子节点url全部添加到urls集合中,若没有子节点,则构建一个empty://…的url,添加到urls中

在这里插入图片描述

notify()
  • 主动调用各个分类节点的监听器,将zk中的数据更新到本地。notify() 方法依次进过:FailbackRegistry、AbstractRegistry、RegistryDirectory
  • 会调用每个分类节点的notify,触发该节点的 watcher,也就是三种分类节点都会走到 RegistryDirectory 的 notify 方法,其会依次处理 configurators、routers、providers 的URL。

在这里插入图片描述

  • 继续分析 RegistryDirectory 的 notify 方法中的 refreshOverrideAndInvoker() 方法:从zk中获取最新的提供者更新到directory

在这里插入图片描述

  • 继续看一下 refreshInvoker 方法:从 ZK 中更新 invoker

在这里插入图片描述

  • toInvokers()方法分析:获取到最新的invoker,key为zk中的节点名称,即url;value为该url对应的invoker。
    • 获取缓存中的所有invoker,若缓存map中存在指定url的invoker,则直接获取缓存中的invoker,否取值null;
    • 若缓存中没有该invoker,则根据zk中的数据创建一个invoker,这个 invoker 是一个委托对象。

在这里插入图片描述

  • 再分析下如何销毁不可用的invoker:
    • 若存放最新的invoker的map为空,则意味着没有可用的invoker,则将缓存中的所有invoker全部销毁
    • 遍历缓存中的所有invoker,若最新的invoker中不包含当前遍历的invoker,则说明这个遍历的invoker主机已经挂了,可以销毁了(将其放入到一个销毁列表中),随后根据销毁集合 deleted 进行销毁。

在这里插入图片描述

将动态列表封装成一个invoker
  • cluster.join(directory) 将动态列表directory中invoker列表抽象(创建)出一个具有容错与降级功能的invoker,这个 join 方法依次进过:RegistryProtocol、Cluster$Adaptive、MockClusterWrapper、FailoverCluster或者FailfastCluster等。
  • 降级的 invoker 中包含一个具有集群容错功能的 invoker:先降级、再集群容错、在容错中进行负载均衡

在这里插入图片描述

远程调用

  • 分析入口:消费者调用提供者是通过代理对象实现远程调用的,而前面我们已经分析知道了代理对象最终是通过 JavassistProxyFactory 的getProxy() 方法创建,实例的创建利用到了 JDK 的动态代理技术:InvokerInvocationHandler 实现了 InvocationHandler 接口,并重写 invoke 方法,这个 invoke 方法就是消费者实现远程调用的分析入口。
public class InvokerInvocationHandler implements InvocationHandler {
    private final Invoker<?> invoker;
    public InvokerInvocationHandler(Invoker<?> handler) {
        this.invoker = handler;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // 获取到RPC远程调用的方法名
        String methodName = method.getName();
        Class<?>[] parameterTypes = method.getParameterTypes();
        // 若当前方法为Object的方法,则为本地方法调用
        if (method.getDeclaringClass() == Object.class) {
            return method.invoke(invoker, args);
        }
        // ...略:toString、hashCode、equals 方法 
        // 远程调用,这里的 invoker 就是前面创建的具有容错与降级功能的invoker(MockClusterInvoker)
        return invoker.invoke(new RpcInvocation(method, args)).recreate();
    }
}

消费者端发出请求

  • 从 invoke 方法出发,我们 Debug 一步一步跟一下代码,依次进过:InvokerInvocationHandler、MockClusterInvoker、AbstractClusterInvoker、FailfastClusterInvoker(或FailoverClusterInvoker等)
  • 首先根据 mock 属性值的设置,进行不降级远程调用或直接降级远程调用,远程调用如果出现异常,还会再走降级

在这里插入图片描述

  • 接着调用具体的容错策略的 doInvoke 方法,进过一些校验和路由选择/负载均衡(这一块先略过,后面详细分析),最终要获取异步结果Result,这里 invoke 方法进过一系列的包装 wrapper 处理,依次进过:FailfastClusterInvoker 或 FailoverClusterInvoker、InvokerWrapper、ListenerInvokerWrapper、ProtocolFilterWrapper#CallbackRegistrationInvoker、ProtocolFilterWrapper方法内匿名Invoker、ConsumerContextFilter、ProtocolFilterWrapper方法内匿名Invoker、FutureFilter、ProtocolFilterWrapper方法内匿名Invoker、MonitorFilter、AsyncToSyncInvoker、AbstractInvoker、DubboInvoker,经过一堆 filter 的invoke方法调用处理(典型的责任链设计模式

在这里插入图片描述

  • 最后我们找到真正做请求的 Invoker ,即 DubboInvoker,并完成同异步转换:通过同异步转换对象 ExchangeClient 发送请求,request 依次进过:DubboInvoker、ReferenceCountExchangeClient、HeaderExchangeClient

在这里插入图片描述

  • 接着继续深跟代码,进过一系列 send 方法,依次进过:HeaderExchangeChannel、AbstractPeer、AbstractClient、NettyChannel

在这里插入图片描述

  • 至此,我们已经跟踪到Dubbo的底层,消费者通过 Netty 的 Channel 发送异步数据,完成远程调用,并获取异步结果。

提供者处理消费者请求

  • 分析入口:NettyServerHandler 的 channelRead 方法

在这里插入图片描述

  • Debug 跟踪一下 received 方法,该方法依次进过:NettyServerHandler、AbstractPeer、MultiMessageHandler、HeartbeatHandler、AllChannelHandler

在这里插入图片描述

  • 到这里我们知道提供者端将Client发送来处数据包装为一个任务,使用线程池中的线程来完成。于是我们可以找到 ChannelEventRunnable 的 run() 方法进行进一步的分析,同样是一系列的 received 方法处理,依次进过:ChannelEventRunnable、DecodeHandler、HeaderExchangeHandler

在这里插入图片描述

  • 至此,message 进过解码后,进入到 Request 请求分支进行请求处理。其中第 ② 步将真正响应结果返回,与前面分析消费端发出请求类似,就不赘述。第 ① 步中调用 ExchangeHandler 的 reply 方法主要完成事情:
    • 获取 invoker 对象:通过之前分析服务发布过程知道,提供者端的 invoker 是存放在 exporterMap 集合中,于是在这里我们就可以通过 serviceKey 获取到 exporter;
    • 而 Exporter 接口是可以获取到 Invoker 的,于是就可以通过调用 Invoker 的 invoke 方法来完成远程调用的本地执行;
    • 最后返回一个异步结果,将结果通过Netty Channel发给了消费者。

消费者处理提供者响应

  • 分析入口:前面在分析服务订阅时,消费者端获取 Invoker 最终是创建一个 invoker 的委托对象
// 通过当前的url创建一个invoker的委托对象(有多少个提供者就有多少个url,就会创建多少个invoker委托对象)
invoker = new InvokerDelegate<>(protocol.refer(serviceType, url), url, providerUrl);
  • 启动多个提供者端,消费者端以 Debug 方式运行跟踪一下 refer 方法,依次进过:RegistryDirectory、Protocol$Adaptive、ProtocolListenerWrapper、ProtocolFilterWrapper、AbstractProtocol、DubboProtocol

在这里插入图片描述

NettyClient的创建
  • 先整体分析下 getClients(URL url) 这个方法:获取NettyClient,即这里会创建NettyClient。并且一个消费者可以发出多个请求,也就会创建多个 NettyClient。
  • 获取消费者端连接数、是否共享等相关配置的属性值;
  • 根据 连接数、是否共享 属性值进行 ExchangerClient 的创建;
  • 如果是共享的则会创建 connections 数量的 ReferenceCountExchangeClient,这个 ExchangerClient 里面封装了一个计数器,用于引用计数。

在这里插入图片描述

  • 先分析一下 getSharedClient(url, connections) 这个方法:为每一个逻辑连接创建一个具有计数功能的 exchangeClient

在这里插入图片描述

  • 接下来再分析一下 initClient(url) 这个方法,创建一个物理连接的 exchangeClient,依次进过一系列 connect 方法处理:DubboProtocol、Exchangers、HeaderExchanger、Transporters、Transporter$Adaptive、NettyTransporter

在这里插入图片描述

  • 再继续往下分析代码,最终就能找到 NettyClient 创建的地方:

在这里插入图片描述

接收提供者端响应
  • 分析入口:找到 NettyClientHandler 的 channelRead 处理方法进行代码跟踪,这个过程和提供者端接收消费者请求的过程很类似。
  • 我们再来 Debug 跟一下 received 方法,依次进过:NettyClientHandler、AbstractPeer、MultiMessageHandler、HeartbeatHandler、AllChannelHandler、ChannelEventRunnable、DecodeHandler

在这里插入图片描述

  • 继续分析 Response 解码后对响应的处理,最终返回一个异步结果:

在这里插入图片描述

服务路由

什么是服务路由

  • 服务路由包含一条路由规则,路由规则决定了服务消费者的调用目标,即规定了服务消费者可调用哪些服务提供者。Dubbo 目前提供了三种服务路由实现,分别为条件路由ConditionRouter脚本路由 ScriptRouter标签路由 TagRouter。其中条件路由是我们最常使用的,下面将以条件路由为例讲解服务路由的用法。

路由规则的设置

路由规则是在 Dubbo 管控平台 Dubbo-Admin 中的。

启动管控平台
  • 启动:将 dubbo-admin-0.1.jar 文件存放到任意目录下,例如 D 盘根目录下,直接运行。注意这里的 dubbo-admin 是已经配置好的。
  • 访问:在浏览器地址栏中输入 http://ip:8080 ,即可看到 Dubbo 管理控制台界面。
设置路由规则

在这里插入图片描述

路由规则详解

初始路由规则
  • 应用粒度路由规则:
scope: application
force: true
runtime: true
enabled: true
key: governance-conditionrouter-consumer
conditions:
  # app1的消费者只能消费所有端口号为20880的服务提供者实例
  - application=app1 => address=*.20880
  # app1的消费者只能消费所有端口号为20881的服务提供者实例
  - application=app2 => address=*.20881
  • 服务粒度路由规则:
scope: service
force: true
runtime: true
enabled: true
key: org.apache.dubbo.samples.governance.api.DemoService
conditions:
  # DemoService的sayHello方法只能消费所有端口号为20880的服务提供者实例
  - method=sayHello => address=*.20880
  # DemoService的sayHi方法只能消费所有端口号为20881的服务提供者实例
  - method=sayHi => address=*.20881
属性详解
scope
  • 必填项。表示路由规则的作用粒度,其取值将会决定 key 的取值。其取值范围如下:
    • service:表示服务粒度
    • application:表示应用粒度
key
  • 必填项。指定规则体将作用于哪个服务或应用。其取值取决于 scope 的取值。
    • scope 取值为 application 时,key 取值为 application 名称,即 <dubbo:application name="" /> 的值。
    • scope 取值为 service 时,key 取值为 [group:]service[:version] 名称,即组、接口名称与版本号。
enabled
  • 可选项。指定当前路由规则是否立即生效。缺省值为 true,表示立即生效。
force
  • 可选项。指定当前路由结果为空时,是否强制执行。如果不强制执行,路由结果为空则路由规则自动失效,不使用路由。缺省值为 fasle,不强制执行。
runtime
  • 可选项。指定是否在每次调用时执行路由规则。若为 false 则表示只在提供者地址列表变更时会预先执行路由规则,并将路由结果进行缓存,消费者调用时直接从缓存中获取路由结果。若为 true 则表示每次调用都要重新计算路由规则,其将会直接影响调用的性能。缺省值为 false。
priority
  • 可选项。用于设置路由规则的优先级,数字越大,优先级越高,越靠前执行。缺省值为 0。
conditions
  • 必填项。规则体,定义具体的路由规则内容,由 1 到任意多条规则组成。
格式
  • 路由规则由两个条件组成,分别用于对服务消费者和提供者进行配置。
[服务消费者匹配条件] => [服务提供者匹配条件]
  • 当消费者的 URL 满足匹配条件时,对该消费者执行后面的过滤规则。
  • => 之后为提供者地址列表的过滤条件,所有参数和提供者的 URL 进行对比,消费者最终只拿到过滤后的地址列表。
举个栗子:
    路由规则为:host=10.20.153.10 => 10.20.153.11。其表示 IP 为 10.20.153.10 的服务消费者只可调用 IP 为 10.20.153.11 机器上的服务,不可调用其他机器上的服务。
  • 不过,这两个条件存在缺省的情况:
  • 服务消费者匹配条件为空,表示不对服务消费者进行限制,所有消费者均将被路由到行后面的提供者。
  • 服务提供者匹配条件为空,表示对复核消费者条件的消费者将禁止调用任何提供者。
举个栗子:
    路由规则为:=> host!=10.20.153.11,则表示所有消费者均可调用 IP 为 10.20.153.11 之外的所有提供者。
    路由规则为:host=10.20.153.10 =>,则表示 IP 为 10.20.153.10 的消费者不能调用任何提供者。
符号支持
  • 参数符号
    • method:将调用方法作为路由规则比较的对象
    • argument:将调用方法参数作为路由规则比较的对象
    • protocol:将调用协议作为路由规则比较的对象
    • host:将 IP 作为路由规则比较的对象
    • port:将端口号作为路由规则比较的对象
    • address:将 IP:端口号 作为路由规则比较的对象
    • application:将应用名称作为路由规则比较的对象
  • 条件符号
    • 等于号(=):将参数类型匹配上参数作为路由条件
    • 不等于(!=):将参数类型不匹配参数值作为路由条件
  • 取值符号
    • 逗号(,):多个取值的分隔符,如:host != 10.20.153.10,10.20.153.11
    • 星号(*):通配符,如:host != 10.20.*
    • 美元符号($):表示引用消费者的参数值,如:host = $host
举例
  • 黑名单:IP 为 10.20.153.10 与 10.20.153.11 的主机将被禁用
host=10.20.153.10,10.20.153.11 =>
  • 白名单:禁用 IP 为 10.20.153.10 与 10.20.153.11 之外的所有主机
host!=10.20.153.10,10.20.153.11 =>
  • 只暴露一部分的提供者:消费者只可访问 IP 为 172.22.3.1* 和 172.22.3.2* 的提供者主机
=> host=172.22.3.1*,172.22.3.2*
  • 为重要应用提供额外的机器:应用名称不为 kylin 的应用不能访问 IP 为 172.22.3.95 和 172.22.3.96 的两台提供者主机。即只有名称为 kylin 的消费者可以访问 IP 为 172.22.3.95 和 172.22.3.96 的两台提供者主机。当然,kylin 还可以访问其它提供者主机,而其它消费者也可以访问 IP 为 172.22.3.95 和 172.22.3.96 之外的所有提供者主机。
application!=kylin => host!=172.22.3.95,172.22.3.96
  • 读写分离:find、list、get、is 开头的消费者方法会被路由到 172.22.3.94、172.22.3.95 与 172.22.3.96 三台提供者主机,而其它方法则会被路由到 172.22.3.97 和 172.22.3.98 两台提供者主机。
method=find*,list*,get*,is* => host=172.22.3.94,172.22.3.95,172.22.3.96
method!=find*,list*,get*,is* => host=172.22.3.97,172.22.3.98
  • 前后台分离:应用名称为 bops 的消费者会被路由到 172.22.3.91、172.22.3.92 与 172.22.3.93 三台提供者主机,而其它消费者则会被路由到 172.22.3.94、172.22.3.95 与 172.22.3.96 三台提供者。
application=bops => host=172.22.3.91,172.22.3.92,172.22.3.93
application!=bops => host=172.22.3.94,172.22.3.95,172.22.3.96
  • 隔离不同机房网段:不是 172.22.3 网段的消费者是不能访问 172.22.3 网段的提供者的。即只有 172.22.3 网段的消费者才可访问 172.22.3 网段的提供者。当然,172.22.3 网段的消费者也可访问其它网段的提供者。
host!=172.22.3.* => host!=172.22.3.*
  • 只访问本机的服务:$host 表示获取消费者请求中的消费者主机 IP。故该规则就表示消费者只能访问本机的服务。
=> host=$host

体验一下

  • 设置黑名单:

在这里插入图片描述

  • 再启动提供者,查看下ZK:

在这里插入图片描述

  • 再启动消费者进行消费:

在这里插入图片描述

源码解析

添加激活RouterFactory到Directory
  • 找到RouterFactory激活类:

在这里插入图片描述

在这里插入图片描述

  • 取到RouterFactory的所有激活类创建的router实例,添加到directory中:

在这里插入图片描述

读取zk中的路由规则

在这里插入图片描述

  • 继续跟踪 super.notify() 方法:

在这里插入图片描述

  • 创建路由 Router 实例:

在这里插入图片描述

  • 到这里我们就将 ZK 中配置的路由规则给读到内存并创建了路由实例,接下来需要将该 Router 添加到 RouterChain:

在这里插入图片描述

服务路由过滤
  • 分析入口:服务路由过滤应该发生在什么时候?==>> 远程调用,即 InvokerInvocationHandler 的 invoke 方法

在这里插入图片描述

  • 继续跟踪 doList() 方法:

在这里插入图片描述

  • 进入 ConditionRouter 的 route() 方法:

在这里插入图片描述

服务降级

服务降级常见设置

  • mock=“force:return null”:表示消费方对该服务的方法调用都直接强制性返回 null 值,不发起远程调用,即使远程的提供者没有出现问题。
  • mock=“fail:return null”:表示消费方对该服务的方法调用在失败后,再返回 null 值,不抛出异常。
  • mock=“true”:表示消费方对该服务的方法调用在失败后,会调用消费方定义的服务降级 Mock 类实例的相应方法。而该 Mock 类的类名为“业务接口名+Mock”,且放在与接口相同的包中。
  • mock=降级类的全限定性类名:与 mock=“true” 功能类似,不同的是,该方式中的降级类名可以是任意名称,在任何包中。

修改消费端代码

  • 为了演示服务降级,在调试之前首先修改消费者工程的代码。
  • 定义 Mock 类:在 org.apache.dubbo.demo 包中创建如下类
public class DemoServiceMock implements DemoService {
    @Override
    public String sayHello(String name) {
        return "mock result ============== " + name;
    }
}
  • 修改配置文件:

在这里插入图片描述

  • 测试:运行服务消费者

在这里插入图片描述

服务降级的调用

forbidden的值
  • 我们前面在进行服务订阅时,跟踪到了如下位置,在这里刷新了 invoker。

在这里插入图片描述

  • 我们注意到这里为 forbidden 赋值为 true,表示当前禁止进行远程访问。
找到服务降级代码
  • 分析入口:InvokerInvocationHandler#invoke 远程调用:

在这里插入图片描述

  • 这里我们的 value 值为 true,所以是先进行远程调用,出现异常再进行服务降级,继续跟踪invoke方法:

在这里插入图片描述

  • 继续跟踪降级的代码 doMockInvoke():

在这里插入图片描述

  • 先看下规范化mock值normalizeMock(URL.decode(mock))

在这里插入图片描述

  • 再跟踪下 getInvoker(mock) 方法:加载并实例化本地降级类

在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

讲文明的喜羊羊拒绝pua

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值