Zookeeper之Java客户端实战

        ZooKeeper应用的开发主要通过Java客户端API去连接和操作ZooKeeper集群。可供选择的Java客户端API有:

  • ZooKeeper官方的Java客户端API。
  • 第三方的Java客户端API,比如Curator。

        接下来我们将逐一学习一下这两个java客户端是如何操作zookeeper的。

1. ZooKeeper官方的Java客户端

1.1 简介

        ZooKeeper官方的客户端API提供了基本的操作。例如,创建会话、创建节点、读取节点、更新数据、删除节点和检查节点是否存在等。不过,对于实际开发来说,ZooKeeper官方API有一些不足之处,具体如下:

  • ZooKeeper的Watcher监听是一次性的,每次触发之后都需要重新进行注册。
  • 会话超时之后没有实现重连机制
  • 异常处理烦琐,ZooKeeper提供了很多异常,对于开发人员来说可能根本不知道应该如何处理这些抛出的异常。
  • 仅提供了简单的byte[]数组类型的接口,没有提供Java POJO级别的序列化数据处理接口。
  • 创建节点时如果抛出异常,需要自行检查节点是否存在
  • 无法实现级联删除

总之,ZooKeeper官方API功能比较简单,在实际开发过程中比较笨重,一般不推荐使用

1.2 基础使用

使用zookeeper原生客户端,需要引入zookeeper客户端的依赖。

  <dependency>
            <groupId>org.apache.zookeeper</groupId>
            <artifactId>zookeeper</artifactId>
            <version>3.8.3</version>
        </dependency>

注意:保持与服务端版本一致,不然会有很多兼容性的问题。

        ZooKeeper原生客户端主要使用org.apache.zookeeper.ZooKeeper这个类来调用ZooKeeper服务的。

1.2.1 连接zk集群

ZooKeeper构造器:

 public ZooKeeper(String connectString, int sessionTimeout, Watcher watcher) throws IOException {
        this(connectString, sessionTimeout, watcher, false);
    }

 connectString:使用逗号分隔的列表,每个ZooKeeper节点是一个host.port对,host 是机器名或者IP地址,port是ZooKeeper节点对客户端提供服务的端口号。客户端会任意选取connectString 中的一个节点建立连接。
sessionTimeout : session timeout时间。
watcher:用于接收到来自ZooKeeper集群的事件。

    如何使用客户端构造器与服务端建立连接:

    建立连接的工具类;因为zookepper建立连接时特别慢,所以采用了CountDownLatch同步工具类,等待zookeeper客户端与服务端建立完成后,继续后续操作。

public class ZooKeeperFacotry {
    private static final int SESSION_TIMEOUT = 5000;

    public static ZooKeeper create(String connectionString) throws Exception {
        final CountDownLatch connectionLatch = new CountDownLatch(1);
        ZooKeeper zooKeeper = new ZooKeeper(connectionString, SESSION_TIMEOUT, new Watcher() {
            @Override
            public void process(WatchedEvent event) {
                if (event.getType()== Event.EventType.None
                        && event.getState() == Watcher.Event.KeeperState.SyncConnected) {
                    connectionLatch.countDown();
                    System.out.println("连接建立");
                }
            }
        });

        System.out.println("等待连接建立...");
        connectionLatch.await();

        return zooKeeper;
    }

}
public class ZkClientDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {
        //创建zookeeper对象
        ZooKeeper zooKeeper = ZooKeeperFacotry.create(CLUSTER_CONNECT_STR);

        //连接
        System.out.println(zooKeeper.getState());
}

运行结果:

1.2.2 操作节点

以下是Zookeeper原生客户端操作服务端的一些主要API:

  • create(path, data, acl,createMode): 创建一个给定路径的 znode,并在 znode 保存 data[]的 数据,createMode指定 znode 的类型。
  • delete(path, version):如果给定 path 上的 znode 的版本和给定的 version 匹配, 删除 znode。
  • exists(path, watch):判断给定 path 上的 znode 是否存在,并在 znode 设置一个 watch。
  • getData(path, watch):返回给定 path 上的 znode 数据,并在 znode 设置一个 watch。
  • setData(path, data, version):如果给定 path 上的 znode 的版本和给定的 version 匹配,设置znode 数据。
  • getChildren(path, watch):返回给定 path 上的 znode 的孩子 znode 名字,并在 znode 设置一个 watch。
  • sync(path):把客户端 session 连接节点和 leader 节点进行同步。

API特点:

  • 所有获取 znode 数据的 API 都可以设置一个 watch 用来监控 znode 的变化。
  • 所有更新 znode 数据的 API 都有两个版本: 无条件更新版本和条件更新版本。如果 version 为 -1,更新为无条件更新。否则只有给定的 version 和 znode 当前的 version 一样,才会进行更新,这样的更新是条件更新。
  • 所有的方法都有同步和异步两个版本。同步版本的方法发送请求给 ZooKeeper 并等待服务器的响 应。异步版本把请求放入客户端的请求队列,然后马上返回。异步版本通过 callback 来接受来 自服务端的响应。

接下来,我们利用这些API对zk的节点进行简单的操作

1.2.2.1 创建持久节点

代码:

public class ZkClientDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {
        //创建zookeeper对象
        ZooKeeper zooKeeper = ZooKeeperFacotry.create(CLUSTER_CONNECT_STR);

        //连接
        System.out.println(zooKeeper.getState());

        Stat stat = zooKeeper.exists("/order",false);
        if(null ==stat){
            //创建持久节点
            zooKeeper.create("/order","001".getBytes(),
                    ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
        }
         System.out.println("执行完了");
}

 运行的结果:

我们在服务器的客户端查看一下数据:

有数据,创建成功了。 

1.2.2.2 永久监听节点

代码:

public class ZkClientDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {
        //创建zookeeper对象
        ZooKeeper zooKeeper = ZooKeeperFacotry.create(CLUSTER_CONNECT_STR);

        //连接
        System.out.println(zooKeeper.getState());

        Stat stat = zooKeeper.exists("/order",false);


        //永久监听  addWatch -m mode
        zooKeeper.addWatch("/order",new Watcher() {
            @Override
            public void process(WatchedEvent event) {
                System.out.println(event);
                //TODO
            }
        },AddWatchMode.PERSISTENT);
        Thread.sleep(Integer.MAX_VALUE);
}

启动程序后,在服务器的客户端修改监听的节点。 

 程序监听到 /order这个节点被修改了。

1.2.2.3 根据版本更新

代码:

public class ZkClientDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {
        //创建zookeeper对象
        ZooKeeper zooKeeper = ZooKeeperFacotry.create(CLUSTER_CONNECT_STR);

        //连接
        System.out.println(zooKeeper.getState());

        Stat stat = zooKeeper.exists("/order",false);

        stat = new Stat();
        byte[] data = zooKeeper.getData("/order", false, stat);
        System.out.println(" data: "+new String(data));
        // -1: 无条件更新
        //zooKeeper.setData("/user", "third".getBytes(), -1);
        // 带版本条件更新
        int version = stat.getVersion();

        zooKeeper.setData("/order", "updateByVersion".getBytes(), version);
        
        Thread.sleep(Integer.MAX_VALUE);

    }

}

第一次打印的数据结果: 

待程序执行完,在服务器客户端查询的结果:更具版本更新数据成功了。

        对于zookeeper java原生客户端的使用,就简单介绍这么多,不在过多赘述,只是为了是让大家感受一下,原生客户端的使用。实际开发中并不推荐使用zk的原生客户端,过于笨重。

2. 开源的第三方客户端:Curator

2.1 简介

        官网:https://curator.apache.org/

        Curator是Netflix公司开源的一套ZooKeeper客户端框架,和ZkClient一样它解决了非常底层的细节开发工作,包括连接、重连、反复注册Watcher的问题以及NodeExistsException异常等。
        Curator是Apache基金会的顶级项目之一,Curator具有更加完善的文档,另外还提供了一套易用性和可读性更强的Fluent风格的客户端API框架。
        Curator还为ZooKeeper客户端框架提供了一些比较普遍的、开箱即用的、分布式开发用的解决方案,例如Recipe、共享锁服务、Master选举机制和分布式计算器等,帮助开发者避免了“重复造轮子”的无效开发工作。

        在实际的开发场景中,使用Curator客户端就足以应付日常的ZooKeeper集群操作的需求。

2.2 基础使用

        使用Curator我们需要引入依赖,官网上介绍到,Curator有多个 artifacts,使用者根据自己的需求,进行引入依赖。对于大多数使用者来说,引入curator-recipes就足够了。

Curator的几个重要包介绍

curator-framework:是对ZooKeeper的底层API的一些封装。
curator-client:提供了一些客户端的操作,例如重试策略等。
curator-recipes:封装了一些高级特性,如:Cache事件监听、选举、分布式锁、分布式计数器、分布式Barrier等。

依赖: 以防Curator中的zookeeper版本和我们的服务端不匹配,我们需要排除Curator中的zookeeper的客户端,自己手动引入合适版本的zk的客户端。

<!-- zookeeper client -->
        <dependency>
            <groupId>org.apache.zookeeper</groupId>
            <artifactId>zookeeper</artifactId>
            <version>3.8.3</version>
        </dependency>

        <!--curator-->
        <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-recipes</artifactId>
            <version>5.1.0</version>
            <exclusions>
                <exclusion>
                    <groupId>org.apache.zookeeper</groupId>
                    <artifactId>zookeeper</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

2.2.1 连接zk集群

        连接zk集群,我们需要创建一个客户端示例,然后启动客户端。创建客户端实例的方法有两种。

方法一:使用工厂类CuratorFrameworkFactory的静态newClient()方法。

public class CuratorDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {

        // 重试策略
        RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
        //创建客户端实例
        CuratorFramework client = CuratorFrameworkFactory.newClient(CLUSTER_CONNECT_STR, retryPolicy);
        //启动客户端
        client.start();
        Thread.sleep(Integer.MAX_VALUE);

    }
}

方法二:使用工厂类CuratorFrameworkFactory的静态builder构造者方法。 

public class CuratorDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {

        //构建客户端实例
        CuratorFramework curatorFramework= CuratorFrameworkFactory.builder()
                .connectString(CLUSTER_CONNECT_STR)
                .retryPolicy(new ExponentialBackoffRetry(1000,3)) // 设置重试策略
                .build();
        //启动客户端
        curatorFramework.start();


        Thread.sleep(Integer.MAX_VALUE);

    }
}

参数解释:

connectionString:服务器地址列表,在指定服务器地址列表的时候可以是一个地址,也可以是多个地址。如果是多个地址,那么每个服务器地址列表用逗号分隔, 如 host1:port1,host2:port2,host3;port3 。

retryPolicy:重试策略,当客户端异常退出或者与服务端失去连接的时候,可以通过设置客户端重新连接ZooKeeper 服务端。而 Curator 提供了 一次重试、多次重试等不同种类的实现方式。在 Curator 内部,可以通过判断服务器返回的 keeperException 的状态代码来判断是否进行重试处理,如果返回的是 OK 表示一切操作都没有问题,而 SYSTEMERROR 表示系统或服务端错误。

超时时间:Curator 客户端创建过程中,有两个超时时间的设置。一个是 sessionTimeoutMs 会话超时时间,用来设置该条会话在 ZooKeeper 服务端的失效时间。另一个是 connectionTimeoutMs 客户端创建会话的超时时间,用来限制客户端发起一个会话连接到接收 ZooKeeper 服务端应答的时间。sessionTimeoutMs 作用在服务端,而 connectionTimeoutMs 作用在客户端。

2.2.2 节点操作

        描述一个节点要包括节点的类型,即临时节点还是持久节点、节点的数据信息、节点是否是有序节点等属性和性质。接下来,我们逐一看一下利用Curator如何操作一个节点的。   

2.2.2.1 创建节点

    那么如何用Curator来创建一个节点呢?

        在 Curator 中,可以使用 create 函数创建数据节点,并通过 withMode 函数指定节点类型(持久化节点,临时节点,顺序节点,临时顺序节点,持久化顺序节点等),默认是持久化节点,之后调用forPath 函数来指定节点的路径和数据信息

第一步: 我们在服务器客户端,查看一下当前服务端有哪些节点

第二步:利用Curator创建一个持久节点,运行下述代码

public class CuratorDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {
        //构建客户端实例
        CuratorFramework curatorFramework= CuratorFrameworkFactory.builder()
                .connectString(CLUSTER_CONNECT_STR)
                .retryPolicy(new ExponentialBackoffRetry(1000,3)) // 设置重试策略
                .build();
        //启动客户端
        curatorFramework.start();

        String path = "/users/user";

        // 检查节点是否存在
        Stat stat = curatorFramework.checkExists().forPath(path);
        if (stat != null) {
            // 删除节点
            curatorFramework.delete()
                    .deletingChildrenIfNeeded()  // 如果存在子节点,则删除所有子节点
                    .forPath(path);  // 删除指定节点
        }
        // 创建节点
        curatorFramework.create()
                .creatingParentsIfNeeded()  // 如果父节点不存在,则创建父节点
                .withMode(CreateMode.PERSISTENT)
                .forPath(path, "user".getBytes());


        Thread.sleep(Integer.MAX_VALUE);

    }
}

第三步: 我们再次在服务器的客户端查看一下,节点创建成功了没有

创建了users和其子节点user成功。 

 如果我们需要创建有序节点,临时节点则只需要改变代码中的withMode的参数。具体参数如下:

PERSISTENT:持久节点

PERSISTENT_SEQUENTIAL:持久有序节点

EPHEMERAL:临时节点

EPHEMERAL_SEQUENTIAL:临时有序节点

CONTAINER:容器节点

PERSISTENT_WITH_TTL:持久TTL节点

PERSISTENT_SEQUENTIAL_WITH_TTL:持久有序TTL节点

 2.2.2.2 更新节点

        那么我们如何更新一个节点呢?

        我们通过客户端实例的 setData() 方法更新 ZooKeeper 服务上的数据节点,在setData 方法的后边,通过 forPath 函数来指定更新的数据节点路径以及要更新的数据。

第一步:服务器端的客户端查看节点/users/user原始数据:

第二步:更改/users/user节点的数据,执行以下代码

public class CuratorDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {
        //构建客户端实例
        CuratorFramework curatorFramework= CuratorFrameworkFactory.builder()
                .connectString(CLUSTER_CONNECT_STR)
                .retryPolicy(new ExponentialBackoffRetry(1000,3)) // 设置重试策略
                .build();
        //启动客户端
        curatorFramework.start();

        String path = "/users/user";

        // 更新节点数据    set //users/user  Update user
        curatorFramework.setData()
                .forPath(path, "Update user".getBytes());


        Thread.sleep(Integer.MAX_VALUE);

    }
}

第三步:再次使用服务器端的客户端查看/users/user的数据有没有更新成功

更新成功了。 

2.2.2.3 查看节点 

      那么我们如何查看一个节点呢?

        我们通过客户端实例的 getData() 方法更新 ZooKeeper 服务上的数据节点,在getData 方法的后边,通过 forPath 函数来指定查看的节点路径

第一步: 服务器端的客户端查看/users/user的节点数据

第二步:执行以下代码,在java客户端,查看/users/user的数据

public class CuratorDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {
        //构建客户端实例
        CuratorFramework curatorFramework= CuratorFrameworkFactory.builder()
                .connectString(CLUSTER_CONNECT_STR)
                .retryPolicy(new ExponentialBackoffRetry(1000,3)) // 设置重试策略
                .build();
        //启动客户端
        curatorFramework.start();

        String path = "/users/user";


        byte[] bytes = curatorFramework.getData().forPath(path);
        System.out.println("get data from  node :{/users/user}  successfully."+new String(bytes));

        Thread.sleep(Integer.MAX_VALUE);

    }
}

执行结果:

 

2.2.2.4 删除节点

  那么我们如何删除一个节点呢?

        我们通过客户端实例的 delete() 方法删除 ZooKeeper 服务上的数据节点,在delete方法的后边,通过 forPath 函数来指定删除的节点路径

第一步:服务器端查看节点

第二步:执行以下代码 删除users节点

public class CuratorDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {
        //构建客户端实例
        CuratorFramework curatorFramework= CuratorFrameworkFactory.builder()
                .connectString(CLUSTER_CONNECT_STR)
                .retryPolicy(new ExponentialBackoffRetry(1000,3)) // 设置重试策略
                .build();
        //启动客户端
        curatorFramework.start();

        String path = "/users";

        //删除节点
        curatorFramework.delete().guaranteed().deletingChildrenIfNeeded().forPath(path);

        Thread.sleep(Integer.MAX_VALUE);

    }
}

第三步:服务器端客户端查看节点是否还存在:users和其子节点user都被删除了

 

删除中一些函数含义:

guaranteed:该函数的功能如字面意思一样,主要起到一个保障删除成功的作用,其底层工作方式是:只要该客户端的会话有效,就会在后台持续发起删除请求,直到该数据节点在ZooKeeper 服务端被删除。
deletingChildrenIfNeeded:指定了该函数后,系统在删除该数据节点的时候会以递归的方式直接删除其子节点,以及子节点的子节点。 

2.2.2.4 监听

        Curator Caches:Curator 引入了 Cache 来实现对 Zookeeper 服务端事件监听,Cache 事件监听可以理解为一个本地缓存视图与远程 Zookeeper 视图的对比过程。Cache 提供了反复注册的功能。Cache 分为两类注册类型:节点监听和子节点监听。

2.2.2.4.1 监听节点数据变化

第一步: 服务器端客户端查看数据

 第二步: 启动以下代码,监听/user节点数据

public class CuratorDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {
        //构建客户端实例
        CuratorFramework curatorFramework= CuratorFrameworkFactory.builder()
                .connectString(CLUSTER_CONNECT_STR)
                .retryPolicy(new ExponentialBackoffRetry(1000,3)) // 设置重试策略
                .build();
        //启动客户端
        curatorFramework.start();

        String path = "/users";
        // 创建节点缓存,用于监听指定节点的变化
        final NodeCache nodeCache = new NodeCache(curatorFramework, path);
        // 启动NodeCache并立即从服务端获取最新数据
        nodeCache.start(true);

        // 注册节点变化监听器
        nodeCache.getListenable().addListener(new NodeCacheListener() {
            @Override
            public void nodeChanged() throws Exception {
                byte[] newData = nodeCache.getCurrentData().getData();
                System.out.println("Node data changed: " + new String(newData));
            }
        });

        Thread.sleep(Integer.MAX_VALUE);

    }
}

第三步:服务器端的客户端更新/users节点的数据,更改了4次

第四步:查看java客户端 监听到了数据

 

2.2.2.4.2 监听一级子节点

第一步服务器端客户端查看节点数据:

第二步: 执行以下代码,监听节点:

注意:PathChildrenCache 会对子节点进行监听,但是不会对二级子节点进行监听

 

public class CuratorDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {
        //构建客户端实例
        CuratorFramework curatorFramework= CuratorFrameworkFactory.builder()
                .connectString(CLUSTER_CONNECT_STR)
                .retryPolicy(new ExponentialBackoffRetry(1000,3)) // 设置重试策略
                .build();
        //启动客户端
        curatorFramework.start();

        String path = "/users";
        // 创建PathChildrenCache
        PathChildrenCache pathChildrenCache = new PathChildrenCache(curatorFramework, path, true);
        pathChildrenCache.start();

        // 注册子节点变化监听器
        pathChildrenCache.getListenable().addListener(new PathChildrenCacheListener() {
            @Override
            public void childEvent(CuratorFramework client, PathChildrenCacheEvent event) throws Exception {
                if (event.getType() == PathChildrenCacheEvent.Type.CHILD_ADDED) {
                    ChildData childData = event.getData();
                    System.out.println("Child added: " + childData.getPath());
                } else if (event.getType() == PathChildrenCacheEvent.Type.CHILD_REMOVED) {
                    ChildData childData = event.getData();
                    System.out.println("Child removed: " + childData.getPath());
                } else if (event.getType() == PathChildrenCacheEvent.Type.CHILD_UPDATED) {
                    ChildData childData = event.getData();
                    System.out.println("Child updated: " + childData.getPath());
                }
            }
        });

        Thread.sleep(Integer.MAX_VALUE);

    }
}

第三步:服务器端客户端增加子节点、修改子节点、删除子节点 

第四步:查看java客户端控制台消息,监听到了对应修改

 

2.2.2.4.3 监听所有子节点 

第一步:服务器端客户端查看节点:

第二步:执行以下代码,进行监听

public class CuratorDemo {

    private final static  String CLUSTER_CONNECT_STR="192.168.31.5:2181,192.168.31.176:2181,192.168.31.232:2181";

    public static void main(String[] args) throws Exception {
        //构建客户端实例
        CuratorFramework curatorFramework= CuratorFrameworkFactory.builder()
                .connectString(CLUSTER_CONNECT_STR)
                .retryPolicy(new ExponentialBackoffRetry(1000,3)) // 设置重试策略
                .build();
        //启动客户端
        curatorFramework.start();

        String path = "/users";
        Stat stat = curatorFramework.checkExists().forPath(path);
        if (stat==null){
            String s = curatorFramework.create().forPath(path);
        }

        TreeCache treeCache = new TreeCache(curatorFramework, path);
        treeCache.getListenable().addListener(new TreeCacheListener() {
            @Override
            public void childEvent(CuratorFramework client, TreeCacheEvent event) throws Exception {
                System.out.println(" tree cache: {}"+event.toString());

                Map<String, ChildData> currentChildren = treeCache.getCurrentChildren(path);
                System.out.println("currentChildren: {}"+ currentChildren.toString());
            }
        });
        treeCache.start();

        Thread.sleep(Integer.MAX_VALUE);

    }
}

第三步:服务器端客户端对节点进行一些操作

 

第四步:查看java客户端控制台信息:监听到了对应操作

3. 总结 

        以上是对zookeeper java客户端对zookeeper的基本操作,到此zookeeper java客户端的介绍到此为止,更详细的实践应用,后续会出一些zk常用场景是如何实现的文章。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

瑜伽娃娃

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

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

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

打赏作者

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

抵扣说明:

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

余额充值