Netty framework learning - codec framework

Posted by SieRobin on Sun, 23 Jan 2022 16:55:41 +0100


Codec

Each network application must define how to parse the original bytes transmitted back and forth between two nodes and how to convert them to and from the data format of the target application. This conversion logic is processed by a codec, which consists of an encoder and a decoder, each of which can convert a byte stream from one format to another

  • The encoder converts the message into a format suitable for transmission (most likely a byte stream)
  • The decoder converts the network byte stream back to the message format of the application

Therefore, the encoder operates on outbound data while the decoder processes inbound data

1. Decoder

In this section, we will study the decoder classes provided by Netty and provide specific examples of when and how to use them. These classes cover two different use cases:

  • Decode bytes into messages -- ByteToMessageDecoder and ReplayingDecoder
  • Decode one message type into another -- MessageToMessageDecoder

When will the decoder be used? Very simple, it is used whenever you need to convert inbound data for the next ChannelInboundHandler in the ChannelPipeline. In addition, thanks to the design of ChannelPipeline, multiple decoders can be linked together to realize any complex conversion logic

1.1 abstract class ByteToMessageDecoder

Decoding bytes into messages is a common task. Netty provides an abstract base class ByteToMessageDecoder, which buffers inbound data until it is ready for processing

The following is an example of how to use this class. Suppose you receive a byte stream containing a simple int, and each int needs to be processed separately. In this case, you need to read each int from the inbound ByteBuf and pass it to the next ChannelInboundHandler in the ChannelPipeline. In order to decode this byte stream, you need to extend the ByteToMessageDecoder class (note that when an int of atomic type is added to the List, it will be automatically boxed as Integer)

// Extend ByteToMessageDecoder to decode bytes into a specific format
public class ToIntegerDecoder extends ByteToMessageDecoder {
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        //Check if there are at least 4 bytes readable (byte length of 1 int)
        if (in.readableBytes() >= 4) {
            //Read an int from the inbound ByteBuf and add it to the List of decoded messages
            out.add(in.readInt());
        }
    }
}

Although ByteToMessageDecoder makes it easy to implement this pattern, you may find it a little cumbersome to have to verify whether the input ByteBuf has enough data before calling the readInt() method. The replaying decoder, which is a special decoder, eliminates this step with a small amount of overhead

1.2 abstract class ReplayingDecoder

ReplayingDecoder extends the ByteToMessageDecoder class so that we don't have to call the readableBytes() method. It does this by using a custom ByteBuf implementation, ReplayingDecoderByteBuf, to wrap the incoming ByteBuf, which will execute the call internally

The complete declaration of this class is:

public abstract class ReplayingDecoder<S> extends ByteToMessageDecoder

The type parameter S specifies the type used for state management, where Void represents that state management is not required. The following code shows the ToIntegerDecoder re implemented based on ReplayingDecoder

// Extend replayingdecoder < void > to decode bytes into messages
public class ToIntegerDecoder2 extends ReplayingDecoder<Void> {
    // The ByteBuf passed in is ReplayingDecoderByteBuf
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        // Read an int from the inbound ByteBuf and add it to the List of decoded messages
        out.add(in.readInt());
    }
}

As before, the int extracted from ByteBuf will be added to the List. If not enough bytes are available, the implementation of the readInt() method will throw an Error, which will be captured and processed in the base class. When more data is available for reading, the decode() method will be called again

Note the following aspects of ReplayingDecoderByteBuf:

  • Not all ByteBuf operations are supported. If an unsupported method is called, an unsupported operationexception will be thrown
  • ReplayingDecoder is slightly slower than ByteToMessageDecoder

The following classes are used to handle more complex use cases:

  • io.netty.handler.codec.LineBasedFrameDecoder - this class is also used inside Netty. It uses end of line control characters (\ n or \ r\n) to parse message data
  • io.netty.handler.codec.http.HttpObjectDecoder -- HTTP data decoder
1.3 abstract class MessageToMessageDecoder

In this section, we will explain how to convert between two message formats, for example, from one POJO type to another

public abstract class MessageToMessageDecoder<I> extends ChannelInboundHandlerAdapter

Parameter type I specifies the type of input parameter msg of the decode() method, which is the only method you must implement

We will write an IntegerToStringDecoder decoder to extend MessageToMessageDecoder. Its decode() method will convert Integer parameters to String representation. As before, the decoded String will be added to the outgoing List and forwarded to the next ChannelInboundHandler

public class IntegerToStringDecoder extends MessageToMessageEncoder<Integer> {
    @Override
    protected void encode(ChannelHandlerContext ctx, Integer msg, List<Object> out) throws Exception {
        //Converts the Integer message to its String representation and adds it to the output List
        out.add(String.valueOf(msg));
    }
}
1.4 TooLongFrameException

Since Netty is an asynchronous framework, you need to buffer bytes in memory before they can be decoded. Therefore, the decoder cannot buffer a large amount of data so that the available memory is exhausted. To address this common concern, Netty provides the TooLongFrameException class, which will be thrown by the decoder when the frame exceeds the specified size limit

To avoid this, you can set a threshold for the maximum number of bytes. If the threshold is exceeded, a TooLongFrameException will be thrown (which will then be captured by the channelhandler. Exceptionguess() method). Then, how to handle the exception depends entirely on the user of the decoder. Some protocols (such as HTTP) may allow you to return a special response. In other cases, the only option may be to close the corresponding connection

The following example uses TooLongFrameException to notify other channelhandlers in ChannelPipeline of frame size overflow. It should be noted that this protection is particularly important if you are using a variable frame size protocol

public class SafeByteToMessageDecoder extends ByteToMessageDecoder {
    public static final int MAX_FRAME_SIZE = 1024;
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        int readable = in.readableBytes();
        // Check whether the buffer exceeds MAX_FRAME_SIZE bytes
        if (readable > MAX_FRAME_SIZE) {
            // Skip all readable bytes, throw TooLongFrameException and notify ChannelHandler
            in.skipBytes(readable);
            throw new TooLongFrameException("Frame too big!");
        }
        //do something
    }
}

2. Encoder

The encoder implements ChannelOutboundHandler and converts outbound data from one format to another, which is just opposite to the function of the decoder we just learned. Netty provides a set of classes to help you write encoders with the following functions:

  • Encode the message into bytes
  • Encode messages as messages
2.1 abstract class MessageToByteEncoder

Previously, we saw how to use ByteToMessageDecoder to convert bytes into messages. Now we use MessageToByteEncoder to do the reverse

This class has only one method, while the decoder has two. The reason is that the decoder usually needs to generate the last message after the Channel is closed (therefore, there is a decodeLast() method. Obviously, this does not apply to the encoder scenario - it is meaningless to generate a message after the connection is closed

The following code shows ShortToByteEncoder, which accepts an instance of Short type as a message, encodes it as the atomic type value of Short, writes it into ByteBuf, and then forwards it to the next ChannelOutboundHandler in ChannelPipeline. Each outgoing Short value will occupy 2 bytes in ByteBuf.

public class ShortToByteEncoder extends MessageToByteEncoder<Short> {
    @Override
    protected void encode(ChannelHandlerContext ctx, Short msg, ByteBuf out) throws Exception {
        // Write Short to ByteBuf
        out.writeShort(msg);
    }
}
2.2 abstract class MessageToMessageEncoder

The encode() method of the MessageToMessageEncoder class provides the ability to decode inbound data from one message format to another

The following code extends MessageToMessageEncoder with IntegerToStringEncoder, which adds the String representation of each outbound Integer to the List

public class IntegerToStringEncoder extends MessageToMessageEncoder {
    @Override
    protected void encode(ChannelHandlerContext ctx, Object msg, List out) throws Exception {
        out.add(String.valueOf(msg));
    }
}

Abstract codec class

Although we have been discussing decoders and encoders as separate entities, you will sometimes find it useful to manage the conversion of inbound and outbound data and messages in the same class. Netty's abstract codec classes are just used for this purpose, because each of them will be bundled with a decoder / encoder pair to handle the two types of operations we have been learning. As you might have guessed, these classes implement both ChannelInboundHandler and ChannelOutboundHandler interfaces

Why don't we always use these composite classes before separate decoders and encoders? Because by separating the two functions as much as possible, it maximizes the reusability and scalability of the code, which is a basic principle of Netty design

1. Abstract class ByteToMessageCodec

Let's study a scenario where we need to decode bytes into some form of message, possibly POJO, and then encode it again. ByteToMessageCodec will handle all this for us because it combines ByteToMessageDecoder and its reverse -- MessageToByteEncoder

Any request / response protocol can be an ideal choice for using ByteToMessageCodec. For example, in an SMTP implementation, the codec will read the incoming bytes and decode them into a custom message type, such as SmtpRequest. At the receiving end, when a response is created, a SmtpResponse will be generated, which will be encoded back to bytes for transmission

2. Abstract class MessageToMessageCodec

By using MessageToMessageCodec, we can implement the round trip process of this transformation in a single class. MessageToMessageCodec is a parameterized class, which is defined as follows:

public abstract class MessageToMessageCodec<INBOUND_IN,OUTBOUND_IN>

The decode() method is to add inbound_ A message of type in is converted to outbound_ The in type message, while the encode() method performs its reverse operation. Add inbound_ Messages of type in are regarded as types sent over the network, while messages of type outbound_ It may be helpful to think of messages of type in as the type that the application is processing

WebSocket protocol

The following example of MessageToMessageCodec refers to a new WebSocket protocol, which can realize full two-way communication between Web browser and server

Our WebSocketConvertHandler will use inbound when parameterizing MessageToMessageCodec_ WebSocketFrame of type in and outbound_ MyWebSocketFrame of type in, which is a static nested class of WebSocketConvertHandler itself

public class WebSocketConvertHandler 
        extends MessageToMessageCodec<WebSocketFrame, WebSocketConvertHandler.MyWebSocketFrame> {


    @Override
    protected void encode(ChannelHandlerContext ctx, MyWebSocketFrame msg, List<Object> out) throws Exception {
        // Instantiate a WebSocketFrame of the specified subtype
        ByteBuf payload = msg.getData().duplicate().retain();
        switch (msg.getType()) {
            case BINARY:
                out.add(new BinaryWebSocketFrame(payload));
                break;
            case TEXT:
                out.add(new TextWebSocketFrame(payload));
                break;
            case CLOSE:
                out.add(new CloseWebSocketFrame(true, 0, payload));
                break;
            case CONTINUATION:
                out.add(new ContinuationWebSocketFrame(payload));
                break;
            case PONG:
                out.add(new PongWebSocketFrame(payload));
                break;
            case PING:
                out.add(new PingWebSocketFrame(payload));
                break;
            default:
                throw new IllegalStateException("Unsupported websocket msg " + msg);
        }
    }

    // Decode WebSocketFrame into MyWebSocketFrame and set FrameType
    @Override
    protected void decode(ChannelHandlerContext ctx, WebSocketFrame msg, List<Object> out) throws Exception {
        ByteBuf paload = msg.content().duplicate().retain();
        if (msg instanceof  BinaryWebSocketFrame) {
            out.add(new MyWebSocketFrame(MyWebSocketFrame.FrameType.BINARY, paload));
        } else
        if (msg instanceof  CloseWebSocketFrame) {
            out.add(new MyWebSocketFrame(MyWebSocketFrame.FrameType.CLOSE, paload));
        } else
        if (msg instanceof  PingWebSocketFrame) {
            out.add(new MyWebSocketFrame(MyWebSocketFrame.FrameType.PING, paload));
        } else
        if (msg instanceof  PongWebSocketFrame) {
            out.add(new MyWebSocketFrame(MyWebSocketFrame.FrameType.PONG, paload));
        } else
        if (msg instanceof  TextWebSocketFrame) {
            out.add(new MyWebSocketFrame(MyWebSocketFrame.FrameType.TEXT, paload));
        } else
        if (msg instanceof  ContinuationWebSocketFrame) {
            out.add(new MyWebSocketFrame(MyWebSocketFrame.FrameType.CONTINUATION, paload));
        } else {
            throw new IllegalStateException("Unsupported websocket msg " + msg);
        }
    }

    public static final class MyWebSocketFrame {
        public enum FrameType {
            BINARY,
            CLOSE,
            PING,
            PONG,
            TEXT,
            CONTINUATION
        }
        private final FrameType type;
        private final ByteBuf data;

        public MyWebSocketFrame(FrameType type, ByteBuf data) {
            this.type = type;
            this.data = data;
        }

        public FrameType getType() {
            return type;
        }

        public ByteBuf getData() {
            return data;
        }
    }
}

3. CombinedChannelDuplexHandler class

As we mentioned earlier, combining a decoder and encoder can have an impact on reusability. However, there is a way to avoid this penalty without sacrificing the convenience of deploying a decoder and an encoder as a separate unit. CombinedChannelDuplexHandler provides this solution, which is declared as:

public class CombinedChannelDuplexHandler
	<I extends ChannelInboundHandler, O extends ChannelOutboundHandler>

This class acts as a container for ChannelInboundHandler and ChannelOutboundHandler (the type parameters I and O of this class). By providing types that inherit the decoder class and encoder class respectively, we can implement a codec without directly extending the abstract codec class

First, let's look at the following code, which extends ByteToMessageDecoder because it reads characters from ByteBuf

public class ByteToCharDecoder extends ByteToMessageDecoder {
    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        while (in.readableBytes() >= 2) {
            out.add(in.readChar());
        }
    }
}

The decode() method here will extract 2 bytes from ByteBuf at a time and write them to the List as characters, which will be automatically boxed into Character objects

The following code converts Character back to bytes. This class extends MessageToByteEncoder because it needs to encode char messages into ByteBuf. This is done by writing directly to ByteBuf

public class CharToByteEncoder extends MessageToByteEncoder<Character> {
    @Override
    protected void encode(ChannelHandlerContext ctx, Character msg, ByteBuf out) throws Exception {
        out.writeChar(msg);
    }
}

Now that we have a decoder and an encoder, we can combine them to build a codec

// The parameterized CombinedByteCharCodec is realized through the decoder and encoder
public class CombinedChannelDuplexHandler extends
        io.netty.channel.CombinedChannelDuplexHandler<ByteToCharDecoder, CharToByteEncoder> {
    public CombinedChannelDuplexHandler() {
        // Pass the delegate instance to the parent class
        super(new ByteToCharDecoder(), new CharToByteEncoder());
    }
}

Topics: Java Netty