Nacos注册中心源码解析-客户端

目录

一、Spring Factories机制

二、客户端(注册和心跳)

注入入口

客户端注册

客户端心跳

三、客户端(更新本地注册表)

注入入口

更新本地注册表

开启定时更新任务 

四、客户端(获取服务提供者列表)

服务发现的流程


一、Spring Factories机制

在外部包的META-INF/spring.factories文件中添加配置文件,Spring Boot项目会自动扫描这个配置文件

二、客户端(注册和心跳)

注入入口

spring.factories

 启动时NacosServiceRegistryAutoConfiguration会注入三个Bean。在NacosRegistration、NacosRegistration、NacosAutoServiceRegistration中进行获取Properties配置信息和封装数据、初始化、进行服务的注册等操作。

public class NacosServiceRegistryAutoConfiguration {

	@Bean
	public NacosServiceRegistry nacosServiceRegistry(
			NacosDiscoveryProperties nacosDiscoveryProperties) {
		return new NacosServiceRegistry(nacosDiscoveryProperties);
	}

	@Bean
	@ConditionalOnBean(AutoServiceRegistrationProperties.class)
	public NacosRegistration nacosRegistration(
			ObjectProvider<List<NacosRegistrationCustomizer>> registrationCustomizers,
			NacosDiscoveryProperties nacosDiscoveryProperties,
			ApplicationContext context) {
		return new NacosRegistration(registrationCustomizers.getIfAvailable(),
				nacosDiscoveryProperties, context);
	}

	@Bean
	@ConditionalOnBean(AutoServiceRegistrationProperties.class)
	public NacosAutoServiceRegistration nacosAutoServiceRegistration(
			NacosServiceRegistry registry,
			AutoServiceRegistrationProperties autoServiceRegistrationProperties,
			NacosRegistration registration) {
		return new NacosAutoServiceRegistration(registry,
				autoServiceRegistrationProperties, registration);
	}

}

客户端注册

启动时start()会调用NacosAutoServiceRegistration.register()方法判断是否开启注册和端口号是否正常

//NacosAutoServiceRegistration.class
@Override
	protected void register() {
		if (!this.registration.getNacosDiscoveryProperties().isRegisterEnabled()) {
			log.debug("Registration disabled.");
			return;
		}
		if (this.registration.getPort() < 0) {
			this.registration.setPort(getPort().get());
		}
		super.register();
	}

最终会执行到NacosServiceRegistry.register()方法,整理注册所需要的数据信息

//NacosServiceRegistry.class
@Override
	public void register(Registration registration) {
		if (StringUtils.isEmpty(registration.getServiceId())) {
			log.warn("No service to register for nacos client...");
			return;
		}
        //获取实例数据、命名空间等
		NamingService namingService = namingService();
		String serviceId = registration.getServiceId();
		String group = nacosDiscoveryProperties.getGroup();
		Instance instance = getNacosInstanceFromRegistration(registration);
		try {
            //执行实例注册
			namingService.registerInstance(serviceId, group, instance);
			log.info("nacos registry, {} {} {}:{} register finished", group, serviceId,
					instance.getIp(), instance.getPort());
		}
		catch (Exception e) {
			if (nacosDiscoveryProperties.isFailFast()) {
				log.error("nacos registry, {} register failed...{},", serviceId,
						registration.toString(), e);
				rethrowRuntimeException(e);
			}
			else {
				log.warn("Failfast is false. {} register failed...{},", serviceId,
						registration.toString(), e);
			}
		}
	}

核心方法namingService.registerInstance(),会检测该实例是否为临时实例。并将获取到系统定义的实例等信息注册到注册中心

//NacosNamingService.class
@Override
public void registerInstance(String serviceName, String groupName, Instance instance) throws NacosException {
    NamingUtils.checkInstanceIsLegal(instance);
    // 格式为:groupId@@微服务名称
    String groupedServiceName = NamingUtils.getGroupedName(serviceName, groupName);
    // 若当前实例为临时实例,则向Server发送心跳
    if (instance.isEphemeral()) {
        // 构建一个心跳信息实例
        BeatInfo beatInfo = beatReactor.buildBeatInfo(groupedServiceName, instance);
        // 向Server端发送心跳(定时任务)
        beatReactor.addBeatInfo(groupedServiceName, beatInfo);
    }
    // 向Server发送注册请求
    serverProxy.registerService(groupedServiceName, groupName, instance);
}

注册请求内容大概就是reqApi()方法封装了http协议发送请求,在callServer()方法中如果响应码不为0或200就认定为失败进行3次的重试

//NamingProxy.class
public String reqApi(String api, Map<String, String> params, Map<String, String> body, List<String> servers,
        String method) throws NacosException {

    params.put(CommonParams.NAMESPACE_ID, getNamespaceId());

    if (CollectionUtils.isEmpty(servers) && StringUtils.isBlank(nacosDomain)) {
        throw new NacosException(NacosException.INVALID_PARAM, "no server available");
    }

    NacosException exception = new NacosException();

    if (StringUtils.isNotBlank(nacosDomain)) {
        // 默认尝试连接3次
        for (int i = 0; i < maxRetry; i++) {
            try {
                return callServer(api, params, body, nacosDomain, method);
            } catch (NacosException e) {
                exception = e;
                if (NAMING_LOGGER.isDebugEnabled()) {
                    NAMING_LOGGER.debug("request {} failed.", nacosDomain, e);
                }
            }
        }
    } else {
        // 生成一个随机数,轮询
        Random random = new Random(System.currentTimeMillis());
        int index = random.nextInt(servers.size());

        // 遍历Server,从中选择一个Server进行连接,轮询
        for (int i = 0; i < servers.size(); i++) {
            String server = servers.get(index);
            try {
                // todo 发送请求
                return callServer(api, params, body, server, method);
            } catch (NacosException e) {
                exception = e;
                if (NAMING_LOGGER.isDebugEnabled()) {
                    NAMING_LOGGER.debug("request {} failed.", server, e);
                }
            }
            index = (index + 1) % servers.size();
        }
    }
    ...
    throw new NacosException(exception.getErrCode(),
            "failed to req API:" + api + " after all servers(" + servers + ") tried: " + exception.getMessage());

}

客户端心跳

先会检测是否为临时实例,构建BeatInfo

//NamingProxyService.class
// 若当前实例为临时实例,则向Server发送心跳
if (instance.isEphemeral()) {
    // 构建一个心跳信息实例
    BeatInfo beatInfo = beatReactor.buildBeatInfo(groupedServiceName, instance);
    // todo 向Server端发送心跳(定时任务)
    beatReactor.addBeatInfo(groupedServiceName, beatInfo);
}

addBeatInfo()开启定时任务,向Server端发送心跳请求 

//BeatReactor.class
public void addBeatInfo(String serviceName, BeatInfo beatInfo) {
    NAMING_LOGGER.info("[BEAT] adding beat: {} to beat map.", beatInfo);
    // 格式为:groupId@@微服务名称#ip#port
    // 这个key就固定一个主机
    String key = buildKey(serviceName, beatInfo.getIp(), beatInfo.getPort());
    BeatInfo existBeat = null;
    //fix #1733
    // dom2Beat为一个map,key为主机 value为该主机发送心跳beatInfo
    if ((existBeat = dom2Beat.remove(key)) != null) {
        existBeat.setStopped(true);
    }
    dom2Beat.put(key, beatInfo);
    // todo 开启一个定时任务
    executorService.schedule(new BeatTask(beatInfo), beatInfo.getPeriod(), TimeUnit.MILLISECONDS);
    MetricsMonitor.getDom2BeatSizeMonitor().set(dom2Beat.size());
}

可以看到元源码中构建了BeatTask(beatInfo)的任务对象,这也就是临时对象通过registerService()方法向注册中心上报自己还活跃(推模式)。

class BeatTask implements Runnable {

    BeatInfo beatInfo;

    public BeatTask(BeatInfo beatInfo) {
        this.beatInfo = beatInfo;
    }

    @Override
    public void run() {
        if (beatInfo.isStopped()) {
            return;
        }
        long nextTime = beatInfo.getPeriod();
        try {
            // 发送心跳
            JsonNode result = serverProxy.sendBeat(beatInfo, BeatReactor.this.lightBeatEnabled);
            long interval = result.get("clientBeatInterval").asLong();
            boolean lightBeatEnabled = false;
            if (result.has(CommonParams.LIGHT_BEAT_ENABLED)) {
                lightBeatEnabled = result.get(CommonParams.LIGHT_BEAT_ENABLED).asBoolean();
            }
            BeatReactor.this.lightBeatEnabled = lightBeatEnabled;
            if (interval > 0) {
                nextTime = interval;
            }
            int code = NamingResponseCode.OK;
            if (result.has(CommonParams.CODE)) {
                code = result.get(CommonParams.CODE).asInt();
            }
            // 若在服务端没有发现这个Client,则Server端返回错误码为20404
            if (code == NamingResponseCode.RESOURCE_NOT_FOUND) {
                Instance instance = new Instance();
                instance.setPort(beatInfo.getPort());
                instance.setIp(beatInfo.getIp());
                instance.setWeight(beatInfo.getWeight());
                instance.setMetadata(beatInfo.getMetadata());
                instance.setClusterName(beatInfo.getCluster());
                instance.setServiceName(beatInfo.getServiceName());
                instance.setInstanceId(instance.getInstanceId());
                instance.setEphemeral(true);
                try {
                    // 向Server端发送注册请求
                    serverProxy.registerService(beatInfo.getServiceName(),
                            NamingUtils.getGroupName(beatInfo.getServiceName()), instance);
                } catch (Exception ignore) {
                }
            }
        } catch (NacosException ex) {
            NAMING_LOGGER.error("[CLIENT-BEAT] failed to send beat: {}, code: {}, msg: {}",
                    JacksonUtils.toJson(beatInfo), ex.getErrCode(), ex.getErrMsg());

        }
        // 设置一个新的定时任务
        executorService.schedule(new BeatTask(beatInfo), nextTime, TimeUnit.MILLISECONDS);
    }
}

三、客户端(更新本地注册表)

注入入口

配置注入了NacosWatchBean对象来管控初始注册表

public class NacosDiscoveryClientConfiguration {

	@Bean
	public DiscoveryClient nacosDiscoveryClient(
			NacosServiceDiscovery nacosServiceDiscovery) {
		return new NacosDiscoveryClient(nacosServiceDiscovery);
	}

	@Bean
	@ConditionalOnMissingBean
	@ConditionalOnProperty(value = "spring.cloud.nacos.discovery.watch.enabled",
			matchIfMissing = true)
	public NacosWatch nacosWatch(NacosServiceManager nacosServiceManager,
			NacosDiscoveryProperties nacosDiscoveryProperties) {
		return new NacosWatch(nacosServiceManager, nacosDiscoveryProperties);
	}

}

Bean实现了SmartLifecycle,在环境准备好了之后,会调用start()方法

//NacosWatch.class
public class NacosWatch implements ApplicationEventPublisherAware, SmartLifecycle {

  @Override
  public void start() {
       if (this.running.compareAndSet(false, true)) {
          EventListener eventListener = listenerMap.computeIfAbsent(buildKey(),
                event -> new EventListener() {
                   @Override
                   public void onEvent(Event event) {
                      if (event instanceof NamingEvent) {
                         List<Instance> instances = ((NamingEvent) event)
                               .getInstances();
                         Optional<Instance> instanceOptional = selectCurrentInstance(
                               instances);
                         instanceOptional.ifPresent(currentInstance -> {
                            resetIfNeeded(currentInstance);
                         });
                      }
                   }
                });
          //构建服务对象
          NamingService namingService = nacosServiceManager
                .getNamingService(properties.getNacosProperties());
          try {
             //订阅
             namingService.subscribe(properties.getService(), properties.getGroup(),
                   Arrays.asList(properties.getClusterName()), eventListener);
          }
          catch (Exception e) {
             log.error("namingService subscribe failed, properties:{}", properties, e);
          }

          this.watchFuture = this.taskScheduler.scheduleWithFixedDelay(
                this::nacosServicesWatch, this.properties.getWatchDelay());
       }
    }
}

com.alibaba.nacos.client.naming.NacosNamingService#subscribe()
@Override
public void subscribe(String serviceName, String groupName, List<String> clusters, EventListener listener)
        throws NacosException {
    hostReactor.subscribe(NamingUtils.getGroupedName(serviceName, groupName), StringUtils.join(clusters, ","),
            listener);
}
com.alibaba.nacos.client.naming.core.HostReactor#subscribe
public void subscribe(String serviceName, String clusters, EventListener eventListener) {
    notifier.registerListener(serviceName, clusters, eventListener);
    getServiceInfo(serviceName, clusters);
}

核心方法getServiceInfo()。

该方法包含两个核心内容:1.更新本地服务、2.开启定时任务

//HostReactor.class
public ServiceInfo getServiceInfo(final String serviceName, final String clusters) {

    NAMING_LOGGER.debug("failover-mode: " + failoverReactor.isFailoverSwitch());
    // key的格式为:groupId@@微服务名称@@clusters名称
    String key = ServiceInfo.getKey(serviceName, clusters);
    if (failoverReactor.isFailoverSwitch()) {
        return failoverReactor.getService(key);
    }

    // 从当前Client本地注册表中获取当前服务
    ServiceInfo serviceObj = getServiceInfo0(serviceName, clusters);

    // 若本地注册表中没有该服务,则创建一个
    if (null == serviceObj) {
        // 创建一个空的服务(没有任何提供者实例instance的ServiceInfo)
        serviceObj = new ServiceInfo(serviceName, clusters);

        serviceInfoMap.put(serviceObj.getKey(), serviceObj);

        // updatingMap 是一个临时缓存,主要使用这个map的key
        // map的key不能重复的特性
        // 只要这个服务名称在这个map中,说明这个服务正在更新中
        updatingMap.put(serviceName, new Object());
        // 更新本地注册表ServiceName的服务
        updateServiceNow(serviceName, clusters);
        // 更新完毕,从updatingMap中删除
        updatingMap.remove(serviceName);

    // 若当前注册表中已经有这个服务,那么查看一下临时map下
    // 是否存在该服务,若存在,说明当前服务正在更新中,所以本次操作先等待一段时间,默认5s
    } 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);
                }
            }
        }
    }

    // 启动一个定时任务,定时更新本地注册表中的当前服务
    scheduleUpdateIfAbsent(serviceName, clusters);

    return serviceInfoMap.get(serviceObj.getKey());
}

更新本地注册表

本地注册表有个好处,就是当一个服务挂了之后,短时间内不会造成影响,因为有个本地注册列表,在服务不更新的情况下,服务还能够正常的运转,其原因如下

Nacos的服务发现,一般是通过订阅的形式来获取服务数据。而通过订阅的方式,则是从本地的服务注册列表中获取(可以理解为缓存)。相反,如果不订阅,那么服务的信息将会从Nacos服务端获取,这时候就需要对应的服务是健康的。(宕机就不能使用了)
在代码设计上,通过Map来存放实例数据,key为实例名称,value为实例的相关信息数据(ServiceInfo对象)。

更新本地注册表主要看updateService()方法:

public void updateService(String serviceName, String clusters) throws NacosException {
    // 本地注册表中获取当前服务
    ServiceInfo oldService = getServiceInfo0(serviceName, clusters);
    try {
        // 提交get请求,获取服务注册信息
        String result = serverProxy.queryList(serviceName, clusters, pushReceiver.getUdpPort(), false);

        if (StringUtils.isNotEmpty(result)) {
            //发送get请求后,服务端响应的ServiceInfo更新到本地
            processServiceJson(result);
        }
    } finally {
        if (oldService != null) {
            synchronized (oldService) {
                oldService.notifyAll();
            }
        }
    }
}

更新执行方法processServiceJson() 

//来自Server的数据是最新数据
public ServiceInfo processServiceJson(String json) {
    // 转成ServiceInfo类
    ServiceInfo serviceInfo = JacksonUtils.toObj(json, ServiceInfo.class);
    // 从本地注册表中获取对应服务
    ServiceInfo oldService = serviceInfoMap.get(serviceInfo.getKey());

    if (pushEmptyProtection && !serviceInfo.validate()) {
        //empty or error push, just ignore
        return oldService;
    }

    boolean changed = false;

    // 当前注册表中存在该服务,想办法将来自server端的数据更新到本地注册表中
    if (oldService != null) {

        // 为了安全起见,这种情况几乎是不存在的
        if (oldService.getLastRefTime() > serviceInfo.getLastRefTime()) {
            NAMING_LOGGER.warn("out of date data received, old-t: " + oldService.getLastRefTime() + ", new-t: "
                    + serviceInfo.getLastRefTime());
        }
        // 来自server的serviceInfo替换到注册表中的当前服务
        serviceInfoMap.put(serviceInfo.getKey(), serviceInfo);

        // 遍历本地注册表中当前服务所有instance实例
        Map<String, Instance> oldHostMap = new HashMap<String, Instance>(oldService.getHosts().size());
        for (Instance host : oldService.getHosts()) {
            // 将当前遍历instance主机的ip:port作为key,instance为value
            // 写入到一个新的map中
            oldHostMap.put(host.toInetAddr(), host);
        }

        // 遍历来自server的当前服务 所有instance实例
        Map<String, Instance> newHostMap = new HashMap<String, Instance>(serviceInfo.getHosts().size());
        for (Instance host : serviceInfo.getHosts()) {
            // 将当前遍历instance主机的ip:port作为key,instance为value
            // 写入到一个新的map中
            newHostMap.put(host.toInetAddr(), host);
        }

        // 该set集合中存放的是,两个map(oldHostMap与newHostMap)中都有的ip:port,
        // 但它们的instance不相同,此时会将来自于server的instance写入到这个set
        Set<Instance> modHosts = new HashSet<Instance>();
        // 只有newHostMap中存在的instance,即在server端新增的instance
        Set<Instance> newHosts = new HashSet<Instance>();
        // 只有oldHostMap中存在的instance,即在server端被删除的instance
        Set<Instance> remvHosts = new HashSet<Instance>();

        List<Map.Entry<String, Instance>> newServiceHosts = new ArrayList<Map.Entry<String, Instance>>(
                newHostMap.entrySet());
        // 遍历来自于server的主机
        for (Map.Entry<String, Instance> entry : newServiceHosts) {

            Instance host = entry.getValue();
            // ip:port
            String key = entry.getKey();
            // 在注册表中存在该ip:port,但这两个instance又不同,则将这个instance写入到modHosts
            if (oldHostMap.containsKey(key) && !StringUtils
                    .equals(host.toString(), oldHostMap.get(key).toString())) {
                modHosts.add(host);
                continue;
            }
            // 若注册表中不存在该ip:port,说明这个主机是新增的,则将其写入到newHosts
            if (!oldHostMap.containsKey(key)) {
                newHosts.add(host);
            }
        }
        // 遍历来自于本地注册表的主机
        for (Map.Entry<String, Instance> entry : oldHostMap.entrySet()) {
            Instance host = entry.getValue();
            String key = entry.getKey();
            if (newHostMap.containsKey(key)) {
                continue;
            }
            // 注册表中存在,但来自于server的serviceInfo中不存在,
            // 说明这个instance被干掉了,将其写入到remvHosts
            if (!newHostMap.containsKey(key)) {
                remvHosts.add(host);
            }

        }

        if (newHosts.size() > 0) {
            changed = true;
            NAMING_LOGGER.info("new ips(" + newHosts.size() + ") service: " + serviceInfo.getKey() + " -> "
                    + JacksonUtils.toJson(newHosts));
        }

        if (remvHosts.size() > 0) {
            changed = true;
            NAMING_LOGGER.info("removed ips(" + remvHosts.size() + ") service: " + serviceInfo.getKey() + " -> "
                    + JacksonUtils.toJson(remvHosts));
        }

        if (modHosts.size() > 0) {
            changed = true;
            // 变更心跳信息BeatInfo
            updateBeatInfo(modHosts);
            NAMING_LOGGER.info("modified ips(" + modHosts.size() + ") service: " + serviceInfo.getKey() + " -> "
                    + JacksonUtils.toJson(modHosts));
        }

        serviceInfo.setJsonFromServer(json);
        // 只要发生了变更,就将这个发生变更的serviceInfo记录到一个缓存队列
        if (newHosts.size() > 0 || remvHosts.size() > 0 || modHosts.size() > 0) {
            NotifyCenter.publishEvent(new InstancesChangeEvent(serviceInfo.getName(), serviceInfo.getGroupName(),
                    serviceInfo.getClusters(), serviceInfo.getHosts()));
            DiskCache.write(serviceInfo, cacheDir);
        }

    // 本地注册表中没有这个服务,直接将来自Server的serviceInfo写入到本地注册表
    } else {
        changed = true;
        NAMING_LOGGER.info("init new ips(" + serviceInfo.ipCount() + ") service: " + serviceInfo.getKey() + " -> "
                + JacksonUtils.toJson(serviceInfo.getHosts()));
        // 将来自于server的serviceInfo写入到注册表
        serviceInfoMap.put(serviceInfo.getKey(), serviceInfo);
        // 将这个发生变更的serviceInfo记录到一个缓存队列
        NotifyCenter.publishEvent(new InstancesChangeEvent(serviceInfo.getName(), serviceInfo.getGroupName(),
                serviceInfo.getClusters(), serviceInfo.getHosts()));
        serviceInfo.setJsonFromServer(json);
        DiskCache.write(serviceInfo, cacheDir);
    }

    MetricsMonitor.getServiceInfoMapSizeMonitor().set(serviceInfoMap.size());

    if (changed) {
        NAMING_LOGGER.info("current ips:(" + serviceInfo.ipCount() + ") service: " + serviceInfo.getKey() + " -> "
                + JacksonUtils.toJson(serviceInfo.getHosts()));
    }

    return serviceInfo;
}

这里可以看到,我启动了两个不同端口的nacos-stock服务,注册中心服务端一并返回了名称相同的实例

开启定时更新任务 

//HostReactor.class
// todo 启动一个定时任务,定时更新本地注册表中的当前服务
scheduleUpdateIfAbsent(serviceName, clusters);

public void scheduleUpdateIfAbsent(String serviceName, String clusters) {
    // futureMap是一个缓存map,其key为 groupId@@微服务名称@@clusters
    // value是一个定时异步操作对象
    // 这种结构称之为:双重检测锁,DCL,Double Check Lock
    // 该结构是为了避免在并发情况下,多线程重复写入数据
    // 该结构的特征:
    // 1)有两个不为null的判断
    // 2)有共享集合
    // 3)有synchronized代码块
    if (futureMap.get(ServiceInfo.getKey(serviceName, clusters)) != null) {
        return;
    }

    synchronized (futureMap) {
        if (futureMap.get(ServiceInfo.getKey(serviceName, clusters)) != null) {
            return;
        }
        // 创建一个定时异步操作对象,并启动这个定时任务
        ScheduledFuture<?> future = addTask(new UpdateTask(serviceName, clusters));
        // 将这个定时异步操作对象写入到缓存map
        futureMap.put(ServiceInfo.getKey(serviceName, clusters), future);
    }
}

四、客户端(获取服务提供者列表)

服务发现的流程:

以调用远程接口(OpenFeign)为例,当执行远程调用时,需要经过服务发现的过程。
服务发现先执行NacosServerList类中的getServers()方法,根据远程调用接口上@FeignClient中的属性作为serviceId传入NacosNamingService.selectInstances()方法中进行调用。
根据subscribe的值来决定服务是从本地注册列表中获取还是从Nacos服务端中获取。
以本地注册列表为例,通过调用HostReactor.getServiceInfo()来获取服务的信息(serviceInfo),Nacos本地注册列表由3个Map来共同维护。

//NacosNamingService.class
@Override
public List<Instance> selectInstances(String serviceName, String groupName, List<String> clusters, boolean healthy,
        boolean subscribe) throws NacosException {

    ServiceInfo serviceInfo;
    //判断是否订阅
    if (subscribe) {
        // 已订阅就会,获取到要调用服务的serviceInfo和更新本地注册表
        serviceInfo = hostReactor.getServiceInfo(NamingUtils.getGroupedName(serviceName, groupName),
                StringUtils.join(clusters, ","));
    } else {
        //未订阅直接向服务端发送请求
        serviceInfo = hostReactor
                .getServiceInfoDirectlyFromServer(NamingUtils.getGroupedName(serviceName, groupName),
                        StringUtils.join(clusters, ","));
    }
    // 从serviceInfo的所有instance实例中检测健康状态过滤出所有可用的
    return selectInstances(serviceInfo, healthy);
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值