Eureka Client 源码详细解读

client端功能:
1.注册服务
2.拉取server列表到本地
3.发送心跳,续约;定时拉取注册表
4.发送下线
 
① spring boot项目引入eureka-client依赖,并注入spring 容器。
    在spring-boot项目中pom文件里面添加的依赖中的bean。是如何注册到spring-boot项目的spring容器中的呢?
    spring.factories文件是帮助spring-boot项目包以外的bean(即在pom文件中添加依赖中的bean)注册到spring-boot项目的spring容器的。
    由于@ComponentScan注解只能扫描spring-boot项目包内的bean并注册到spring容器中,因此需要@EnableAutoConfiguration(在SpringBootApplication下),
    注解来注册项目包外的bean。而spring.factories文件,则是用来记录项目包外需要注册的bean类名。
 
    点进去@SpringBootApplication注解,发现@EnableAutoConfiguration。点@EnableAutoConfiguration进去。
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {
    点AutoConfigurationImportSelector进去
发现下面代码
    @Override
    public String[] selectImports(AnnotationMetadata annotationMetadata) {
        if (!isEnabled(annotationMetadata)) {
            return NO_IMPORTS;
        }
        AutoConfigurationMetadata autoConfigurationMetadata = AutoConfigurationMetadataLoader
                .loadMetadata(this.beanClassLoader);
        AutoConfigurationEntry autoConfigurationEntry = getAutoConfigurationEntry(autoConfigurationMetadata,
                annotationMetadata);
        return StringUtils.toStringArray(autoConfigurationEntry.getConfigurations());
    }
此方法时,向spring ioc容器注入bean。selectImports,返回bean全名。import将bean全名注入。而注入的bean都是些什么呢?
点:getAutoConfigurationEntry进去,有一句
List<String> configurations = getCandidateConfigurations(annotationMetadata, attributes);
点getCandidateConfigurations进去:
List<String> configurations = SpringFactoriesLoader.loadFactoryNames(getSpringFactoriesLoaderFactoryClass(),
                getBeanClassLoader());
点SpringFactoriesLoader进去:
public static final String FACTORIES_RESOURCE_LOCATION = "META-INF/spring.factories";   
② 找eureka client 配置相关类
在api-listen-order(其他eureka client项目均可)项目中,找到
spring-cloud-netflix-eureka-client-2.1.2.RELEASE下META-INF下spring.factories。此文件中,有如下配置信息:

EurekaClientAutoConfiguration(Eureka client自动配置类,负责Eureka client中关键beans的配置和初始化),
RibbonEurekaAutoConfiguration(Ribbon负载均衡相关配置)    
EurekaDiscoveryClientConfiguration(配置自动注册和应用的健康检查器)。
③ EurekaDiscoveryClientConfiguration介绍
找到此类:org.springframework.cloud.netflix.eureka.EurekaDiscoveryClientConfiguration中的注解@ConditionalOnClass(EurekaClientConfig.class),
④ EurekaClientConfig介绍
点击进去查看EurekaClientConfig是个接口,查看其实现类EurekaClientConfigBean。此类里封装了Eureka Client和Eureka Server交互所需要的配置信息。看此类代码:

  public static final String PREFIX = "eureka.client";
     表示在配置文件中用eureka.client.属性名配置。
⑤ Eureka 实例相关配置
从org.springframework.cloud.client.discovery.DiscoveryClient顶级接口入手,前面介绍过spring common。看其在Eureka中的实现类org.springframework.cloud.netflix.eureka.EurekaDiscoveryClient。有一个属性:
private final EurekaClient eurekaClient,查看其实现类:com.netflix.discovery.DiscoveryClient。
有一个属性:
private final ApplicationInfoManager applicationInfoManager(应用信息管理器,点进去此类,发现此类总有两个属性:
private InstanceInfo instanceInfo;
private EurekaInstanceConfig config;
服务实例的信息类InstanceInfo和服务实例配置信息类EurekaInstanceConfig)。
⑥ InstanceInfo介绍
打开InstanceInfo里面有instanceId等服务实例信息。
InstanceInfo封装了将被发送到Eureka Server进行注册的服务实例元数据。
它在Eureka Server列表中代表一个服务实例,其他服务可以通过instanceInfo了解到该服务的实例相关信息,包括地址等,从而发起请求。
⑦ EurekaInstanceConfig介绍
EurekaInstanceConfig是个接口,找到它的实现类org.springframework.cloud.netflix.eureka.EurekaInstanceConfigBean。
此类封装了EurekaClient自身服务实例的配置信息,主要用于构建InstanceInfo。看到此类有一段代码:@ConfigurationProperties("eureka.instance"),
在配置文件中用eureka.instance.属性配置。EurekaInstanceConfigBean提供了默认值。
⑧ 通过EurekaInstanceConfig构建instanceInfo
在ApplicationInfoManager中有一个方法
public void initComponent(EurekaInstanceConfig config)中有一句:
this.instanceInfo = new EurekaConfigBasedInstanceInfoProvider(config).get();
通过EurekaInstanceConfig构造instanceInfo。
⑨ 顶级接口DiscoveryClient介绍
 
介绍一下spring-cloud-commons-2.2.1.realease包下,org.springframework.cloud.client.discovery.DiscoveryClient接口。定义用来服务发现的客户端接口,是客户端进行服务发现的核心接口,是spring cloud用来进行服务发现的顶级接口,在common中可以看到其地位。在Netflix Eureka和Consul中都有具体的实现类。
org.springframework.cloud.client.discovery.DiscoveryClient的类注释:
    Represents read operations commonly available to discovery services such as Netflix Eureka or consul.io。
    代表通用于服务发现的读操作,例如在 eureka或consul中。有
    String description();//获取实现类的描述。
    List<String> getServices();//获取所有服务实例id。
    List<ServiceInstance> getInstances(String serviceId);//通过服务id查询服务实例信息列表。

⑩ Eureka 的实现

接下来我们找Eureka的实现类。org.springframework.cloud.netflix.eureka.EurekaDiscoveryClient。
查看方法。
public List<ServiceInstance> getInstances(String serviceId),
组合了com.netflix.discovery.EurekaClient来实现。
⑩①EurekaClient的实现
EurekaClient有一个注解@ImplementedBy(DiscoveryClient.class),此类的默认实现类:com.netflix.discovery.DiscoveryClient。提供了:
服务注册到server方法register().
续约boolean renew().
下线public synchronized void shutdown().
查询服务列表 功能。
想想前面的图中client的功能。提供了于Eureka Server交互的关键逻辑。
com.netflix.discovery.DiscoveryClient
com.netflix.discovery.DiscoveryClient实现了EurekaClient(继承了LookupService)
com.netflix.discovery.shared.LookupService
LookupService作用:发现活跃的服务实例。
根据服务实例注册的appName来获取封装有相同appName的服务实例信息容器:
Application getApplication(String appName)。
获取所有的服务实例信息:
Applications getApplications();
根据实例id,获取服务实例信息:
List<InstanceInfo> getInstancesById(String id);

上面提到一个Application,它持有服务实例信息列表。它是同一个服务的集群信息。比如api-passenger的所有服务信息,这些服务都在api-passenger服务名下面。

而instanceInfo代表一个服务实例的信息。为了保证原子性,比如对某个instanceInfo的操作,使用了大量同步的代码。比如下面代码:
public void addInstance(InstanceInfo i) {
    instancesMap.put(i.getId(), i);
    synchronized (instances) {
        instances.remove(i);
        instances.add(i);
        isDirty = true;
    }
}

Applications是注册表中,所有服务实例信息的集合。
⑩② 健康检测器和事件监听器
EurekaClient在LookupService上做了扩充。提供了更丰富的获取服务实例的方法。按住不表。我们看一下另外两个方法:

public void registerHealthCheck(HealthCheckHandler healthCheckHandler),向client注册 健康检查处理器,client存在一个定时任务通过HealthCheckHandler检查当前client状态,当client状态发生变化时,将会触发新的注册事件,去更新eureka server的注册表中的服务实例信息。
通过HealthCheckHandler 实现应用状态检测。HealthCheckHandler的实现类org.springframework.cloud.netflix.eureka.EurekaHealthCheckHandler,看其构造函数:
public EurekaHealthCheckHandler(HealthAggregator healthAggregator) {
        Assert.notNull(healthAggregator, "HealthAggregator must not be null");
        this.healthIndicator = new CompositeHealthIndicator(healthAggregator);
}
private final CompositeHealthIndicator healthIndicator;此类事属于org.springframework.boot.actuate.health包下,可以得出,是通过actuator来实现对应用的检测的。

public void registerEventListener(EurekaEventListener eventListener)注册事件监听器,当实例信息有变时,触发对应的处理事件。
⑩③ 找到com.netflix.discovery.DiscoveryClient
在api-listen-order项目中,找到spring-cloud-netflix-eureka-client-2.1.2.RELEASE下META-INF下spring.factories。此文件中org.springframework.cloud.bootstrap.BootstrapConfiguration=\
org.springframework.cloud.netflix.eureka.config.EurekaDiscoveryClientConfigServiceBootstrapConfiguration,此类有个注解:
@Import({ EurekaDiscoveryClientConfiguration.class, // this emulates
        // @EnableDiscoveryClient, the import
        // selector doesn't run before the
        // bootstrap phase
        EurekaClientAutoConfiguration.class })
注解中有个类:    EurekaClientAutoConfiguration,此类中有如下代码:
CloudEurekaClient cloudEurekaClient = new CloudEurekaClient(appManager,
                    config, this.optionalArgs, this.context);
(debug可以调试到)
通过CloudEurekaClient找到:public class CloudEurekaClient extends DiscoveryClient。
⑩④ com.netflix.discovery.DiscoveryClient构造函数-不注册不拉取
DiscoveryClient的构造函数:
DiscoveryClient(ApplicationInfoManager applicationInfoManager, EurekaClientConfig config, AbstractDiscoveryClientOptionalArgs args,Provider<BackupRegistry> backupRegistryProvider, EndpointRandomizer endpointRandomizer)
此方法中依次执行了 从eureka server中拉取注册表,服务注册,初始化发送心跳,缓存刷新(定时拉取注册表信息),按需注册定时任务等,贯穿了Eureka Client启动阶段的各项工作。

构造函数353行:
if (config.shouldFetchRegistry()) {
      this.registryStalenessMonitor = new ThresholdLevelsMetric(this, METRIC_REGISTRY_PREFIX + "lastUpdateSec_", new long[]{15L, 30L, 60L, 120L, 240L, 480L});
} else {
      this.registryStalenessMonitor = ThresholdLevelsMetric.NO_OP_METRIC;
}
  shouldFetchRegistry,点其实现类EurekaClientConfigBean,找到它其实对应于:eureka.client.fetch-register,true:表示client从server拉取注册表信息。

下面:
if (config.shouldRegisterWithEureka()) {
      this.heartbeatStalenessMonitor = new ThresholdLevelsMetric(this, METRIC_REGISTRATION_PREFIX + "lastHeartbeatSec_", new long[]{15L, 30L, 60L, 120L, 240L, 480L});
} else {
      this.heartbeatStalenessMonitor = ThresholdLevelsMetric.NO_OP_METRIC;
}
  shouldRegisterWithEureka,点其实现类EurekaClientConfigBean,找到它其实对应于:
  eureka.client.register-with-eureka:true:表示client将注册到server。

  if (!config.shouldRegisterWithEureka() && !config.shouldFetchRegistry()) {
  如果以上两个都为false,则直接返回,构造方法执行结束,既不服务注册,也不服务发现。
⑩⑤ com.netflix.discovery.DiscoveryClient构造函数-两个定时任务
顺着上面代码往下看:
  scheduler = Executors.newScheduledThreadPool(2,
                      new ThreadFactoryBuilder()
                              .setNameFormat("DiscoveryClient-%d")
                              .setDaemon(true)
                              .build());
  定义了一个基于线程池的定时器线程池,大小为2。
  往下:
  heartbeatExecutor:用于发送心跳,
  cacheRefreshExecutor:用于刷新缓存。
⑩⑥ com.netflix.discovery.DiscoveryClient构造函数-client和server交互的Jersey客户端
接着构建eurekaTransport = new EurekaTransport();它是eureka Client和eureka server进行http交互jersey客户端。点开EurekaTransport,看到许多httpclient相关的属性。
⑩⑦ com.netflix.discovery.DiscoveryClient构造函数-拉取注册信息
if (clientConfig.shouldFetchRegistry() && !fetchRegistry(false)) {
              fetchRegistryFromBackup();
  }
  如果判断的前部分为true,执行后半部分fetchRegistry。此时会从eureka server拉取注册表中的信息,将注册表缓存到本地,可以就近获取其他服务信息,减少于server的交互。
⑩⑧ com.netflix.discovery.DiscoveryClient构造函数-服务注册
if (clientConfig.shouldRegisterWithEureka() && clientConfig.shouldEnforceRegistrationAtInit()) {
              try {
                  if (!register() ) {
                      throw new IllegalStateException("Registration error at startup. Invalid server response.");
                  }
              } catch (Throwable th) {
                  logger.error("Registration error at startup: {}", th.getMessage());
                  throw new IllegalStateException(th);
              }
          }注册失败抛异常。
⑩⑨ com.netflix.discovery.DiscoveryClient构造函数-启动定时任务
在构造方法的最后initScheduledTasks();此方法中,启动3个定时任务。方法内有statusChangeListener,按需注册是一个事件StatusChangeEvent,状态改变,则向server注册。
②⑩ com.netflix.discovery.DiscoveryClient构造函数-总结
总结DiscoveryClient构造关键过程:
  初始化一堆信息。
  从拉取注册表信息。
  向server注册自己。
  初始化3个任务。
  详细后面继续讲。源码就是这样,得层层拨开。
②⑩① 拉取注册表信息详解
上面的fetchRegistry(false),点进去,看注释:
  // If the delta is disabled or if it is the first time, get all  applications。
  如果增量式拉取被禁止或第一次拉取注册表,则进行全量拉取:getAndStoreFullRegistry()。
  否则进行增量拉取注册表信息getAndUpdateDelta(applications)。
  一般情况,在Eureka client第一次启动,会进行全量拉取。之后的拉取都尽量尝试只进行增量拉取。

  拉取服务注册表:
  全量拉取:getAndStoreFullRegistry();
  增量拉取:getAndUpdateDelta(applications);
全量拉取
进入getAndStoreFullRegistry() 方法,有一方法:eurekaTransport.queryClient.getApplications。
  通过debug发现 实现类是AbstractJerseyEurekaHttpClient,点开,debug出
  webResource地址为:http://root:root@eureka-7900:7900/eureka/apps/,此端点用于获取server中所有的注册表信息。
  getAndStoreFullRegistry()可能被多个线程同时调用,导致新拉取的注册表被旧的覆盖(如果新拉取的动作设置apps阻塞的情况下)。
  此时用了AutomicLong来进行版本管理,如果更新时版本不一致,不保存apps。
  通过这个判断fetchRegistryGeneration.compareAndSet(currentUpdateGeneration, currentUpdateGeneration + 1),如果版本一致,并设置新版本(+1),
  接着执行localRegionApps.set(this.filterAndShuffle(apps));过滤并洗牌apps。点开this.filterAndShuffle(apps)实现,继续点apps.shuffleAndIndexInstances,继续点shuffleInstances,继续点application.shuffleAndStoreInstances,继续点_shuffleAndStoreInstances,发现if (filterUpInstances && InstanceStatus.UP != instanceInfo.getStatus())。只保留状态为UP的服务。
增量拉取
回到刚才的fetchRegistry方法中,getAndUpdateDelta,增量拉取。通过getDelta方法,看到实际拉取的地址是:apps/delta,如果获取到的delta为空,则全量拉取。
  通常来讲是3分钟之内注册表的信息变化(在server端判断),获取到delta后,会更新本地注册表。
  增量式拉取是为了维护client和server端 注册表的一致性,防止本地数据过久,而失效,采用增量式拉取的方式,减少了client和server的通信量。
  client有一个注册表缓存刷新定时器,专门负责维护两者之间的信息同步,但是当增量出现意外时,定时器将执行,全量拉取以更新本地缓存信息。更新本地注册表方法updateDelta,有一个细节。
  if (ActionType.ADDED.equals(instance.getActionType())) ,public enum ActionType {
          ADDED, // Added in the discovery server
          MODIFIED, // Changed in the discovery server
          DELETED
          // Deleted from the discovery server
      },
  在InstanceInfo instance中有一个instance.getActionType(),ADDED和MODIFIED状态的将更新本地注册表applications.addApplication,DELETED将从本地剔除掉existingApp.removeInstance(instance)。
服务注册
好了拉取完eureka server中的注册表了,接着进行服务注册。回到DiscoveryClient构造函数。
  拉取fetchRegistry完后进行register注册。由于构造函数开始时已经将服务实例元数据封装好了instanceInfo,所以此处之间向server发送instanceInfo,
  通过方法httpResponse = eurekaTransport.registrationClient.register(instanceInfo);看到String urlPath = "apps/" + info.getAppName();又是一个server端点,退上去f7,httpResponse.getStatusCode() == Status.NO_CONTENT.getStatusCode();204状态码,则注册成功。
初始化3个定时任务
接着
  // finally, init the schedule tasks (e.g. cluster resolvers, heartbeat, instanceInfo replicator, fetch
  initScheduledTasks();看注释初始化3个定时任务。
  题外话:
  client会定时向server发送心跳,维持自己服务租约的有效性,用心跳定时任务实现;
  而server中会有不同的服务实例注册进来,一进一出,就需要数据的同步。所以client需要定时从server拉取注册表信息,用缓存定时任务实现;
  client如果有变化,也会及时更新server中自己的信息,用按需注册定时任务实现。

  就是这三个定时任务。    

进 initScheduledTasks()方法中,clientConfig.shouldFetchRegistry(),
从server拉取注册表信息。
int registryFetchIntervalSeconds = clientConfig.getRegistryFetchIntervalSeconds()拉取的时间间隔,eureka.client.registry-fetch-interval-seconds进行设置。

int renewalIntervalInSecs = nstanceInfo.getLeaseInfo().getRenewalIntervalInSecs();心跳定时器,默认30秒。

心跳定时任务和缓存刷新定时任务是有scheduler 的 schedule提交的,鼠标放到scheduler上,看到一句话 A scheduler to be used for the following 3 tasks:- updating service urls- scheduling a TimedSuperVisorTask。
知道循环逻辑是由TimedSuperVisorTask实现的。
  new TimedSupervisorTask(
                              "heartbeat",
                              scheduler,
                              heartbeatExecutor,
                              renewalIntervalInSecs,
                              TimeUnit.SECONDS,
                              expBackOffBound,
                              new HeartbeatThread()看到HeartbeatThread线程。
点进去public void run() {
              if (renew()) {
                  lastSuccessfulHeartbeatTimestamp = System.currentTimeMillis();
              }
          }
  里面是renew()方法。
  
  scheduler.schedule(
                      new TimedSupervisorTask(
                              "cacheRefresh",
                              scheduler,
                              cacheRefreshExecutor,
                              registryFetchIntervalSeconds,
                              TimeUnit.SECONDS,
                              expBackOffBound,
                              new CacheRefreshThread()
                      ),
  看到CacheRefreshThread,进去,发现 class CacheRefreshThread implements Runnable {
          public void run() {
              refreshRegistry();
          }
      }是用的refreshRegistry,进去发现fetchRegistry。回到原来讲过的地方。
      
  boolean renew() {
          EurekaHttpResponse<InstanceInfo> httpResponse;
          try {
              httpResponse = eurekaTransport.registrationClient.sendHeartBeat(instanceInfo.getAppName(), instanceInfo.getId(), instanceInfo, null);
              logger.debug(PREFIX + "{} - Heartbeat status: {}", appPathIdentifier, httpResponse.getStatusCode());
              if (httpResponse.getStatusCode() == Status.NOT_FOUND.getStatusCode()) {
                  REREGISTER_COUNTER.increment();
                  logger.info(PREFIX + "{} - Re-registering apps/{}", appPathIdentifier, instanceInfo.getAppName());
                  long timestamp = instanceInfo.setIsDirtyWithTime();
                  boolean success = register();
                  if (success) {
                      instanceInfo.unsetIsDirty(timestamp);
                  }
                  return success;
              }
              return httpResponse.getStatusCode() == Status.OK.getStatusCode();
          } catch (Throwable e) {
              logger.error(PREFIX + "{} - was unable to send heartbeat!", appPathIdentifier, e);
              return false;
          }
      }看到如果遇到404,server没有此实例,则重新发起注册。如果续约成功返回 200.
      点sendHeartBeat进去String urlPath = "apps/" + appName + '/' + id;
      
还有一个定时任务,按需注册。当instanceinfo和status发生变化时,需要向server同步,去更新自己在server中的实例信息。保证server注册表中服务实例信息的有效和可用。
  // InstanceInfo replicator
          instanceInfoReplicator = new InstanceInfoReplicator(
                  this,
                  instanceInfo,
                  clientConfig.getInstanceInfoReplicationIntervalSeconds(),
                  2); // burstSize
  
          statusChangeListener = new ApplicationInfoManager.StatusChangeListener() {
              @Override
              public String getId() {
                  return "statusChangeListener";
              }
  
           @Override
              public void notify(StatusChangeEvent statusChangeEvent) {
                  if (InstanceStatus.DOWN == statusChangeEvent.getStatus() ||
                          InstanceStatus.DOWN == statusChangeEvent.getPreviousStatus()) {
                      // log at warn level if DOWN was involved
                      logger.warn("Saw local status change event {}", statusChangeEvent);
                  } else {
                      logger.info("Saw local status change event {}", statusChangeEvent);
                  }
                  instanceInfoReplicator.onDemandUpdate();
              }
          };
          if (clientConfig.shouldOnDemandUpdateStatusChange()) {
              applicationInfoManager.registerStatusChangeListener(statusChangeListener);
          }
      instanceInfoReplicator.start(clientConfig.getInitialInstanceInfoReplicationIntervalSeconds());    
      
此定时任务有2个部分,
  1:定时刷新服务实例信息和检查应用状态的变化,在服务实例信息发生改变的情况下向server重新发起注册。InstanceInfoReplicator点进去。看到一个方法    
  public void run() {
          try {
              discoveryClient.refreshInstanceInfo();//刷新instanceinfo。
              //如果实例信息有变,返回数据更新时间。
              Long dirtyTimestamp = instanceInfo.isDirtyWithTime();
              if (dirtyTimestamp != null) {
                  discoveryClient.register();//注册服务实例。
                  instanceInfo.unsetIsDirty(dirtyTimestamp);
              }
          } catch (Throwable t) {
              logger.warn("There was a problem with the instance info replicator", t);
          } finally {
          //延时执行下一个检查任务。用于再次调用run方法,继续检查服务实例信息和状态的变化。
              Future next = scheduler.schedule(this, replicationIntervalSeconds, TimeUnit.SECONDS);
              scheduledPeriodicRef.set(next);
          }
      }      

refreshInstanceInfo点进去,看方法注释:如果有变化,在下次心跳时,同步向server。

2.注册状态改变监听器,在应用状态发生变化时,刷新服务实例信息,在服务实例信息发生改变时向server注册。  看这段            
   statusChangeListener = new ApplicationInfoManager.StatusChangeListener() {
                  @Override
                  public String getId() {
                      return "statusChangeListener";
                  }
@Override
              public void notify(StatusChangeEvent statusChangeEvent) {
                  if (InstanceStatus.DOWN == statusChangeEvent.getStatus() ||
                          InstanceStatus.DOWN == statusChangeEvent.getPreviousStatus()) {
                      // log at warn level if DOWN was involved
                      logger.warn("Saw local status change event {}", statusChangeEvent);
                  } else {
                      logger.info("Saw local status change event {}", statusChangeEvent);
                  }
                  instanceInfoReplicator.onDemandUpdate();
              }
          };如果状态发生改变,调用onDemandUpdate(),点onDemandUpdate进去,看到InstanceInfoReplicator.this.run();     
          
总结:两部分,一部分自己去检查,一部分等待状态监听事件。

初始化定时任务完成,最后一步启动步骤完成。接下来就是正常服务于业务。然后消亡。          
服务下线
服务下线:在应用关闭时,client会向server注销自己,在Discoveryclient销毁前,会执行下面清理方法。
@PreDestroy
@Override
public synchronized void shutdown() ,看此方法上有一个注解,表示:在销毁前执行此方法。unregisterStatusChangeListener注销监听器。cancelScheduledTasks取消定时任务。unregister服务下线。eurekaTransport.shutdown关闭jersy客户端 等。

unregister点进去。cancel点进去。AbstractJerseyEurekaHttpClient。String urlPath = "apps/" + appName + '/' + id;看到url和http请求delete方法。   
②⑩② client源码总结
总结:源码其实两部分内容:
1、client自身的操作。
2、server的配合。
 
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值