本章的主要知识就是如何使用Netty简单快速实现自定义协议的编解码器。实现自定义的解码器可以很方便的进行测试和复用,感谢Netty这种扩展性比较强的设计。
为了更容易大家理解,我们这里通过Netty的编解码器模拟实现开源工具Memcached的协议。Memcached是一款免费,开源,高性能的分布式内存对象缓存系统,可以通用在任何类型应用上,不过主要还是使用在WEB应用,加快服务端响应速度,降低数据库的负载。它实际上就是在内存中使用键值对存储数据。
我们这里为什么使用Memcached呢?因为原书就是使用Memcached,作为翻译我也没什么办法。实际上这里说实现Memcached的协议并不是实现一个Memcached。只是实现客户端向Memcached发送的指令的编解码过程。就算我们实现现在更流行的Redis协议,其实基本也一样,客户端发送服务端的指令还是那几个,下面来详细介绍下。
Memcached的客户端指令也是很多的,如果我们一一去实现,搞不好大家都毕业了,这里就主要实现添加数据,查询数据和删除数据指令就好了。也就是Memcached的SET,GET和DELETE指令。
Memcached既有二进制的协议,也有普通文本的协议。这两种协议都可以和服务端通讯,主要取决于服务端支持哪种协议或者两种都支持。这一章我们实现的是常用的二进制协议。
当需要实现给定协议的编解码时,最好首先去了解一下协议是如何工作的。一般来说,协议的官方文档就已经很详细了,而且Memcached的文档确实对协议进行了很多详细的描述。
大家可以去这个网站看看Memcached二进制协议的详细介绍。如果大家去看了,就会协议还真是蛮多的,我们前面说过,本章只实现常用的SET,GET和DELETE,其他的大同小异。不然本章的内容就太过庞大了。只要学会实现这三个,其他也都差不多,大家在下面可以自己试试。当然,我们本章的实现主要是为了学习实现协议,如果真要使用Memcached,还是要使用大量使用并经过验证的客户端。
前面说过,我们要实现Memcached的GET,SET和DELETE操作协议。虽然只实现三个,但其他协议的结构是差不多的,只需要修改很少的参数,就可以改变请求或响应。也就是通过继承我们的实现,也很容易实现其他的协议。一般来说,Memcached协议的请求和响应有24字节的头信息,可以按照下表顺序进行分解。
字段 |
位置 |
值 |
数据类型 |
0 |
0x80是请求,0x81是响应 |
操作码 |
1 |
0x01-0x1A |
键长度 |
2和3 |
1-32767 |
附加数据长度 |
4 |
0x00,x04或0x08 |
数据类型 |
5 |
0x00 |
预留 |
6和7 |
0x00 |
数据体总长度 |
8-11 |
数据总长,包括附加数据 |
Opaque |
12-15 |
32位整数,方便请求方知道响应的是哪次请求 |
CAS |
16-23 |
数据版本校验 |
注意这里每个地方用了多少字节。这可以帮助我们确定使用什么样的数据类型。例如只使用0的地方我们可以使用Java的byte类型,使用2个字节的地方我们可以使用Java的short类型,使用4个字节的地方我们可以使用Java的int类型,以此类推。
上图中红色标注的第一部分就是请求头,第二部分是响应头。每一行有4个字节,总共6行,也就是我们前面说的24字节。这里就可以和前面的表一一对应。
上面就是我们需要了解的Memcached的二进制协议,当然是比较基础的,涉及到我们下面的编解码实现的内容。下一小节,我们就开始使用Netty实现这个协议的请求。
Netty是个很不错的网络框架,但它也不是万能的。例如需要实现的这个Memcached的协议,需要发送一个请求对象给服务端,也就是我们需要创建一个请求类。这是个很重要的事情,但是Netty就不知道该创建什么样的类是符合Memcached服务端的。Netty只知道Memcached需要的是字节序列,因为不管是什么样的协议,只要是网络应用传输的数据都是字节。
为了将请求对象转成Memcached需要的字节序列,Netty提供了编码器,用来将一种数据格式转成另一种。Netty提供的编码器不仅能将对象转成字节,还能转成另一种对象或其他格式。关于编解码器的详细知识,已经在前面第七章介绍过了。
我们先解决对象转成字节序列的问题。Netty提供了一个抽象类MessageToByteEncoder,就是为了解决这类问题。在本例中我们主要是将MemcachedRequest转成字节序列。而且MessageToByteEncoder是支持泛型的,所以我们使用的是MessageToByteEncoder
解码器也是类似的,只不过和编码器是反着来的,也就是将字节序列转成对象。Netty提供了类ByteToMessageDecoder,它的方法decode就是解码的。下面的章节我们就会去实现这个编码器和解码器。不过还有一点很重要,有的时候我们并不需要实现编码器和解码器,因为Netty已经有提供了一些。我们这里要自己实现是因为Netty没有提供关于Memcached的编码器和解码器。像其他常见的协议,如HTTP、WebSocket等,Netty已经提供了对应的编码器和解码器,就不需要开发者自己实现了。
关于编码器和解码器,大家要记住它们的特点。编码器是处理发送的数据,解码器是处理收到的数据。也就是说编码器处理的数据是要发送给对端的,而解码器处理的数据是从对端读到的。
我们这里的实现,没有那些数据正确性的验证,如数据量过大等错误,这是为了我们例子的简单性。在实际项目中,肯定会有很多数据正确性的校验,一旦发现数据是错误,可以抛出EncoderException或DecoderException异常。
首先我们来实现编码器,也就是将请求对象转成字节序列。然后就可以将字节序列发送给服务端。不过首先我们需要将请求对象定义好,然后我们后面的编码器就是将这个请求对象序列化成字节序列。
首先我们先定义一下状态码常量和操作码常量。
package com.nan.netty.memcached;
public class Status {
public static final short NO_ERROR = 0x0000;
public static final short KEY_NOT_FOUND = 0x0001;
public static final short KEY_EXISTS = 0x0002;
public static final short VALUE_TOO_LARGE = 0x0003;
public static final short INVALID_ARGUMENTS = 0x0004;
public static final short ITEM_NOT_STORED = 0x0005;
public static final short INC_DEC_NON_NUM_VAL = 0x0006;
}
package com.nan.netty.memcached;
public class Opcode {
public static final byte GET = 0x00;
public static final byte SET = 0x01;
public static final byte DELETE = 0x04;
}
Opcode就是操作码,也就是让Memcached知道客户端发送的是什么指令。每个指令就是一个字节。然后Memcached返回给客户端一个响应,响应里面包含2个字节的状态码。接下来实现请求类。
package com.nan.netty.memcached;
import java.util.Random;
public class MemcachedRequest {
private static final Random rand = new Random();
//逻辑码,请求对象固定0x80
private byte magic = (byte) 0x80;
//操作码,SET或GET等
private byte opCode;
//键
private String key;
//附加数据标记
private int flags = 0xdeadbeef;
//实效,0永不失效
private int expires;
//值,SET命令时使用
private String body;
//类似唯一编号,方便请求端收到响应时知道是哪次请求
private int id = rand.nextInt();
//数据版本校验
private long cas;
//是否有附加数据
private boolean hasExtras;
public MemcachedRequest(byte opcode, String key, String value) {
this.opCode = opcode;
this.key = key;
this.body = value == null ? "" : value;
hasExtras = opcode == Opcode.SET;
}
public MemcachedRequest(byte opCode, String key) {
this(opCode, key, null);
}
public byte magic() {
return magic;
}
public int opCode() {
return opCode;
}
public String key() {
return key;
}
public int flags() {
return flags;
}
public int expires() {
return expires;
}
public String body() {
return body;
}
public int id() {
return id;
}
public long cas() {
return cas;
}
public boolean hasExtras() {
return hasExtras;
}
}
MemcachedRequest中比较重要的就是它的构造方法了,构造方法里的每个参数都是需要用到的。其实大家看看MemcachedRequest的属性,和上面说的Memcached协议中基本一致,其实没错,本来就是为了实现它的协议,当然按照它的协议来定义属性了。
现在我们开始实现编码器,也就是将上面定义的MemcachedRequest序列化成字节序列。这里我们继承MessageToByteEncoder类,因为这个类很适合我们的场景。
package com.nan.netty.memcached;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;
import io.netty.util.CharsetUtil;
public class MemcachedRequestEncoder extends MessageToByteEncoder {
@Override
protected void encode(ChannelHandlerContext ctx, MemcachedRequest msg, ByteBuf out) throws Exception {
//将键值转为字节
byte[] key = msg.key().getBytes(CharsetUtil.UTF_8);
byte[] body = msg.body().getBytes(CharsetUtil.UTF_8);
//总大小就是键值长度加附加数据长度
int bodySize = key.length + body.length + (msg.hasExtras() ? 8 : 0);
//逻辑码序列化
out.writeByte(msg.magic());
//操作码序列化
out.writeByte(msg.opCode());
//序列化键长度
out.writeShort(key.length);
//附加数据长度
int extraSize = msg.hasExtras() ? 0x08 : 0x0;
out.writeByte(extraSize);
//数据类型,由于我们没实现,所以填0
out.writeByte(0);
//预留位置,这里我们没有使用,还是写0
out.writeShort(0);
//数据大小
out.writeInt(bodySize);
//请求标识
out.writeInt(msg.id());
//数据版本校验
out.writeLong(msg.cas());
if (msg.hasExtras()) {
//附加数据,标识码和过期
out.writeInt(msg.flags());
out.writeInt(msg.expires());
}
//键
out.writeBytes(key);
//值
out.writeBytes(body);
}
}
这里其实是将数据都写到了ByteBuf中,只要在ByteBuf的数据,Netty都会将它发送给服务端的。下一章我们开始实现如何将这些字节序列解码。
前面我们将MemcachedRequest序列化成字节序列了,然后Memcached会返回字节序列,但是我们要使用这些字节序列,所以需要将字节序列反序列化成Java对象。这就需要解码器的帮助了。
我们先创建一个类,代表Memcached响应的数据。
package com.nan.netty.memcached;
public class MemcachedResponse {
private byte magic;
private byte opCode;
private byte dataType;
private short status;
private int id;
private long cas;
private int flags;
private int expires;
private String key;
private String data;
public MemcachedResponse(byte magic, byte opCode,
byte dataType, short status, int id, long cas,
int flags, int expires, String key, String data) {
this.magic = magic;
this.opCode = opCode;
this.dataType = dataType;
this.status = status;
this.id = id;
this.cas = cas;
this.flags = flags;
this.expires = expires;
this.key = key;
this.data = data;
}
public byte magic() {
return magic;
}
public byte opCode() {
return opCode;
}
public byte dataType() {
return dataType;
}
public short status() {
return status;
}
public int id() {
return id;
}
public long cas() {
return cas;
}
public int flags() {
return flags;
}
public int expires() {
return expires;
}
public String key() {
return key;
}
public String data() {
return data;
}
}
这个MemcachedResponse就是一个很普通的Java Bean,我们的解码器就是将服务端返回的字节序列反序列化成这个类的对象。我们的解码器使用的是Netty提供的ByteToMessageDecoder。
package com.nan.netty.memcached;
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.util.CharsetUtil;
import java.util.List;
public class MemcachedResponseDecoder extends ByteToMessageDecoder {
private enum State {
Header,
Body
}
private State state = State.Header;
private int totalBodySize;
private byte magic;
private byte opCode;
private short keyLength;
private byte extraLength;
private byte dataType;
private short status;
private int id;
private long cas;
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List
我们知道Memcached的响应的头有24个字节,但我们不知道是否所有的数据都放到ByteBuf中了。因为如果网络异常的话可能就会破坏掉传输的数据。所以我们解码的时候需要检查ByteBuf是否够24个字节。一旦我们解析完24字节的头信息,我们就知道了整个消息的实际大小,因为那些信息都已经存到头信息中了。
当我们解码完成之后,就会创建一个MemcachedResponse对象,然后将它放到输出列表中,然后会传给ChannelPipeline中的下一个ChannelInboundHandler进行后续处理。
虽然我们已经实现了编码器和解码器,但是我们漏掉了一个重要的步骤:单元测试。
单元测试的重要性不用我过多和大家说了,前面章节我们也知道,单元测试ChannelHandler一般是使用EmbeddedChannel。这里我们还是使用它来测试我们实现的编码器和解码器。
首先来看编码器的单元测试。
package com.nan.netty.memcache;
import com.nan.netty.memcached.MemcachedRequest;
import com.nan.netty.memcached.MemcachedRequestEncoder;
import com.nan.netty.memcached.Opcode;
import io.netty.buffer.ByteBuf;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.util.CharsetUtil;
import org.junit.Assert;
import org.junit.Test;
public class MemcachedRequestEncoderTest {
@Test
public void testMemcachedRequestEncoder() {
//SET指令请求对象
MemcachedRequest request = new MemcachedRequest(Opcode.SET, "key1", "value1");
EmbeddedChannel channel = new EmbeddedChannel(new MemcachedRequestEncoder());
Assert.assertTrue(channel.writeOutbound(request));
ByteBuf encoded = channel.readOutbound();
Assert.assertNotNull(encoded);
//比对协议中的各个属性
Assert.assertEquals(request.magic(), encoded.readByte());
Assert.assertEquals(request.opCode(), encoded.readByte());
Assert.assertEquals(4, encoded.readShort());
Assert.assertEquals((byte) 0x08, encoded.readByte());
Assert.assertEquals((byte) 0, encoded.readByte());
Assert.assertEquals(0, encoded.readShort());
Assert.assertEquals(4 + 6 + 8, encoded.readInt());
Assert.assertEquals(request.id(), encoded.readInt());
Assert.assertEquals(request.cas(), encoded.readLong());
Assert.assertEquals(request.flags(), encoded.readInt());
Assert.assertEquals(request.expires(), encoded.readInt());
byte[] data = new byte[encoded.readableBytes()];
encoded.readBytes(data);
Assert.assertArrayEquals((request.key() + request.body()).getBytes(CharsetUtil.UTF_8), data);
Assert.assertFalse(encoded.isReadable());
Assert.assertFalse(channel.finish());
Assert.assertNull(channel.readInbound());
}
}
解码器测试OK后,接下来就开始测试编码器了。
package com.nan.netty.memcache;
import com.nan.netty.memcached.MemcachedResponse;
import com.nan.netty.memcached.MemcachedResponseDecoder;
import com.nan.netty.memcached.Opcode;
import com.nan.netty.memcached.Status;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.util.CharsetUtil;
import org.junit.Assert;
import org.junit.Test;
public class MemcachedResponseDecoderTest {
@Test
public void testMemcachedResponseDecoder() {
EmbeddedChannel channel = new EmbeddedChannel(new MemcachedResponseDecoder());
byte magic = 1;
byte opCode = Opcode.SET;
byte dataType = 0;
byte[] key = "Key1".getBytes(CharsetUtil.US_ASCII);
byte[] body = "Value".getBytes(CharsetUtil.US_ASCII);
int id = (int) System.currentTimeMillis();
long cas = System.currentTimeMillis();
ByteBuf buffer = Unpooled.buffer();
buffer.writeByte(magic);
buffer.writeByte(opCode);
buffer.writeShort(key.length);
buffer.writeByte(0);
buffer.writeByte(dataType);
buffer.writeShort(Status.KEY_EXISTS);
buffer.writeInt(body.length + key.length);
buffer.writeInt(id);
buffer.writeLong(cas);
buffer.writeBytes(key);
buffer.writeBytes(body);
Assert.assertTrue(channel.writeInbound(buffer));
MemcachedResponse response = channel.readInbound();
assertResponse(response, magic, opCode, dataType, Status.KEY_EXISTS, 0, 0, id, cas, key, body);
}
@Test
public void testMemcachedResponseDecoderFragments() {
EmbeddedChannel channel = new EmbeddedChannel(new MemcachedResponseDecoder());
byte magic = 1;
byte opCode = Opcode.SET;
byte dataType = 0;
byte[] key = "Key1".getBytes(CharsetUtil.US_ASCII);
byte[] body = "Value".getBytes(CharsetUtil.US_ASCII);
int id = (int) System.currentTimeMillis();
long cas = System.currentTimeMillis();
ByteBuf buffer = Unpooled.buffer();
buffer.writeByte(magic);
buffer.writeByte(opCode);
buffer.writeShort(key.length);
buffer.writeByte(0);
buffer.writeByte(dataType);
buffer.writeShort(Status.KEY_EXISTS);
buffer.writeInt(body.length + key.length);
buffer.writeInt(id);
buffer.writeLong(cas);
buffer.writeBytes(key);
buffer.writeBytes(body);
ByteBuf fragment1 = buffer.readBytes(8);
ByteBuf fragment2 = buffer.readBytes(24);
ByteBuf fragment3 = buffer;
Assert.assertFalse(channel.writeInbound(fragment1));
Assert.assertFalse(channel.writeInbound(fragment2));
Assert.assertTrue(channel.writeInbound(fragment3));
MemcachedResponse response = channel.readInbound();
assertResponse(response, magic, opCode, dataType, Status.KEY_EXISTS, 0, 0, id, cas, key, body);
}
private static void assertResponse(MemcachedResponse response, byte magic, byte opCode, byte dataType, short status,
int expires, int flags, int id, long cas, byte[] key, byte[] body) {
Assert.assertEquals(magic, response.magic());
Assert.assertArrayEquals(key, response.key().getBytes(CharsetUtil.US_ASCII));
Assert.assertEquals(opCode, response.opCode());
Assert.assertEquals(dataType, response.dataType());
Assert.assertEquals(status, response.status());
Assert.assertEquals(cas, response.cas());
Assert.assertEquals(expires, response.expires());
Assert.assertEquals(flags, response.flags());
Assert.assertArrayEquals(body, response.data().getBytes(CharsetUtil.US_ASCII));
Assert.assertEquals(id, response.id());
}
}
不过大家也要记住,虽然我们的单元测试看起来代码量挺多,测试覆盖也很全面,但和应用的测试还有一些差距,所以很多公司都有测试团队。我们这里最重要的就是学会使用EmbeddedChannel测试我们自己实现的ChannelHandler。测试代码的复杂度取决于我们的实现的复杂度,不过无论多么复杂的单元测试代码,也不可能完全覆盖所有应该测试的地方。
这一章我们主要学习了实现传输协议的编解码器。主要内容就是字节序列与Java对象之间的相互转换。根据协议的详细信息定义属性与编解码具体实现。
除此之外,我们还加深学习了ChannelHandler的单元测试,在本例中就是测试我们实现的编解码器。单元测试还是很方便的,例如本例中虽然实现的是Memcached的协议,但是单元测试的时候压根就不需要使用Memcached。这对于构建大型系统是很有指导意义的。