Überprüfung des C ++ - Serialisierungsdesigns

9

Ich schreibe eine C ++ - Anwendung. Die meisten Anwendungen lesen und schreiben Daten , und dies ist keine Ausnahme. Ich habe ein übergeordnetes Design für das Datenmodell und die Serialisierungslogik erstellt. Diese Frage erfordert eine Überprüfung meines Designs unter Berücksichtigung dieser spezifischen Ziele:

  • Einfache und flexible Möglichkeit zum Lesen und Schreiben von Datenmodellen in beliebigen Formaten: Raw Binary, XML, JSON, et. al. Das Datenformat sollte von den Daten selbst sowie dem Code, der die Serialisierung anfordert, entkoppelt werden.

  • Um sicherzustellen, dass die Serialisierung so fehlerfrei wie möglich ist. E / A ist aus verschiedenen Gründen von Natur aus riskant: Führt mein Design mehr Möglichkeiten zum Fehlschlagen ein? Wenn ja, wie könnte ich das Design umgestalten, um diese Risiken zu minimieren?

  • Dieses Projekt verwendet C ++. Ob Sie es lieben oder hassen, die Sprache hat ihre eigene Art, Dinge zu tun, und das Design zielt darauf ab, mit der Sprache zu arbeiten, nicht dagegen .

  • Schließlich baut das Projekt auf wxWidgets auf . Während ich nach einer Lösung suche, die auf einen allgemeineren Fall anwendbar ist, sollte diese spezifische Implementierung mit diesem Toolkit gut funktionieren.

Was folgt, ist eine sehr einfache Reihe von in C ++ geschriebenen Klassen, die das Design veranschaulichen. Dies sind nicht die tatsächlichen Klassen, die ich bisher teilweise geschrieben habe. Dieser Code veranschaulicht lediglich das von mir verwendete Design.


Zunächst einige Beispiel-DAOs:

#include <iostream>
#include <map>
#include <memory>
#include <string>
#include <vector>

// One widget represents one record in the application.
class Widget {
public:
  using id_type = int;
private:
  id_type id;
};

// Container for widgets. Much more than a dumb container,
// it will also have indexes and other metadata. This represents
// one data file the user may open in the application.
class WidgetDatabase {
  ::std::map<Widget::id_type, ::std::shared_ptr<Widget>> widgets;
};

Als nächstes definiere ich reine virtuelle Klassen (Schnittstellen) zum Lesen und Schreiben von DAOs. Die Idee ist, die Serialisierung von Daten von den Daten selbst ( SRP ) zu abstrahieren .

class WidgetReader {
public:
  virtual Widget read(::std::istream &in) const abstract;
};

class WidgetWriter {
public:
  virtual void write(::std::ostream &out, const Widget &widget) const abstract;
};

class WidgetDatabaseReader {
public:
  virtual WidgetDatabase read(::std::istream &in) const abstract;
};

class WidgetDatabaseWriter {
public:
  virtual void write(::std::ostream &out, const WidgetDatabase &widgetDb) const abstract;
};

Schließlich ist hier der Code, der den richtigen Leser / Schreiber für den gewünschten E / A-Typ erhält. Es würden auch Unterklassen der Leser / Autoren definiert, aber diese tragen nichts zur Entwurfsprüfung bei:

enum class WidgetIoType {
  BINARY,
  JSON,
  XML
  // Other types TBD.
};

WidgetIoType forFilename(::std::string &name) { return ...; }

class WidgetIoFactory {
public:
  static ::std::unique_ptr<WidgetReader> getWidgetReader(const WidgetIoType &type) {
    return ::std::unique_ptr<WidgetReader>(/* TODO */);
  }

  static ::std::unique_ptr<WidgetWriter> getWidgetWriter(const WidgetIoType &type) {
    return ::std::unique_ptr<WidgetWriter>(/* TODO */);
  }

  static ::std::unique_ptr<WidgetDatabaseReader> getWidgetDatabaseReader(const WidgetIoType &type) {
    return ::std::unique_ptr<WidgetDatabaseReader>(/* TODO */);
  }

  static ::std::unique_ptr<WidgetDatabaseWriter> getWidgetDatabaseWriter(const WidgetIoType &type) {
    return ::std::unique_ptr<WidgetDatabaseWriter>(/* TODO */);
  }
};

Gemäß den angegebenen Zielen meines Entwurfs habe ich ein spezifisches Anliegen. C ++ - Streams können im Text- oder Binärmodus geöffnet werden, es gibt jedoch keine Möglichkeit, einen bereits geöffneten Stream zu überprüfen. Es könnte durch einen Programmiererfehler möglich sein, einem XML- oder JSON-Leser / -Schreiber beispielsweise einen Binärstrom bereitzustellen. Dies kann zu subtilen (oder nicht so subtilen) Fehlern führen. Ich würde es vorziehen, wenn der Code schnell ausfällt, aber ich bin mir nicht sicher, ob dieses Design dies tun würde.

Eine Möglichkeit, dies zu umgehen, könnte darin bestehen, die Verantwortung für das Öffnen des Streams für den Leser oder Schreiber zu verlagern, aber ich glaube, dass dies gegen SRP verstößt und den Code komplexer machen würde. Beim Schreiben eines DAO sollte sich der Writer nicht darum kümmern, wohin der Stream geht: Es kann sich um eine Datei, einen Standardausgang, eine HTTP-Antwort, einen Socket oder etwas anderes handeln. Sobald dieses Problem in der Serialisierungslogik enthalten ist, wird es weitaus komplexer: Es muss den spezifischen Stream-Typ und den aufzurufenden Konstruktor kennen.

Abgesehen von dieser Option bin ich mir nicht sicher, wie diese Objekte besser modelliert werden können. Dies ist einfach, flexibel und hilft, Logikfehler in dem Code zu vermeiden, der sie verwendet.


Der Anwendungsfall, in den die Lösung integriert werden muss, ist ein einfaches Dialogfeld zur Dateiauswahl . Der Benutzer wählt "Öffnen ..." oder "Speichern unter ..." aus dem Menü "Datei" und das Programm öffnet oder speichert die WidgetDatabase. Es gibt auch die Optionen "Importieren ..." und "Exportieren ..." für einzelne Widgets.

Wenn der Benutzer eine Datei zum Öffnen oder Speichern auswählt, gibt wxWidgets einen Dateinamen zurück. Der Handler, der auf dieses Ereignis reagiert, muss ein Allzweckcode sein, der den Dateinamen verwendet, einen Serializer abruft und eine Funktion aufruft, um das schwere Heben durchzuführen. Idealerweise funktioniert dieses Design auch, wenn ein anderer Code Nicht-Datei-E / A ausführt, z. B. das Senden einer WidgetDatabase über einen Socket an ein mobiles Gerät.


Speichert ein Widget in einem eigenen Format? Funktioniert es mit vorhandenen Formaten? Ja! Alles das oben Genannte. Denken Sie beim Zurückkehren zum Dateidialog an Microsoft Word. Microsoft konnte das DOCX-Format unter bestimmten Bedingungen nach Belieben entwickeln. Gleichzeitig liest oder schreibt Word auch Legacy- und Drittanbieterformate (z. B. PDF). Dieses Programm ist nicht anders: Das "binäre" Format, über das ich spreche, ist ein noch zu definierendes internes Format, das auf Geschwindigkeit ausgelegt ist. Gleichzeitig muss es in der Lage sein, offene Standardformate in seiner Domäne zu lesen und zu schreiben (für die Frage irrelevant), damit es mit anderer Software arbeiten kann.

Schließlich gibt es nur einen Widget-Typ. Es werden untergeordnete Objekte vorhanden sein, die jedoch von dieser Serialisierungslogik verarbeitet werden. Das Programm lädt niemals sowohl Widgets als auch Kettenräder. Dieser Entwurf nur muss mit Widgets und WidgetDatabases zur Beunruhigung.

Gemeinschaft
quelle
1
Haben Sie darüber nachgedacht, die Boost-Serialisierungsbibliothek dafür zu verwenden? Es enthält alle Designziele, die Sie haben.
Bart van Ingen Schenau
1
@BartvanIngenSchenau hatte ich nicht, hauptsächlich wegen der Hassliebe, die ich zu Boost habe. Ich denke, in diesem Fall sind einige der Formate, die ich unterstützen muss, möglicherweise komplexer, als Boost Serialization verarbeiten kann, ohne die Komplexität zu erhöhen, die mir nicht viel bringt, wenn ich sie verwende.
Ah! Sie serialisieren also keine Widget-Instanzen (das wäre seltsam…), aber diese Widgets müssen nur strukturierte Daten lesen und schreiben? Müssen Sie vorhandene Dateiformate implementieren oder können Sie ein Ad-hoc-Format definieren? Verwenden verschiedene Widgets gemeinsame oder ähnliche Formate, die als gemeinsames Modell implementiert werden könnten? Sie könnten dann eine Aufteilung von Benutzeroberfläche, Domänenlogik, Modell und DAL durchführen, anstatt alles als WxWidget-Gottobjekt zusammenzufassen. Tatsächlich verstehe ich nicht, warum Widgets hier relevant sind.
Amon
@amon Ich habe die Frage erneut bearbeitet. wxWidgets sind nur für die Benutzeroberfläche relevant: Die Widgets, über die ich spreche, haben nichts mit dem wxWidgets-Framework zu tun (dh kein Gott-Objekt). Ich benutze diesen Begriff nur als generischen Namen für eine Art DAO.
1
@LarsViklund Sie machen ein überzeugendes Argument und Sie haben meine Meinung zu diesem Thema geändert. Ich habe den Beispielcode aktualisiert.

Antworten:

7

Ich kann mich irren, aber Ihr Design scheint schrecklich überarbeitet zu sein. Serialisiert nur ein Widget, möchten Sie definieren WidgetReader, WidgetWriter, WidgetDatabaseReader, WidgetDatabaseWriterSchnittstellen , die jeweils Implementierungen für XML, JSON und binäre Codierungen und einer Fabrik alle diese Klassen zusammen zu binden. Dies ist aus folgenden Gründen problematisch:

  • Wenn ich eine nicht serialisiert werden wollen WidgetKlasse, nennen wir es Foo, ich habe diesen ganzen Zoo von Klassen neu zu implementieren, und erstellen FooReader, FooWriter, FooDatabaseReader, FooDatabaseWriterSchnittstellen, mal drei für jede Serialisierungsformat sowie eine Fabrik, um es auch nur entfernt verwendbar. Sag mir nicht, dass dort kein Kopieren und Einfügen stattfinden wird! Diese kombinatorische Explosion scheint ziemlich unerträglich zu sein, selbst wenn jede dieser Klassen im Wesentlichen nur eine einzige Methode enthält.

  • Widgetkann nicht angemessen gekapselt werden. Entweder öffnen Sie alles, was für die offene Welt serialisiert werden soll, mit Getter-Methoden, oder Sie müssen friendjede WidgetWriter(und wahrscheinlich auch alle WidgetReader) Implementierungen ausführen. In beiden Fällen führen Sie eine erhebliche Kopplung zwischen den Serialisierungsimplementierungen und dem ein Widget.

  • Der Lese- / Schreibzoo lädt zu Inkonsistenzen ein. Wenn Sie ein Mitglied hinzufügen Widget, müssen Sie alle zugehörigen Serialisierungsklassen aktualisieren, um dieses Mitglied zu speichern / abzurufen. Dies kann nicht statisch auf Richtigkeit überprüft werden, daher müssen Sie für jeden Leser und Schreiber einen separaten Test schreiben. Bei Ihrem aktuellen Design sind dies 4 * 3 = 12 Tests pro Klasse, die Sie serialisieren möchten.

    In der anderen Richtung ist das Hinzufügen eines neuen Serialisierungsformats wie YAML ebenfalls problematisch. Für jede Klasse, die Sie serialisieren möchten, müssen Sie daran denken, einen YAML-Leser und -Schreiber hinzuzufügen und diesen Fall der Aufzählung und der Factory hinzuzufügen. Auch dies kann nicht statisch getestet werden, es sei denn, Sie werden (zu) clever und erstellen eine Vorlagenschnittstelle für Fabriken, die unabhängig ist Widgetund sicherstellt, dass für jeden Serialisierungstyp eine Implementierung für jeden In / Out-Vorgang bereitgestellt wird.

  • Vielleicht Widgeterfüllt das jetzt die SRP, da es nicht für die Serialisierung verantwortlich ist. Die Lese- und Schreibimplementierungen tun dies jedoch eindeutig nicht. Bei der Interpretation „SRP = jedes Objekt hat einen Grund zur Änderung“ müssen sich die Implementierungen ändern, wenn sich entweder das Serialisierungsformat oder die WidgetÄnderungen ändern.

Wenn Sie in der Lage sind, ein Minimum an Zeit im Voraus zu investieren, versuchen Sie bitte, ein allgemeineres Serialisierungsframework als dieses Ad-hoc-Gewirr von Klassen zu erstellen. Sie können beispielsweise eine allgemeine Austauschdarstellung SerializationInfomit einem JavaScript-ähnlichen Objektmodell definieren: Die meisten Objekte können als std::map<std::string, SerializationInfo>oder oder als std::vector<SerializationInfo>oder als Grundelement wie z int.

Für jedes Serialisierungsformat hätten Sie dann eine Klasse, die das Lesen und Schreiben einer Serialisierungsdarstellung aus diesem Stream verwaltet. Und für jede Klasse, die Sie serialisieren möchten, gibt es einen Mechanismus, der Instanzen von / in die Serialisierungsdarstellung konvertiert.

Ich habe ein solches Design mit cxxtools ( Homepage , GitHub , Serialisierungsdemo ) erlebt und es ist größtenteils äußerst intuitiv, allgemein anwendbar und für meine Anwendungsfälle zufriedenstellend - die einzigen Probleme sind das ziemlich schwache Objektmodell der Serialisierungsdarstellung, das Sie benötigt Während der Deserialisierung genau zu wissen, welche Art von Objekt Sie erwarten, und diese Deserialisierung impliziert standardmäßig konstruierbare Objekte, die später initialisiert werden können. Hier ist ein erfundenes Anwendungsbeispiel:

class Point {
  int _x;
  int _y;
public:
  Point(x, y) : _x(x), _y(y) {}
  int x() const { return _x; }
  int y() const { return _y; }
};

void operator <<= (SerializationInfo& si, const Point& p) {
  si.addMember("x") <<= p.x();
  si.addMember("y") <<= p.y();
}

void operator >>= (const SerializationInfo& si, Point& p) {
  int x;
  si.getMember("x") >>= x;  // will throw if x entry not found
  int y;
  si.getMember("y") >>= y;
  p = Point(x, y);
}

int main() {
  // cxxtools::Json<T>(T&) wrapper sets up a SerializationInfo and manages Json I/O
  // wrappers for other formats also exist, e.g. cxxtools::Xml<T>(T&)

  Point a(42, -15);
  std::cout << cxxtools::Json(a);
  ...
  Point b(0, 0);
  std::cin >> cxxtools::Json(p);
}

Ich sage nicht, dass Sie cxxtools verwenden oder dieses Design genau kopieren sollten, aber meiner Erfahrung nach ist es aufgrund seines Designs trivial, die Serialisierung auch für kleine, einmalige Klassen hinzuzufügen, vorausgesetzt, Sie interessieren sich nicht zu sehr für das Serialisierungsformat ( Beispielsweise werden in der Standard-XML-Ausgabe Mitgliedsnamen als Elementnamen verwendet und niemals Attribute für Ihre Daten verwendet.

Das Problem mit dem Binär- / Textmodus für Streams scheint nicht lösbar zu sein, aber das ist nicht so schlimm. Zum einen ist es nur für Binärformate von Bedeutung, auf Plattformen, für die ich normalerweise nicht programmiere ;-) Im Ernst, es ist eine Einschränkung Ihrer Serialisierungsinfrastruktur, die Sie nur dokumentieren müssen und hoffen, dass jeder sie richtig verwendet. Das Öffnen der Streams in Ihren Lesern oder Schreibern ist viel zu unflexibel, und C ++ verfügt nicht über einen integrierten Mechanismus auf Typebene, um Text von Binärdaten zu unterscheiden.

amon
quelle
Wie würde sich Ihr Rat ändern, wenn diese DAOs im Grunde bereits eine "Serialization Info" -Klasse sind? Dies ist das C ++ - Äquivalent von POJOs . Ich werde meine Frage auch mit ein wenig mehr Informationen darüber bearbeiten, wie diese Objekte verwendet werden.