09 - Sérialisation de l'optimisation de la communication réseau : évitez d'utiliser la sérialisation Java

        La plupart des services backend actuels sont implémentés sur la base de l'architecture des microservices. Les services sont divisés selon les divisions commerciales, ce qui réalise le découplage des services, mais en même temps cela pose de nouveaux problèmes : la communication entre les différentes entreprises doit s'effectuer via des interfaces. Pour partager un objet de données entre deux services, il est nécessaire de convertir l'objet en flux binaire, de le transmettre via le réseau, de l'envoyer à l'autre service, puis de le reconvertir en objet pour que la méthode de service l'appelle. Ce processus de codage et de décodage est appelé sérialisation et désérialisation.

        Dans le cas d'un grand nombre de requêtes simultanées, si la vitesse de sérialisation est lente, le temps de réponse des requêtes augmentera et le volume de données de transmission sérialisées est important, ce qui entraînera une diminution du débit du réseau. Ainsi, un excellent cadre de sérialisation peut améliorer les performances globales du système.

        Nous savons que Java fournit le cadre RMI pour réaliser l'exposition et l'invocation de l'interface entre les services, et que la sérialisation des objets de données dans RMI utilise la sérialisation Java. Cependant, les frameworks de microservices traditionnels actuels n'utilisent pratiquement pas la sérialisation Java. SpringCloud utilise la sérialisation Json. Bien que Dubbo soit compatible avec la sérialisation Java, il utilise la sérialisation hessienne par défaut. Pourquoi est-ce?

        Aujourd'hui, nous allons en apprendre davantage sur la sérialisation Java et la comparer avec la sérialisation Protobuf, devenue populaire au cours des deux dernières années, pour voir comment Protobuf parvient à une sérialisation optimale.

1. Sérialisation Java

Avant de parler de défauts, il faut d’abord savoir ce qu’est la sérialisation Java et comment elle fonctionne.

Java fournit un mécanisme de sérialisation qui peut sérialiser un objet sous une forme binaire (tableau d'octets) pour l'écrire sur le disque ou le sortir sur le réseau, et peut également lire des tableaux d'octets à partir du réseau ou du disque, désérialisés en un objet et utilisés dans le programme. .

Les deux objets de flux d'entrée et de sortie ObjectInputStream et ObjectOutputStream fournis par JDK ne peuvent désérialiser et sérialiser que les objets des classes qui implémentent l'interface Serialisable.

La méthode de sérialisation par défaut d'ObjectOutputStream sérialise uniquement les variables d'instance non transitoires de l'objet, mais ne sérialise pas les variables d'instance transitoires de l'objet, ni les variables statiques.

Dans l'objet de la classe qui implémente l'interface Serialisable, un numéro de version SerialVersionUID sera généré. A quoi sert ce numéro de version ? Il vérifiera si l'objet sérialisé est chargé avec la classe désérialisée pendant le processus de désérialisation. S'il s'agit d'une classe avec un numéro de version différent du même nom de classe, l'objet ne peut pas être obtenu lors de la désérialisation.

L'implémentation spécifique de la sérialisation est writeObject et readObject. Habituellement, ces deux méthodes sont celles par défaut. Bien sûr, nous pouvons également les réécrire dans la classe qui implémente l'interface Serialisable pour personnaliser notre propre ensemble de mécanismes de sérialisation et de désérialisation.

De plus, deux méthodes de réécriture sont définies dans la classe de sérialisation Java : writeReplace() et readResolve(). La première est utilisée pour remplacer l'objet sérialisé avant la sérialisation, et la seconde est utilisée pour résoudre l'objet après la désérialisation. Renvoie l'objet pour traitement.

2. Défauts de sérialisation Java

Si vous avez utilisé certains frameworks de communication RPC, vous constaterez que ces frameworks utilisent rarement la sérialisation fournie par le JDK. En fait, cela est principalement lié au fait qu'il n'est pas utile et qu'il n'est pas facile à utiliser. Jetons un coup d'œil aux défauts de la sérialisation par défaut du JDK.

2.1, impossible de traverser les langues

La conception des systèmes d'aujourd'hui est de plus en plus diversifiée et de nombreux systèmes utilisent plusieurs langages pour écrire des applications. Par exemple, certains jeux à grande échelle développés par notre société utilisent plusieurs langages : C++ est utilisé pour écrire des services de jeux, Java/Go est utilisé pour écrire des services périphériques et Python est utilisé pour écrire certaines applications de surveillance.

La sérialisation Java ne s'applique actuellement qu'aux frameworks basés sur le langage Java, et la plupart des autres langages n'utilisent pas le framework de sérialisation Java et n'implémentent pas non plus le protocole de sérialisation Java. Par conséquent, si deux applications écrites dans des langages différents communiquent entre elles, la sérialisation et la désérialisation des objets transférés entre les deux services applicatifs ne peuvent pas être réalisées.

2.2. Vulnérable aux attaques

Les directives de codage sécurisé du site Web officiel de Java indiquent : « La désérialisation de données non fiables est intrinsèquement dangereuse et doit être évitée. » On peut voir que la sérialisation Java n'est pas sûre.

Nous savons que les objets sont désérialisés en appelant la méthode readObject() sur ObjectInputStream. Cette méthode est en fait un constructeur magique, qui peut instancier presque tous les objets du chemin de classe qui implémentent l'interface Serialisable.

Cela signifie également que lors du processus de désérialisation du flux d'octets, cette méthode peut exécuter n'importe quel type de code, ce qui est très dangereux.

Pour les objets devant être désérialisés pendant une longue période, une attaque peut être lancée sans exécuter de code. L'attaquant peut créer une chaîne d'objets circulaire, puis transférer l'objet sérialisé vers le programme pour désérialisation. Cela entraînera une augmentation exponentielle du nombre de fois où la méthode hashCode est appelée, provoquant ainsi une exception de débordement de pile. Par exemple, le cas suivant peut être bien illustré.

    Set root = new HashSet();
    Set s1 = root;
    Set s2 = new HashSet();
    for (int i = 0; i < 100; i++) {
        Set t1 = new HashSet();
        Set t2 = new HashSet();
        t1.add("foo"); // 使 t2 不等于 t1
        s1.add(t1);
        s1.add(t2);
        s2.add(t1);
        s2.add(t2);
        s1 = t1;
        s2 = t2;
    }

En 2015, breenmachine de l'équipe de sécurité de FoxGlove Security a publié un long blog dont le contenu principal est le suivant : Les vulnérabilités de désérialisation Java peuvent être attaquées via Apache Commons Collections. Il a autrefois balayé les dernières versions de WebLogic, WebSphere, JBoss, Jenkins et OpenNMS, et tous les principaux serveurs Web Java ont déposé leurs armes.

En fait, Apache Commons Collections est une bibliothèque de base tierce qui étend la structure Collection dans la bibliothèque standard Java, fournit de nombreux types de structures de données puissants et implémente diverses classes d'outils de collection.

Le principe de l'attaque est le suivant : Apache Commons Collections permet d'enchaîner des appels de réflexion de fonction de classe arbitraires, l'attaquant télécharge le code d'attaque sur le serveur via le port qui "implémente le protocole de sérialisation Java", puis le TransformedMap dans Apache Commons Collections est exécuté.

Alors, comment avez-vous résolu cette vulnérabilité ?

De nombreux protocoles de sérialisation ont développé un ensemble de structures de données pour sauvegarder et récupérer des objets. Par exemple, la sérialisation JSON, ProtocolBuf, etc., ils ne prennent en charge que certains types de base et types de données de tableau, ce qui peut éviter la désérialisation pour créer des instances incertaines. Bien qu’ils soient de conception simple, ils suffisent à répondre aux besoins de transmission de données de la plupart des systèmes actuels.

Nous pouvons également contrôler les objets désérialisés via la liste blanche des objets désérialisés. Nous pouvons remplacer la méthode solveClass et vérifier le nom de l'objet dans cette méthode. Le code ressemble à ceci :

    @Override
    protected Class resolveClass(ObjectStreamClass desc) throws IOException,ClassNotFoundException {
        if (!desc.getName().equals(Bicycle.class.getName())) {
            throw new InvalidClassException(
                    "Unauthorized deserialization attempt", desc.getName());
        }
        return super.resolveClass(desc);
    }

2.3. Le flux sérialisé est trop volumineux

La taille du flux binaire sérialisé peut refléter les performances de sérialisation. Plus la baie binaire sérialisée est grande, plus elle occupe d'espace de stockage et plus le coût du matériel de stockage est élevé. Si nous effectuons une transmission réseau, plus de bande passante sera occupée, ce qui affectera le débit du système.

ObjectOutputStream est utilisé dans la sérialisation Java pour convertir des objets en codage binaire. Y a-t-il donc une différence dans la taille du tableau binaire complété par le codage binaire implémenté par ce mécanisme de sérialisation par rapport à la taille du tableau complété par le codage binaire implémenté par ByteBuffer dans NIO. ? ?

Nous pouvons le vérifier avec un exemple simple :

    User user = new User();
    user.setUserName("test");
    user.setPassword("test");

    ByteArrayOutputStream os =new ByteArrayOutputStream();
    ObjectOutputStream out = new ObjectOutputStream(os);
    out.writeObject(user);

    byte[] testByte = os.toByteArray();
    System.out.print("ObjectOutputStream 字节编码长度:" + testByte.length + "\n");
    ByteBuffer byteBuffer = ByteBuffer.allocate( 2048);

    byte[] userName = user.getUserName().getBytes();
    byte[] password = user.getPassword().getBytes();
    byteBuffer.putInt(userName.length);
    byteBuffer.put(userName);
    byteBuffer.putInt(password.length);
    byteBuffer.put(password);
        
    byteBuffer.flip();
    byte[] bytes = new byte[byteBuffer.remaining()];
    System.out.print("ByteBuffer 字节编码长度:" + bytes.length+ "\n");

résultat de l'opération :

ObjectOutputStream 字节编码长度:99
ByteBuffer 字节编码长度:16

Ici, nous pouvons clairement voir que la taille du tableau binaire complété par le codage binaire implémenté par la sérialisation Java est plusieurs fois supérieure à la taille du tableau binaire complété par le codage binaire implémenté par ByteBuffer. Par conséquent, le flux après la séquence Java deviendra plus grand, ce qui finira par affecter le débit du système.

2.4. Les performances de sérialisation sont trop mauvaises

La vitesse de sérialisation est également un indicateur important des performances de sérialisation. Si la vitesse de sérialisation est lente, cela affectera l'efficacité de la communication réseau, augmentant ainsi le temps de réponse du système. Utilisons l'exemple ci-dessus pour comparer les performances de la sérialisation Java et de l'encodage ByteBuffer dans NIO :

    User user = new User();
    user.setUserName("test");
    user.setPassword("test");

    long startTime = System.currentTimeMillis();
    for(int i=0; i<1000; i++) {
        ByteArrayOutputStream os =new ByteArrayOutputStream();
        ObjectOutputStream out = new ObjectOutputStream(os);
        out.writeObject(user);
        out.flush();
        out.close();
        byte[] testByte = os.toByteArray();
        os.close();
    }

    long endTime = System.currentTimeMillis();
    System.out.print("ObjectOutputStream 序列化时间:" + (endTime - startTime) + "\n");
    long startTime1 = System.currentTimeMillis();
    for(int i=0; i<1000; i++) {
        ByteBuffer byteBuffer = ByteBuffer.allocate( 2048);

        byte[] userName = user.getUserName().getBytes();
        byte[] password = user.getPassword().getBytes();
        byteBuffer.putInt(userName.length);
        byteBuffer.put(userName);
        byteBuffer.putInt(password.length);
        byteBuffer.put(password);

        byteBuffer.flip();
        byte[] bytes = new byte[byteBuffer.remaining()];
    }
    long endTime1 = System.currentTimeMillis();
    System.out.print("ByteBuffer 序列化时间:" + (endTime1 - startTime1)+ "\n");

résultat de l'opération :

ObjectOutputStream 序列化时间:29
ByteBuffer 序列化时间:6

A travers les cas ci-dessus, nous pouvons clairement voir que le temps d'encodage dans la sérialisation Java est beaucoup plus long que celui de ByteBuffer.

3. Remplacez la sérialisation Java par la sérialisation Protobuf

À l'heure actuelle, il existe de nombreux excellents frameworks de sérialisation dans l'industrie, et la plupart d'entre eux évitent certains défauts de la sérialisation par défaut de Java. Par exemple, FastJson, Kryo, Protobuf, Hessian, etc. ont été populaires ces dernières années. Nous pouvons trouver un moyen de remplacer la sérialisation Java, ici je recommande d'utiliser le framework de sérialisation Protobuf.

Protobuf est un framework de sérialisation lancé par Google qui prend en charge plusieurs langues. Actuellement, dans le rapport de test de comparaison des performances du framework de sérialisation sur les sites Web grand public, Protobuf se classe parmi les meilleurs en termes de temps d'encodage et de décodage et de taille de compression du flux binaire.

Protobuf est basé sur un fichier avec un suffixe .proto. Ce fichier décrit les champs et les types de champs, et les outils peuvent générer des fichiers de structure de données dans différentes langues. Lors de la sérialisation de l'objet de données, Protobuf génère l'encodage au format Protocol Buffers via la description du fichier .proto.

Pour développer un peu ici, permettez-moi de parler de ce qu'est le format de stockage des Protocol Buffers et de son principe de mise en œuvre.

Protocol Buffers est un format de stockage de données structuré léger et efficace. Il utilise le format de données TLV (identity-length-field value) pour stocker les données, T représente la séquence de nombres positive (étiquette) du champ, et Protocol Buffers associe chaque champ de l'objet à la séquence de nombres positive et aux informations du la relation correspondante est fournie par Le code généré est garanti. Lors de la sérialisation, une valeur entière est utilisée pour remplacer le nom du champ, ce qui permet de réduire considérablement le trafic de transmission ; L représente la longueur en octets de la valeur, qui n'occupe généralement qu'un octet ; V représente la valeur codée de la valeur du champ. Ce format de données ne nécessite pas de délimiteurs, ne nécessite pas d'espaces et réduit les noms de champs redondants.

Protobuf définit sa propre méthode de codage, qui peut mapper presque tous les types de données de base de Java/Python et d'autres langages. Différentes méthodes de codage correspondent à différents types de données et différents formats de stockage peuvent également être utilisés. Comme indiqué ci-dessous:

 Pour stocker des données codées Varint, puisque l'espace de stockage occupé par les données est fixe, il n'est pas nécessaire de stocker la longueur en octets, donc en fait la méthode de stockage des tampons de protocole est T-V, ce qui réduit l'espace de stockage d'un octet.

La méthode de codage Varint définie par Protobuf est une méthode de codage à longueur variable. Le dernier bit d'un octet de chaque type de données est un bit d'indicateur (msb), qui est représenté par 0 et 1, et 0 indique que l'octet actuel est le dernier octet, 1 signifie qu'il y a un octet supplémentaire après ce numéro.

Pour les nombres de type int32, il faut généralement 4 octets pour représenter, si vous utilisez la méthode de codage Varint, pour les très petits nombres de type int32, vous pouvez utiliser 1 octet pour représenter. Pour la plupart des données de type entier, il est généralement inférieur à 256, cette opération peut donc compresser efficacement les données.

Nous savons que int32 représente des nombres positifs et négatifs, donc généralement le dernier bit est utilisé pour représenter des valeurs positives et négatives. Maintenant, la méthode de codage Varint utilise le dernier bit comme bit d'indicateur, alors comment représenter des entiers positifs et négatifs ? Si vous utilisez int32/int64 pour représenter des nombres négatifs, vous avez besoin de plusieurs octets pour les représenter. Dans le type d'encodage Varint, convertissez-les via l'encodage Zigzag, convertissez les nombres négatifs en nombres non signés, puis utilisez sint32/sint64 pour représenter les nombres négatifs, ce qui peut être grandement amélioré.Réduire le nombre d’octets codés.

Ce format de stockage de données de Protobuf a non seulement un bon effet de compression et de stockage des données, mais est également très efficace en termes de performances d'encodage et de décodage. Le processus d'encodage et de décodage de Protobuf est combiné avec le format de fichier .proto et le format d'encodage unique de Protocol Buffer. Il ne nécessite que des opérations de données simples et des opérations de déplacement pour terminer l'encodage et le décodage. On peut dire que les performances globales de Protobuf sont très bonnes.

4. Résumé

Qu'il s'agisse de transmission réseau ou de données persistantes sur disque, nous devons encoder les données en bytecodes, et les données que nous utilisons habituellement dans le programme sont des types de données ou des objets basés sur la mémoire, nous devons convertir ces données en bytecodes via l'encodage du flux d'octets binaires. ; s'il doit être reçu ou réutilisé, il doit être décodé pour convertir le flux d'octets binaires en données mémoire. Nous appelons généralement ces deux processus sérialisation et désérialisation.

La sérialisation par défaut de Java est implémentée via l'interface Serialisable. Tant que la classe implémente l'interface et génère un numéro de version par défaut, la classe implémentera automatiquement la sérialisation et la désérialisation sans réglage manuel.

Bien que la sérialisation par défaut de Java soit pratique à implémenter, elle présente des défauts tels que des failles de sécurité, une absence de langage multilingue et des performances médiocres. Je vous recommande donc fortement d'éviter d'utiliser la sérialisation Java.

En regardant les cadres de sérialisation traditionnels, FastJson, Protobuf et Kryo sont assez distinctifs, et leurs performances et leur sécurité ont été reconnues par l'industrie.Nous pouvons combiner nos propres activités pour choisir un cadre de sérialisation approprié afin d'optimiser la séquence des performances du système.

5. Questions de réflexion

Il s'agit d'une classe implémentée à l'aide du modèle singleton, si nous implémentons l'interface sérialisable de Java sur cette classe, est-ce toujours un singleton ? Si vous deviez écrire un singleton implémentant l'interface sérialisable de Java, comment l'écririez-vous ?

public class Singleton implements Serializable{
 
    private final static Singleton singleInstance = new Singleton();
 
    private Singleton(){}
 
    public static Singleton getInstance(){
       return singleInstance; 
    }
}

Je suppose que tu aimes

Origine blog.csdn.net/qq_34272760/article/details/132345202
conseillé
Classement