Revue de conception de sérialisation C ++

9

J'écris une application C ++. La plupart des applications lisent et écrivent la citation de données nécessaire et celle-ci ne fait pas exception. J'ai créé une conception de haut niveau pour le modèle de données et la logique de sérialisation. Cette question demande une révision de ma conception avec ces objectifs spécifiques à l'esprit:

  • Avoir un moyen simple et flexible de lire et d'écrire des modèles de données dans des formats arbitraires: binaire brut, XML, JSON, et. Al. Le format des données doit être découplé des données elles-mêmes ainsi que du code qui demande la sérialisation.

  • Pour garantir que la sérialisation est aussi exempte d'erreurs que raisonnablement possible. Les E / S sont intrinsèquement risquées pour diverses raisons: ma conception présente-t-elle davantage de façons d'échouer? Si oui, comment pourrais-je refactoriser la conception afin d'atténuer ces risques?

  • Ce projet utilise C ++. Que vous l'aimiez ou que vous le détestiez, la langue a sa propre façon de faire les choses et la conception vise à travailler avec la langue, pas contre elle .

  • Enfin, le projet est construit sur wxWidgets . Bien que je recherche une solution applicable à un cas plus général, cette implémentation spécifique devrait bien fonctionner avec cette boîte à outils.

Ce qui suit est un ensemble très simple de classes écrites en C ++ qui illustrent la conception. Ce ne sont pas les classes réelles que j'ai partiellement écrites jusqu'à présent, ce code illustre simplement la conception que j'utilise.


Tout d'abord, quelques exemples de DAO:

#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;
};

Ensuite, je définis des classes virtuelles pures (interfaces) pour lire et écrire des DAO. L'idée est d'abstraire la sérialisation des données des données elles-mêmes ( SRP ).

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;
};

Enfin, voici le code qui obtient le lecteur / enregistreur approprié pour le type d'E / S souhaité. Il y aurait des sous-classes de lecteurs / auteurs également définies, mais celles-ci n'ajoutent rien à la revue de conception:

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 */);
  }
};

Selon les objectifs énoncés de ma conception, j'ai une préoccupation particulière. Les flux C ++ peuvent être ouverts en mode texte ou binaire, mais il n'y a aucun moyen de vérifier un flux déjà ouvert. Il pourrait être possible, grâce à une erreur de programmation, de fournir, par exemple, un flux binaire à un lecteur / graveur XML ou JSON. Cela pourrait provoquer des erreurs subtiles (ou pas si subtiles). Je préférerais que le code échoue rapidement, mais je ne suis pas sûr que cette conception le ferait.

Un moyen de contourner cela pourrait être de décharger la responsabilité d'ouvrir le flux au lecteur ou à l'écrivain, mais je crois que cela viole SRP et rendrait le code plus complexe. Lors de l'écriture d'un DAO, l'auteur ne doit pas se soucier de la destination du flux: il peut s'agir d'un fichier, d'une sortie standard, d'une réponse HTTP, d'une socket, de n'importe quoi. Une fois que cette préoccupation est encapsulée dans la logique de sérialisation, elle devient beaucoup plus complexe: elle doit connaître le type spécifique de flux et le constructeur à appeler.

Mis à part cette option, je ne sais pas quelle serait une meilleure façon de modéliser ces objets qui est simple, flexible et aide à prévenir les erreurs logiques dans le code qui l'utilise.


Le cas d'utilisation avec lequel la solution doit être intégrée est une simple boîte de dialogue de sélection de fichiers . L'utilisateur sélectionne "Ouvrir ..." ou "Enregistrer sous ..." dans le menu Fichier, et le programme ouvre ou enregistre la WidgetDatabase. Il y aura également des options "Importer ..." et "Exporter ..." pour les widgets individuels.

Lorsque l'utilisateur sélectionne un fichier à ouvrir ou à enregistrer, wxWidgets renverra un nom de fichier. Le gestionnaire qui répond à cet événement doit être un code à usage général qui prend le nom de fichier, acquiert un sérialiseur et appelle une fonction pour faire le gros du travail. Idéalement, cette conception fonctionnerait également si un autre morceau de code effectue des E / S non-fichiers, comme l'envoi d'une WidgetDatabase à un appareil mobile via un socket.


Un widget enregistre-t-il dans son propre format? Interagit-il avec les formats existants? Oui! Tout ce qui précède. Pour revenir à la boîte de dialogue de fichier, pensez à Microsoft Word. Microsoft était libre de développer le format DOCX comme bon lui semblait dans certaines limites. Dans le même temps, Word lit ou écrit également des formats hérités et tiers (par exemple PDF). Ce programme n'est pas différent: le format "binaire" dont je parle est un format interne encore à définir conçu pour la vitesse. Dans le même temps, il doit être capable de lire et d'écrire des formats standard ouverts dans son domaine (sans rapport avec la question) afin de pouvoir travailler avec d'autres logiciels.

Enfin, il n'y a qu'un seul type de Widget. Il aura des objets enfants, mais ceux-ci seront gérés par cette logique de sérialisation. Le programme ne chargera jamais les widgets et les pignons. Cette conception ne doit concerner que les Widgets et les WidgetDatabases.

Communauté
la source
1
Avez-vous envisagé d'utiliser la bibliothèque Boost Serialization pour cela? Il intègre tous les objectifs de conception que vous avez.
Bart van Ingen Schenau
1
@BartvanIngenSchenau Je n'en avais pas, principalement à cause de la relation amour / haine que j'ai avec Boost. Je pense que dans ce cas, certains des formats que je dois prendre en charge pourraient être plus complexes que Boost Serialization ne peut gérer sans ajouter suffisamment de complexité pour que son utilisation ne m'achète pas beaucoup.
Ah! Vous n'êtes donc pas (dé-) sérialisant les instances de widget (ce serait bizarre…), mais ces widgets ont juste besoin de lire et d'écrire des données structurées? Devez-vous mettre en œuvre des formats de fichiers existants ou êtes-vous libre de définir un format ad hoc? Les différents widgets utilisent-ils des formats communs ou similaires qui pourraient être mis en œuvre en tant que modèle commun? Vous pouvez alors effectuer une division interface utilisateur – logique de domaine – modèle – DAL plutôt que de tout assembler en tant qu'objet divin WxWidget. En fait, je ne vois pas pourquoi les widgets sont pertinents ici.
amon
@amon J'ai de nouveau modifié la question. wxWidgets ne sont pertinents que pour l'interface avec l'utilisateur: les Widgets dont je parle n'ont rien à voir avec le framework wxWidgets (c'est-à-dire sans objet divin). J'utilise simplement ce terme comme nom générique pour un type de DAO.
1
@LarsViklund vous faites un argument convaincant et vous avez changé mon opinion sur la question. J'ai mis à jour l'exemple de code.

Réponses:

7

Je me trompe peut-être, mais votre conception semble horriblement surdimensionnée. Pour sérialiser un seul Widget, vous voulez définir WidgetReader, WidgetWriter, WidgetDatabaseReader, WidgetDatabaseWriterinterfaces qui ont chacune des implémentations pour XML, JSON, et codages binaires, et une usine de lier toutes ces classes ensemble. Cela pose problème pour les raisons suivantes:

  • Si je veux une sérialisation non Widgetclasse, Appelons - le Foo, je dois réimplémentez tout ce zoo de classes, et de créer FooReader, FooWriter, FooDatabaseReader, FooDatabaseWriterinterfaces, trois fois pour chaque format de sérialisation, en plus d' une usine pour le rendre encore utilisable à distance. Ne me dites pas qu'il n'y aura pas de copier-coller! Cette explosion combinatoire semble être assez impossible à maintenir, même si chacune de ces classes ne contient essentiellement qu'une seule méthode.

  • Widgetne peut pas être raisonnablement encapsulé. Soit vous ouvrez tout ce qui doit être sérialisé dans le monde ouvert avec des méthodes getter, soit vous avez friendtoutes et toutes WidgetWriter(et probablement aussi toutes WidgetReader) les implémentations. Dans les deux cas, vous introduirez un couplage considérable entre les implémentations de sérialisation et le Widget.

  • Le zoo du lecteur / écrivain invite les incohérences. Chaque fois que vous ajoutez un membre à Widget, vous devrez mettre à jour toutes les classes de sérialisation associées pour stocker / récupérer ce membre. Ceci est quelque chose qui ne peut pas être vérifié statiquement pour l'exactitude, vous devrez donc également écrire un test distinct pour chaque lecteur et écrivain. À votre conception actuelle, c'est 4 * 3 = 12 tests par classe que vous souhaitez sérialiser.

    Dans l'autre sens, l'ajout d'un nouveau format de sérialisation tel que YAML est également problématique. Pour chaque classe que vous souhaitez sérialiser, vous devez vous rappeler d'ajouter un lecteur et un écrivain YAML, et d'ajouter ce cas à l'énumération et à l'usine. Encore une fois, c'est quelque chose qui ne peut pas être testé statiquement, à moins que vous ne soyez (trop) intelligent et que vous élaboriez une interface basée sur des modèles pour les usines qui soit indépendante Widgetet s'assure qu'une implémentation pour chaque type de sérialisation pour chaque opération d'entrée / sortie est fournie.

  • Peut-être que Widgetmaintenant satisfait le SRP car il n'est pas responsable de la sérialisation. Mais les implémentations de lecteur et d'écrivain ne le sont clairement pas, avec l'interprétation «SRP = chaque objet a une raison de changer»: les implémentations doivent changer lorsque le format de sérialisation change ou lorsque les Widgetchangements.

Si vous êtes en mesure d'investir un minimum de temps à l'avance, veuillez essayer d'élaborer un cadre de sérialisation plus générique que cet enchevêtrement ad hoc de classes. Par exemple, vous pouvez définir une représentation d'échange commune, appelons-la SerializationInfo, avec un modèle d'objet de type JavaScript: la plupart des objets peuvent être vus comme un std::map<std::string, SerializationInfo>, ou comme un std::vector<SerializationInfo>, ou comme une primitive comme int.

Pour chaque format de sérialisation, vous auriez alors une classe qui gère la lecture et l'écriture d'une représentation de sérialisation à partir de ce flux. Et pour chaque classe que vous souhaitez sérialiser, vous auriez un mécanisme qui convertit les instances de / vers la représentation de sérialisation.

J'ai expérimenté une telle conception avec cxxtools ( page d'accueil , GitHub , démo de sérialisation ), et il est surtout extrêmement intuitif, largement applicable et satisfaisant pour mes cas d'utilisation - les seuls problèmes étant le modèle objet assez faible de la représentation de sérialisation qui vous nécessite savoir pendant la désérialisation quel type d'objet vous attendez et cette désérialisation implique des objets constructibles par défaut qui peuvent être initialisés ultérieurement. Voici un exemple d'utilisation artificielle:

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);
}

Je ne dis pas que vous devez utiliser cxxtools ou copier exactement cette conception, mais d'après mon expérience, sa conception rend trivial l'ajout de la sérialisation même pour de petites classes ponctuelles, à condition que vous ne vous souciez pas trop du format de sérialisation ( par exemple, la sortie XML par défaut utilisera les noms de membres comme noms d'éléments et n'utilisera jamais d'attributs pour vos données).

Le problème avec le mode binaire / texte pour les flux ne semble pas résolu, mais ce n'est pas si grave. D'une part, cela n'a d'importance que pour les formats binaires, sur les plates-formes pour lesquelles je n'ai pas tendance à programmer ;-) Plus sérieusement, c'est une restriction de votre infrastructure de sérialisation que vous aurez juste à documenter et à espérer que tout le monde utilise correctement. L'ouverture des flux au sein de vos lecteurs ou écrivains est beaucoup trop rigide et C ++ ne dispose pas d'un mécanisme de niveau type intégré pour distinguer le texte des données binaires.

amon
la source
Comment votre avis changerait-il étant donné que ces DAO sont déjà une classe "d'informations de sérialisation"? Ce sont les équivalents C ++ des POJO . Je vais également modifier ma question avec un peu plus d'informations sur la façon dont ces objets seront utilisés.