nacos架构和源码解析(上)-架构和注册中心

nacos整体架构

nacos整体架构可以分为三个部分,位于核心C位的nacos服务,方便用户直观查看和操作的nacos控制台,以及使用nacos功能的客户端服务,如图:
nacos整体架构

nacos服务架构

nacos服务主要是由功能访问入口OpenAPI,注册中心Naming Service,配置中心Config Service,nacos core核心包,以及一致性协议等部分组成,如图:
nacos服务架构
外部服务,包括nacos客户端服务,nacos控制台服务,都是通过OpenAPI操作使用注册中心和配置中心的,而一致性协议,保证了nacos集群各节点之间的数据同步。

注册中心

注册中心的使用可以分两部分来看,一部分是服务提供者在注册中心注册服务,一部分是服务消费者在注册中心拉取服务列表发现服务,下面我们分别从服务注册、服务发现的客户端代码和服务端代码,对服务注册和发现的整个过程进行详细了解。

服务注册
客户端

nacos提供了供客户端引入的SDK工具包,之前的篇章中已经进行了简单的使用,不同的客户端架构可以通过符合自身架构的方式对工具包进行调用,这不是本篇重点,不做详细展开,只以SpringCloud为例进行一下简单说明:

SpringCloud使用了自动装配的功能,其中AutoServiceRegistrationAutoConfiguration就是服务注册相关的配置类,该类中注入了AutoServiceRegistration的实例,AutoServiceRegistration是一个接口类,抽象类AbstractAutoServiceRegistration实现了该接口,而NacosAutoServiceRegistration就继承了AbstractAutoServiceRegistration;
​​​项目启动时,因为AbstractAutoServiceRegistration同时实现了ApplicationListener,项目最终会调用AbstractAutoServiceRegistration中的onApplicationEvent方法,方法中会调用到NacosAutoServiceRegistration的register()注册方法,继续向下运行其最终会调用到nacos工具包中NamingService的registerInstance方法,该方法之前的篇章中有进行过调用,是nacos SDK中提供的用于服务注册的方法。

我们的源码解析就从NamingService的registerInstance方法开始,registerInstance方法的实现代码:

    @Override
    public void registerInstance(String serviceName, String groupName, Instance instance) throws NacosException {
        if (instance.isEphemeral()) {
            BeatInfo beatInfo = new BeatInfo();
            beatInfo.setServiceName(NamingUtils.getGroupedName(serviceName, groupName));
            beatInfo.setIp(instance.getIp());
            beatInfo.setPort(instance.getPort());
            beatInfo.setCluster(instance.getClusterName());
            beatInfo.setWeight(instance.getWeight());
            beatInfo.setMetadata(instance.getMetadata());
            beatInfo.setScheduled(false);
            long instanceInterval = instance.getInstanceHeartBeatInterval();
            //设置心跳周期,默认值5秒
            beatInfo.setPeriod(instanceInterval == 0 ? DEFAULT_HEART_BEAT_INTERVAL : instanceInterval);
            //建立心跳连接
            beatReactor.addBeatInfo(NamingUtils.getGroupedName(serviceName, groupName), beatInfo);
        }
        //服务注册
        serverProxy.registerService(NamingUtils.getGroupedName(serviceName, groupName), groupName, instance);
    }

可以看到,客户端在进行注册时,同时进行了两步操作,注册和建立心跳,我们先从服务注册的代码看起:

    public void registerService(String serviceName, String groupName, Instance instance) throws NacosException {
        final Map<String, String> params = new HashMap<String, String>(9);
        params.put(CommonParams.NAMESPACE_ID, namespaceId);
        params.put(CommonParams.SERVICE_NAME, serviceName);
        params.put(CommonParams.GROUP_NAME, groupName);
        params.put(CommonParams.CLUSTER_NAME, instance.getClusterName());
        params.put("ip", instance.getIp());
        params.put("port", String.valueOf(instance.getPort()));
        params.put("weight", String.valueOf(instance.getWeight()));
        params.put("enable", String.valueOf(instance.isEnabled()));
        params.put("healthy", String.valueOf(instance.isHealthy()));
        params.put("ephemeral", String.valueOf(instance.isEphemeral()));
        params.put("metadata", JSON.toJSONString(instance.getMetadata()));
        //封装了一个http请求,请求地址是UtilAndComs.NACOS_URL_INSTANCE
        reqAPI(UtilAndComs.NACOS_URL_INSTANCE, params, HttpMethod.POST);
    }

访问地址就是OpenAPI的服务注册接口:/nacos/v1/ns/instance(POST),之前篇章中有进行过直接调用。

    //请求地址拼接"/nacos/v1/ns/instance(POST)"
    public static String WEB_CONTEXT = "/nacos";
    public static String NACOS_URL_BASE = WEB_CONTEXT + "/v1/ns";
    public static String NACOS_URL_INSTANCE = NACOS_URL_BASE + "/instance";

再看一下建立心跳部分:

    public void addBeatInfo(String serviceName, BeatInfo beatInfo) {
        String key = buildKey(serviceName, beatInfo.getIp(), beatInfo.getPort());
        BeatInfo existBeat = null;
        if ((existBeat = dom2Beat.remove(key)) != null) {
            existBeat.setStopped(true);
        }
        dom2Beat.put(key, beatInfo);
        //启动一个BeatTask线程任务,延时getPeriod()(默认5秒)后开始执行
        executorService.schedule(new BeatTask(beatInfo), beatInfo.getPeriod(), TimeUnit.MILLISECONDS);
        MetricsMonitor.getDom2BeatSizeMonitor().set(dom2Beat.size());
    }

BeatTask线程任务:

        @Override
        public void run() {
            if (beatInfo.isStopped()) {
                return;
            }
            //sendBeat发送心跳请求
            //请求地址为"/nacos/v1/ns/instance/beat(PUT)"
            long result = serverProxy.sendBeat(beatInfo);
            long nextTime = result > 0 ? result : beatInfo.getPeriod();
            //启动下一次任务,默认5秒后开始
            executorService.schedule(new BeatTask(beatInfo), nextTime, TimeUnit.MILLISECONDS);
        }

通过客户端的代码可以看出,客户端再进行服务注册时,先进行了一次注册服务的请求,然后每隔5秒(默认),就会发出一次心跳请求,下边我们来看服务端时怎么处理这两个请求的;

服务端

同样先从注册开始

    @CanDistro
    @PostMapping
    public String register(HttpServletRequest request) throws Exception {
        String serviceName = WebUtils.required(request, CommonParams.SERVICE_NAME);
        String namespaceId = WebUtils.optional(request, CommonParams.NAMESPACE_ID, Constants.DEFAULT_NAMESPACE_ID);
        //具体注册逻辑的实现
        serviceManager.registerInstance(namespaceId, serviceName, parseInstance(request));
        return "ok";
    }
    public void registerInstance(String namespaceId, String serviceName, Instance instance) throws NacosException {
        //创建一个空服务
        createEmptyService(namespaceId, serviceName, instance.isEphemeral());
        //获取服务对象校验是否已存在(创建成功)
        Service service = getService(namespaceId, serviceName);
        if (service == null) {
            throw new NacosException(NacosException.INVALID_PARAM,
                "service not found, namespace: " + namespaceId + ", service: " + serviceName);
        }
        //向服务内添加服务实例
        addInstance(namespaceId, serviceName, instance.isEphemeral(), instance);
    }

重点在于createEmptyService创建空服务的过程,后续两步主要就是为了将注册的服务实例放入创建的服务中,注意一个服务可以注册多个服务实例。创建空服务方法中会同样先调用getService方法,查看服务是否已存在,不存在则会创建服务并调用putServiceAndInit方法:

    private void putServiceAndInit(Service service) throws NacosException {
        //将创建的服务放到内存中
        putService(service);
        //建立服务端心跳状态检查机制,方法启动了一个定时任务ClientBeatCheckTask,延时5秒开始,每5秒执行一次
        service.init();
        //数据一致性监听 
        consistencyService.listen(KeyBuilder.buildInstanceListKey(service.getNamespaceId(), service.getName(), true), service);
        consistencyService.listen(KeyBuilder.buildInstanceListKey(service.getNamespaceId(), service.getName(), false), service);
    }

首先调用putService,这个方法将新的service放入服务内存中,存放方式是放入了一个双层的ConcurrentHashMap容器中,第一层key值是NamespaceId命名空间ID,第二层key值是serviceName,最后的value为service对象:

    public void putService(Service service) {
        //serviceMap是一个两层的ConcurrentHashMap<String, Map<String, Service>>
        //第一层key值是NamespaceId命名空间ID,不存在加锁创建
        if (!serviceMap.containsKey(service.getNamespaceId())) {
            synchronized (putServiceLock) {
                if (!serviceMap.containsKey(service.getNamespaceId())) {
                    serviceMap.put(service.getNamespaceId(), new ConcurrentHashMap<>(16));
                }
            }
        }
        //第一层key值是serviceName
        serviceMap.get(service.getNamespaceId()).put(service.getName(), service);
    }

存放完service空服务之后调用了service.init()方法,这个方法主要创建了一个线程任务ClientBeatCheckTask,用于检查心跳更新健康状态:

 @Override
 public void run() {
     try {
         if (!getDistroMapper().responsible(service.getName())) {
             return;
         }
         //获取service所有注册服务实例,所以该线程一旦启动,后续无论服务再注册多少实例,都在该线程的检测范围内
         List<Instance> instances = service.allIPs(true);
         for (Instance instance : instances) {
             //如果当前时间-最后一次客户端心跳发送时间>心跳超时时间,则判断当前实例不健康
             if (System.currentTimeMillis() - instance.getLastBeat() > instance.getInstanceHeartBeatTimeOut()) {
                 if (!instance.isMarked()) {
                     if (instance.isHealthy()) {
                         //修改健康状态
                         instance.setHealthy(false);
                         //启动服务变更事件,发送基于UDP协议的消息
                         getPushService().serviceChanged(service);
                         SpringContext.getAppContext().publishEvent(new InstanceHeartbeatTimeoutEvent(this, instance));
                     }
                 }
             }
         }
         if (!getGlobalConfig().isExpireInstance()) {
             return;
         }
         for (Instance instance : instances) {
             if (instance.isMarked()) {
                 continue;
             }
             //超出删除时间的实例进行删除
             if (System.currentTimeMillis() - instance.getLastBeat() > instance.getIpDeleteTimeout()) {
                 deleteIP(instance);
             }
         }
     } catch (Exception e) {
     }

 }

从代码可以看出,服务端在这里只是检查了心跳的更新时间,或者说是在检查客户端最后一次心跳请求的时间,并不会主动连接客户端去确认服务实例的状态。

再看接收客户端心跳部分:

    @CanDistro
    @PutMapping("/beat")
    public JSONObject beat(HttpServletRequest request) throws Exception {
        JSONObject result = new JSONObject();
        result.put("clientBeatInterval", switchDomain.getClientBeatInterval());
        String serviceName = WebUtils.required(request, CommonParams.SERVICE_NAME);
        String namespaceId = WebUtils.optional(request, CommonParams.NAMESPACE_ID,
            Constants.DEFAULT_NAMESPACE_ID);
        String beat = WebUtils.required(request, "beat");
        RsInfo clientBeat = JSON.parseObject(beat, RsInfo.class);
        if (!switchDomain.isDefaultInstanceEphemeral() && !clientBeat.isEphemeral()) {
            return result;
        }
        if (StringUtils.isBlank(clientBeat.getCluster())) {
            clientBeat.setCluster(UtilsAndCommons.DEFAULT_CLUSTER_NAME);
        }
        String clusterName = clientBeat.getCluster();
        Instance instance = serviceManager.getInstance(namespaceId, serviceName, clientBeat.getCluster(),
            clientBeat.getIp(),
            clientBeat.getPort());
        if (instance == null) {
            instance = new Instance();
            instance.setPort(clientBeat.getPort());
            instance.setIp(clientBeat.getIp());
            instance.setWeight(clientBeat.getWeight());
            instance.setMetadata(clientBeat.getMetadata());
            instance.setClusterName(clusterName);
            instance.setServiceName(serviceName);
            instance.setInstanceId(instance.getInstanceId());
            instance.setEphemeral(clientBeat.isEphemeral());
            serviceManager.registerInstance(namespaceId, serviceName, instance);
        }
        //获取service
        Service service = serviceManager.getService(namespaceId, serviceName);
        if (service == null) {
            throw new NacosException(NacosException.SERVER_ERROR,
                "service not found: " + serviceName + "@" + namespaceId);
        }
        //启动一个线程任务ClientBeatProcessor更新了心跳时间和健康状态
        service.processClientBeat(clientBeat);
        result.put("clientBeatInterval", instance.getInstanceHeartBeatInterval());
        return result;
    }

重点在service.processClientBeat方法内的线程,更新了最后心跳时间,如果该实例已经是不健康状态,还会将其修改回健康状态:

    @Override
    public void run() {
        Service service = this.service;
        String ip = rsInfo.getIp();
        String clusterName = rsInfo.getCluster();
        int port = rsInfo.getPort();
        Cluster cluster = service.getClusterMap().get(clusterName);
        List<Instance> instances = cluster.allIPs(true);
        for (Instance instance : instances) {
            if (instance.getIp().equals(ip) && instance.getPort() == port) {
                //设定最后心跳时间
                instance.setLastBeat(System.currentTimeMillis());
                if (!instance.isMarked()) {
                    //如果已经是不健康状态,更新为健康
                    if (!instance.isHealthy()) {
                        instance.setHealthy(true);
                        //发送服务变更事件,发送基于UDP协议的消息
                        getPushService().serviceChanged(service);
                    }
                }
            }
        }
    }

注意在注册部分检查最后心跳时间和接收客户端心跳请求部分,在健康状态发生变更的部分,都有调用getPushService().serviceChanged(service)方法,这是一个服务变更的事件,会向指定服务推送服务的信息,和下边说的服务发现部分有关。

服务发现
客户端

服务发现需要调用SDK中namingService.selectInstances方法,实现代码如下:

    @Override
    public List<Instance> selectInstances(String serviceName, String groupName, List<String> clusters, boolean healthy, boolean subscribe) throws NacosException {
        ServiceInfo serviceInfo;
        //subscribe控制是否订阅,默认设置为true
        if (subscribe) {
            //方法内启用了一个UpdateTask线程
            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);
    }

可以看出,客户端在请求服务信息的同时,默认对服务信息进行了订阅,向内追溯可以发现方法内启动了一个UpdateTask线程任务:

        @Override
        public void run() {
            try {
                ServiceInfo serviceObj = serviceInfoMap.get(ServiceInfo.getKey(serviceName, clusters));
                if (serviceObj == null) {
                    //发送请求,更新本地服务列表
                    updateServiceNow(serviceName, clusters);
                    executor.schedule(this, DEFAULT_DELAY, TimeUnit.MILLISECONDS);
                    return;
                }
                if (serviceObj.getLastRefTime() <= lastRefTime) {
                    //同上
                    updateServiceNow(serviceName, clusters);
                    serviceObj = serviceInfoMap.get(ServiceInfo.getKey(serviceName, clusters));
                } else {
                    refreshOnly(serviceName, clusters);
                }
                //开启下一次任务,缓存间隔CacheMillis在服务端设置,默认为10秒
                executor.schedule(this, serviceObj.getCacheMillis(), TimeUnit.MILLISECONDS);
                //获取最后关联时间,在服务端设置,为服务端当前系统时间
                lastRefTime = serviceObj.getLastRefTime();
            } catch (Throwable e) {
                NAMING_LOGGER.warn("[NA] failed to update serviceName: " + serviceName, e);
            }
        }
    public void updateServiceNow(String serviceName, String clusters) {
        ServiceInfo oldService = getServiceInfo0(serviceName, clusters);
        try {
            //发送请求,地址"/nacos/v1/ns/instance/list(GET)"
            String result = serverProxy.queryList(serviceName, clusters, pushReceiver.getUDPPort(), false);
            if (StringUtils.isNotEmpty(result)) {
            //更新本地服务信息
                processServiceJSON(result);
            }
        } catch (Exception e) {
            NAMING_LOGGER.error("[NA] failed to update serviceName: " + serviceName, e);
        } finally {
            if (oldService != null) {
                synchronized (oldService) {
                    oldService.notifyAll();
                }
            }
        }
    }

从代码可以看出,线程任务每10秒发送一次请求用来获取服务信息,那么10秒内服务信息发生的变化,客户端在周期内无法感知吗?不是的,发送请求的方法中,有一个参数pushReceiver.getUDPPort(),说明客户端有接收UDP请求的端口,并在请求获取服务信息时告知了服务端。我们反过来看UpdateTask,发现他是HostReactor的一个内部类,HostReactor的构造方法如下:

  public HostReactor(EventDispatcher eventDispatcher, NamingProxy serverProxy, String cacheDir,
                       boolean loadCacheAtStart, int pollingThreadCount) {

        executor = new ScheduledThreadPoolExecutor(pollingThreadCount, new ThreadFactory() {
            @Override
            public Thread newThread(Runnable r) {
                Thread thread = new Thread(r);
                thread.setDaemon(true);
                thread.setName("com.alibaba.nacos.client.naming.updater");
                return thread;
            }
        });
        this.eventDispatcher = eventDispatcher;
        this.serverProxy = serverProxy;
        this.cacheDir = cacheDir;
        if (loadCacheAtStart) {
            this.serviceInfoMap = new ConcurrentHashMap<String, ServiceInfo>(DiskCache.read(this.cacheDir));
        } else {
            this.serviceInfoMap = new ConcurrentHashMap<String, ServiceInfo>(16);
        }
        this.updatingMap = new ConcurrentHashMap<String, Object>();
        this.failoverReactor = new FailoverReactor(this, cacheDir);
        //接受UDP通讯信息
        this.pushReceiver = new PushReceiver(this);
    }

HostReactor在创建的同时,创建了一个用于UDP通讯的类,内部代码不贴了,大概意思就是创建一个线程池内有一个线程执行UDP通讯,这时回想刚刚服务注册的代码中,每当健康状态发生变更时,都会调用getPushService().serviceChanged方法发出UDP通讯,可知除了每10秒获取一次服务信息,服务端和客户端还在通过UDP协议进行通讯。

服务端
    @GetMapping("/list")
    public JSONObject 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);
        String agent = WebUtils.getUserAgent(request);
        String clusters = WebUtils.optional(request, "clusters", StringUtils.EMPTY);
        String clientIP = WebUtils.optional(request, "clientIP", StringUtils.EMPTY);
        Integer 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);
    }

doSrvIPXT代码比较长,但重点逻辑比较容易理解,看注释:

public JSONObject 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);
        JSONObject result = new JSONObject();
        //获取指定服务service对象
        Service service = serviceManager.getService(namespaceId, serviceName);
        //获取缓存时间间隔cacheMillis,默认10秒
        long cacheMillis = switchDomain.getDefaultCacheMillis();
        try {
            if (udpPort > 0 && pushService.canEnablePush(agent)) {
            //添加UDP通讯客户端,就是在一个map里维护了所有订阅该服务的客户端通讯信息
                pushService.addClient(namespaceId, serviceName,
                    clusters,
                    agent,
                    new InetSocketAddress(clientIP, udpPort),
                    pushDataSource,
                    tid,
                    app);
                cacheMillis = switchDomain.getPushCacheMillis(serviceName);
            }
        } catch (Exception e) {
            cacheMillis = switchDomain.getDefaultCacheMillis();
        }
    //获取服务下所有实例
        List<Instance> srvedIPs;
        srvedIPs = service.srvIPs(Arrays.asList(StringUtils.split(clusters, ",")));
        if (service.getSelector() != null && StringUtils.isNotBlank(clientIP)) {
            srvedIPs = service.getSelector().select(clientIP, srvedIPs);
        }
        //根据健康与否,将服务的实例放入ipMap 
        Map<Boolean, List<Instance>> ipMap = new HashMap<>(2);
        ipMap.put(Boolean.TRUE, new ArrayList<>());
        ipMap.put(Boolean.FALSE, new ArrayList<>());
        for (Instance ip : srvedIPs) {
            ipMap.get(ip.isHealthy()).add(ip);
        }
        JSONArray hosts = new JSONArray();
        //将实例信息放入JSONObject ipObj
        for (Map.Entry<Boolean, List<Instance>> entry : ipMap.entrySet()) {
            List<Instance> ips = entry.getValue();
            if (healthyOnly && !entry.getKey()) {
                continue;
            }
            for (Instance instance : ips) {
                if (!instance.isEnabled()) {
                    continue;
                }
                JSONObject ipObj = new JSONObject();
                ipObj.put("ip", instance.getIp());
                ipObj.put("port", instance.getPort());
                ipObj.put("valid", entry.getKey());
                ipObj.put("healthy", entry.getKey());
                ipObj.put("marked", instance.isMarked());
                ipObj.put("instanceId", instance.getInstanceId());
                ipObj.put("metadata", instance.getMetadata());
                ipObj.put("enabled", instance.isEnabled());
                ipObj.put("weight", instance.getWeight());
                ipObj.put("clusterName", instance.getClusterName());
                if (clientInfo.type == ClientInfo.ClientType.JAVA &&
                    clientInfo.version.compareTo(VersionUtil.parseVersion("1.0.0")) >= 0) {
                    ipObj.put("serviceName", instance.getServiceName());
                } else {
                    ipObj.put("serviceName", NamingUtils.getServiceName(instance.getServiceName()));
                }
                ipObj.put("ephemeral", instance.isEphemeral());
                hosts.add(ipObj);
            }
        }
        //实例信息放入返回结果
        result.put("hosts", hosts);
        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.put("metadata", service.getMetadata());
        return result;
    }

综上,服务发现的大概流程就是:客户端启动定时任务,默认每10秒请求一次服务端更新订阅的服务信息到本地,同时维护一个UDP协议的通讯任务;服务端在接到请求后,返回客户端请求获取的服务信息,并将客户端的通讯地址信息维护到本地内存,对应其订阅的服务,当该服务中实例的健康状态发生变化时,将变更情况基于UDP推送给所有订阅该服务的客户端。

整体运行图

整体运行过程如图所示:
注册中心运行过程

结束

注册中心的运行过程实现原理大致就是这些,下次再解析一下配置中心。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值