系列文章链接
- jetcd实战之一:极速体验
- jetcd实战之二:基本操作
- jetcd实战之三:进阶操作(事务、监听、租约)
本篇概览
本文是《jetcd实战系列》的第二篇,经过前面的准备,我们有了可用的etcd集群环境和gradle父工程,并且写了个helloworld程序连接etcd简单体验了一番,今天的实战咱们聚焦那些常用的etcd操作,例如写、读、删除等,这些操作可以覆盖到日常大部分场景,本文主要有以下几部分组成:
- 编写接口类EtcdService.java,定义常用的etcd操作;
- 编写接口类的实现EtcdServiceImpl.java,这里面主要是调用jetcd提供的API来完成具体的etcd操作;
- 编写单元测试类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文件内容如下:
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操作:
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:
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前缀):
private static String key(String name) {
return "/EtcdServiceImplTest/" name "-" System.currentTimeMillis();
}
- 定义EtcdServiceImp实例作为静态变量,后面的测试中都会用到,另外还要在测试结束时关闭客户端连接:
private static EtcdService etcdService = new EtcdServiceImpl();
@AfterAll
static void close() {
etcdService.close();
}
- 接下来开始体验etcd的基本操作;
基本写操作
- 写操作非常简单,就是调用put方法传入key和value,至于验证,在开始读操作之前先简单点,确认header非空即可:
@Test
@Order(1)
@DisplayName("基本写操作")
void put() throws Exception {
Response.Header header = etcdService.put(key("put"), "123");
assertNotNull(header);
}
读操作
- 先测试最基本的读操作,用getSingle方法可以返回单个结果:
@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对象,我们可以进行跟多复杂的读操作,先看如何通过前缀查询多个键值对:
@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());
}
- 假设总共有十条结果,还可以控制只返回五条记录(不过总数字段还是十):
@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过往的某个版本的值:
@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都能用作排序字段,并且可以选择升序还是降序:
@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:
@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:
@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之前的值才会被返回:
@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方法:
@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对象,可以实现更多类型的删除,下面是删除指定前缀的所有记录:
@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之前的记录才会被删除:
@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的特性,包括事务、监听、租约;