netty系列之:netty中常用的物件編碼解碼器

flydean 發表於 2022-05-17
Netty

簡介

我們在程式中除了使用常用的字串進行資料傳遞之外,使用最多的還是JAVA物件。在JDK中,物件如果需要在網路中傳輸,必須實現Serializable介面,表示這個物件是可以被序列化的。這樣就可以呼叫JDK自身的物件物件方法,進行物件的讀寫。

那麼在netty中進行物件的傳遞可不可以直接使用JDK的物件序列化方法呢?如果不能的話,又應該怎麼處理呢?

今天帶大家來看看netty中提供的物件編碼器。

什麼是序列化

序列化就是將java物件按照一定的順序組織起來,用於在網路上傳輸或者寫入儲存中。而反序列化就是從網路中或者儲存中讀取儲存的物件,將其轉換成為真正的java物件。

所以序列化的目的就是為了傳輸物件,對於一些複雜的物件,我們可以使用第三方的優秀框架,比如Thrift,Protocol Buffer等,使用起來非常的方便。

JDK本身也提供了序列化的功能。要讓一個物件可序列化,則可以實現java.io.Serializable介面。

java.io.Serializable是從JDK1.1開始就有的介面,它實際上是一個marker interface,因為java.io.Serializable並沒有需要實現的介面。繼承java.io.Serializable就表明這個class物件是可以被序列化的。

@Data
@AllArgsConstructor
public class CustUser implements java.io.Serializable{
    private static final long serialVersionUID = -178469307574906636L;
    private String name;
    private String address;
}

上面我們定義了一個CustUser可序列化物件。這個物件有兩個屬性:name和address。

接下看下怎麼序列化和反序列化:

public void testCusUser() throws IOException, ClassNotFoundException {
        CustUser custUserA=new CustUser("jack","www.flydean.com");
        CustUser custUserB=new CustUser("mark","www.flydean.com");

        try(FileOutputStream fileOutputStream = new FileOutputStream("target/custUser.ser")){
            ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
            objectOutputStream.writeObject(custUserA);
            objectOutputStream.writeObject(custUserB);
        }
        
        try(FileInputStream fileInputStream = new FileInputStream("target/custUser.ser")){
            ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
            CustUser custUser1 = (CustUser) objectInputStream.readObject();
            CustUser custUser2 = (CustUser) objectInputStream.readObject();
            log.info("{}",custUser1);
            log.info("{}",custUser2);
        }
    }

上面的例子中,我們例項化了兩個CustUser物件,並使用objectOutputStream將物件寫入檔案中,最後使用ObjectInputStream從檔案中讀取物件。

上面是最基本的使用。需要注意的是CustUser class中有一個serialVersionUID欄位。

serialVersionUID是序列化物件的唯一標記,如果class中定義的serialVersionUID和序列化儲存中的serialVersionUID一致,則表明這兩個物件是一個物件,我們可以將儲存的物件反序列化。

如果我們沒有顯示的定義serialVersionUID,則JVM會自動根據class中的欄位,方法等資訊生成。很多時候我在看程式碼的時候,發現很多人都將serialVersionUID設定為1L,這樣做是不對的,因為他們沒有理解serialVersionUID的真正含義。

重構序列化物件

假如我們有一個序列化的物件正在使用了,但是突然我們發現這個物件好像少了一個欄位,要把他加上去,可不可以加呢?加上去之後原序列化過的物件能不能轉換成這個新的物件呢?

答案是肯定的,前提是兩個版本的serialVersionUID必須一樣。新加的欄位在反序列化之後是空值。

序列化不是加密

有很多同學在使用序列化的過程中可能會這樣想,序列化已經將物件變成了二進位制檔案,是不是說該物件已經被加密了呢?

這其實是序列化的一個誤區,序列化並不是加密,因為即使你序列化了,還是能從序列化之後的資料中知道你的類的結構。比如在RMI遠端呼叫的環境中,即使是class中的private欄位也是可以從stream流中解析出來的。

如果我們想在序列化的時候對某些欄位進行加密操作該怎麼辦呢?

這時候可以考慮在序列化物件中新增writeObject和readObject方法:

private String name;
    private String address;
    private int age;

    private void writeObject(ObjectOutputStream stream)
            throws IOException
    {
        //給age加密
        age = age + 2;
        log.info("age is {}", age);
        stream.defaultWriteObject();
    }

    private void readObject(ObjectInputStream stream)
            throws IOException, ClassNotFoundException
    {
        stream.defaultReadObject();
        log.info("age is {}", age);
        //給age解密
        age = age - 2;
    }

上面的例子中,我們為CustUser新增了一個age物件,並在writeObject中對age進行了加密(加2),在readObject中對age進行了解密(減2)。

注意,writeObject和readObject都是private void的方法。他們的呼叫是通過反射來實現的。

使用真正的加密

上面的例子, 我們只是對age欄位進行了加密,如果我們想對整個物件進行加密有沒有什麼好的處理辦法呢?

JDK為我們提供了javax.crypto.SealedObject 和java.security.SignedObject來作為對序列化物件的封裝。從而將整個序列化物件進行了加密。

還是舉個例子:

public void testCusUserSealed() throws IOException, ClassNotFoundException, NoSuchPaddingException, NoSuchAlgorithmException, IllegalBlockSizeException, BadPaddingException, InvalidAlgorithmParameterException, InvalidKeyException {
        CustUser custUserA=new CustUser("jack","www.flydean.com");
        Cipher enCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        Cipher deCipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
        SecretKey secretKey = new SecretKeySpec("saltkey111111111".getBytes(), "AES");
        IvParameterSpec iv = new IvParameterSpec("vectorKey1111111".getBytes());
        enCipher.init(Cipher.ENCRYPT_MODE, secretKey, iv);
        deCipher.init(Cipher.DECRYPT_MODE,secretKey,iv);
        SealedObject sealedObject= new SealedObject(custUserA, enCipher);

        try(FileOutputStream fileOutputStream = new FileOutputStream("target/custUser.ser")){
            ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
            objectOutputStream.writeObject(sealedObject);
        }

        try(FileInputStream fileInputStream = new FileInputStream("target/custUser.ser")){
            ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
            SealedObject custUser1 = (SealedObject) objectInputStream.readObject();
            CustUser custUserV2= (CustUser) custUser1.getObject(deCipher);
            log.info("{}",custUserV2);
        }
    }

上面的例子中,我們構建了一個SealedObject物件和相應的加密解密演算法。

SealedObject就像是一個代理,我們寫入和讀取的都是這個代理的加密物件。從而保證了在資料傳輸過程中的安全性。

使用代理

上面的SealedObject實際上就是一種代理,考慮這樣一種情況,如果class中的欄位比較多,而這些欄位都可以從其中的某一個欄位中自動生成,那麼我們其實並不需要序列化所有的欄位,我們只把那一個欄位序列化就可以了,其他的欄位可以從該欄位衍生得到。

在這個案例中,我們就需要用到序列化物件的代理功能。

首先,序列化物件需要實現writeReplace方法,表示替換成真正想要寫入的物件:

public class CustUserV3 implements java.io.Serializable{

    private String name;
    private String address;

    private Object writeReplace()
            throws java.io.ObjectStreamException
    {
        log.info("writeReplace {}",this);
        return new CustUserV3Proxy(this);
    }
}

然後在Proxy物件中,需要實現readResolve方法,用於從系列化過的資料中重構序列化物件。如下所示:

public class CustUserV3Proxy implements java.io.Serializable{

    private String data;

    public CustUserV3Proxy(CustUserV3 custUserV3){
        data =custUserV3.getName()+ "," + custUserV3.getAddress();
    }

    private Object readResolve()
            throws java.io.ObjectStreamException
    {
        String[] pieces = data.split(",");
        CustUserV3 result = new CustUserV3(pieces[0], pieces[1]);
        log.info("readResolve {}",result);
        return result;
    }
}

我們看下怎麼使用:

public void testCusUserV3() throws IOException, ClassNotFoundException {
        CustUserV3 custUserA=new CustUserV3("jack","www.flydean.com");

        try(FileOutputStream fileOutputStream = new FileOutputStream("target/custUser.ser")){
            ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
            objectOutputStream.writeObject(custUserA);
        }

        try(FileInputStream fileInputStream = new FileInputStream("target/custUser.ser")){
            ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
            CustUserV3 custUser1 = (CustUserV3) objectInputStream.readObject();
            log.info("{}",custUser1);
        }
    }

注意,我們寫入和讀出的都是CustUserV3物件。

Serializable和Externalizable的區別

最後我們講下Externalizable和Serializable的區別。Externalizable繼承自Serializable,它需要實現兩個方法:

 void writeExternal(ObjectOutput out) throws IOException;
 void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;

什麼時候需要用到writeExternal和readExternal呢?

使用Serializable,Java會自動為類的物件和欄位進行物件序列化,可能會佔用更多空間。而Externalizable則完全需要我們自己來控制如何寫/讀,比較麻煩,但是如果考慮效能的話,則可以使用Externalizable。

另外Serializable進行反序列化不需要執行建構函式。而Externalizable需要執行建構函式構造出物件,然後呼叫readExternal方法來填充物件。所以Externalizable的物件需要一個無參的建構函式。

netty中物件的傳輸

在上面的序列化一節中,我們已經知道了對於定義好的JAVA物件,我們可以通過使用ObjectOutputStream和ObjectInputStream來實現物件的讀寫工作,那麼在netty中是否也可以使用同樣的方式來進行物件的讀寫呢?

很遺憾的是,在netty中並不能直接使用JDK中的物件讀寫方法,我們需要對其進行改造。

這是因為我們需要一個通用的物件編碼和解碼器,如果使用ObjectOutputStream和ObjectInputStream,因為不同物件的結構是不一樣的,所以我們在讀取物件的時候需要知道讀取資料的物件型別才能進行完美的轉換。

而在netty中我們需要的是一種更加通用的編碼解碼器,那麼應該怎麼做呢?

還記得之前我們在講解通用的frame decoder中講過的LengthFieldBasedFrameDecoder? 通過在真實的資料前面加上資料的長度,從而達到根據資料長度進行frame區分的目的。

netty中提供的編碼解碼器名字叫做ObjectEncoder和ObjectDecoder,先來看下他們的定義:

public class ObjectEncoder extends MessageToByteEncoder<Serializable> {
public class ObjectDecoder extends LengthFieldBasedFrameDecoder {

可以看到ObjectEncoder繼承自MessageToByteEncoder,其中的泛型是Serializable,表示encoder是從可序列化的物件encode成為ByteBuf。

而ObjectDecoder正如上面我們所說的繼承自LengthFieldBasedFrameDecoder,所以可以通過一個長度欄位來區分實際要讀取物件的長度。

接下來我們詳細瞭解一下這兩個類是如何工作的。

ObjectEncoder

先來看ObjectEncoder是如何將一個物件序列化成為ByteBuf的。

根據LengthFieldBasedFrameDecoder的定義,我們需要一個陣列來儲存真實資料的長度,這裡使用的是一個4位元組的byte陣列叫做LENGTH_PLACEHOLDER,如下所示:

private static final byte[] LENGTH_PLACEHOLDER = new byte[4];

我們看下它的encode方法的實現:

    protected void encode(ChannelHandlerContext ctx, Serializable msg, ByteBuf out) throws Exception {
        int startIdx = out.writerIndex();

        ByteBufOutputStream bout = new ByteBufOutputStream(out);
        ObjectOutputStream oout = null;
        try {
            bout.write(LENGTH_PLACEHOLDER);
            oout = new CompactObjectOutputStream(bout);
            oout.writeObject(msg);
            oout.flush();
        } finally {
            if (oout != null) {
                oout.close();
            } else {
                bout.close();
            }
        }
        int endIdx = out.writerIndex();
        out.setInt(startIdx, endIdx - startIdx - 4);
    }

這裡首先建立了一個ByteBufOutputStream,然後向這個Stream中寫入4位元組的長度欄位,接著將ByteBufOutputStream封裝到CompactObjectOutputStream中。

CompactObjectOutputStream是ObjectOutputStream的子類,它重寫了writeStreamHeader和writeClassDescriptor兩個方法。

CompactObjectOutputStream將最終的資料msg寫入流中,一個encode的過程就差不多完成了。

為什麼說差不多完成了呢?因為長度欄位還是空的。

在最開始的時候,我們只是寫入了一個長度的placeholder,這個placeholder是空的,並沒有任何資料,這個資料是在最後一步out.setInt中寫入的:

out.setInt(startIdx, endIdx - startIdx - 4);

這種實現也給了我們一種思路,在我們還不知道訊息的真實長度的時候,如果希望在訊息之前寫入訊息的長度,可以先佔個位置,等訊息全部讀取完畢,知道真實的長度之後,再替換資料。

到此,物件資料已經全部編碼完畢,接下來我們看一下如何從編碼過後的資料中讀取物件。

ObjectDecoder

之前說過了ObjectDecoder繼承自LengthFieldBasedFrameDecoder,它的decode方法是這樣的:

    protected Object decode(ChannelHandlerContext ctx, ByteBuf in) throws Exception {
        ByteBuf frame = (ByteBuf) super.decode(ctx, in);
        if (frame == null) {
            return null;
        }

        ObjectInputStream ois = new CompactObjectInputStream(new ByteBufInputStream(frame, true), classResolver);
        try {
            return ois.readObject();
        } finally {
            ois.close();
        }
    }

首先呼叫LengthFieldBasedFrameDecoder的decode方法,根據物件的長度,讀取到真實的物件資料放到ByteBuf中。

然後通過自定義的CompactObjectInputStream從ByteBuf中讀取到真實的物件,並返回。

CompactObjectInputStream繼承自ObjectInputStream,是和CompactObjectOutputStream相反的操作。

ObjectEncoderOutputStream和ObjectDecoderInputStream

ObjectEncoder和ObjectDecoder是物件和ByteBuf之間的轉換,netty還提供了和ObjectEncoder,ObjectDecoder相容的ObjectEncoderOutputStream和ObjectDecoderInputStream,這兩個類可以從stream中對物件編碼和解碼,並且和ObjectEncoder,ObjectDecoder完全相容的。

總結

以上就是netty中提供的物件編碼和解碼器,大家如果希望在netty中傳遞物件,那麼netty提供的這兩個編碼解碼器是最好的選擇。

本文已收錄於 http://www.flydean.com/14-8-netty-codec-object/

最通俗的解讀,最深刻的乾貨,最簡潔的教程,眾多你不知道的小技巧等你來發現!

歡迎關注我的公眾號:「程式那些事」,懂技術,更懂你!