Natürlich kenne ich Formate wie xml, json, bson, yaml, protobuf, Thrift, ASN.1. Ich habe sogar einen exotischen Baum gefunden, der selbst ein Killer von JSON, XML, YAML und anderen wie ihnen ist .
Warum passten sie nicht alle? Warum musste ich einen anderen Serializer schreiben?
Nach der Veröffentlichung des Artikels in den Kommentaren gaben sie mehrere Links zu den Formaten CBOR , UBJSON und MessagePack , die ich verpasst hatte . Und sie werden wahrscheinlich mein Problem lösen, ohne ein Fahrrad zu schreiben.
Es ist eine Schande, dass ich diese Spezifikationen nicht früher finden konnte, deshalb werde ich diesen Absatz für die Leser und für meine eigene Erinnerung hinzufügen, nicht zu eilen, um Code zu schreiben ;-).
Rezensionen von Formaten auf Habré: CBOR , UBJSON
Grundvoraussetzungen
Stellen Sie sich vor, Sie müssen ein verteiltes System ändern, das aus mehreren hundert Geräten unterschiedlichen Typs besteht (mehr als zehn Gerätetypen mit unterschiedlichen Funktionen). Sie werden zu Gruppen zusammengefasst, die über serielle Kommunikationsleitungen unter Verwendung des Modbus RTU-Protokolls Daten miteinander austauschen.
Einige dieser Geräte sind außerdem an eine gemeinsame CAN-Kommunikationsleitung angeschlossen, die die Datenübertragung innerhalb des gesamten Systems ermöglicht. Die Datenübertragungsrate auf der Modbus-Kommunikationsleitung beträgt bis zu 115200 Baud, und die Geschwindigkeit auf dem CAN-Bus ist aufgrund seiner Länge und des Vorhandenseins schwerwiegender industrieller Störungen auf die Geschwindigkeit von bis zu 50 kBaud begrenzt.
Die überwiegende Mehrheit der Geräte ist auf Mikrocontrollern der Serien STM32F1x und STM32F2x ausgelegt. Obwohl einige von ihnen auch auf STM32F4x funktionieren. Und natürlich Windows / Linux-basierte Systeme mit x86-Mikroprozessoren als Top-Level-Controller.
So schätzen Sie die Datenmenge, die zwischen Geräten verarbeitet und übertragen oder als Einstellungen / Betriebsparameter gespeichert wird: In einem Fall - 2 Nummern von 1 Byte und 6 Nummern von 4 Bytes, in dem anderen - 11 Nummern von 1 Byte und 1 Nummer von 4 Bytes und etc. Als Referenz beträgt die Datengröße in einem Standard-CAN-Frame bis zu 8 Byte und in einem Modbus-Frame bis zu 252 Byte Nutzlast.
Wenn Sie die Tiefe des Kaninchenbaues noch nicht durchdrungen haben, fügen Sie diesen Eingabedaten hinzu: die Notwendigkeit, Protokollversionen und Firmware-Versionen für verschiedene Gerätetypen im Auge zu behalten, sowie die Anforderung, die Kompatibilität nicht nur mit derzeit vorhandenen Datenformaten aufrechtzuerhalten, sondern auch die Verbindung sicherzustellen Arbeit von Geräten mit zukünftigen Generationen, die ebenfalls nicht stillstehen und sich ständig weiterentwickeln und überarbeiten, während sich die Funktionalität entwickelt und Pfosten in Implementierungen gefunden werden. Plus, Interaktion mit externen Systemen, Erweiterung der Anforderungen usw.
Aufgrund begrenzter Ressourcen und geringer Geschwindigkeit der Kommunikationsleitungen wurde zunächst ein Binärformat für den Datenaustausch verwendet, das nur an Modbus-Register gebunden war. Eine solche Implementierung hat jedoch den ersten Test auf Kompatibilität und Erweiterbarkeit nicht bestanden.
Daher musste bei der Neugestaltung der Architektur auf die Verwendung von Standard-Modbus-Registern verzichtet werden. Und nicht einmal, weil zusätzlich zu diesem Protokoll andere Kommunikationsleitungen verwendet werden, sondern weil die Organisation von Datenstrukturen auf Basis von 16-Bit-Registern übermäßig eingeschränkt ist.
In der Tat kann es angesichts der unvermeidlichen Entwicklung des Systems in Zukunft erforderlich sein (und tatsächlich war dies bereits erforderlich), Textzeichenfolgen oder Arrays zu übertragen. Theoretisch können sie auch auf der Modbus-Registerkarte angezeigt werden, dies stellt sich jedoch als Öl heraus, weil kommt Abstraktion über Abstraktion.
Natürlich können Sie Daten als binären Blob mit Bezug auf die Protokollversion und den Blocktyp übertragen. Und obwohl diese Idee auf den ersten Blick vernünftig erscheint, können Sie durch das Festlegen bestimmter Anforderungen an die Architektur Datenformate ein für alle Mal definieren und so die Overhead-Kosten erheblich sparen, die bei der Verwendung von Formaten wie XML oder JSON unvermeidlich sind.
Um den Vergleich der Optionen zu vereinfachen, habe ich die folgende Tabelle für mich erstellt:
Übertragung von Binärdaten ohne Feldidentifikation:
Vorteile:
:
:
:
:
Vorteile:
- . , .
:
- , .
- . , .
- . , , . , .
- , .
:
:
- .
:
- . , .
- , , .
Und stellen Sie sich vor, wie mehrere hundert Geräte anfangen, Binärdaten miteinander auszutauschen, selbst wenn jede Nachricht an die Protokollversion und / oder den Gerätetyp gebunden ist. Dann wird die Notwendigkeit, einen Serializer mit benannten Feldern zu verwenden, sofort offensichtlich. Schließlich zwingt Sie selbst eine einfache Interpolation der Komplexität der Unterstützung einer solchen Lösung als Ganzes, wenn auch nach sehr kurzer Zeit, dazu, sich den Kopf zu schnappen.
Und dies, auch ohne Berücksichtigung der erwarteten Wünsche des Kunden, die Funktionalität zu erhöhen, das Vorhandensein von obligatorischen Pfosten in der Implementierung und "geringfügigen" Verbesserungen auf den ersten Blick, die sicherlich eine besondere Pikantheit bei der Suche nach wiederkehrenden Pfosten in der gut koordinierten Arbeit eines solchen Zoos mit sich bringen werden ...
Was sind die Möglichkeiten?
Nach solchen Überlegungen kommen Sie unwillkürlich zu dem Schluss, dass es von Anfang an erforderlich ist, eine universelle Identifikation von Binärdaten zu erstellen, auch beim Austausch von Paketen über langsame Kommunikationsleitungen.
Und als ich zu dem Schluss kam, dass man auf einen Serializer nicht verzichten kann, habe ich mir zunächst die vorhandenen Lösungen angesehen, die sich bereits von der besten Seite bewährt haben und die bereits in vielen Projekten eingesetzt werden.
Die Grundformate xml, json, yaml und andere Textvarianten mit einer sehr praktischen und einfachen formalen Syntax, die sich gut für die Verarbeitung von Dokumenten und gleichzeitig für das Lesen und Bearbeiten durch Menschen eignet, mussten sofort gelöscht werden. Und nur wegen ihrer Bequemlichkeit und Einfachheit haben sie einen sehr großen Overhead beim Speichern von Binärdaten, die nur verarbeitet werden mussten.
Angesichts begrenzter Ressourcen und langsamer Kommunikationsleitungen wurde daher beschlossen, ein binäres Datenpräsentationsformat zu verwenden. Aber selbst bei Formaten, die Daten in eine binäre Darstellung konvertieren können, wie z. B. Protokollpuffer, FlatBuffers, ASN.1 oder Apache Thrift, haben der Aufwand für die Serialisierung von Daten sowie die allgemeine Benutzerfreundlichkeit nicht zur sofortigen Implementierung einer dieser Bibliotheken beigetragen.
Das BSON-Format mit minimalem Overhead passte am besten zu den Parametern. Und ich habe ernsthaft darüber nachgedacht, es zu benutzen. Infolgedessen entschied er sich dennoch, es aufzugeben, da alle anderen Dinge gleich sind und sogar BSON inakzeptable Gemeinkosten haben wird.
Für manche mag es seltsam erscheinen, dass Sie sich um ein Dutzend zusätzliche Bytes sorgen müssen, aber leider müssen diese Dutzend Bytes jedes Mal übertragen werden, wenn eine Nachricht gesendet wird. Und bei der Arbeit an langsamen Kommunikationsleitungen sind sogar zusätzliche zehn Bytes in jedem Paket wichtig.
Mit anderen Worten, wenn Sie mit zehn Bytes arbeiten, beginnen Sie, jedes von ihnen zu zählen. Neben den Daten werden auch Geräteadressen, Paketprüfsummen und andere Informationen, die für jede Kommunikationsleitung und jedes Protokoll spezifisch sind, an das Netzwerk übertragen.
Was ist passiert
Als Ergebnis von Überlegungen und mehreren Experimenten wurde ein Serializer mit den folgenden Merkmalen und Eigenschaften erhalten:
- Overhead für Daten fester Größe - 1 Byte (ohne die Länge des Datenfeldnamens).
- , , — 2 ( ). , CAN Modbus, .
- — 16 .
- , , .. . , 16 .
- (, ) — 252 (.. ).
- — .
- . .
- « », , . , , - ( 0xFF).
- . , . .
- , . .
- 8 64 .
- .
- ( ).
- — . , , . ;-)
- . , .
Ich möchte separat vermerken
Die Implementierung erfolgt in C ++ x11 in einer einzelnen Header-Datei unter Verwendung des SFINAE-Vorlagenmechanismus (Substitutionsfehler ist kein Fehler).
Unterstützt durch das korrekte Lesen der Daten im Puffer (Variable) b Etwa größer als der gespeicherte Datentyp. Beispielsweise kann eine Ganzzahl von 8 Bit in eine Variable von 8 bis 64 Bit eingelesen werden. Ich denke, vielleicht lohnt es sich, eine Packung Ganzzahlen hinzuzufügen, deren Größe 8 Bit überschreitet, damit sie in einer kleineren Anzahl übertragen werden können.
Serialisierte Arrays können sowohl durch Kopieren in den angegebenen Speicherbereich als auch durch Abrufen eines normalen Verweises auf die Daten im Originalpuffer gelesen werden, wenn Sie das Kopieren vermeiden möchten, falls dies nicht erforderlich ist. Diese Funktion sollte jedoch mit Vorsicht verwendet werden, da Arrays von Ganzzahlen werden in der Reihenfolge der Netzwerkbytes gespeichert, die sich zwischen den Maschinen unterscheiden kann.
Die Serialisierung von Strukturen oder komplexeren Objekten war nicht einmal geplant. Es ist im Allgemeinen gefährlich, Strukturen in binärer Form zu übertragen, da ihre Felder möglicherweise ausgerichtet sind. Wenn dieses Problem jedoch auf relativ einfache Weise gelöst wird, besteht immer noch das Problem, alle Felder von Objekten, die Ganzzahlen enthalten, in die Reihenfolge der Netzwerkbytes und zurück zu konvertieren.
Darüber hinaus können Strukturen im Notfall immer als Array von Bytes gespeichert und wiederhergestellt werden. In diesem Fall muss die Konvertierung von Ganzzahlen natürlich manuell erfolgen.
Implementierung
Die Implementierung finden Sie hier: https://github.com/rsashka/microprop Die
Verwendung wird in Beispielen mit unterschiedlichem Detaillierungsgrad beschrieben:
Schnelle Verwendung
#include "microprop.h"
Microprop prop(buffer, sizeof (buffer));//
prop.FieldExist(string || integer); // ID
prop.FieldType(string || integer); //
prop.Append(string || integer, value); //
prop.Read(string || integer, value); //
Langsamer und nachdenklicher Gebrauch
#include "microprop.h"
Microprop prop(buffer, sizeof (buffer)); //
prop.AssignBuffer(buffer, sizeof (buffer)); //
prop.AssignBuffer((const)buffer, sizeof (buffer)); // read only
prop.AssignBuffer(buffer, sizeof (buffer), true); // read only
prop.FieldNext(ptr); //
prop.FieldName(string || integer, size_t *length = nullptr); // ID
prop.FieldDataSize(string || integer); //
//
prop.Append(string || blob || integer, value || array);
prop.Read(string || blob || integer, value || array);
prop.Append(string || blob || integer, uint8_t *, size_t);
prop.Read(string || blob || integer, uint8_t *, size_t);
prop.AppendAsString(string || blob || integer, string);
const char * ReadAsString(string || blob || integer);
Beispielimplementierung mit enum als Datenkennung
class Property : public Microprop {
public:
enum ID {
ID1, ID2, ID3
};
template <typename ... Types>
inline const uint8_t * FieldExist(ID id, Types ... arg) {
return Microprop::FieldExist((uint8_t) id, arg...);
}
template <typename ... Types>
inline size_t Append(ID id, Types ... arg) {
return Microprop::Append((uint8_t) id, arg...);
}
template <typename T>
inline size_t Read(ID id, T & val) {
return Microprop::Read((uint8_t) id, val);
}
inline size_t Read(ID id, uint8_t *data, size_t size) {
return Microprop::Read((uint8_t) id, data, size);
}
template <typename ... Types>
inline size_t AppendAsString(ID id, Types ... arg) {
return Microprop::AppendAsString((uint8_t) id, arg...);
}
template <typename ... Types>
inline const char * ReadAsString(ID id, Types... arg) {
return Microprop::ReadAsString((uint8_t) id, arg...);
}
};
Der Code wird unter der MIT-Lizenz veröffentlicht. Verwenden Sie ihn daher für die Gesundheit.
Ich freue mich über Feedback, einschließlich Kommentare und / oder Vorschläge.
Update: Ich habe mich nicht geirrt, ein Bild für den Artikel zu wählen ;-)