【Nacos源码系列】Nacos服务发现的原理

前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站。

上篇文章介绍了 Nacos服务注册的原理 ,本篇文章将从客户端和服务端的角度介绍Nacos服务发现的原理。

服务发现是什么

服务发现是一种机制,用于在分布式系统中动态地查找和识别可用的服务实例。它解决了微服务架构中服务之间的通信和调用的核心问题。

在传统的单体应用中,各个组件之间的通信往往是直接的函数调用或者数据库查询,因为它们都在同一个进程内部。而在微服务架构中,各个服务被拆分成独立的、自治的服务,可能分布在不同的主机或容器中。这就需要一种机制来帮助服务在网络环境下找到彼此。

服务发现机制通过在微服务架构中引入一个独立的服务注册中心,服务实例会将自己的网络地址、端口以及其他标识信息注册到注册中心中。其他服务可以通过查询注册中心来获取所需服务的具体网络位置,从而能够进行跨服务的通信和调用。

Nacos版本如下:

客户端服务端
版本Spring Cloud Alibaba 2021.0.1.01.4.1

客户端服务发现

对于Spring Cloud Alibaba Nacos想查看源头还是要从spring.factories文件开始。
spring.factories

我们来看客户端的服务发现自动配置类 NacosDiscoveryClientConfiguration

@Configuration(proxyBeanMethods = false)
@ConditionalOnDiscoveryEnabled
@ConditionalOnBlockingDiscoveryEnabled
@ConditionalOnNacosDiscoveryEnabled
@AutoConfigureBefore({ SimpleDiscoveryClientAutoConfiguration.class,
		CommonsClientAutoConfiguration.class })
@AutoConfigureAfter(NacosDiscoveryAutoConfiguration.class)
public class NacosDiscoveryClientConfiguration {

    //Nacos服务发现客户端
	@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,
			ObjectProvider<ThreadPoolTaskScheduler> taskExecutorObjectProvider) {
		return new NacosWatch(nacosServiceManager, nacosDiscoveryProperties,
				taskExecutorObjectProvider);
	}

}

NacosDiscoveryClientConfiguration 类注入两个类 NacosDiscoveryClientNacosWatch

NacosWatch 用于监控服务实例的变化,这里先不过多介绍。

NacosDiscoveryClient 用于管理和维护服务实例的列表,以便客户端进行服务调用和负载均衡。它实现了 DiscoveryClient 接口,DiscoveryClient 是Spring Cloud提供的用于服务发现的客户端。

public class NacosDiscoveryClient implements DiscoveryClient {

	private static final Logger log = LoggerFactory.getLogger(NacosDiscoveryClient.class);

	/**
	 * Nacos Discovery Client Description.
	 */
	public static final String DESCRIPTION = "Spring Cloud Nacos Discovery Client";

	//服务发现类
	private NacosServiceDiscovery serviceDiscovery;

	//是否开启容忍失败获取
	@Value("${spring.cloud.nacos.discovery.failure-tolerance-enabled:false}")
	private boolean failureToleranceEnabled;

	public NacosDiscoveryClient(NacosServiceDiscovery nacosServiceDiscovery) {
		this.serviceDiscovery = nacosServiceDiscovery;
	}

	@Override
	public String description() {
		return DESCRIPTION;
	}

    /**
     * 根据serviceId获取某个服务实例的列表
     * @param serviceId
     * @return
     */
	@Override
	public List<ServiceInstance> getInstances(String serviceId) {
		try {
		    //从注册中心获取服务实例列表
			return Optional.of(serviceDiscovery.getInstances(serviceId)).map(instances -> {
                                                //将服务实例放入缓存中
						ServiceCache.setInstances(serviceId, instances);
						return instances;}).get();
		}
		catch (Exception e) {
		    //如果容忍获取失败开启,获取失败后从本地缓存中返回实例列表
			if (failureToleranceEnabled) {
				return ServiceCache.getInstances(serviceId);
			}
			throw new RuntimeException(
					"Can not get hosts from nacos server. serviceId: " + serviceId, e);
		}
	}

    /**
     * 获取所有服务名称
     * @return
     */
	@Override
	public List<String> getServices() {
		try {
			return Optional.of(serviceDiscovery.getServices()).map(services -> {
						ServiceCache.set(services);
						return services;
					}).get();
		}
		catch (Exception e) {
			log.error("get service name from nacos server fail,", e);
            //如果容忍获取失败开启,获取失败后从本地缓存中返回服务名列表
			return failureToleranceEnabled ? ServiceCache.get() : Collections.emptyList();
		}
	}
}

NacosDiscoveryClient 在进行服务调用时,会调用 getInstances() 方法返回该服务的所有实例信息,实际通过 NacosServiceDiscovery#getInstances方法将 Instance 列表 转为 ServiceInstance 列表并返回。

getInstances() 方法中在服务最新实例列表获取成功后会放入 ServiceCache 缓存中,获取失败抛异常后会从 ServiceCache 缓存中获取实例列表。这就保证了当注册中心挂了以后,客户端在一定程度上保证了服务的正常调用。

public final class ServiceCache {

	private ServiceCache() {
	}

	private static List<String> services = Collections.emptyList();
	//key:服务id ,value:服务对应的实例列表
	private static Map<String, List<ServiceInstance>> instancesMap = new ConcurrentHashMap<>();

	public static void setInstances(String serviceId, List<ServiceInstance> instances) {
		instancesMap.put(serviceId, Collections.unmodifiableList(instances));
	}

	public static List<ServiceInstance> getInstances(String serviceId) {
		return Optional.ofNullable(instancesMap.get(serviceId)).orElse(Collections.emptyList());
	}

	public static void set(List<String> newServices) {
		services = Collections.unmodifiableList(newServices);
	}

	public static List<String> get() {
		return services;
	}
}

NacosServiceDiscovery 用于服务的发现,当一个微服务需要调用其他服务时,可以通过NacosServiceDiscovery类从Nacos注册中心中查询目标服务的信息,包括服务名称、IP地址、端口号等信息。

重点看下NacosServiceDiscovery#getInstances方法:

public class NacosServiceDiscovery {

	private NacosDiscoveryProperties discoveryProperties;

	private NacosServiceManager nacosServiceManager;

	public NacosServiceDiscovery(NacosDiscoveryProperties discoveryProperties,
			NacosServiceManager nacosServiceManager) {
		this.discoveryProperties = discoveryProperties;
		this.nacosServiceManager = nacosServiceManager;
	}

	/**
     * 返回某个服务的所有实例信息
	 * Return all instances for the given service.
	 * @param serviceId id of service
	 * @return list of instances
	 * @throws NacosException nacosException
	 */
	public List<ServiceInstance> getInstances(String serviceId) throws NacosException {
		String group = discoveryProperties.getGroup();
		//获取服务的实例列表
		List<Instance> instances = namingService().selectInstances(serviceId, group,
				true);
		//把Instance实例信息转为ServiceInstance信息
		return hostToServiceInstanceList(instances, serviceId);
	}

	/**
     * 返回所有的服务名称
	 * Return the names of all services.
	 * @return list of service names
	 * @throws NacosException nacosException
	 */
	public List<String> getServices() throws NacosException {
		String group = discoveryProperties.getGroup();
		ListView<String> services = namingService().getServicesOfServer(1,
				Integer.MAX_VALUE, group);
		return services.getData();
	}

    //Instance转成ServiceInstance
	public static List<ServiceInstance> hostToServiceInstanceList(
			List<Instance> instances, String serviceId) {
		List<ServiceInstance> result = new ArrayList<>(instances.size());
		for (Instance instance : instances) {
			ServiceInstance serviceInstance = hostToServiceInstance(instance, serviceId);
			if (serviceInstance != null) {
				result.add(serviceInstance);
			}
		}
		return result;
	}

	//Instance转成ServiceInstance
	public static ServiceInstance hostToServiceInstance(Instance instance,
			String serviceId) {
		if (instance == null || !instance.isEnabled() || !instance.isHealthy()) {
			return null;
		}
		NacosServiceInstance nacosServiceInstance = new NacosServiceInstance();
		nacosServiceInstance.setHost(instance.getIp());
		nacosServiceInstance.setPort(instance.getPort());
		nacosServiceInstance.setServiceId(serviceId);

		Map<String, String> metadata = new HashMap<>();
		metadata.put("nacos.instanceId", instance.getInstanceId());
		metadata.put("nacos.weight", instance.getWeight() + "");
		metadata.put("nacos.healthy", instance.isHealthy() + "");
		metadata.put("nacos.cluster", instance.getClusterName() + "");
		if (instance.getMetadata() != null) {
			metadata.putAll(instance.getMetadata());
		}
		metadata.put("nacos.ephemeral", String.valueOf(instance.isEphemeral()));
		nacosServiceInstance.setMetadata(metadata);

		if (metadata.containsKey("secure")) {
			boolean secure = Boolean.parseBoolean(metadata.get("secure"));
			nacosServiceInstance.setSecure(secure);
		}
		return nacosServiceInstance;
	}

	private NamingService namingService() {
		return nacosServiceManager
				.getNamingService(discoveryProperties.getNacosProperties());
	}

}

当进行服务调用的时候,NacosServiceDiscovery#getInstances() 会从注册中心获取被调用服务的所有实例信息,获取到实例信息之后会通过hostToServiceInstanceList()方法把实例信息转为 ServiceInstance 列表,ServiceInstance类会存放服务实例的IP、端口号、元数据等信息。

ServiceInstance

getInstances()方法中可以看到,它是调用的NamingService#selectInstances()方法来获取实例信息的,接下来下NamingService#selectInstances()方法的实现:

private HostReactor hostReactor;

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

    ServiceInfo serviceInfo;
    //如果是订阅方式,从本地缓存获取服务
    if (subscribe) {
        serviceInfo = hostReactor.getServiceInfo(NamingUtils.getGroupedName(serviceName, groupName),
                StringUtils.join(clusters, ","));
    } else {
        //直接从服务端拉取服务实例列表
        serviceInfo = hostReactor
                .getServiceInfoDirectlyFromServer(NamingUtils.getGroupedName(serviceName, groupName),
                        StringUtils.join(clusters, ","));
    }
    return selectInstances(serviceInfo, healthy);
}

private List<Instance> selectInstances(ServiceInfo serviceInfo, boolean healthy) {
    List<Instance> list;
    if (serviceInfo == null || CollectionUtils.isEmpty(list = serviceInfo.getHosts())) {
        return new ArrayList<Instance>();
    }

    Iterator<Instance> iterator = list.iterator();
    while (iterator.hasNext()) {
        Instance instance = iterator.next();
        //移除健康状况 != healthy的,或者不接受请求的,或者权重<= 0的
        if (healthy != instance.isHealthy() || !instance.isEnabled() || instance.getWeight() <= 0) {
            iterator.remove();
        }
    }
    return list;
}

第一个selectInstances(String, String, List<String>, boolean,boolean)方法会根据 subscribe 判断是否订阅了调用的服务,如果是则从本地缓存中获取服务信息,否则从服务端直接获取服务信息,通常情况下 subscribe 都为true。然后通过它的重载方法selectInstances(ServiceInfo, boolean)筛选出可用实例,并返回。

接下来主要就是看HostReactor#getServiceInfo()方法和HostReactor#getServiceInfoDirectlyFromServer()方法。

HostReactor#getServiceInfoDirectlyFromServer()方法很简单,就是直接从注册中心获取服务列表。

private final NamingProxy serverProxy;

public ServiceInfo getServiceInfoDirectlyFromServer(final String serviceName, final String clusters)
            throws NacosException {
    //从注册中心获取最新服务列表服务
    String result = serverProxy.queryList(serviceName, clusters, 0, false);
    if (StringUtils.isNotEmpty(result)) {
        return JacksonUtils.toObj(result, ServiceInfo.class);
    }
    return null;
}

再看下NamingProxy#queryList()方法:

public String queryList(String serviceName, String clusters, int udpPort, boolean healthyOnly)
            throws NacosException {

    final Map<String, String> params = new HashMap<String, String>(8);
    params.put(CommonParams.NAMESPACE_ID, namespaceId);
    params.put(CommonParams.SERVICE_NAME, serviceName);
    params.put("clusters", clusters);
    params.put("udpPort", String.valueOf(udpPort));
    params.put("clientIP", NetUtils.localIP());
    params.put("healthyOnly", String.valueOf(healthyOnly));
    //直接调用服务端instance/list接口
    return reqApi(UtilAndComs.nacosUrlBase + "/instance/list", params, HttpMethod.GET);
}

queryList()方法会携带客户端服务名和版本号等信息,直接先服务端发起查询服务列表请求。

服务端请求信息

重点来看一下HostReactor#getServiceInfo()方法:

//服务信息缓存
private final Map<String, ServiceInfo> serviceInfoMap;
//待更新服务缓存
private final Map<String, Object> updatingMap;

public ServiceInfo getServiceInfo(final String serviceName, final String clusters) {

    NAMING_LOGGER.debug("failover-mode: " + failoverReactor.isFailoverSwitch());
    String key = ServiceInfo.getKey(serviceName, clusters);
    //快速失败开关是否打开
    if (failoverReactor.isFailoverSwitch()) {
        return failoverReactor.getService(key);
    }

    //从serviceInfoMap缓存中获取服务信息
    ServiceInfo serviceObj = getServiceInfo0(serviceName, clusters);
    //如果serviceInfoMap缓存中没有,则创建一个服务并放入缓存中
    if (null == serviceObj) {
        //创建一个服务
        serviceObj = new ServiceInfo(serviceName, clusters);
        //放入缓存中
        serviceInfoMap.put(serviceObj.getKey(), serviceObj);
        //放入待更新服务缓存中
        updatingMap.put(serviceName, new Object());
        //从注册中心获取最新服务列表,立即更新集群中的服务信息
        updateServiceNow(serviceName, clusters);
        //集群信息更新完成,从待更新中删除
        updatingMap.remove(serviceName);

    } else if (updatingMap.containsKey(serviceName)) {
        //如果在待更新的服务缓存中
        //更新时间间隔如果大于UPDATE_HOLD_INTERVAL=5s
        if (UPDATE_HOLD_INTERVAL > 0) {
            // 等待UPDATE_HOLD_INTERVAL时间直到updateServiceNow方法更新完成
            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());
}

private void updateServiceNow(String serviceName, String clusters) {
    try {
        updateService(serviceName, clusters);
    } catch (NacosException e) {
        NAMING_LOGGER.error("[NA] failed to update serviceName: " + serviceName, e);
    }
}

/**
* Update service now.
*
* @param serviceName service name
* @param clusters    clusters
*/
public void updateService(String serviceName, String clusters) throws NacosException {
    ServiceInfo oldService = getServiceInfo0(serviceName, clusters);
    try {
        //获取集群中服务列表
        String result = serverProxy.queryList(serviceName, clusters, pushReceiver.getUdpPort(), false);

        if (StringUtils.isNotEmpty(result)) {
            processServiceJson(result);
        }
    } finally {
        if (oldService != null) {
            synchronized (oldService) {
                oldService.notifyAll();
            }
        }
    }
}

接下来看一下scheduleUpdateIfAbsent()方法:

private final Map<String, ScheduledFuture<?>> futureMap = new HashMap<String, ScheduledFuture<?>>();

public void scheduleUpdateIfAbsent(String serviceName, String clusters) {
    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));
        futureMap.put(ServiceInfo.getKey(serviceName, clusters), future);
    }
}

public synchronized ScheduledFuture<?> addTask(UpdateTask task) {
    //延迟1秒执行定时任务,UpdateTask定时任务每10秒执行一次
    return executor.schedule(task, DEFAULT_DELAY, TimeUnit.MILLISECONDS);
}

scheduleUpdateIfAbsent()方法中会添加一个每10s执行一次的 UpdateTask 定时任务。 UpdateTask 类是 HostReactor 的一个内部类,它实现了 Runnable 接口。它用于实现服务实例的动态更新和维护,保证服务实例列表的及时更新和健康状态的维护,从而提高服务的可用性和稳定性。

至此客户端服务发现就结束了。

总结一下客户端的服务发现,消费者发起服务调用,通常会采用 subscribe 方式获取服务实例列表,也就是从本地缓存中获取实例列表,如果注册中心宕机,客户端也能调用到服务,提高了Nacos的可用性。

服务端发现

服务端发现主要就是看服务端收到客户端请求后会做哪些操作,

@GetMapping("/list")
    @Secured(parser = NamingResourceParser.class, action = ActionTypes.READ)
    public ObjectNode list(HttpServletRequest request) throws Exception {

    String namespaceId = WebUtils.optional(request, CommonParams.NAMESPACE_ID, Constants.DEFAULT_NAMESPACE_ID);
    String serviceName = WebUtils.required(request, CommonParams.SERVICE_NAME);
    //检查服务名格式
    checkServiceNameFormat(serviceName);

    String agent = WebUtils.getUserAgent(request);
    String clusters = WebUtils.optional(request, "clusters", StringUtils.EMPTY);
    String clientIP = WebUtils.optional(request, "clientIP", StringUtils.EMPTY);
    int udpPort = Integer.parseInt(WebUtils.optional(request, "udpPort", "0"));
    String env = WebUtils.optional(request, "env", StringUtils.EMPTY);
    boolean isCheck = Boolean.parseBoolean(WebUtils.optional(request, "isCheck", "false"));

    String app = WebUtils.optional(request, "app", StringUtils.EMPTY);
    String tenant = WebUtils.optional(request, "tid", StringUtils.EMPTY);
    boolean healthyOnly = Boolean.parseBoolean(WebUtils.optional(request, "healthyOnly", "false"));

    //获取服务全部信息
    return doSrvIpxt(namespaceId, serviceName, agent, clusters, clientIP, udpPort, env, isCheck, app, tenant,
            healthyOnly);
}

InstanceController#list()方法会对请求参数进行解析,然后传递给 doSrvIpxt()方法。doSrvIpxt()方法首先会从 ServiceManager 本地缓存中获取服务信息,如果获取不到直接返回;否则进行各种判断去查询服务实例的全部信息,然后再返回给客户端。

@Autowired
private ServiceManager serviceManager;

public ObjectNode doSrvIpxt(String namespaceId, String serviceName, String agent, String clusters, String clientIP,
            int udpPort, String env, boolean isCheck, String app, String tid, boolean healthyOnly) throws Exception {

    ClientInfo clientInfo = new ClientInfo(agent);
    ObjectNode result = JacksonUtils.createEmptyJsonNode();
    //从缓存中获取服务
    Service service = serviceManager.getService(namespaceId, serviceName);

    long cacheMillis = switchDomain.getDefaultCacheMillis();

    // now try to enable the push
    try {
        if (udpPort > 0 && pushService.canEnablePush(agent)) {

            pushService
                    .addClient(namespaceId, serviceName, clusters, agent, new InetSocketAddress(clientIP, udpPort),
                            pushDataSource, tid, app);
            cacheMillis = switchDomain.getPushCacheMillis(serviceName);
        }
    } catch (Exception e) {
        Loggers.SRV_LOG
                .error("[NACOS-API] failed to added push client {}, {}:{}", clientInfo, clientIP, udpPort, e);
        cacheMillis = switchDomain.getDefaultCacheMillis();
    }

    //缓存中没有,说明该服务不存在或者下线了
    if (service == null) {
        if (Loggers.SRV_LOG.isDebugEnabled()) {
            Loggers.SRV_LOG.debug("no instance to serve for service: {}", serviceName);
        }
        result.put("name", serviceName);
        result.put("clusters", clusters);
        result.put("cacheMillis", cacheMillis);
        result.replace("hosts", JacksonUtils.createEmptyArrayNode());
        return result;
    }
    //检查服务是否能用
    checkIfDisabled(service);

    //省略若干代码

    result.replace("hosts", hosts);
    if (clientInfo.type == ClientInfo.ClientType.JAVA
            && clientInfo.version.compareTo(VersionUtil.parseVersion("1.0.0")) >= 0) {
        result.put("dom", serviceName);
    } else {
        result.put("dom", NamingUtils.getServiceName(serviceName));
    }
    result.put("name", serviceName);
    result.put("cacheMillis", cacheMillis);
    result.put("lastRefTime", System.currentTimeMillis());
    result.put("checksum", service.getChecksum());
    result.put("useSpecifiedURL", false);
    result.put("clusters", clusters);
    result.put("env", env);
    result.replace("metadata", JacksonUtils.transferToJsonNode(service.getMetadata()));
    return result;
}

至此,服务端的服务发现就结束,也相对简单一些。

总结一下:Nacos注册中心会在本地缓存或持久化存储中查找指定服务名的实例列表,并返回给服务消费者。如果本地缓存中没有该服务的实例信息,Nacos注册中心会直接返回。

总结

最后总结一下服务发现的流程:

  1. 服务启动时,根据spring.factories文件自动注入 NacosDiscoveryClientConfiguration 客户端服务发现配置类,同时也会注入 DiscoveryClient 接口的实现类 NacosDiscoveryClient
  2. 当发生服务调用时会调用 NacosDiscoveryClient#getInstances()方法获取服务实例列表 ,然后调用NacosServiceDiscovery#getInstances()方法
    将实例列表转为 ServiceInstance 服务实例信息列表。
  3. NacosServiceDiscovery#getInstances()方法会调用 NamingService#selectInstances()方法去查询实例信息,查询到之后会放入 ServiceCache 服务缓存中,如果因为网络或服务端宕机等原因出现异常,如果缓存中有的话,会从 ServiceCache 返回该服务实例信息,这提高了Nacos服务调用的可用性;在NamingService#selectInstances()方法, 因为是subscribe=true,所以会调用 HostReactor#getServiceInfo() 方法。
  4. HostReactor#getServiceInfo() 方法中,会先从serviceInfoMap 缓存中获取服务信息,如果没有则会创建一个服务,然后会把创建的服务放到待更新缓存 updatingMap 中,接着就去更新服务实例信息,更新完成再从 updatingMap 中剔除。 如果 serviceInfoMap 缓存中能获取到服务信息,则会判断是否该服务是否在待更新缓存 updatingMap 中,在的话就会要等待更新任务完成。
  5. 最后在HostReactor#getServiceInfo() 方法中会调用scheduleUpdateIfAbsent()方法创建一个 UpdateTask 定时任务,定时任务延迟1s执行,之后会每隔10s执行一次。UpdateTask 任务主要工作就是用于实现服务实例的动态更新和维护,保证服务实例列表的及时更新和健康状态的维护,从而提高服务的可用性和稳定性。
  6. 在Nacos注册中心接收到服务实例查询请求后,会调用InstanceController#list()方法,该方法会处理请求参数,然后将参数传递给doSrvIpxt()方法,最后doSrvIpxt()方法会从缓存中获取服务信息,获取不到直接返回;否则会对服务进行各种条件判断,最后返回服务实例全部信息。
  7. 服务消费者收到实例信息后就会根据负载均衡算法选择一台服务提供者,并向其发起调用请求。
  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

索码理

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

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

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

打赏作者

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

抵扣说明:

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

余额充值