SpringCloud Alibaba(三) Nacos 客户端源码浅析(AP架构)

    前面我们介绍了Nacos的简单使用,此篇,我们将围绕Nacos的客户端来展开,一步步揭开Nacos神秘的面纱,这次我们使用的是Nacos1.4.1(21年初)版本,尽管现在已经出了2.0版本,他们之间最大的改变是1.X的Http请求,2.X使用的是grpc,但是市面上用得最多的仍然是1.X版本,我们只需要学会他的思想 ,万变不离其宗.Spring Cloud版本为Hoxton.SR8,Spring Cloud Alibaba版本为2.2.5.RELEASE.

1.Nacos客户端(我们先基于Nacos服务端即注册中心是单体的情况,客户端注册的节点都是临时节点)

1.1我们先来看一下什么是Nacos客户端

    我们Nacos服务端(注册中心)启动好之后,我们的业务微服务,例如仓库服务,库存服务只需要引入Nacos客户端的pom,加上配置文件就可以注册上我们的Nacos服务,我们的仓库服务,库存服务就是Nacos客户端,它引入了Nacos client,才能注册到Nacos服务.至于Nacos AP和CP模式后面会讲,这里先有一个概念.

   下图是Nacos客户端向Nacos服务端注册的时候的一个参数ephemeral()是否临时节点),默认是true,true就代表使用的AP模式,服务端集群同步客户端注册过来的数据的时候用的阿里的Distro协议,需要注意的一点就是,AP模式下所有服务端集群节点都是平等的,即没有主从节点的概念,所有节点都有可能接受客户端的注册请求,然后会将注册的实例数据同步到其它集群节点。

   如下图我的客户端 订单服务要注册到我们的Nacos集群(集群为了避免单点故障),他只会请求到集群中的某一台机器,假设现在注册到了NacosServer1,那我下一次去NacosServer2拉取数据的时候不就找不到数据.所以我们需要NacosServer1,NacosServer2,NacosServer3之间数据同步.AP模式是没有leader的概念,集群之间数据同步会最终一致.

1.2 Nacos客户端核心功能

      1.我们需要注册到Nacos服务端,所以一定会有注册到服务端功能

      2.Nacos服务端需要知道我客户端是否还存活,所以客户端必须会有一个定时任务,定时向服务端发送心跳.

      3.Nacos客户端必须知道有哪一些服务注册到了服务端,这样才能调用另外的服务.所以客户端必须会有一个定时任务,定时拉取服务端对应的服务列表.

下面所有源码介绍都会围绕这3个功能去说.

1.2 开启Nacos Client源码入口

   1.入口一:熟悉SpringBoot自动配置原理我们会知道,SpringBoot会自动扫描我们所有jar下面的Spring.factories,并且加载我们的自动配置类.如下图的pom.xml以及对应Nacos-discovery的Spring.factories文件

   2.入口二:启动类上面@EnableDiscoveryClient,但是新版本我们已经不需要写这个注解了,因为自动配置帮我们做了他应该做的事情,如下图,这个注解已经不需要了.

     pom.xml
     <!-- nacos服务注册与发现 -->
        <dependency>
            <groupId>com.alibaba.cloud</groupId>
            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
        </dependency>

1.3 @EnableDiscoveryClient

    这个注解做了两件事,一个是import了EnableDiscoveryClientImportSelector,另外一个就是默认autoRegister为true,开启自动注册,并且引入AutoServiceRegistrationConfiguration到Spring容器.后面会说这个注解为什么加不加都行.

1.4 Spring.factories引入的自动配置类

 1.4.1 NacosServiceAutoConfiguration

    首先是NacosServiceAutoConfiguration,这里只加载了NacosServiceManager,是service核心管理类,NamingService就是他管理的

 1.4.2 NacosDiscoveryAutoConfiguration

    这个是用于服务发现的,他加载了两个类NacosDiscoveryProperties和NacosServiceDiscovery
NacosDiscoveryProperties是配置类,我们配置注册中心的信息就是在这里配置。
NacosServiceDiscovery主要是用于服务发现。

 1.4.3 NacosServiceRegistryAutoConfiguration

    1.NacosServiceRegistryAutoConfiguration,这个是用于自动注册的.我们看到他上面的注解信息,@AutoConfigureAfter确保AutoServiceRegistrationConfiguration先加载,然后判断spring.cloud.service-registry.auto-registration.enabled是否为true,为true才可以加载。所以上面@EnableDiscoveryClient的autoRegister如果设置为false,则不加载。 因为spring.cloud.service-registry.auto-registration.enabled默认是true的,所以@EnableDiscoveryClient其实不引入,也是可以加载NacosServiceRegistryAutoConfiguration.所以才说为什么不需要@EnableDiscoveryClient.

    2.这个类会加载NacosServiceRegistry,NacosRegistration,NacosAutoServiceRegistration.
NacosServiceRegistry,NacosRegistration会注入到NacosAutoServiceRegistration.所以重点在于NacosAutoServiceRegistration.

   NacosServiceRegistry主要用于注册、取消注册。
   NacosRegistration是当前实例的信息,比如实例名称、实例id、端口、ip等.
   NacosAutoServiceRegistration初始化的时候会调用方法调用上面的NacosServiceRegistry去注册到服务端.后面会详细说这里

 1.4.4 NacosDiscoveryClientConfiguration

    这里加载了两个类,DiscoveryClient和NacosWatch.
DiscoveryClient主要用于实例获取和服务获取。
NacosWatch实现了SmartLifecycle接口,所以会调用start和stop方法。
start方法主要是加入NamingEvent监听、获取NacosNamingService、注册监听、发布HeartbeatEvent事件。

 这里实例化了namingService(命名服务)

1.5  下面我们重点说一下上面1.4红标的两个类

 1.5.1 NacosAutoServiceRegistration

    我们可以看到NacosAutoServiceRegistration他实现了ApplicationEventListener,所以启动的时候,Spring会调用其中的onApplicationEvent方法,而这个方法在他的父类AbstractAutoServiceRegistration里面.

    AbstractAutoServiceRegistration#bind,onApplicationEvent调用了bind,bind主要逻辑调用了this.start().

    我们初始化NacosAutoServiceRegistration的时候传入了NacosServiceRegistry,这个类里面有注册,取消注册的方法,而上面的start()方法最终会调用到NacosServiceRegistry#register

    NacosServiceRegistry#register

    这里的registration,参数是我们初始化的时候传进来的NacosRegistration是当前实例的信息,比如实例名称、实例id、端口、ip等.

    NacosNamingService#registerInstance

    上面的NacosServiceRegistry#register,最终会调用到我的NacosNamingService#registerInstance.

    这个NacosNamingService就是在我们1.4红色类NacosWatch实例化的,这样就串起来了.在这个registerInstance方法我们做了两件事情

 1.调用serverProxy#registerService,这里才是真正的注册.serverProxy的类是NamingProxy

 2.判断当前节点是否临时实例(默认true后面讲AP,CP架构会解释)如果是则调用beatReactor#addBeatInfo,心跳任务

  功能一:服务注册

     NamingProxy#registerService

     这里我们发现了他发送了一个POST请求,路径是/v1/ns/instance,我们在Nacos官网可以看到,这个接口是,Nacos服务端注册实例的接口,由此我们可以知道两个关键信息,Nacos客户端和服务端交互用的http请求,Nacos服务端就是一个普通的web服务,并且,他是随机请求一台Nacos服务,即我们配置文件配置的server-addr: 127.0.0.1:8848,127.0.0.1:8849,127.0.0.1:8850的随机一个.

    这里有同学会有疑问,假如我的Nacos服务是集群呢?随机请求一台Nacos服务他如何同步到整个集群?我们后面讲Nacos集群会解释,他利用Distro协议来保证整个集群数据最终一致.

    NamingProxy#reqApi 这里是随机挑选一个服务去请求 Random,并且如果异常,会重试,单机的时候nacosDomain不为空,则重试最大次数,集群的时候会一个个节点重试.最后还失败,抛异常.

    功能二:心跳任务

beatReactor#buildBeatInfo 封装心跳信息

beatReactor#addBeatInfo 新增心跳任务

    beatReactor#addBeatInfo,这个方法,它定义了一个延时线程池,默认延时5秒之后执行BeatTask#run

    既然是线程池我们就得看BeatTask#run

    他这里给服务端发送了心跳,如果返回发现没心跳信息则重新注册,然后继续套娃线程池丢下一个5秒的延时任务,保持心跳.这样做的好处是不需要受固定的时间间隔的约束

     NamingProxy#sendBeat

    这里我们发现了他发送了一个PUT请求,路径是/v1/ns/instance/beat,我们在Nacos官网可以看到,这个接口是,Nacos服务端注册实例心跳保活接口,和上面的请求一样.

    这里有同学会有疑问,假如我的Nacos服务是集群呢?心跳随机请求一台Nacos服务他如何同步到整个集群?我们后面讲Nacos集群会解释

1.5.2 NacosDiscoveryClientConfiguration

    这是1.4的第二个红标类这里加载了两个类,DiscoveryClient和NacosWatch.
DiscoveryClient主要用于实例获取和服务获取。
NacosWatch实现了SmartLifecycle接口,所以Spring启动会调用start和stop方法。
start方法主要是加入NamingEvent监听、获取NacosNamingService、注册监听、发布HeartbeatEvent事件。

    NacosWatch#start

     NacosServiceManager#buildNamingService

    上面的NacosServiceManager#getNamingService会调用buildNamingService这个方法,这里加锁做了双重校验,不存在就通过反射创建NacosNamingService

     NamingFactory#createNamingService

     最终通过反射创建NacosNamingService

   

NacosNamingService

    这个类实例化会调用NacosNamingService#init

    主要是初始化namespace、序列化、注册中心服务地址、WebRootContext上下文、缓存路径、日志名称、NamingProxy、BeatReactor、HostReactor。 NamingProxy负责和Nacos服务的通信,比如服务注册、服务取消注册、心跳等。 BeatReactor负责检测心跳。 HostReactor负责获取、更新并保存服务信息。

private void init(Properties properties) throws NacosException {
    ValidatorUtils.checkInitParam(properties);
    // namespace默认public
    this.namespace = InitUtils.initNamespaceForNaming(properties);
    // 序列化初始化
    InitUtils.initSerialization();
    // 注册中心服务地址初始化,这个从配置文件取
    initServerAddr(properties);
    //初始化WebRootContext上下文
    InitUtils.initWebRootContext();
    // 初始化缓存路径 System.getProperty("user.home") + "/nacos/naming/" + namespace
    initCacheDir();
    // 初始化日志名称naming.log
    initLogName(properties);

    // 初始化NamingProxy
    this.serverProxy = new NamingProxy(this.namespace, this.endpoint, this.serverList, properties);
    // 初始化BeatReactor
    this.beatReactor = new BeatReactor(this.serverProxy, initClientBeatThreadCount(properties));
    // 初始化HostReactor
    this.hostReactor = new HostReactor(this.eventDispatcher, this.serverProxy, beatReactor, this.cacheDir,
            isLoadCacheAtStart(properties), initPollingThreadCount(properties));
}

    NamingProxy

    我们可以看到这个类的方法都是负责和Nacos服务的通信,比如服务注册、服务取消注册、心跳等,他还有一个功能,就是定时任务去endpoint拉取我最新的Nacos地址,这一点我们了解一下就行.

  • NamingProxy的构造器执行了initRefreshSrvIfNeed方法,该方法在endpoint不为空的时候,会注册一个定时任务,每隔vipSrvRefInterMillis时间执行一次refreshSrvIfNeed方法
  • refreshSrvIfNeed方法在serverList为空,且距离lastSrvRefTime大于等于vipSrvRefInterMillis时会通过getServerListFromEndpoint()方法获取serverList更新serversFromEndpoint及lastSrvRefTime
  • getServiceList方法优先以serverList作为server端地址列表,如果它为空再以serversFromEndpoint为准,然后通过reqAPI方法请求的时候,随机选择一个server进行请求,最多请求server.size()次,请求成功则跳出循环返回,请求失败则递增index并对servers.size()取余继续下次循环,如果都请求失败则最后抛出IllegalStateException

    关于endpoint是什么,其实就是一个地址服务器,请求他,返回一连串Nacos服务地址,但是我们一般用得不多,我们一般是在配置文件配置一个nginx反向代理nacos集群,endpoint详情看下面博客介绍.

Nacos 集群部署模式最佳实践_阿里巴巴中间件-CSDN博客

    下图是NamingProxy的一些方法

    BeatReactor

    这个类上面1.4已经描述过,服务注册之后,会开启一个定时任务,不断给服务端发心跳

HostReactor

这个类主要是获取,更新并保存我传入的微服务信息.

我们再来看一下NacosWatch#start

这里初始化了NamingService,并且调用了namingService#subscribe

namingService#subscribe最终会调用HostReactor#subcribe方法

HostReactor#subcribe

他会调用HostReactor#getServiceInfo

HostReactor#getServiceInfo

里面有个成员变量serviceInfoMap,用来专门存服务的信息,假设我们user服务启动,他就会调用updateServiceNow去Nacos获取user服务所有服务的信息,并且有一个定时任务定时去更新user服务的信息,包括当前有多少个节点等.假设我user服务去调用order服务,这个时候也会调用getServiceInfo这个方法去Nacos服务获取,然后缓存到serviceInfoMap,并且开启定时任务.Nacos重写了Spring cloud规范的接口,所以Feing获取其他微服务的信息会调用Nacos的getServiceInfo.

 public ServiceInfo getServiceInfo(final String serviceName, final String clusters) {
        
        NAMING_LOGGER.debug("failover-mode: " + failoverReactor.isFailoverSwitch());
        // key为{服务名}@@{集群名}。
        String key = ServiceInfo.getKey(serviceName, clusters);
        if (failoverReactor.isFailoverSwitch()) {
            return failoverReactor.getService(key);
        }
        // 读取本地服务列表的缓存,缓存是一个Map,格式:Map<String, ServiceInfo>
        ServiceInfo serviceObj = getServiceInfo0(serviceName, clusters);
        
        if (null == serviceObj) {
        //创建空ServiceInfo
            serviceObj = new ServiceInfo(serviceName, clusters);
            
            serviceInfoMap.put(serviceObj.getKey(), serviceObj);
            
            updatingMap.put(serviceName, new Object());
            //远程去Nacos调用接口获取服务信息
            updateServiceNow(serviceName, clusters);
            updatingMap.remove(serviceName);
            
        } else if (updatingMap.containsKey(serviceName)) {
            // 缓存中有,但是需要更新
            if (UPDATE_HOLD_INTERVAL > 0) {
                // hold a moment waiting for update finish
                synchronized (serviceObj) {
                    try {
                        serviceObj.wait(UPDATE_HOLD_INTERVAL);
                    } catch (InterruptedException e) {
                        NAMING_LOGGER
                                .error("[getServiceInfo] serviceName:" + serviceName + ", clusters:" + clusters, e);
                    }
                }
            }
        }
        //开启定时任务定时去Nacos更新服务的信息
        scheduleUpdateIfAbsent(serviceName, clusters);
        
        return serviceInfoMap.get(serviceObj.getKey());
    }

HostReactor#updateServiceNow

updateServiceNow会调用updateService获取服务信息,并且更新我们的serviceInfoMap,这里不仅仅获取健康的节点,个人猜想这个和Nacos的健康阈值有关

  • 为了防止因过多实例 (Instance) 不健康导致流量全部流向健康实例 (Instance) ,继而造成流量压力把健康 健康实例 (Instance) 压垮并形成雪崩效应,应将健康保护阈值定义为一个 0 到 1 之间的浮点数。当域名健康实例 (Instance) 占总服务实例 (Instance) 的比例小于该值时,无论实例 (Instance) 是否健康,都会将这个实例 (Instance) 返回给客户端。这样做虽然损失了一部分流量,但是保证了集群的剩余健康实例 (Instance) 能正常工作。

 NamingService#queryList

调用Nacos服务的/v1/ns/instance/list,获取服务信息

返回结果

 HostReactor#updateServiceNow

这里面addTask,肯定是放进一个阻塞队列或者加入一个线程池里面

 果然开启了一个延时任务,1秒后执行,所以我们应该去看UpateTask的run方法.Update肯定实现了Runnable

 UpdateTask

    这个类是HostReactor的一个内部类,他的Run方法就是调用上面的updateService方法去刷新服务的信息,然后finally里面又套娃开启了一个延时任务,多少秒之后重新执行.

 public class UpdateTask implements Runnable {
        
        long lastRefTime = Long.MAX_VALUE;
        
        private final String clusters;
        
        private final String serviceName;
        
        /**
         * the fail situation. 1:can't connect to server 2:serviceInfo's hosts is empty
         */
        private int failCount = 0;
        
        public UpdateTask(String serviceName, String clusters) {
            this.serviceName = serviceName;
            this.clusters = clusters;
        }
        
        private void incFailCount() {
            int limit = 6;
            if (failCount == limit) {
                return;
            }
            failCount++;
        }
        
        private void resetFailCount() {
            failCount = 0;
        }
        
        @Override
        public void run() {
            long delayTime = DEFAULT_DELAY;
            
            try {
                ServiceInfo serviceObj = serviceInfoMap.get(ServiceInfo.getKey(serviceName, clusters));
                
                if (serviceObj == null) {
                    updateService(serviceName, clusters);
                    return;
                }
                
                if (serviceObj.getLastRefTime() <= lastRefTime) {
                    updateService(serviceName, clusters);
                    serviceObj = serviceInfoMap.get(ServiceInfo.getKey(serviceName, clusters));
                } else {
                    // if serviceName already updated by push, we should not override it
                    // since the push data may be different from pull through force push
                    refreshOnly(serviceName, clusters);
                }
                
                lastRefTime = serviceObj.getLastRefTime();
                
                if (!notifier.isSubscribed(serviceName, clusters) && !futureMap
                        .containsKey(ServiceInfo.getKey(serviceName, clusters))) {
                    // abort the update task
                    NAMING_LOGGER.info("update task is stopped, service:" + serviceName + ", clusters:" + clusters);
                    return;
                }
                if (CollectionUtils.isEmpty(serviceObj.getHosts())) {
                    incFailCount();
                    return;
                }
                delayTime = serviceObj.getCacheMillis();
                resetFailCount();
            } catch (Throwable e) {
                incFailCount();
                NAMING_LOGGER.warn("[NA] failed to update serviceName: " + serviceName, e);
            } finally {
                executor.schedule(this, Math.min(delayTime << failCount, DEFAULT_DELAY * 60), TimeUnit.MILLISECONDS);
            }
        }
    }

HostReactor构造函数

    构建hostReactor的时候,他会初始化我们的serviceInfoMap,并且他还new FailoverReactor和

new PushReceiver

PushReceiver

      当服务注册到Nacos服务端的时候,假设我们的HostReactor#UpdateTask没有及时去拉取最新的服务信息,这段定时任务的时间段内我们的serviceInfoMap肯定还是老的,所以Nacos服务端还有一个机制,当服务注册到Nacos服务端的时候,用UDP去广播给其他服务端,告诉其他服务端新注册了一个服务,你们更新一下serviceInfoMap.这样就可以近实时的刷新我们缓存的服务列表

  • PushReceiver实现了Runnable接口,其构造器会创建udpSocket以及ScheduledThreadPoolExecutor,然后往ScheduledThreadPoolExecutor注册自己
  • 其run方法使用while true循环来执行udpSocket.receive(packet),之后将接收到的数据解析为PushPacket,然后根据不同pushPacket.type做不同处理
  • 当pushPacket.type为dom或者service的时候会调用hostReactor.processServiceJSON(pushPacket.data);当pushPacket.type为dump的时候会将hostReactor.getServiceInfoMap()序列化到ack中,最后将ack返回回去

FailoverReactor

   这是故障转移模式,也就是做容灾备份

   我们都知道即使Nacos服务挂了,我们的Nacos客户端仍然可以依靠serviceInfoMap保证一定的可用信.但是注册中心发生故障最坏的一个情况是整个 Server 端宕机,这时候 Nacos 依旧有高可用机制做兜底。

   注册中心发生故障最坏的一个情况是整个 Server 端宕机,如果三个Server 端都宕机了,怎么办呢?这时候 Nacos 依旧有高可用机制做兜底。

    本地缓存文件 Failover 机制,Nacos 存在本地文件缓存机制,nacos-client 在接收到 nacos-server 的服务推送之后,会在内存中保存一份,随后会落盘存储一份快照snapshot 。有了这份快照,本地的RPC调用,还是能正常的进行。

关键是,这个本地文件缓存机制,默认是关闭的。

    Nacos 注册中心宕机,Dubbo /springcloud 应用发生重启,会不会影响 RPC 调用。如果了解了 Nacos 的 Failover 机制,应当得到和上一题同样的回答:不会。

这份文件有两种价值,一是用来排查服务端是否正常推送了服务;二是当客户端加载服务时,如果无法从服务端拉取到数据,会默认从本地文件中加载。snapshot 默认的存储路径为:{USER_HOME}/nacos/naming/ 中:

在生产环境,推荐开启该参数,以避免注册中心宕机后,导致服务不可用,在服务注册发现场景,可用性和一致性 trade off 时,我们大多数时候会优先考虑可用性。

另外:{USER_HOME}/nacos/naming/{namespace} 下除了缓存文件之外还有一个 failover 文件夹,里面存放着和 snapshot 一致的文件夹。

这是 Nacos 的另一个 failover 机制,snapshot 是按照某个历史时刻的服务快照恢复恢复,而 failover 中的服务可以人为修改,以应对一些极端场景。

该可用性保证存在于 nacos-client 端。

可以参考下面的源码分享

     Nacos源码分析05-客户端本地缓存与故障转移_努力推石头的西西弗斯-CSDN博客

     一文详解 Nacos 高可用特性 - 知乎

2.Fegin与Nacos整合

        我有一段很简单的代码就是我当前user服务调用mall-order服务的接口,ribbon和Fegin调用的结果一样,Feign底层就是ribbon,如下代码段.

    @RequestMapping(value = "/findOrderByUserId/{id}")
    public R  findOrderByUserId(@PathVariable("id") Integer id) {
        log.info("根据userId:"+id+"查询订单信息");
        // RestTemplate调用
//        String url = "http://localhost:8020/order/findOrderByUserId/"+id;
//        R result = restTemplate.getForObject(url,R.class);

        //模拟ribbon实现
        //String url = getUri("mall-order")+"/order/findOrderByUserId/"+id;
        // 添加@LoadBalanced
        String url = "http://mall-order/order/findOrderByUserId/"+id;
        R result = restTemplate.getForObject(url,R.class);

        //feign调用
        //R result = orderFeignService.findOrderByUserId(id);

        return result;
    }

 当我第一次调用的时候我们在上面HostReactor#getServiceInfo打一个断点.
   很明显我们的ribbon负载均衡器去调用Nacos获取了服务列表.    

   这里我们简单说一下feign的原理,他是包装了我们的ribbon,然后通过我们的restTemplate拦截器,去修改我们的地址.

   例如我当前调用​http://mall-order/order/findOrderByUserId/​1,他会去Nacos服务器获取我们的mall-order服务的所有ip:port,然后缓存起来. 例如我现在获取了127.0.0.1:8001,127.0.0.1:8002,他会根据你选的负载均衡策略选出一个地址,然后替换为​http://127.0.0.1:8001/order/findOrderByUserId/​1

    我们看一下我我们ribbonLoadBalancer负载均衡器初始化的时候传入了我们的NacosServerList,所以不注入我们默认的ribbonServerList,然后获取服务列表的时候自然是去Nacos服务获取,然后缓起来,以后每次都去缓存获取,然后有一个定时任务去我们的serviceInfoMap获取服务信息去更新Feign自己的缓存.

3.总结

1.我们需要注册到Nacos服务端,所以一定会有注册到服务端功能

2.Nacos服务端需要知道我客户端是否还存活,所以客户端必须会有一个定时任务,定时向服务端发送心跳.

3.Nacos客户端必须知道有哪一些服务注册到了服务端,这样才能调用另外的服务.所以客户端必须会有一个定时任务,定时拉取服务端所有的服务列表.

我们会发现上述3个主要功能都是通过Http请求去Nacos服务端获取的,并且他们有许多定时任务去获取我们的服务列表以及发送心跳.

我们也了解到Nacos高可用不仅仅是服务端做集群,也可以是客户端缓存了服务列表以及failover机制.

    

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值