Spring Cloud Eureka Server 源码解析(六)处理客户端全量下载请求

处理客户端全量下载请求

1. 检查服务器是否允许访问注册表

入口ApplicationsResource,getContainers方法:

//ApplicationsResource.java
@GET
public Response getContainers(@PathParam("version") String version,
                              @HeaderParam(HEADER_ACCEPT) String acceptHeader,
                              @HeaderParam(HEADER_ACCEPT_ENCODING) String acceptEncoding,
                              @HeaderParam(EurekaAccept.HTTP_X_EUREKA_ACCEPT) String eurekaAccept,
                              @Context UriInfo uriInfo,
                              @Nullable @QueryParam("regions") String regionsStr) {

	//regionsStr不空,则isRemoteRegionRequested为true
	//代表这次请求需要下载包括远程region中的注册表
    boolean isRemoteRegionRequested = null != regionsStr && !regionsStr.isEmpty();
    String[] regions = null;
    if (!isRemoteRegionRequested) {
        EurekaMonitors.GET_ALL.increment();//全量下载计数器+1
    } else {
	    //regionsStr逗号分割,打乱
        regions = regionsStr.toLowerCase().split(",");
        Arrays.sort(regions); // So we don't have different caches for same regions queried in different order. 
        //因此,对于以不同顺序查询的相同区域,我们没有不同的缓存。
        EurekaMonitors.GET_ALL_WITH_REMOTE_REGIONS.increment();//远程region全量下载计数器+1
    }

    // Check if the server allows the access to the registry. The server can
    // restrict access if it is not
    // ready to serve traffic depending on various reasons.
    // 检查服务器是否允许访问注册表。 如果服务器由于各种原因尚未准备好服务流量,则可以限制访问。
    if (!registry.shouldAllowAccess(isRemoteRegionRequested)) {
	    //判断是否允许访问注册表,不允许就返回403,拒绝请求
        return Response.status(Status.FORBIDDEN).build();
    }
    CurrentRequestVersion.set(Version.toEnum(version));
    KeyType keyType = Key.KeyType.JSON;
    String returnMediaType = MediaType.APPLICATION_JSON;
    if (acceptHeader == null || !acceptHeader.contains(HEADER_JSON_VALUE)) {
        keyType = Key.KeyType.XML;
        returnMediaType = MediaType.APPLICATION_XML;
    }

    Key cacheKey = new Key(Key.EntityType.Application,
            ResponseCacheImpl.ALL_APPS,
            keyType, CurrentRequestVersion.get(), EurekaAccept.fromString(eurekaAccept), regions
    );

    Response response;
    if (acceptEncoding != null && acceptEncoding.contains(HEADER_GZIP_VALUE)) {
        response = Response.ok(responseCache.getGZIP(cacheKey))
                .header(HEADER_CONTENT_ENCODING, HEADER_GZIP_VALUE)
                .header(HEADER_CONTENT_TYPE, returnMediaType)
                .build();
    } else {
        response = Response.ok(responseCache.get(cacheKey))
                .build();
    }
    return response;
}

先看shouldAllowAccess方法,判断是否允许访问:

//PeerAwareInstanceRegistryImpl.java
//检查是否允许注册表访问,或者服务器是否处于无法全部获取注册表信息的情况。 
//如果服务器无法在启动时从对等eureka节点获取注册表信息,则在
//{@link com.netflix.eureka.EurekaServerConfig#getWaitTimeInMsWhenSyncEmpty()}中指定的期限内,服务器不会返回注册表信息。
public boolean shouldAllowAccess(boolean remoteRegionRequired) {
    if (this.peerInstancesTransferEmptyOnStartup) {
    	//如果服务器无法在启动时从对等eureka节点获取注册表信息,
	    //集群中第一个服务端实例启动的时候注册表数据肯定是空的,这个时候在
	    //getWaitTimeInMsWhenSyncEmpty指定的期限内,是不允许访问注册表的
        if (!(System.currentTimeMillis() > this.startupTime + serverConfig.getWaitTimeInMsWhenSyncEmpty())) {
            return false;
        }
    }
    //如果这次下载,包括远程region的注册表
    if (remoteRegionRequired) {
        for (RemoteRegionRegistry remoteRegionRegistry : this.regionNameVSRemoteRegistry.values()) {
	        //如果需要远程region,那么
	        //所有remoteRegion中,只要有任何一个没有做好准备,就不允许访问
            if (!remoteRegionRegistry.isReadyForServingData()) {
                return false;
            }
        }
    }
    //如果副本传输中的实例计数返回零,并且等待时间尚未过去, 返回false,否则返回true
    return true;
}

2. 全量下载

2.1 定义缓存key

回到Resouece,继续往后看:

//ApplicationsResource.java
@GET
public Response getContainers(@PathParam("version") String version,
                              @HeaderParam(HEADER_ACCEPT) String acceptHeader,
                              @HeaderParam(HEADER_ACCEPT_ENCODING) String acceptEncoding,
                              @HeaderParam(EurekaAccept.HTTP_X_EUREKA_ACCEPT) String eurekaAccept,
                              @Context UriInfo uriInfo,
                              @Nullable @QueryParam("regions") String regionsStr) {

	...
	
    //定义缓存key --start
    CurrentRequestVersion.set(Version.toEnum(version));
    KeyType keyType = Key.KeyType.JSON;
    String returnMediaType = MediaType.APPLICATION_JSON;
    if (acceptHeader == null || !acceptHeader.contains(HEADER_JSON_VALUE)) {
        keyType = Key.KeyType.XML;
        returnMediaType = MediaType.APPLICATION_XML;
    }
    //Key.EntityType.Application: 实体类型,Application代表获取注册表数据
	//ResponseCacheImpl.ALL_APPS:实体名称,ALL_APPS表示全量下载
	//keyType:key的数据类型,默认json
	//CurrentRequestVersion.get():当前请求的版本号
	//EurekaAccept.fromString(eurekaAccept):就两个值full, compact(紧凑的),应该是控制返回的数据是否需要紧凑的格式
	//regions:要获取的region的列表
    Key cacheKey = new Key(Key.EntityType.Application,
            ResponseCacheImpl.ALL_APPS,
            keyType, CurrentRequestVersion.get(), EurekaAccept.fromString(eurekaAccept), regions
    );
	//定义缓存key --end
	
	//后面就是通过这个缓存key获取对应的注册表缓存信息的
    Response response;
    //acceptEncoding是从请求头中传过来的
    //acceptEncoding是GIZP,代表需要将数据压缩后返回
    if (acceptEncoding != null && acceptEncoding.contains(HEADER_GZIP_VALUE)) {
        response = Response.ok(responseCache.getGZIP(cacheKey))
                .header(HEADER_CONTENT_ENCODING, HEADER_GZIP_VALUE)
                .header(HEADER_CONTENT_TYPE, returnMediaType)
                .build();
    } else {
	    //不需要压缩,则正常获取返回
        response = Response.ok(responseCache.get(cacheKey))
                .build();
    }
    return response;
}

2.2 从缓存中获取注册表

我们看responseCache.getGZIP方法,压缩方式获取注册表:

//ResponseCacheImpl.java
public byte[] getGZIP(Key key) {
	//根据key获取注册表缓存信息
	//shouldUseReadOnlyResponseCache:是否使用只读的响应缓存(配置文件可配,默认true)
    Value payload = getValue(key, shouldUseReadOnlyResponseCache);
    if (payload == null) {
        return null;
    }
    //返回压缩后的数据
    return payload.getGzipped();
}

看getValue方法:

//ResponseCacheImpl.java
@VisibleForTesting
Value getValue(final Key key, boolean useReadOnlyCache) {
    Value payload = null;
    try {
        if (useReadOnlyCache) {//如果true,从只读缓存获取
            final Value currentPayload = readOnlyCacheMap.get(key);
            if (currentPayload != null) {
                payload = currentPayload;
            } else {
	            //只读缓存为空的话,先从读写缓存获取,再保存到只读缓存
                payload = readWriteCacheMap.get(key);
                readOnlyCacheMap.put(key, payload);
            }
        } else {
	        //否则直接从读写缓存中获取
            payload = readWriteCacheMap.get(key);
        }
    } catch (Throwable t) {
        logger.error("Cannot get value for key : {}", key, t);
    }
    return payload;
}

三个问题:

  • 读写缓存readWriteCacheMap、只读缓存readOnlyCacheMap,在哪构建的?有缓存key,缓存value是在哪构建的?
  • 只读缓存readOnlyCacheMap,怎么从读写缓存中同步数据的?(上面已经看到读取的时候先从只读缓存获取,为空再从读写缓存中获取,然后放入只读缓存,但是之后如果读写缓存的数据更新了,怎么同步到只读缓存?其实还有一个定时任务)
  • 用只读缓存为了解决什么问题?集合迭代稳定性:

    一个线程在读取集合的过程中,另一个线程同时对集合中的元素进行修改、增删,那么读的线程在遍历的过程中就会发现集合中的数据一直在变化,是不稳定的,就会造成一些问题,用只读缓存可以解决这个问题。


    读的线程读的是只读缓存的数据,写的线程写的是读写缓存中的数据,这样写的操作就不会对读有影响,只要写完以后把新的数据替换掉原来只读缓存中的数据即可。

2.3 读写缓存、只读缓存的构建

找到ResponseCacheImpl的构造器:
在这里插入图片描述

//ResponseCacheImpl.java的构造器
ResponseCacheImpl(EurekaServerConfig serverConfig, ServerCodecs serverCodecs, AbstractInstanceRegistry registry) {
    this.serverConfig = serverConfig;
    this.serverCodecs = serverCodecs;
    this.shouldUseReadOnlyResponseCache = serverConfig.shouldUseReadOnlyResponseCache();
    this.registry = registry;

    long responseCacheUpdateIntervalMs = serverConfig.getResponseCacheUpdateIntervalMs();
    //可以看到读写缓存map就是在这构建的
    //serverConfig.getInitialCapacityOfResponseCache : 配置文件配置的响应缓存的初始大小
    //serverConfig.getResponseCacheAutoExpirationInSeconds():配置文件配置的缓存的过期时间,由此可以看出缓存value是有时效的
    this.readWriteCacheMap =
            CacheBuilder.newBuilder().initialCapacity(serverConfig.getInitialCapacityOfResponseCache())
            //指定缓存的过期时间
            .expireAfterWrite(serverConfig.getResponseCacheAutoExpirationInSeconds(), TimeUnit.SECONDS)
                	//指定一个缓存被移除的监听器
                    .removalListener(new RemovalListener<Key, Value>() {
                        @Override
                        public void onRemoval(RemovalNotification<Key, Value> notification) {
	                        //一但过期被删除,会触发当前监听方法
                            Key removedKey = notification.getKey();
                            if (removedKey.hasRegions()) {
                            	//注意regionSpecificKeys是一个Multimap,它的一个键对应多个值
                            	//cloneWithNoRegion的
                            	//    key是 不带区域的键
                            	//    value是 带区域的键的列表
                            	//    (key和value中的键除了region,其他参数完全一样)
                            	
                            	//这里如果发现当前移除的key是带区域的,就把其和不带区域的键的
                            	//映射关系移除
                                Key cloneWithNoRegions = removedKey.cloneWithoutRegions();
                                regionSpecificKeys.remove(cloneWithNoRegions, removedKey);
                            }
                        }
                    })
                    .build(new CacheLoader<Key, Value>() {
	                    //这里就是根据key构建具体缓存内容的方法
	                    //这个readWriteCacheMap我们是不需要put值的
	                    //直接readWriteCacheMap.get(key)即可
	                    //如果对应key的value不存在就会调用下面方法生成然后缓存
                        @Override
                        public Value load(Key key) throws Exception {
                            if (key.hasRegions()) {
                            	//如果发现key是带区域的,保存维护
                            	//不带区域的键 与 带区域的键 的关系
                                Key cloneWithNoRegions = key.cloneWithoutRegions();
                                regionSpecificKeys.put(cloneWithNoRegions, key);
                            }
                            //主要关注generatePayload方法
                            Value value = generatePayload(key);
                            return value;
                        }
                    });

    if (shouldUseReadOnlyResponseCache) {//如果开启了使用只读响应缓存
	    //开启一个定时任务,这个任务就是用来处理 只读缓存 和 读写缓存 之间数据同步的
	    
	    //是repeated定时任务,固定的间隔时间一直循环执行
	    //入参:
	    //缓存更新任务
	    //第一次执行的延迟
	    //每次定时循环执行的时间间隔
        timer.schedule(getCacheUpdateTask(),
                new Date(((System.currentTimeMillis() / responseCacheUpdateIntervalMs) * responseCacheUpdateIntervalMs)
                        + responseCacheUpdateIntervalMs),
                responseCacheUpdateIntervalMs);
    }

    try {
        Monitors.registerObject(this);
    } catch (Throwable e) {
        logger.warn("Cannot register the JMX monitor for the InstanceRegistry", e);
    }
}

看一下默认的响应缓存的自动过期时间:
在这里插入图片描述

2.3.1 缓存清除

在读写缓存中,我们看到生成缓存,或者缓存过期移除的时候都修改了一个map,regionSpecificKeys:
在这里插入图片描述
看下这个map:
在这里插入图片描述
这个map维护了不带区域的缓存key 和 带区域的缓存key 之间的关系(缓存key中除了region其他参数完全一样)

那这个map是干啥用的呢?主要是清除缓存的时候用的,前面几章分析的流程,除了处理客户端续约请求,其他所有操作最后都调用了invalidateCache方法,让缓存失效:
在这里插入图片描述
现在我们看invalidateCache方法:

//AbstractInstanceRegistry.java
private void invalidateCache(String appName, @Nullable String vipAddress, @Nullable String secureVipAddress) {
    // invalidate cache
    responseCache.invalidate(appName, vipAddress, secureVipAddress);
}

//ResponseCacheImpl#invalidate.java
public void invalidate(String appName, @Nullable String vipAddress, @Nullable String secureVipAddress) {
	//可以看到,这里其实是想把 指定的微服务 所相关的 所有注册表类型的缓存 变为失效。
	//并且注意这些缓存key都是不带region的!
    for (Key.KeyType type : Key.KeyType.values()) {
        for (Version v : Version.values()) {
            invalidate(
                    new Key(Key.EntityType.Application, appName, type, v, EurekaAccept.full),
                    new Key(Key.EntityType.Application, appName, type, v, EurekaAccept.compact),
                    new Key(Key.EntityType.Application, ALL_APPS, type, v, EurekaAccept.full),
                    new Key(Key.EntityType.Application, ALL_APPS, type, v, EurekaAccept.compact),
                    new Key(Key.EntityType.Application, ALL_APPS_DELTA, type, v, EurekaAccept.full),
                    new Key(Key.EntityType.Application, ALL_APPS_DELTA, type, v, EurekaAccept.compact)
            );
            if (null != vipAddress) {
                invalidate(new Key(Key.EntityType.VIP, vipAddress, type, v, EurekaAccept.full));
            }
            if (null != secureVipAddress) {
                invalidate(new Key(Key.EntityType.SVIP, secureVipAddress, type, v, EurekaAccept.full));
            }
        }
    }
}

注意这些缓存key都是不带region的!继续看invalidate:

/**
 * Invalidate the cache information given the list of keys.
 * 指定键列表的缓存信息无效。
 * 
 * @param keys the list of keys for which the cache information needs to be invalidated.
 */
public void invalidate(Key... keys) {
    for (Key key : keys) {
        logger.debug("Invalidating the response cache key : {} {} {} {}, {}",
                key.getEntityType(), key.getName(), key.getVersion(), key.getType(), key.getEurekaAccept());
		//先将不带region的缓存key对应的缓存失效
        readWriteCacheMap.invalidate(key);
        //根据不带region的缓存key,获取映射关系中所有带region的key
        //让带region的所有key的缓存失效
        Collection<Key> keysWithRegions = regionSpecificKeys.get(key);
        if (null != keysWithRegions && !keysWithRegions.isEmpty()) {
            for (Key keysWithRegion : keysWithRegions) {
                logger.debug("Invalidating the response cache key : {} {} {} {} {}",
                        key.getEntityType(), key.getName(), key.getVersion(), key.getType(), key.getEurekaAccept());
                readWriteCacheMap.invalidate(keysWithRegion);
            }
        }
    }
}

2.3.2 缓存value的创建(全量下载的数据内容)

现在我们要看一下缓存中的value到底是啥:
在这里插入图片描述

//ResponseCacheImpl.java
private Value generatePayload(Key key) {
    Stopwatch tracer = null;
    try {
        String payload;
        switch (key.getEntityType()) {//对应有三种类型的缓存
            case Application:
            	//我们主要关注这个注册表类型
                boolean isRemoteRegionRequested = key.hasRegions();

                if (ALL_APPS.equals(key.getName())) {//ALL_APPS代表全量下载
                    if (isRemoteRegionRequested) {//是否包含远程region的注册表
                        tracer = serializeAllAppsWithRemoteRegionTimer.start();
                        //直接看getApplicationsFromMultipleRegions方法
                        //从多个region中获取注册表
                        payload = getPayLoad(key, registry.getApplicationsFromMultipleRegions(key.getRegions()));
                    } else {
                        tracer = serializeAllAppsTimer.start();
                        payload = getPayLoad(key, registry.getApplications());
                    }
                } else if (ALL_APPS_DELTA.equals(key.getName())) {
                	//ALL_APPS_DELTA代表增量下载
					...
                } else {
                    tracer = serializeOneApptimer.start();
                    payload = getPayLoad(key, registry.getApplication(key.getName()));
                }
                break;
            case VIP:
            case SVIP:
                tracer = serializeViptimer.start();
                payload = getPayLoad(key, getApplicationsForVip(key, registry));
                break;
            default:
                logger.error("Unidentified entity type: {} found in the cache key.", key.getEntityType());
                payload = "";
                break;
        }
        return new Value(payload);
    } finally {
        if (tracer != null) {
            tracer.stop();
        }
    }
}

从多个region中获取注册表:

//AbstractInstanceRegistry.java
public Applications getApplicationsFromMultipleRegions(String[] remoteRegions) {
	//判断是否包含远程region
    boolean includeRemoteRegion = null != remoteRegions && remoteRegions.length != 0;

    logger.debug("Fetching applications registry with remote regions: {}, Regions argument {}",
            includeRemoteRegion, remoteRegions);

    if (includeRemoteRegion) {
        GET_ALL_WITH_REMOTE_REGIONS_CACHE_MISS.increment();//对应计数器+1
    } else {
        GET_ALL_CACHE_MISS.increment();//对应计数器+1
    }
    //新增一个注册表对象,这个是返回给客户端的注册表、也是缓存中的value
    Applications apps = new Applications();
    apps.setVersion(1L);
    //registry是服务端本地注册表
    for (Entry<String, Map<String, Lease<InstanceInfo>>> entry : registry.entrySet()) {
	    //先遍历本地注册表,key是微服务名称,value是内层map
        Application app = null;

        if (entry.getValue() != null) {
            for (Entry<String, Lease<InstanceInfo>> stringLeaseEntry : entry.getValue().entrySet()) {
	            //遍历本地注册表的内层map,key是instanceInfoId,value是实例信息
                Lease<InstanceInfo> lease = stringLeaseEntry.getValue();
                if (app == null) {
                	//微服务对应的Application为空,则新建一个
                    app = new Application(lease.getHolder().getAppName());
                }
                //添加实例到缓存(Application是客户端使用的注册表相关的数据结构)
                //decorateInstanceInfo:以前跟过,把lease包装成InstanceInfo
                app.addInstance(decorateInstanceInfo(lease));
            }
        }
        if (app != null) {
	        //添加到缓存的注册表中(apps,Applications是客户端使用的注册表的数据结构)
            apps.addApplication(app);
        }
    }
    if (includeRemoteRegion) {
	    //本地处理完以后,处理远程region的注册表
	    //注意此时处理的是已经缓存在Server端本地的远程region注册表信息
	    //(Server端什么时候从远程region下载的,暂时不清楚)
	    
	    //遍历需要获取的region
        for (String remoteRegion : remoteRegions) {
	        //获取远程region对应的Registry
            RemoteRegionRegistry remoteRegistry = regionNameVSRemoteRegistry.get(remoteRegion);
            if (null != remoteRegistry) {
	            //获取远程region的注册表
                Applications remoteApps = remoteRegistry.getApplications();
                //遍历注册表中的每个Application
                for (Application application : remoteApps.getRegisteredApplications()) {
	                //判断这个微服务的注册表是否允许从这个region中获取
                    if (shouldFetchFromRemoteRegistry(application.getName(), remoteRegion)) {
                        logger.info("Application {}  fetched from the remote region {}",
                                application.getName(), remoteRegion);
						//apps就是当前缓存key对应的缓存value,也是返回给客户端的注册表
						//可以看到,这里将本地的和远程的是整合在一起处理的
                        Application appInstanceTillNow = apps.getRegisteredApplications(application.getName());
                        if (appInstanceTillNow == null) {
	                        //如果Application不存在进行构建
                            appInstanceTillNow = new Application(application.getName());
                            apps.addApplication(appInstanceTillNow);
                        }
                        for (InstanceInfo instanceInfo : application.getInstances()) {
	                        //添加远程region中的实例信息
                            appInstanceTillNow.addInstance(instanceInfo);
                        }
                    } else {
                        logger.debug("Application {} not fetched from the remote region {} as there exists a "
                                        + "whitelist and this app is not in the whitelist.",
                                application.getName(), remoteRegion);
                    }
                }
            } else {
                logger.warn("No remote registry available for the remote region {}", remoteRegion);
            }
        }
    }
    apps.setAppsHashCode(apps.getReconcileHashCode());
    return apps;
}

2.3.3 只读缓存 和 读写缓存的数据同步

最后看一下只读缓存和读写缓存的同步逻辑,回到ResponseCacheImpl的构造器:

//ResponseCacheImpl.java的构造器
ResponseCacheImpl(EurekaServerConfig serverConfig, ServerCodecs serverCodecs, AbstractInstanceRegistry registry) {
	...
    if (shouldUseReadOnlyResponseCache) {//如果开启了使用只读响应缓存
	    //开启一个定时任务,是repeated定时任务,固定的间隔时间一直循环执行
	    //入参:
	    //缓存更新任务
	    //第一次执行的延迟
	    //每次定时循环执行的时间间隔
        timer.schedule(getCacheUpdateTask(),
                new Date(((System.currentTimeMillis() / responseCacheUpdateIntervalMs) * responseCacheUpdateIntervalMs)
                        + responseCacheUpdateIntervalMs),
                responseCacheUpdateIntervalMs);
    }

    try {
        Monitors.registerObject(this);
    } catch (Throwable e) {
        logger.warn("Cannot register the JMX monitor for the InstanceRegistry", e);
    }
}

默认应该是30秒同步一次
在这里插入图片描述
在这里插入图片描述

直接看缓存更新任务:

//ResponseCacheImpl.java
private TimerTask getCacheUpdateTask() {
    return new TimerTask() {
        @Override
        public void run() {
            logger.debug("Updating the client cache from response cache");
            for (Key key : readOnlyCacheMap.keySet()) {
	            //遍历只读缓存map的key
                if (logger.isDebugEnabled()) {
                    logger.debug("Updating the client cache from response cache for key : {} {} {} {}",
                            key.getEntityType(), key.getName(), key.getVersion(), key.getType());
                }
                try {
                	//ThreadLocal中设置请求版本号
                    CurrentRequestVersion.set(key.getVersion());
                    //根据key从读写缓存map中获取cacheValue
                    Value cacheValue = readWriteCacheMap.get(key);
                    //从只读缓存map中获取cacheValue
                    Value currentCacheValue = readOnlyCacheMap.get(key);
                    if (cacheValue != currentCacheValue) {
	                    //直接比较地址值即可
                        readOnlyCacheMap.put(key, cacheValue);
                    }
                } catch (Throwable th) {
                    logger.error("Error while updating the client cache from response cache for key {}", key.toStringCompact(), th);
                }
            }
        }
    };
}

这里可以看到定时任务主要是将只读缓存map中已经存在的所有key对应的value进行更新,至于什么时候向只读缓存map中添加值,其实之前我们已经看到了,在一开始获取的时候:
在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

犬豪

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

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

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

打赏作者

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

抵扣说明:

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

余额充值