基于zookeeper的属性配置管理的详细实现

2 篇文章 0 订阅
1 篇文章 0 订阅

一、关于zookeeper

ZooKeeper是一个分布式的,开放源码的分布式应用程序协调服务,是Google的Chubby一个开源的实现,是Hadoop和Hbase的重要组件。它是一个为分布式应用提供一致性服务的软件,提供的功能包括:配置维护、域名服务、分布式同步、组服务等。(不要慌这段来自于百度百科)  ,zookeeper简化了分布式应用的管理和部署,允许开发人员专注与核心的开发逻辑,而不用担心应用服务的分布式特性

二、关于zookeeper配置管理

zookeeper的内存数据模型是树结构,在内存中存储了整个树的内容,既然是树结构对应单个树节点信息包括节点路径、节点数据、ACL信息等。zookeeper对节点提供了watch监听功能,当我们使用zookeeper客户端操作内存中节点时,就会实时获取监听的事件如:新增节点、删除节点、更新节点,以及节点变化后的值。在分布式服务中,我们把配置信息存储到zookeeper节点中,为每个应用服务添加zookeeper节点监听后,一旦相关配置属性有变化,所有用用服务节点都能及时监听到配置属性的变化事件以及变化后的值。说到这里大家可能还是一头雾水不知所云,不急这些都是概念性东西,慢慢后面有代码实现的时候就会有所理解了。

三、为什么使用zookeeper配置管理

正如上面说的一个例子,在实际项目开发中我们会有很多配置信息,同时不同环境使用不同的配置信息,一般项目中我们习惯使用.properties或xml或微服务的yml结尾的文件存放配置信息,项目发布各个环境时方便点的使用jenkins打包后,使用对应环境的配置文件执行启动脚本,一个服务算是正常启动了。但是当我们项目采用分布式服务,尤其是大型项目每个服务都会部署在多个服务节点上,当面对那些容易变化的属性时,一旦修改了配置文件就需要重新打包然后各个服务节点重新启动,耗时耗力。当我们使用zookeeper配置管理,从zookeeper中读取配置属性,一旦属性有变化,因为属性节点添加了监听器,各个服务节点都会及时监听到变化然后读取最新的配置信息,这样我们就不用对项目进行修改、打包和发布操作了。当然这只是zookeeper配置管理一小部分的使用,我们还可以使用其配置网站或app一下静态资源信息(避免页面写死)、配置后台定时任务等

四、怎么使用zookeeper实现配置管理

4.1 案例环境

开发环境:springboot

本地jdk版本:jdk1.8.0_201

zookeeper版本:zookeeper-3.4.10

zookeeper所在服务器jdk版本 :jdk1.8.0_181

4.2 zookeeper集群搭建

1)下载zookeeper3.4.10的tar.gz压缩包文件
2)解压tar -zxvf zookeeper.3.4.10.tar.gz
3)进入解压后的文件的conf目录下,修改配置文件名zoo_sample.cfg为zoo.cfg
4)在解压目录下或其他目录下创建data和logs文件夹,修改配置文件时使用
5)修改zoo.cfg文件内容,如配置data和logs目录,自定义端口默认为2181
   dataDir=/xxx/data,建议使用绝对路径
   dataLogDir=/xxx/logs,建议使用绝对路径
   clietPort=2181
6)如果搭建集群,需要在每个zookeeper安装目录下的data文件中创建名为myid的文件,修改zoo.cfg内容如下:
   server.1=xxxx:2881:3881
   server.2=xxxx:2881:3881
   server.3=xxxx:2881:3881
   格式:server.num=xxxx:port1:port2

    
   num对应myid中的内容,port1是zookeeper集群中各服务间的通信端口,port2是zookeeper集群选举leader的端口

7)zookeeper常用相关操作命令

  • 启动zookeeper服务,可以通过指定配置文件启动../conf/zoo.cfg

        sh zkServer.sh start (../conf/zoo.cfg)括号部分可省略

  • 查看zookeeper服务状态

       sh zkServer.sh status (../conf/zoo.cfg)括号部分可省略

       

4.3 zookeeper客户端操作实现

再介绍客户端开发之前,先简单介绍使用的相关类
1)CuratorFramework:zookeeper客户端,用来操作zookeeper节点
2)ConnectionStateListener:zookeeper连接状态监听器,用来监听客户端与zookeeper服务端的连接状态
3)CuratorWatcher:zookeeper节点监视器,用来监视zookeeper节点的变化,可根据变化状态枚举,进行节点的变更操作等
对于第3点的使用,但是使用过程中需要用户反复注册Watcher(体现在三个地方:获取节点数据、获取子节点、判断节点是否存在,对应CuratorFramework的的这三种方法)。用户可以自定义监视器实现CuratorWacher接口,通过实现接口的process方法来获取及判断节点状态的变化来避免反复注册的问题,当然用户也可以使用Curator引入的Cache来实现对Zookeeper节点变化的监听和状态连接的监听,它能够自动为开发人员处理反复注册监听从而大大简化开发,目前有三种NodeCache、PathChildrenCache、TreeCache,Cache事件监听可理解为一个本地缓存视图与zookeeper视图对比的过程,分为两类注册类型:节点监听和子节点监听

4)Cache
  PathChildrenCache监听ZNode子节点变化情况,不会对二级子节点进行监听,对应监听器PathChildrenCacheListener
  NodeCache监听ZNode节点本身,不会监听子节点,对应监听器NodeCacheListener
  TreeCache是已上两种的合并,监听的是所有节点,对应监听器TreeCacheListener

   
5)zookeeper客户端及zookeeper服务端的jar包版本需要保持一致,同时注意使用zookeeper客户端高级的api时引入的receips的jar包的版本,否则容易导致客户端与服务端的连接总是丢失的问题。还有JDK版本的问题也会导致连接不稳定丢失的问题,后面会说。

6)代码实现,本案例使用的是TreeCache

  • 项目结构

  • yml配置文件

  • 自定义zookeeper客户端,引入CuratorFramework客户端作为属性

配置类ZKConfig.java,定义各个配置信息,常量类ZKConstant.java,定义常量信息

package com.learn.zw.zookeeper.starter.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component("zkConfig")
@ConfigurationProperties(value = "learn.zk")
@Data
public class ZKConfig {

    /**
     * zookeeper连接地址
     */
    private String connectionAddr;
    /**
     * zookeeper重连等待时间(毫秒)
     */
    private int retrySleepTime;
    /**
     * zookeeper重连次数
     */
    private int retryCount;
    /**
     * zookeeper会话超时时长(毫秒)
     */
    private int sessionTimeout;
    /**
     * zookeeper连接超时时长(毫秒
     */
    private int connectionTimeout;

}
package com.learn.zw.zookeeper.constant;

public class ZKConstant {

    public static final String separator = "/";

    public static final String root = "config";

}

自定义客户端ZKClient.java,包括节点的增删改查,注册节点监听器、状态监听器等方法

package com.learn.zw.zookeeper.client;

import com.learn.zw.zookeeper.constant.ZKConstant;
import com.learn.zw.zookeeper.starter.config.ZKConfig;
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.RetryPolicy;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.cache.NodeCache;
import org.apache.curator.framework.recipes.cache.NodeCacheListener;
import org.apache.curator.framework.recipes.cache.PathChildrenCache;
import org.apache.curator.framework.recipes.cache.PathChildrenCacheListener;
import org.apache.curator.framework.recipes.cache.TreeCache;
import org.apache.curator.framework.recipes.cache.TreeCacheListener;
import org.apache.curator.framework.state.ConnectionStateListener;
import org.apache.curator.retry.ExponentialBackoffRetry;
import org.apache.zookeeper.CreateMode;
import org.apache.zookeeper.data.Stat;

/**
 * @ClassName: ZKClient
 * @Description: TODO
 * @Author: zw
 * @Date: 2019/3/8 14:52
 * @Version: 1.0
 */
@Slf4j
public class ZKClient {

    private CuratorFramework client;

    private PathChildrenCache pathChildrenCache;

    private NodeCache nodeCache;

    private TreeCache treeCache;

    public ZKClient(ZKConfig config) {
        // zookeeper连接重试策略
        RetryPolicy retryPolicy = new ExponentialBackoffRetry(
                config.getRetrySleepTime(), config.getRetryCount());
        // 构建CuratorFramework客户端
        client = CuratorFrameworkFactory.builder().connectString(config.getConnectionAddr())
                .connectionTimeoutMs(config.getConnectionTimeout())
                .sessionTimeoutMs(config.getSessionTimeout())
                .retryPolicy(retryPolicy)
                .namespace(ZKConstant.root)
                .build();
        client.start();
    }

    public CuratorFramework getClient() {
        return client;
    }

    public PathChildrenCache getPathChildrenCache() {
        return pathChildrenCache;
    }

    public NodeCache getNodeCache() {
        return nodeCache;
    }

    public TreeCache getTreeCache() {
        return treeCache;
    }

    public void setData(String path, CreateMode mode, byte[] data) {
        try {
            if (!isExist(path)) {
                client.create().withMode(mode).forPath(path, data);
                return;
            }
            client.setData().forPath(path, data);
        } catch (Exception e) {
            log.error("设置属性节点:{},发生异常:{}", path, e);
        }
    }

    public void setData(String path, byte[] data) {
        try {
            if (!isExist(path)) {
                client.create().withMode(CreateMode.PERSISTENT).forPath(path, data);
                return;
            }
            client.setData().forPath(path, data);
        } catch (Exception e) {
            log.error("更新属性节点:{},发生异常:{}", path, e);
        }
    }

    public void setDataParentsIfNeeded(String path, byte[] data) {
        try {
            if (!isExist(path)) {
                client.create().creatingParentsIfNeeded().withMode(CreateMode.PERSISTENT).forPath(path, data);
                return;
            }
            client.setData().forPath(path, data);
        } catch (Exception e) {
            log.error("设置属性节点:{},发生异常:{}", path, e);
        }
    }



    public byte[] getData(String path) {
        try {
            if (!isExist(path)) {
                return null;
            }
            return client.getData().forPath(path);
        } catch (Exception e) {
            log.error("获取属性节点:{},发生异常:{}", path, e);
            return null;
        }
    }

    public boolean deleteData(String path) {
        try {
            if (isExist(path)) {
                client.delete().deletingChildrenIfNeeded().forPath(path);
            }
        } catch (Exception e) {
            log.error("删除属性节点:{},发生异常:{}", path, e);
            return false;
        }
        return true;
    }

    public boolean isExist(String path) {
        try {
            Stat stat = client.checkExists().forPath(path);
            return stat == null ? false : true;
        } catch (Exception e) {
            log.error("校验属性节点:{},是否存在发生异常:{}", path, e);
            return false;
        }
    }

    /**
     *  设置Path Cache, 监控本节点的子节点被创建,更新或者删除,注意是子节点, 子节点下的子节点不能递归监控
     *  事件类型有3个, 可以根据不同的动作触发不同的动作
     *  @Param path 监控的节点路径, cacheData 是否缓存data
     *  可重入监听
     * */
    public void registerPathChildrenCacheListener(PathChildrenCacheListener listener, String path, boolean cacheData) {
        try {
            pathChildrenCache = new PathChildrenCache(client, path, cacheData);
            pathChildrenCache.getListenable().addListener(listener);
            pathChildrenCache.start(PathChildrenCache.StartMode.POST_INITIALIZED_EVENT);
        } catch (Exception e) {
            log.error("节点:{},注册PathChildrenCacheListener监听发生异常:{}", path, e);
        }
    }

    /**
     *  设置Node Cache, 监控本节点的新增,删除,更新
     *  节点的update可以监控到, 如果删除会自动再次创建空节点
     *  @Param path 监控的节点路径, dataIsCompressed 数据是否压缩
     *  不可重入监听
     * */
    public void registerNodeCacheListener(NodeCacheListener listener, String path, boolean dataIsCompressed) {
        try {
            nodeCache = new NodeCache(client, path, dataIsCompressed);
            nodeCache.getListenable().addListener(listener);
            nodeCache.start();
        } catch (Exception e) {
            log.error("节点:{},注册NodeCacheListener监听发生异常:{}", path, e);
        }
    }

    /**
     *  设置Tree Cache, 监控本节点的新增,删除,更新
     *  节点的update可以监控到, 如果删除不会自动再次创建
     *  @Param path 监控的节点路径, dataIsCompressed 数据是否压缩
     *  可重入监听
     * */
    public void registerTreeCacheListener(TreeCacheListener listener, String path) {
        try {
            treeCache = new TreeCache(client, path);
            treeCache.getListenable().addListener(listener);
            treeCache.start();
        } catch (Exception e) {
            log.error("节点:{},注册TreeCacheListener监听发生异常:{}", path, e);
        }
    }

    /**
     * 注册zookeeper连接状态监听器
     * @param stateListener
     */
    public void registerConnectionStateListener(ConnectionStateListener stateListener) {
        this.client.getConnectionStateListenable().addListener(stateListener);
    }


}
  • 自定义属性操作类PropertyCache.java

可能会有疑惑定义了ZKClient客户端为什么还定义这个类,因为客户端直接是对zookeeper节点的操作,定义PropertyCache.java主要作用是获取节点存储的数据即属性值,而不提供针对节点操作的其他方法。但是本案例为了测试需要,也在PropertyCahche中提供了增删改的方法。

package com.learn.zw.zookeeper.cache;

import com.learn.zw.zookeeper.client.ZKClient;
import com.learn.zw.zookeeper.constant.ZKConstant;
import com.learn.zw.zookeeper.starter.config.ZKConfig;
import lombok.extern.slf4j.Slf4j;
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.recipes.cache.ChildData;
import org.apache.curator.framework.recipes.cache.TreeCacheEvent;
import org.apache.curator.framework.recipes.cache.TreeCacheListener;

/**
 * @ClassName: PropertyCacheClient
 * @Description: TODO
 * @Author: zhang.wei
 * @Date: 2019/3/7 16:22
 * @Version: 1.0
 */
@Slf4j
public class PropertyCache {

    // 节点操作客户端
    private ZKClient zkClient;

    // 环境:dev、prd
    private String profile;

    public PropertyCache(ZKConfig config, String profile) {
        this.profile = profile;
        this.zkClient = new ZKClient(config);
        this.zkClient.registerTreeCacheListener(new CacheListener2(profile), ZKConstant.separator + profile);
    }

    /**
     * 根据属性节点路径从缓存中获取属性值
     *
     * @param path
     * @return
     * @throws Exception
     */
    public String getProperty(String path) throws Exception {
        ChildData currentData = zkClient.getTreeCache().getCurrentData(getPrefixPath() + path);
        if (currentData == null) {
            log.warn("未发现路径为:{}的属性节点", path);
            return null;
        }
        byte[] bytes = currentData.getData();
        String data = new String(bytes, "utf-8");
        log.info("获取当前属性路径:" + path + ",属性值:" + (data.length() > 15 ? data.substring(0, 15) : data));
        return data;
    }

    /**
     * 删除属性节点,级联删除字节点
     *
     * @param path
     */
    public void deleteProperty(String path) {
        zkClient.deleteData(getPrefixPath() + path);
    }

    /**
     * 添加属性节点,父路径必须已存在
     *
     * @param path
     * @param value
     */
    public void addProperty(String path, String value) {
        zkClient.setData(getPrefixPath() + path, value.getBytes());
    }

    /**
     * 添加属性节点,父路径可不存在
     *
     * @param path
     * @param value
     */
    public void addPropertyParentsIfNeeded(String path, String value) {
        zkClient.setDataParentsIfNeeded(getPrefixPath() + path, value.getBytes());
    }

    /**
     * 获取当前环境下根节点路径,如:/dev,/prd
     *
     * @return
     */
    public String getPrefixPath() {
        return ZKConstant.separator + profile;
    }

    /**
     * 自定义TreeCache类型监听器,用来监听当前路径下的所有节点的状态
     */
    class CacheListener2 implements TreeCacheListener {

        private String path;

        public CacheListener2(String path) {
            this.path = path;
        }

        @Override
        public void childEvent(CuratorFramework client, TreeCacheEvent event) throws Exception {
            switch (event.getType()) {
                case NODE_ADDED:
                    log.info("监听路径:{},新增属性节点CHILD_ADDED,属性路径:{},属性值:{}", path,
                            event.getData().getPath(), new String(event.getData().getData(), "utf-8"));
                    break;
                case NODE_UPDATED:
                    log.info("监听路径:{},更新属性节点CHILD_UPDATED,属性路径;{},属性值:{}", path,
                            event.getData().getPath(), new String(event.getData().getData(), "utf-8"));
                    break;
                case NODE_REMOVED:
                    log.info("监听路径:{},移除属性节点NODE_REMOVED,属性路径;{},属性值:{}", path,
                            event.getData().getPath(), new String(event.getData().getData(), "utf-8"));
                    break;
                case CONNECTION_LOST:
                    log.warn("监听路径:{},连接丢失:CONNECTION_LOST", path);
                    break;
                case CONNECTION_RECONNECTED:
                    log.warn("监听路径:{},重新连接:CONNECTION_RECONNECTED", path);
                    break;
                case INITIALIZED:
                    log.warn("监听路径:{},连接初始化:INITIALIZED", path);
                    break;
                case CONNECTION_SUSPENDED:
                    log.warn("监听路径:{},连接挂起:CONNECTION_SUSPENDED", path);
                    break;
                default:
                    log.warn("监听到未定义状态变化类型");
                    break;
            }
        }
    }

}
  • 定义bean的加载类ZKBoot.java,用来程序启动时创建自定义的相关bean的实例并注入IOC中
package com.learn.zw.zookeeper.starter;

import com.learn.zw.zookeeper.cache.PropertyCache;
import com.learn.zw.zookeeper.cache.PropertyExtCache;
import com.learn.zw.zookeeper.client.ZKExtClient;
import com.learn.zw.zookeeper.starter.config.ZKConfig;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ZKBoot {

    @Value("${learn.zk.profile}")
    private String profile;

    
    /**
     * 此bean用来操作TreeCache类型节点,加载指定路径下所有节点
     * 注:TreeCahce特性
     * @param config
     * @return
     */
    //@Bean
    public PropertyCache propertyCache(@Qualifier("zkConfig") ZKConfig config) {
        return new PropertyCache(config, profile);
    }

}

 

4.4 zookeeper配置管理测试

为了方便测试,在Controller层定义一个类ZookeeperController.java提供测试方法

package com.learn.zw.zookeeper.controller;

import com.learn.zw.zookeeper.cache.PropertyCache;
import com.learn.zw.zookeeper.cache.PropertyExtCache;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @ClassName: TestController
 * @Description: TODO
 * @Author: zw
 * @Date: 2019/3/7 18:23
 * @Version: 1.0
 */
@RestController
public class ZookeeperController {

    @Autowired
    private PropertyCache propertyCache;

    //@Autowired
    private PropertyExtCache propertyExtCache;


    @RequestMapping(value = "/test/get")
    public String test(String path) {
        try {
            //String property = propertyCache.getProperty(path);
            String property = propertyExtCache.getPropertyWithCache(path);
            return property;
        } catch (Exception e) {
            e.printStackTrace();
            return "error";
        }
    }

    @RequestMapping(value = "/test/delete")
    public String delete(String path) {
        try {
            propertyCache.deleteProperty(path);
            return "success";
        } catch (Exception e) {
            e.printStackTrace();
            return "error";
        }
    }

    @RequestMapping(value = "/test/add")
    public String add(String path, String value) {
        try {
            propertyExtCache.addPropertyParentsIfNeeded(path, value);
            return value;
        } catch (Exception e) {
            e.printStackTrace();
            return "error";
        }
    }
}

新增属性节点:

查询属性节点:

删除属性节点:

后台日志:

2019-04-05 22:46:52.155  INFO 3288 --- [tor-TreeCache-0] c.l.zw.zookeeper.cache.PropertyCache     : 监听路径:dev,新增属性节点CHILD_ADDED,属性路径:/dev/level1/value1,属性值:value1
2019-04-05 22:47:06.441  INFO 3288 --- [nio-8090-exec-2] c.l.zw.zookeeper.cache.PropertyCache     : 获取当前属性路径:/level1/value1,属性值:value1
2019-04-05 22:47:13.966  INFO 3288 --- [tor-TreeCache-0] c.l.zw.zookeeper.cache.PropertyCache     : 监听路径:dev,移除属性节点NODE_REMOVED,属性路径;/dev/level1/value1,属性值:value1

五、注意事项

1、jdk版本问题

建议使用jdk版本1.8以上,建议本地开发环境的jdk版本、zookeeper版本与zookeeper部署服务器环境版本一致,至少前者版本要大于后者,否则会不断刷新报zookeeper服务连接丢失、重连的问题。作者就因为这问题折磨了好几天。

2、zookeeper依赖和Curator依赖版本问题,本文没深作研究,网上有针对这两个依赖使用时的版本介绍可自行查阅,这块也会导致连接丢失的问题

3、为了方便读者学习使用而不用顾虑版本问题,作者贴出本地开发的pom文件依赖版本,jdk版本上文以说明

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<parent>
		<groupId>com.learn.zw</groupId>
		<artifactId>zw-parent</artifactId>
		<version>0.0.1-SNAPSHOT</version>
	</parent>
	<groupId>com.learn.zw</groupId>
	<artifactId>learn-zookeeper</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<packaging>jar</packaging>
	<name>learn-zookeeper</name>
	<description>Demo project for Spring Boot</description>

	<properties>
		<java.version>1.8</java.version>
	</properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>Finchley.SR1</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

        <!-- zookeeper客户端依赖 -->
        <dependency>
            <groupId>org.apache.zookeeper</groupId>
            <artifactId>zookeeper</artifactId>
            <version>3.4.10</version>
        </dependency>
        <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-recipes</artifactId>
            <version>4.0.1</version>
        </dependency>

	</dependencies>

	<build>
		<plugins>
			<plugin>
				<groupId>org.springframework.boot</groupId>
				<artifactId>spring-boot-maven-plugin</artifactId>
			</plugin>
		</plugins>
	</build>

</project>

六、总结

1、本案例是根据自己公司使用zookeeper实现配置管理感觉挺有意思的然后学习编写的案例,与公司项目实现的不同点是公司使用的是自定义的节点监听器而非Curator引入的Cache。

2、后续文章中会发布针对这块使用的扩展,使项目启动时加载指定环境的指定路径下的节点信息,因为首先实际项目会发布不同的环境,其次当需要的属性配置过多时,项目启动为了避免zookeeper预加载所有节点信息,可以为每个项目配置不同的节点路径,这样项目启动时只预加载指定路径下的节点信息。

 

 

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值