jetcd实战之二:基本操作

2021-12-07 08:20:12 浏览数 (1)

系列文章链接

  1. jetcd实战之一:极速体验
  2. jetcd实战之二:基本操作
  3. jetcd实战之三:进阶操作(事务、监听、租约)

本篇概览

本文是《jetcd实战系列》的第二篇,经过前面的准备,我们有了可用的etcd集群环境和gradle父工程,并且写了个helloworld程序连接etcd简单体验了一番,今天的实战咱们聚焦那些常用的etcd操作,例如写、读、删除等,这些操作可以覆盖到日常大部分场景,本文主要有以下几部分组成:

  1. 编写接口类EtcdService.java,定义常用的etcd操作;
  2. 编写接口类的实现EtcdServiceImpl.java,这里面主要是调用jetcd提供的API来完成具体的etcd操作;
  3. 编写单元测试类EtcdServiceImplTest.java,这里面有很多测试方法,来演示如何使用EtcdService的接口来实现各种复杂的操作;

源码下载

  • 本篇实战中的完整源码可在GitHub下载到,地址和链接信息如下表所示(https://github.com/zq2599/blog_demos):

名称

链接

备注

项目主页

https://github.com/zq2599/blog_demos

该项目在GitHub上的主页

git仓库地址(https)

https://github.com/zq2599/blog_demos.git

该项目源码的仓库地址,https协议

git仓库地址(ssh)

git@github.com:zq2599/blog_demos.git

该项目源码的仓库地址,ssh协议

  • 这个git项目中有多个文件夹,kubebuilder相关的应用在jetcd-tutorials文件夹下,如下图红框所示:
  • jetcd-tutorials文件夹下有多个子项目,本篇的是base-operate:

新建子模块base-operate

  • 在父工程jetcd-tutorials下新增名为base-operate的Gradle子模块,其build.gradle文件内容如下:
代码语言:javascript复制
plugins {
    id 'java-library'
}

// 子模块自己的依赖
dependencies {
    api 'io.etcd:jetcd-core'
    api 'org.projectlombok:lombok'
    // annotationProcessor不会传递,使用了lombok生成代码的模块,需要自己声明annotationProcessor
    annotationProcessor 'org.projectlombok:lombok'
    // slf4j的包自己用就行了,不要继承到其他工程中去,否则容易和其他日志包起冲突
    implementation 'org.slf4j:slf4j-log4j12'
    testImplementation('org.junit.jupiter:junit-jupiter')
}

test {
    useJUnitPlatform()
}
  • 新增接口EtcdService.java,这里面定义了常用的etcd操作:
代码语言:javascript复制
package com.bolingcavalry.dao;

import io.etcd.jetcd.Response.Header;
import io.etcd.jetcd.kv.GetResponse;
import io.etcd.jetcd.options.DeleteOption;
import io.etcd.jetcd.options.GetOption;

/**
 * @Description: Etcd操作服务的接口
 * @author: willzhao E-mail: zq2599@gmail.com
 * @date: 2021/3/30 7:55
 */
public interface EtcdService {

    /**
     * 写入
     * @param key
     * @param value
     */
    Header put(String key, String value) throws Exception;

    /**
     * 读取
     * @param key
     * @return
     */
    String getSingle(String key) throws Exception;


    /**
     * 带额外条件的查询操作,例如前缀、结果排序等
     * @param key
     * @param getOption
     * @return
     */
    GetResponse getRange(String key, GetOption getOption) throws Exception;

    /**
     * 单个删除
     * @param key
     * @return
     */
    long deleteSingle(String key) throws Exception;

    /**
     * 范围删除
     * @param key
     * @param deleteOption
     * @return
     */
    long deleteRange(String key, DeleteOption deleteOption) throws Exception;

    /**
     * 关闭,释放资源
     */
    void close();
}
  • 新增上述接口对应的实现类,可见大多数是直接调用jetcd提供的API:
代码语言:javascript复制
package com.bolingcavalry.dao.impl;

import com.bolingcavalry.dao.EtcdService;
import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.KV;
import io.etcd.jetcd.Response;
import io.etcd.jetcd.kv.GetResponse;
import io.etcd.jetcd.options.DeleteOption;
import io.etcd.jetcd.options.GetOption;

import static com.google.common.base.Charsets.UTF_8;

/**
 * @Description: etcd服务的实现类
 * @author: willzhao E-mail: zq2599@gmail.com
 * @date: 2021/3/30 8:28
 */
public class EtcdServiceImpl implements EtcdService {



    private Client client;

    private String endpoints;

    private Object lock = new Object();

    public EtcdServiceImpl(String endpoints) {
        super();
        this.endpoints = endpoints;
    }

    /**
     * 将字符串转为客户端所需的ByteSequence实例
     * @param val
     * @return
     */
    public static ByteSequence bytesOf(String val) {
        return ByteSequence.from(val, UTF_8);
    }

    /**
     * 新建key-value客户端实例
     * @return
     */
    private KV getKVClient(){

        if (null==client) {
            synchronized (lock) {
                if (null==client) {

                    client = Client.builder().endpoints(endpoints.split(",")).build();
                }
            }
        }

        return client.getKVClient();
    }

    @Override
    public void close() {
        client.close();
        client = null;
    }

    @Override
    public Response.Header put(String key, String value) throws Exception {
        return getKVClient().put(bytesOf(key), bytesOf(value)).get().getHeader();
    }

    @Override
    public String getSingle(String key) throws Exception {
        GetResponse getResponse = getKVClient().get(bytesOf(key)).get();

        return getResponse.getCount()>0 ?
               getResponse.getKvs().get(0).getValue().toString(UTF_8) :
               null;
    }

    @Override
    public GetResponse getRange(String key, GetOption getOption) throws Exception {
        return getKVClient().get(bytesOf(key), getOption).get();
    }

    @Override
    public long deleteSingle(String key) throws Exception {
        return getKVClient().delete(bytesOf(key)).get().getDeleted();
    }

    @Override
    public long deleteRange(String key, DeleteOption deleteOption) throws Exception {
        return getKVClient().delete(bytesOf(key), deleteOption).get().getDeleted();
    }
}
  • 看到这里,您一定觉得太easy了,确实,调用上述方法就能轻松完成常用的读写操作,但很多时候咱们的操作并非对指定的key做读写那么简单,例如按前缀查询、只返回数量不返回数据、批量删除直到指定的key出现为止,其实只要用好EtcdService提供的那几个接口,上述复杂操作都能轻松完成;
  • 接下来咱们通过单元测试来逐一体验EtcdService提供的那几个接口,并尝试完成各种复杂操作;

编写单元测试用例

  • 新增单元测试类EtcdServiceImplTest,如下图所示,为了让其内部的方法按我们指定的顺序执行,记得给类添加注解@TestMethodOrder(MethodOrderer.OrderAnnotation.class):
  • 如下图红框,默认使用Gradle作为测试工具,这里请改成红框中的IntelliJ IDEA,这样单元测试代码中的Order、DisplayName等注解才能生效:
  • 接下来开始在EtcdServiceImplTest中写代码,先写个key方法,这里面用当前时间和输入的字符串拼接成一个独一无二的字符串,可以作为后面的测试是的key(或者key前缀):
代码语言:javascript复制
	private static String key(String name) {
        return "/EtcdServiceImplTest/"   name   "-"   System.currentTimeMillis();
    }
  • 定义EtcdServiceImp实例作为静态变量,后面的测试中都会用到,另外还要在测试结束时关闭客户端连接:
代码语言:javascript复制
	private static EtcdService etcdService = new EtcdServiceImpl();

    @AfterAll
    static void close() {
        etcdService.close();
    }
  • 接下来开始体验etcd的基本操作;

基本写操作

  • 写操作非常简单,就是调用put方法传入key和value,至于验证,在开始读操作之前先简单点,确认header非空即可:
代码语言:javascript复制
    @Test
    @Order(1)
    @DisplayName("基本写操作")
    void put() throws Exception {
        Response.Header header = etcdService.put(key("put"), "123");
        assertNotNull(header);
    }

读操作

  • 先测试最基本的读操作,用getSingle方法可以返回单个结果:
代码语言:javascript复制
    @Test
    @Order(2)
    @DisplayName("基本读操作")
    void getSingle() throws Exception {
        String key = key("getSingle");
        String value = String.valueOf(System.currentTimeMillis());

        // 先写入
        etcdService.put(key, value);

        // 再读取
        String queryRlt = etcdService.getSingle(key);

        assertEquals(value, queryRlt);
    }
  • 接下来借助GetOption对象,我们可以进行跟多复杂的读操作,先看如何通过前缀查询多个键值对:
代码语言:javascript复制
    @Test
    @Order(3)
    @DisplayName("读操作(指定前缀)")
    void getWithPrefix() throws Exception {
        String prefix = key("getWithPrefix");

        // 先写入十条
        int num = 10;

        for (int i=0;i<num;i  ) {
            // 写入,每个key都不同
            etcdService.put(prefix   i, String.valueOf(i));
        }

        // 带前缀的方式查询,注意要入参key和prefix是同一个值
        GetOption getOption = GetOption.newBuilder().withPrefix(EtcdServiceImpl.bytesOf(prefix)).build();
        GetResponse getResponse = etcdService.getRange(prefix, getOption);

        // 总数应该是十个
        assertEquals(num, getResponse.getCount());
    }
  • 假设总共有十条结果,还可以控制只返回五条记录(不过总数字段还是十):
代码语言:javascript复制
    @Test
    @Order(4)
    @DisplayName("读操作(指定KeyValue结果数量)")
    void getWithLimit() throws Exception {
        String prefix = key("getWithLimit");

        // 先写入十条
        int num = 10;
        int limit = num/2;

        for (int i=0;i<num;i  ) {
            // 写入,每个key都不同
            etcdService.put(prefix   i, String.valueOf(i));
        }

        // 带前缀的方式查询,查出来应该是十个,再加上数量限制为五个
        GetOption getOption = GetOption.newBuilder()
                .withPrefix(EtcdServiceImpl.bytesOf(prefix))
                .withLimit(limit)
                .build();

        GetResponse getResponse = etcdService.getRange(prefix, getOption);

        // 总数还是十个
        assertEquals(num, getResponse.getCount());
        // 结果的数量和limit有关,是5个
        assertEquals(limit, getResponse.getKvs().size());
    }
  • revision字段是etcd的全局版本号,每次写入都会对应一个revision值,可以用该revision值作为查询条件,查到指定key过往的某个版本的值:
代码语言:javascript复制
    @Test
    @Order(5)
    @DisplayName("读操作(指定revision)")
    void getWithRevision() throws Exception {
        String key = key("getWithRevision");

        // 先写入十条
        int num = 10;
        int limit = num/2;

        // 第一次写入时的revision
        long firstRevision = 0L;

        // 第一次写入的value
        String firstValue = null;

        // 最后一次写入的value
        String lastValue = null;

        for (int i=0;i<num;i  ) {
            // 用同一个key写十次,每次的value都不同
            String value = String.valueOf(i);
            // 注意,key一直没有变化
            Response.Header header = etcdService.put(key, value);

            // 第一次写入的revision和value都保存下来,后面用revision取出值,和value对比应该相等
            if (0==i) {
                firstRevision = header.getRevision();
                firstValue = value;
            } else if ((num-1)==i) {
                // 将最后一次写入的value记录下来
                lastValue = value;
            }
        }


        // 记录下来的第一次写入的值和最后一次写入的值应该不等
        assertNotEquals(firstValue, lastValue);

        // 如果不带其他条件只用key查找,查出的值应该等于最后一次写入的
        assertEquals(lastValue, etcdService.getSingle(key));

        // 查询条件中指定第一次写入的revision
        GetOption getOption = GetOption.newBuilder()
                              .withRevision(firstRevision)
                              .build();

        GetResponse getResponse = etcdService.getRange(key, getOption);

        // 总数是一个
        assertEquals(1, getResponse.getCount());

        // 结果的value应该和前面记录的第一次写入的值相等
        assertEquals(firstValue, getResponse.getKvs().get(0).getValue().toString(UTF_8));
    }
  • 当前查询结果有多个时,还可以对结果进行排序,key或者value都能用作排序字段,并且可以选择升序还是降序:
代码语言:javascript复制
    @Test
    @Order(6)
    @DisplayName("读操作(结果排序)")
    void getWithOrder() throws Exception {
        String prefix = key("getWithOrder");

        // 先写入十条,每一条的key都不同,value也不同
        int num = 10;

        // 第一次写的key
        String firstKey = null;
        // 第一次写的value
        String firstValue = null;
        // 最后一次写的key
        String lastKey = null;
        // 最后一次写的value
        String lastValue = null;

        for (int i=0;i<num;i  ) {
            String key = prefix   i;
            String value = String.valueOf(i);
            // 写入,每个key都不同
            etcdService.put(key, value);

            // 把第一次写的key、value,最后一次写的key、value保存到对应的变量中
            if(0==i) {
                firstKey = key;
                firstValue = value;
            } else if((num-1)==i) {
                lastKey = key;
                lastValue = value;
            }
        }


        // 第一次查询,结果用key排序,从大到小
        GetOption getOption = GetOption.newBuilder()
                                .withPrefix(EtcdServiceImpl.bytesOf(prefix))
                                .withSortField(GetOption.SortTarget.KEY)
                                .withSortOrder(GetOption.SortOrder.DESCEND)
                                .build();

        GetResponse getResponse = etcdService.getRange(prefix, getOption);

        // 总数还是十个
        assertEquals(num, getResponse.getCount());

        // 取查询结果的第一条
        KeyValue firstResult = getResponse.getKvs().get(0);

        // 因为是从大到小,查询结果的第一条应该是最后一次写入的(key是lastKey,value是lastValue)
        assertEquals(lastKey, firstResult.getKey().toString(UTF_8));
        assertEquals(lastValue, firstResult.getValue().toString(UTF_8));


        // 第二次查询,结果用key排序,从小到大
        getOption = GetOption.newBuilder()
                    .withPrefix(EtcdServiceImpl.bytesOf(prefix))
                    .withSortField(GetOption.SortTarget.KEY)
                    .withSortOrder(GetOption.SortOrder.ASCEND)
                    .build();

        getResponse = etcdService.getRange(prefix, getOption);

        // 总数还是十个
        assertEquals(num, getResponse.getCount());

        // 取查询结果的第一条
        firstResult = getResponse.getKvs().get(0);

        // 因为是从小到大,查询结果的第一条应该是第一次写入的(key是firstKey,value是firstValue)
        assertEquals(firstKey, firstResult.getKey().toString(UTF_8));
        assertEquals(firstValue, firstResult.getValue().toString(UTF_8));
    }
  • 指定返回结果中只有key没有value:
代码语言:javascript复制
    @Test
    @Order(7)
    @DisplayName("读操作(只返回key)")
    void getOnlyKey() throws Exception {
        String key = key("getOnlyKey");
        // 写入一条记录
        etcdService.put(key, String.valueOf(System.currentTimeMillis()));

        // 查询条件中指定只返回key
        GetOption getOption = GetOption.newBuilder()
                            .withKeysOnly(true)
                            .build();

        GetResponse getResponse = etcdService.getRange(key, getOption);

        assertEquals(1, getResponse.getCount());

        KeyValue keyValue = getResponse.getKvs().get(0);

        assertNotNull(keyValue);

        assertEquals(key, keyValue.getKey().toString(UTF_8));

        // value应该是空的
        assertTrue(keyValue.getValue().isEmpty());
    }
  • 返回的结果中只有数量,不包含key和value:
代码语言:javascript复制
    @Test
    @Order(8)
    @DisplayName("读操作(只返回数量)")
    void getOnlyCount() throws Exception {
        String key = key("getOnlyCount");
        // 写入一条记录
        etcdService.put(key, String.valueOf(System.currentTimeMillis()));

        // 查询条件中指定只返回key
        GetOption getOption = GetOption.newBuilder()
                            .withCountOnly(true)
                            .build();

        GetResponse getResponse = etcdService.getRange(key, getOption);

        // 数量应该是1
        assertEquals(1, getResponse.getCount());

        // KeyValue应该是空的
        assertTrue(getResponse.getKvs().isEmpty());
    }
  • 假设etcd有三个key:a1、a2、a3,那么通过前缀a可以将这三个key都查出来,与此同时还可以再加个endKey查询条件,假设endKey等于a2,那么查找工作在查到a2时就会停止并返回,而返回值中只有a1,不包含a2,换言之endKey之前的值才会被返回:
代码语言:javascript复制
    @Test
    @Order(9)
    @DisplayName("读操作(查到指定key就结束)")
    void getWithEndKey() throws Exception {
        String prefix = key("getWithEndKey");
        String endKey = null;

        int num = 10;

        for (int i=0;i<num;i  ) {
            String key = prefix   i;
            // 写入,每个key都不同
            etcdService.put(key, String.valueOf(i));

            // 总共写入十条记录,把第九条的key作为endKey保存
            if ((num-2)==i) {
                endKey = key;
            }
        }

        // 查询条件中指定了endKey是上面写入的第九条记录的key
        // 注意,查询结果中不包含endKey那条记录,也就是说只返回前八条
        GetOption getOption = GetOption.newBuilder()
                .withRange(EtcdServiceImpl.bytesOf(endKey))
                .build();

        GetResponse getResponse = etcdService.getRange(prefix, getOption);

        // 注意,查询结果中不包含endKey那条记录,也就是说只返回前八条
        assertEquals(num-2, getResponse.getCount());
    }
  • 以上就是读操作的常见用法,接下来看删除;

删除操作

  • 最基本的删除就是调用deleteSingle方法:
代码语言:javascript复制
    @Test
    @Order(10)
    @DisplayName("单个删除")
    void deleteSingle() throws Exception {
        String key = key("deleteSingle");

        // 写入一条记录
        etcdService.put(key, String.valueOf(System.currentTimeMillis()));

        // 此时应该能查到
        assertNotNull(etcdService.getSingle(key));

        // 删除
        etcdService.deleteSingle(key);

        // 此时应该查不到了
        assertNull(etcdService.getSingle(key));
    }
  • 借助DeleteOption对象,可以实现更多类型的删除,下面是删除指定前缀的所有记录:
代码语言:javascript复制
   @Test
    @Order(11)
    @DisplayName("删除(指定前缀)")
    void deleteWithPrefix() throws Exception {
        String prefix = key("deleteWithPrefix");

        int num = 10;

        // 写入,每个key都不同,但是有相同的前缀
        for (int i=0;i<num;i  ) {
            etcdService.put(prefix   i, String.valueOf(i));
        }

        GetOption getOption = GetOption.newBuilder()
                                .withPrefix(EtcdServiceImpl.bytesOf(prefix))
                                .build();

        // 此时总数应该是十
        assertEquals(num, etcdService.getRange(prefix, getOption).getCount());

        // 删除条件是指定前缀
        DeleteOption deleteOption = DeleteOption.newBuilder()
                                    .withPrefix(EtcdServiceImpl.bytesOf(prefix))
                                    .build();

        // 删除
        etcdService.deleteRange(prefix, deleteOption);

        // 删除后再查,总数应该是0
        assertEquals(0, etcdService.getRange(prefix, getOption).getCount());
    }
  • 与读操作的endKey类似,删除操作也有endKey参数,假设etcd有三个key:a1、a2、a3,那么通过前缀a可以将这三个key都删除,与此同时还可以再加个endKey删除条件,假设endKey等于a2,那么删除工作在查到a2时就会停止并返回,被删除的记录只有a1,不包含a2,换言之endKey之前的记录才会被删除:
代码语言:javascript复制
    @Test
    @Order(11)
    @DisplayName("删除(删到指定key就结束)")
    void deleteWithEndKey() throws Exception {
        String prefix = key("deleteWithEndKey");

        int num = 10;
        String endKey = null;

        // 写入,每个key都不同,但是有相同的前缀
        for (int i=0;i<num;i  ) {
            String key = prefix   i;

            etcdService.put(key, String.valueOf(i));

            // 把第九条记录的key保存在endKey变量中
            if((num-2)==i) {
                endKey = key;
            }
        }

        GetOption getOption = GetOption.newBuilder()
                .withPrefix(EtcdServiceImpl.bytesOf(prefix))
                .build();

        // 此时总数应该是十
        assertEquals(num, etcdService.getRange(prefix, getOption).getCount());

        // 删除条件是指定前缀,并且遇到第九条记录的key就停止删除操作,此时第九条和第十条都不会被删除
        DeleteOption deleteOption = DeleteOption.newBuilder()
                .withPrefix(EtcdServiceImpl.bytesOf(prefix))
                .withRange(EtcdServiceImpl.bytesOf(endKey))
                .build();

        // 删除
        etcdService.deleteRange(prefix, deleteOption);

        // 删除后再查,总数应该是二

        assertEquals(2, etcdService.getRange(prefix, getOption).getCount());
    }
  • 至此,编码结束,执行单元测试试;

执行单元测试

  • 点击下图红框中的按钮,在弹出的菜单中点击Run EtcdServiceImplTest,即可开始单元测试:
  • 如下图,单元测试通过:
  • 至此,使用jetcd对etcd进行基本操作的实战已经完成,希望能给您的开发带来一些参考,接下来的章节,咱们去操作一些etcd的特性,包括事务、监听、租约;

0 人点赞