首页 » Netty实战 » Netty实战全文在线阅读

《Netty实战》第9章 单元测试

关灯直达底部

本章主要内容

  • 单元测试
  • EmbeddedChannel概述
  • 使用EmbeddedChannel测试ChannelHandler

ChannelHandler是Netty应用程序的关键元素,所以彻底地测试它们应该是你的开发过程的一个标准部分。最佳实践要求你的测试不仅要能够证明你的实现是正确的,而且还要能够很容易地隔离那些因修改代码而突然出现的问题。这种类型的测试叫作单元测试。

虽然单元测试没有统一的定义,但是大多数的从业者都有基本的共识。其基本思想是,以尽可能小的区块测试你的代码,并且尽可能地和其他的代码模块以及运行时的依赖(如数据库和网络)相隔离。如果你的应用程序能通过测试验证每个单元本身都能够正常地工作,那么在出了问题时将可以更加容易地找出根本原因。

在本章中,我们将学习一种特殊的Channel实现——EmbeddedChannel,它是Netty专门为改进针对ChannelHandler的单元测试而提供的。

因为正在被测试的代码模块或者单元将在它正常的运行时环境之外被执行,所以你需要一个框架或者脚手架以便在其中运行它。在我们的例子中,我们将使用JUnit 4作为我们的测试框架,所以你需要对它的用法有一个基本的了解。如果它对你来说比较陌生,不要害怕;虽然它功能强大,但却很简单,你可以在JUnit的官方网站(www.junit.org)上找到你所需要的所有信息。

你可能会发现回顾前面关于ChannelHandler的章节很有用,因为这将为我们的示例提供素材。

9.1 EmbeddedChannel概述

你已经知道,可以将ChannelPipeline中的ChannelHandler实现链接在一起,以构建你的应用程序的业务逻辑。我们已经在前面解释过,这种设计支持将任何潜在的复杂处理过程分解为小的可重用的组件,每个组件都将处理一个明确定义的任务或者步骤。在本章中,我们还将展示它是如何简化测试的。

Netty提供了它所谓的Embedded传输,用于测试ChannelHandler。这个传输是一种特殊的Channel实现——EmbeddedChannel——的功能,这个实现提供了通过ChannelPipeline传播事件的简便方法。

这个想法是直截了当的:将入站数据或者出站数据写入到EmbeddedChannel中,然后检查是否有任何东西到达了ChannelPipeline的尾端。以这种方式,你便可以确定消息是否已经被编码或者被解码过了,以及是否触发了任何的ChannelHandler动作。

表9-1中列出了EmbeddedChannel的相关方法。

表9-1 特殊的EmbeddedChannel方法

名  称

职  责

writeInbound(
   Object... msgs)

将入站消息写到EmbeddedChannel中。如果可以通过readInbound方法从EmbeddedChannel中读取数据,则返回true

readInbound

EmbeddedChannel中读取一个入站消息。任何返回的东西都穿越了整个ChannelPipeline。如果没有任何可供读取的,则返回null

writeOutbound(
   Object... msgs)

将出站消息写到EmbeddedChannel中。如果现在可以通过readOutbound方法从EmbeddedChannel中读取到什么东西,则返回true

readOutbound

EmbeddedChannel中读取一个出站消息。任何返回的东西都穿越了整个ChannelPipeline。如果没有任何可供读取的,则返回null

finish

EmbeddedChannel标记为完成,并且如果有可被读取的入站数据或者出站数据,则返回true。这个方法还将会调用EmbeddedChannel上的close方法

入站数据由ChannelInboundHandler处理,代表从远程节点读取的数据。出站数据由ChannelOutboundHandler处理,代表将要写到远程节点的数据。根据你要测试的Channel-Handler,你将使用*Inbound或者*Outbound方法对,或者兼而有之。

图9-1展示了使用EmbeddedChannel的方法,数据是如何流经ChannelPipeline的。你可以使用writeOutbound方法将消息写到Channel中,并通过ChannelPipeline沿着出站的方向传递。随后,你可以使用readOutbound方法来读取已被处理过的消息,以确定结果是否和预期一样。 类似地,对于入站数据,你需要使用writeInboundreadInbound方法。

在每种情况下,消息都将会传递过ChannelPipeline,并且被相关的ChannelInbound-Handler或者ChannelOutboundHandler处理。如果消息没有被消费,那么你可以使用readInbound或者readOutbound方法来在处理过了这些消息之后,酌情把它们从Channel中读出来。

图9-1 EmbeddedChannel的数据流

接下来让我们仔细看看这两种场景,以及它们是如何应用于测试你的应用程序逻辑的吧。

9.2 使用EmbeddedChannel测试ChannelHandler

在这一节中,我们将讲解如何使用EmbeddedChannel来测试ChannelHandler

JUnit断言

org.junit.Assert类提供了很多用于测试的静态方法。失败的断言将导致一个异常被抛出,并将终止当前正在执行中的测试。导入这些断言的最高效的方式是通过一个import static语句来实现:

import static org.junit.Assert.*;

一旦这样做了,就可以直接调用Assert方法了:

assertEquals(buf.readSlice(3), read);

9.2.1 测试入站消息

图9-2 展示了一个简单的ByteToMessageDecoder实现。给定足够的数据,这个实现将产生固定大小的帧。如果没有足够的数据可供读取,它将等待下一个数据块的到来,并将再次检查是否能够产生一个新的帧。

图9-2 通过FixedLengthFrameDecoder解码

正如可以从图9-2右侧的帧看到的那样,这个特定的解码器将产生固定为3字节大小的帧。因此,它可能会需要多个事件来提供足够的字节数以产生一个帧。

最终,每个帧都会被传递给ChannelPipeline中的下一个ChannelHandler

该解码器的实现,如代码清单9-1所示。

代码清单9-1 FixedLengthFrameDecoder

public class FixedLengthFrameDecoder extends ByteToMessageDecoder {   ← --  扩展ByteToMessageDecoder 以处理入站字节,并将它们解码为消息  private final int frameLength;  public FixedLengthFrameDecoder(int frameLength) {   ← -- 指定要生成的帧的长度    if (frameLength <= 0) {      throw new IllegalArgumentException(        "frameLength must be a positive integer: " + frameLength);    }    this.frameLength = frameLength;  }  @Override  protected void decode(ChannelHandlerContext ctx, ByteBuf in,    List<Object> out) throws Exception {    while (in.readableBytes >= frameLength) {  ← --   检查是否有足够的字节可以被读取,以生成下一个帧      ByteBuf buf = in.readBytes(frameLength);  ← --  从ByteBuf 中读取一个新帧      out.add(buf);  ← --  将该帧添加到已被解码的消息列表中     }  }}  

现在,让我们创建一个单元测试,以确保这段代码将按照预期执行。正如我们前面所指出的,即使是在简单的代码中,单元测试也能帮助我们防止在将来代码重构时可能会导致的问题,并且能在问题发生时帮助我们诊断它们。

代码清单9-2展示了一个使用EmbeddedChannel的对于前面代码的测试。

代码清单9-2 测试FixedLengthFrameDecoder

public class FixedLengthFrameDecoderTest {  ← --  使用了注解@Test 标注,因此JUnit 将会执行该方法  @Test   public void testFramesDecoded {  ← --  第一个测试方法:testFramesDecoded    ByteBuf buf = Unpooled.buffer;   ← -- 创建一个ByteBuf,并存储9 字节    for (int i = 0; i < 9; i++) {       buf.writeByte(i);    }    ByteBuf input = buf.duplicate;    EmbeddedChannel channel = new EmbeddedChannel(    ← -- 创建一个EmbeddedChannel,并添加一个FixedLengthFrameDecoder,其将以3 字节的帧长度被测试      new FixedLengthFrameDecoder(3));    // write bytes    assertTrue(channel.writeInbound(input.retain));    ← -- 将数据写入Embedded-Channel    assertTrue(channel.finish);  ← -- 标记Channel为已完成状态     // read messages  ← -- 读取所生成的消息,并且验证是否有3 帧(切片),其中每帧(切片)都为3 字节    ByteBuf read = (ByteBuf) channel.readInbound;    assertEquals(buf.readSlice(3), read);    read.release;    read = (ByteBuf) channel.readInbound;    assertEquals(buf.readSlice(3), read);    read.release;    read = (ByteBuf) channel.readInbound;    assertEquals(buf.readSlice(3), read);    read.release;    assertNull(channel.readInbound);    buf.release;  }  @Test  public void testFramesDecoded2 {  ← --  第二个测试方法:testFramesDecoded2     ByteBuf buf = Unpooled.buffer;    for (int i = 0; i < 9; i++) {      buf.writeByte(i);    }    ByteBuf input = buf.duplicate;    EmbeddedChannel channel = new EmbeddedChannel(      new FixedLengthFrameDecoder(3));    assertFalse(channel.writeInbound(input.readBytes(2)));   ← --  返回false,因为没有一个完整的可供读取的帧    assertTrue(channel.writeInbound(input.readBytes(7)));    assertTrue(channel.finish);    ByteBuf read = (ByteBuf) channel.readInbound;    assertEquals(buf.readSlice(3), read);    read.release;    read = (ByteBuf) channel.readInbound;    assertEquals(buf.readSlice(3), read);    read.release;    read = (ByteBuf) channel.readInbound;    assertEquals(buf.readSlice(3), read);    read.release;    assertNull(channel.readInbound);    buf.release;  }}  

testFramesDecoded方法验证了:一个包含9个可读字节的ByteBuf被解码为3个ByteBuf,每个都包含了3字节。需要注意的是,仅通过一次对writeInbound方法的调用,ByteBuf是如何被填充了9个可读字节的。在此之后,通过执行finish方法,将EmbeddedChannel标记为了已完成状态。最后,通过调用readInbound方法,从Embedded-Channel中正好读取了3个帧和一个null

testFramesDecoded2方法也是类似的,只有一处不同:入站ByteBuf是通过两个步骤写入的。当writeInbound(input.readBytes(2))被调用时,返回了false。为什么呢?正如同表9-1中所描述的,如果对readInbound的后续调用将会返回数据,那么write-Inbound方法将会返回true。但是只有当有3个或者更多的字节可供读取时,FixedLength-FrameDecoder才会产生输出。该测试剩下的部分和testFramesDecoded是相同的。

9.2.2 测试出站消息

测试出站消息的处理过程和刚才所看到的类似。在下面的例子中,我们将会展示如何使用EmbeddedChannel来测试一个编码器形式的ChannelOutboundHandler,编码器是一种将一种消息格式转换为另一种的组件。你将在下一章中非常详细地学习编码器和解码器,所以现在我们只需要简单地提及我们正在测试的处理器——AbsIntegerEncoder,它是Netty的MessageToMessageEncoder的一个特殊化的实现,用于将负值整数转换为绝对值。

该示例将会按照下列方式工作:

  • 持有AbsIntegerEncoderEmbeddedChannel将会以4字节的负整数的形式写出站数据;
  • 编码器将从传入的ByteBuf中读取每个负整数,并将会调用Math.abs方法来获取其绝对值;
  • 编码器将会把每个负整数的绝对值写到ChannelPipeline中。

图9-3展示了该逻辑。

图9-3 通过AbsIntegerEncoder编码

代码清单9-3实现了这个逻辑,如图9-3所示。encode方法将把产生的值写到一个List中。

代码清单9-3 AbsIntegerEncoder

public class AbsIntegerEncoder extends  MessageToMessageEncoder<ByteBuf> {   ← --  扩展MessageToMessageEncoder 以将一个消息编码为另外一种格式  @Override  protected void encode(ChannelHandlerContext channelHandlerContext,    ByteBuf in, List<Object> out) throws Exception {![../tu/p45-2.tif{25}](/api/storage/getbykey/original?key=17058221f3a645320473)    while (in.readableBytes >= 4) {   ← -- 检查是否有足够的字节用来编码      int value = Math.abs(in.readInt);  ← --  从输入的ByteBuf中读取下一个整数,并且计算其绝对值      out.add(value);  ← --  将该整数写入到编码消息的List 中    }  }}  

代码清单9-4使用了EmbeddedChannel来测试代码。

代码清单9-4 测试AbsIntegerEncoder

public class AbsIntegerEncoderTest {  @Test  public void testEncoded {    ByteBuf buf = Unpooled.buffer;   ← --  ❶创建一个ByteBuf,并且写入9 个负整数    for (int i = 1; i < 10; i++) {      buf.writeInt(i * -1);    }    EmbeddedChannel channel = new EmbeddedChannel( ← -- ❷创建一个EmbeddedChannel,并安装一个要测试的AbsIntegerEncoder      new AbsIntegerEncoder);    assertTrue(channel.writeOutbound(buf));  ← -- ❸写入ByteBuf,并断言调用readOutbound方法将会产生数据     assertTrue(channel.finish);  ← -- ❹将该Channel标记为已完成状态    // read bytes     for (int i = 1; i < 10; i++) {  ← -- ❺读取所产生的消息,并断言它们包含了对应的绝对值      assertEquals(i, channel.readOutbound);    }    assertNull(channel.readOutbound);  }}  

下面是代码中执行的步骤。

❶ 将4字节的负整数写到一个新的ByteBuf中。

❷ 创建一个EmbeddedChannel,并为它分配一个AbsIntegerEncoder

❸ 调用EmbeddedChannel上的writeOutbound方法来写入该ByteBuf

❹ 标记该Channel为已完成状态。

❺ 从EmbeddedChannel的出站端读取所有的整数,并验证是否只产生了绝对值。

9.3 测试异常处理

应用程序通常需要执行比转换数据更加复杂的任务。例如,你可能需要处理格式不正确的输入或者过量的数据。在下一个示例中,如果所读取的字节数超出了某个特定的限制,我们将会抛出一个TooLongFrameException。这是一种经常用来防范资源被耗尽的方法。

在图9-4中,最大的帧大小已经被设置为3字节。如果一个帧的大小超出了该限制,那么程序将会丢弃它的字节,并抛出一个TooLongFrameException。位于ChannelPipeline中的其他ChannelHandler可以选择在exceptionCaught方法中处理该异常或者忽略它。

图9-4 通过FrameChunkDecoder解码

其实现如代码清单9-5所示。

代码清单9-5 FrameChunkDecoder

public class FrameChunkDecoder extends ByteToMessageDecoder {   ← --  扩展ByteToMessage-Decoder 以将入站字节解码为消息  private final int maxFrameSize;  public FrameChunkDecoder(int maxFrameSize) {  ← --  指定将要产生的帧的最大允许大小    this.maxFrameSize = maxFrameSize;  }  @Override  protected void decode(ChannelHandlerContext ctx, ByteBuf in,    List<Object> out) throws Exception {    int readableBytes = in.readableBytes;     if (readableBytes > maxFrameSize) {      // discard the bytes  ← --  如果该帧太大,则丢弃它并抛 出一个TooLongFrameException……      in.clear;      throw new TooLongFrameException;    }    ByteBuf buf = in.readBytes(readableBytes);  ← -- ……否则,从ByteBuf 中读取一个新的帧      out.add(buf);   ← --  将该帧添加到解码消息的List 中  }}  

我们再使用EmbeddedChannel来测试一次这段代码,如代码清单9-6所示。

代码清单9-6 测试FrameChunkDecoder

public class FrameChunkDecoderTest {  @Test  public void testFramesDecoded {    ByteBuf buf = Unpooled.buffer;   ← --  创建一个ByteBuf,并向它写入9 字节    for (int i = 0; i < 9; i++) {      buf.writeByte(i);    }      ByteBuf input = buf.duplicate;    EmbeddedChannel channel = new EmbeddedChannel(      new FrameChunkDecoder(3));  ← --  创建一个EmbeddedChannel,并向其安装一个帧大小为3 字节的FixedLengthFrameDecoder    assertTrue(channel.writeInbound(input.readBytes(2)));  ← -- 向它写入2 字节,并断言它们将会产生一个新帧     try {      channel.writeInbound(input.readBytes(4));  ← -- 写入一个4 字节大小的帧,并捕获预期的TooLongFrameException      Assert.fail;  ← -- 如果上面没有抛出异常,那么就会到达这个断言,并且测试失败    } catch (TooLongFrameException e) {      // expected exception    }    assertTrue(channel.writeInbound(input.readBytes(3)));  ← -- 写入剩余的2 字节,并断言将会产生一个有效帧     assertTrue(channel.finish);   ← -- 将该Channel 标记为已完成状态    // Read frames   ← -- 读取产 生的消息,并且验证值    ByteBuf read = (ByteBuf) channel.readInbound;    assertEquals(buf.readSlice(2), read);    read.release;    read = (ByteBuf) channel.readInbound;    assertEquals(buf.skipBytes(4).readSlice(3), read);    read.release;    buf.release;  }} 

乍一看,这看起来非常类似于代码清单9-2中的测试,但是它有一个有趣的转折点,即对TooLongFrameException的处理。这里使用的try/catch块是EmbeddedChannel的一个特殊功能。如果其中一个write*方法产生了一个受检查的Exception,那么它将会被包装在一个RuntimeException中并抛出[1]。这使得可以容易地测试出一个Exception是否在处理数据的过程中已经被处理了。

这里介绍的测试方法可以用于任何能抛出ExceptionChannelHandler实现。

9.4 小结

使用JUnit这样的测试工具来进行单元测试是一种非常行之有效的方式,它能保证你的代码的正确性并提高它的可维护性。在本章中,你学习了如何使用Netty提供的测试工具来测试你自定义的ChannelHandler

在接下来的章节中,我们将专注于使用Netty编写真实世界的应用程序。我们不会再提供任何进一步的测试代码示例了,所以我们希望你将这里所展示的测试方法的重要性牢记于心。


[1] 需要注意的是,如果该类实现了exceptionCaught方法并处理了该异常,那么它将不会被catch块所捕获。