基於Netty實現Redis協議的編碼解碼器

老錢發表於2018-03-19

基於Netty實現Redis協議的編碼解碼器

Netty訊息處理結構

上面是Netty的伺服器端基本訊息處理結構,為了便於初學者理解,它和真實的結構有稍許出入。Netty是基於NIO的訊息處理框架,用來高效處理網路IO。處理網路訊息一般走以下步驟

  1. 監聽埠 Bind & Listen

  2. 接收新連線 Accept

  3. 通過連線收取客戶端傳送的位元組流,轉換成輸入訊息物件 Read & Decode

  4. 處理訊息,生成輸出訊息物件 Process

  5. 轉換成位元組,通過連線傳送到客戶端 Encode & Write

步驟2拿到新連線之後,如果是開啟了新執行緒進入步驟3,那就是走傳統的多執行緒伺服器模式。一個執行緒一個連線,每個執行緒是都阻塞式讀寫訊息。如果併發量比較大,需要的執行緒資源也是比較多的。

Netty的訊息處理基於NIO的多路複用機理,一個執行緒通過NIO Selector非阻塞地讀寫非常多的連線。傳統的多執行緒伺服器需要的執行緒數到了NIO這裡就可以大幅縮減,節省了很多作業系統資源。

基於Netty實現Redis協議的編碼解碼器

Netty的執行緒劃分為兩種,一種是用來監聽ServerSocket並接受新連線的Acceptor執行緒,另一種用來讀寫套件字連線上的訊息的IO執行緒,兩種執行緒都是使用NIO Selector非同步並行管理多個套件字。Acceptor執行緒可以同時監聽多個ServerSocket,管理多個埠,將接收到的新連線扔到IO執行緒。IO執行緒可以同時讀寫多個Socket,管理多個連線的讀寫。

IO執行緒從套件字上讀取到的是位元組流,然後通過訊息解碼器將位元組流反序列化成輸入訊息物件,再傳遞到業務處理器進行處理,業務處理器會生成輸出訊息物件,通過訊息編碼器序列化成位元組流,再通過套件字輸出到客戶端。

Redis協議編碼解碼的實現

本文的重點是教讀者實現一個簡單的Redis Protocol編碼解碼器。

基於Netty實現Redis協議的編碼解碼器

首先我們來介紹一下Redis Protocol的格式,Redis協議分為指令和返回兩個部分,指令的格式比較簡單,就是一個字串陣列,比如指令setnx a b就是三個字串的陣列,如果指令中有整數,也是以字串的形式傳送的。Redis協議的返回就比較複雜了,因為要支援複雜的資料型別和結構巢狀。本文是以服務端的角色來處理Redis協議,也就是編寫指令的解碼器和返回物件的編碼器。而客戶端則是反過來的,客戶端需要編寫指令的編碼器和返回物件的解碼器。

指令的編碼格式

setnx a b => *3\r\n$5\r\nsetnx\r\n$1\r\na\r\n$1\r\nb\r\n

指令是一個字串陣列,編碼一個字串陣列,首先需要編碼陣列長度*3\r\n。然後依次編碼各個字串引數。編碼字串首先需要編碼字串的長度$5\r\n。然後再編碼字串的內容setnx\r\n。Redis訊息以\r\n作為分隔符,這樣設計其實挺浪費網路傳輸流量的,訊息內容裡面到處都是\r\n符號。但是這樣的訊息可讀性會比較好,便於除錯。這也是軟體世界犧牲效能換取可讀性便捷性的一個經典例子。

指令解碼器的實現 網路位元組流的讀取存在半包問題。所謂半包問題是指一次Read呼叫從套件字讀到的位元組陣列可能只是一個完整訊息的一部分。而另外一部分則需要發起另外一次Read呼叫才可能讀到,甚至要發起多個Read呼叫才可以讀到完整的一條訊息。

如果我們拿部分訊息去反序列化成輸入訊息物件肯定是要失敗的,或者說生成的訊息物件是不完整填充的。這個時候我們需要等待下一次Read呼叫,然後將這兩次Read呼叫的位元組陣列拼起來,嘗試再一次反序列化。

問題來了,如果一個輸入訊息物件很大,就可能需要多個Read呼叫和多次反序列化操作才能完整的解包出一個輸入物件。那這個反序列化的過程就會重複了多次。比如第一次完成了30%,然後第二次從頭開始又完成了60%,第三次又從頭開始完成了90%,第四次又從頭開始總算完成了100%,這下終於可以放心交給業務處理器處理了。

基於Netty實現Redis協議的編碼解碼器

Netty使用ReplayingDecoder引入檢查點機制[Checkpoint]解決了這個重複反序列化的問題。

基於Netty實現Redis協議的編碼解碼器

在反序列化的過程中我們反覆打點記錄下當前讀到了哪個位置,也就是檢查點,然後下次反序列化的時候就可以從上次記錄的檢查點直接繼續反序列化。這樣就避免了重複的問題。

這就好比我們玩單機RPG遊戲一樣,這些遊戲往往有自動儲存的功能。這樣就可以避免程式不小心退出時,再進來的時候就可以從上次儲存的狀態直接繼續進行下去,而不是像Flappy Bird一樣重新玩它一遍又一遍,這簡直要把人虐死。

基於Netty實現Redis協議的編碼解碼器

import java.util.ArrayList;
import java.util.List;

import com.google.common.base.Charsets;

import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.DecoderException;
import io.netty.handler.codec.ReplayingDecoder;

class InputState {
    public int index;
}

public class RedisInputDecoder extends ReplayingDecoder<InputState> {

    private int length;
    private List<String> params;

    @Override
    protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
        InputState state = this.state();
        if (state == null) {
            length = readParamsLen(in);
            this.params = new ArrayList<>(length);
            state = new InputState();
            this.checkpoint(state);
        }
        for (int i = state.index; i < length; i++) {
            String param = readParam(in);
            this.params.add(param);
            state.index = state.index + 1;
            this.checkpoint(state);
        }
        out.add(new RedisInput(params));
        this.checkpoint(null);
    }

    private final static int CR = '\r';
    private final static int LF = '\n';
    private final static int DOLLAR = '$';
    private final static int ASTERISK = '*';

    private int readParamsLen(ByteBuf in) {
        int c = in.readByte();
        if (c != ASTERISK) {
            throw new DecoderException("expect character *");
        }
        int len = readLen(in, 3); // max 999 params
        if (len == 0) {
            throw new DecoderException("expect non-zero params");
        }
        return len;
    }

    private String readParam(ByteBuf in) {
        int len = readStrLen(in);
        return readStr(in, len);
    }

    private String readStr(ByteBuf in, int len) {
        if (len == 0) {
            return "";
        }
        byte[] cs = new byte[len];
        in.readBytes(cs);
        skipCrlf(in);
        return new String(cs, Charsets.UTF_8);
    }

    private int readStrLen(ByteBuf in) {
        int c = in.readByte();
        if (c != DOLLAR) {
            throw new DecoderException("expect character $");
        }
        return readLen(in, 6); // string maxlen 999999
    }

    private int readLen(ByteBuf in, int maxBytes) {
        byte[] digits = new byte[maxBytes]; // max 999個引數
        int len = 0;
        while (true) {
            byte d = in.getByte(in.readerIndex());
            if (!Character.isDigit(d)) {
                break;
            }
            in.readByte();
            digits[len] = d;
            len++;
            if (len > maxBytes) {
                throw new DecoderException("params length too large");
            }
        }
        skipCrlf(in);
        if (len == 0) {
            throw new DecoderException("expect digit");
        }
        return Integer.parseInt(new String(digits, 0, len));
    }

    private void skipCrlf(ByteBuf in) {
        int c = in.readByte();
        if (c == CR) {
            c = in.readByte();
            if (c == LF) {
                return;
            }
        }
        throw new DecoderException("expect cr ln");
    }

}
複製程式碼

輸出訊息編碼器的實現

高能預警:前方有大量程式碼,請酌情觀看

輸出訊息的結構要複雜很多,要支援多種資料型別,包括狀態、整數、錯誤、字串和陣列,要支援資料結構巢狀,陣列裡還有陣列。相比解碼器而言它簡單的地方在於不用考慮半包問題,編碼器只負責將訊息序列化成位元組流,剩下的事由Netty偷偷幫你搞定。

首先我們定義一個輸出訊息物件介面,所有的資料型別都要實現該介面,將物件內部的狀態轉換成位元組陣列放置到ByteBuf中。

import io.netty.buffer.ByteBuf;

public interface IRedisOutput {

    public void encode(ByteBuf buf);

}
複製程式碼

整數輸出訊息類,整數的序列化格式為:value\r\n,value是整數的字串表示。

import com.google.common.base.Charsets;
import io.netty.buffer.ByteBuf;

public class IntegerOutput implements IRedisOutput {

    private long value;

    public IntegerOutput(long value) {
        this.value = value;
    }

    @Override
    public void encode(ByteBuf buf) {
        buf.writeByte(':');
        buf.writeBytes(String.valueOf(value).getBytes(Charsets.UTF_8));
        buf.writeByte('\r');
        buf.writeByte('\n');
    }

    public static IntegerOutput of(long value) {
        return new IntegerOutput(value);
    }

    public static IntegerOutput ZERO = new IntegerOutput(0);
    public static IntegerOutput ONE = new IntegerOutput(1);

}
複製程式碼

狀態輸出訊息類,序列化格式為+status\r\n

import com.google.common.base.Charsets;
import io.netty.buffer.ByteBuf;

public class StateOutput implements IRedisOutput {

    private String state;

    public StateOutput(String state) {
        this.state = state;
    }

    public void encode(ByteBuf buf) {
        buf.writeByte('+');
        buf.writeBytes(state.getBytes(Charsets.UTF_8));
        buf.writeByte('\r');
        buf.writeByte('\n');
    }

    public static StateOutput of(String state) {
        return new StateOutput(state);
    }

    public final static StateOutput OK = new StateOutput("OK");

    public final static StateOutput PONG = new StateOutput("PONG");

}
複製程式碼

錯誤輸出訊息類,序列化格式為-type reason\r\n,reason必須為單行字串

import com.google.common.base.Charsets;
import io.netty.buffer.ByteBuf;

public class ErrorOutput implements IRedisOutput {

    private String type;
    private String reason;

    public ErrorOutput(String type, String reason) {
        this.type = type;
        this.reason = reason;
    }

    public String getType() {
        return type;
    }

    public String getReason() {
        return reason;
    }

    @Override
    public void encode(ByteBuf buf) {
        buf.writeByte('-');
        // reason不允許多行字串
        buf.writeBytes(String.format("%s %s", type, headOf(reason)).getBytes(Charsets.UTF_8));
        buf.writeByte('\r');
        buf.writeByte('\n');
    }

    private String headOf(String reason) {
        int idx = reason.indexOf("\n");
        if (idx < 0) {
            return reason;
        }
        return reason.substring(0, idx).trim();
    }

    // 通用錯誤
    public static ErrorOutput errorOf(String reason) {
        return new ErrorOutput("ERR", reason);
    }

    // 語法錯誤
    public static ErrorOutput syntaxOf(String reason) {
        return new ErrorOutput("SYNTAX", reason);
    }

    // 協議錯誤
    public static ErrorOutput protoOf(String reason) {
        return new ErrorOutput("PROTO", reason);
    }

    // 引數無效
    public static ErrorOutput paramOf(String reason) {
        return new ErrorOutput("PARAM", reason);
    }

    // 伺服器內部錯誤
    public static ErrorOutput serverOf(String reason) {
        return new ErrorOutput("SERVER", reason);
    }

}
複製程式碼

字串輸出訊息類,字串分為null、空串和普通字串。null的序列化格式為$-1\r\n,普通字串的格式為$len\r\ncontent\r\n,空串就是一個長度為0的字串,格式為$0\r\n\r\n。

import com.google.common.base.Charsets;
import io.netty.buffer.ByteBuf;

public class StringOutput implements IRedisOutput {

    private String content;

    public StringOutput(String content) {
        this.content = content;
    }

    @Override
    public void encode(ByteBuf buf) {
        buf.writeByte('$');
        if (content == null) {
            // $-1\r\n
            buf.writeByte('-');
            buf.writeByte('1');
            buf.writeByte('\r');
            buf.writeByte('\n');
            return;
        }
        byte[] bytes = content.getBytes(Charsets.UTF_8);
        buf.writeBytes(String.valueOf(bytes.length).getBytes(Charsets.UTF_8));
        buf.writeByte('\r');
        buf.writeByte('\n');
        if (content.length() > 0) {
            buf.writeBytes(bytes);
        }
        buf.writeByte('\r');
        buf.writeByte('\n');
    }

    public static StringOutput of(String content) {
        return new StringOutput(content);
    }

    public static StringOutput of(long value) {
        return new StringOutput(String.valueOf(value));
    }

    public final static StringOutput NULL = new StringOutput(null);

}
複製程式碼

最後一個陣列輸出訊息類,支援資料結構巢狀就靠它了。陣列的內部是多個子訊息,每個子訊息的型別是不定的,型別可以不一樣。比如scan操作的返回就是一個陣列,陣列的第一個子訊息是遊標的offset字串,第二個子訊息是一個字串陣列。它的序列化格式開頭為*len\r\n,後面依次是內部所有子訊息的序列化形式。

import java.util.ArrayList;
import java.util.List;
import com.google.common.base.Charsets;
import io.netty.buffer.ByteBuf;

public class ArrayOutput implements IRedisOutput {

    private List<IRedisOutput> outputs = new ArrayList<>();

    public static ArrayOutput newArray() {
        return new ArrayOutput();
    }

    public ArrayOutput append(IRedisOutput output) {
        outputs.add(output);
        return this;
    }

    @Override
    public void encode(ByteBuf buf) {
        buf.writeByte('*');
        buf.writeBytes(String.valueOf(outputs.size()).getBytes(Charsets.UTF_8));
        buf.writeByte('\r');
        buf.writeByte('\n');
        for (IRedisOutput output : outputs) {
            output.encode(buf);
        }
    }

}
複製程式碼

下面是ArrayOutput物件使用的一個例項,是從小編的某個專案裡扒下來的,巢狀了三層陣列。讀者可以不用死磕下面的程式碼,重點看看ArrayOutput大致怎麼使用的就可以了。

ArrayOutput out = ArrayOutput.newArray();
for (Result result : res) {
    if (result.isEmpty()) {
        continue;
    }
    ArrayOutput row = ArrayOutput.newArray();
    row.append(StringOutput.of(new String(result.getRow(), Charsets.UTF_8)));
    for (KeyValue kv : result.list()) {
        ArrayOutput item = ArrayOutput.newArray();
        item.append(StringOutput.of("family"));
        item.append(StringOutput.of(new String(kv.getFamily(), Charsets.UTF_8)));
        item.append(StringOutput.of("qualifier"));
        item.append(StringOutput.of(new String(kv.getQualifier(), Charsets.UTF_8)));
        item.append(StringOutput.of("value"));
        item.append(StringOutput.of(new String(kv.getValue(), Charsets.UTF_8)));
        item.append(StringOutput.of("timestamp"));
        item.append(StringOutput.of(kv.getTimestamp()));
        row.append(item);
    }
    out.append(row);
}
ctx.writeAndFlush(out);
複製程式碼

最後,有了以上清晰的類結構,解碼器類的實現就非常簡單了。

import java.util.List;
import io.netty.channel.ChannelHandler.Sharable;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToMessageEncoder;

@Sharable
public class RedisOutputEncoder extends MessageToMessageEncoder<IRedisOutput> {

    @Override
    protected void encode(ChannelHandlerContext ctx, IRedisOutput msg, List<Object> out) throws Exception {
        ByteBuf buf = PooledByteBufAllocator.DEFAULT.directBuffer();
        msg.encode(buf);
        out.add(buf);
    }

}
複製程式碼

因為解碼器物件是無狀態的,所以它可以被channel共享。解碼器的實現非常簡單,就是分配一個ByteBuf,然後將將訊息輸出物件序列化的位元組陣列塞到ByteBuf中輸出就可以了。

基於Netty實現Redis協議的編碼解碼器
閱讀相關文章,關注公眾號【碼洞】

相關文章