Revisione del progetto di serializzazione C ++


9

Sto scrivendo un'applicazione C ++. La maggior parte delle applicazioni legge e scrive citazioni di dati necessarie e questa non fa eccezione. Ho creato un design di alto livello per il modello di dati e la logica di serializzazione. Questa domanda richiede una revisione del mio progetto tenendo presenti questi obiettivi specifici:

  • Per avere un modo semplice e flessibile di leggere e scrivere modelli di dati in formati arbitrari: binario grezzo, XML, JSON, et. al. Il formato dei dati dovrebbe essere disaccoppiato dai dati stessi e dal codice che richiede la serializzazione.

  • Garantire che la serializzazione sia il più possibile priva di errori. L'I / O è intrinsecamente rischioso per una serie di motivi: il mio progetto introduce più modi per fallire? In tal caso, come potrei riformattare il progetto per mitigare tali rischi?

  • Questo progetto utilizza C ++. Che tu lo ami o lo odi, il linguaggio ha il suo modo di fare le cose e il design mira a lavorare con il linguaggio, non contro di esso .

  • Infine, il progetto è basato su wxWidgets . Mentre sto cercando una soluzione applicabile a un caso più generale, questa specifica implementazione dovrebbe funzionare bene con quel toolkit.

Quello che segue è un insieme molto semplice di classi scritte in C ++ che illustrano il design. Queste non sono le classi reali che ho parzialmente scritto finora, questo codice illustra semplicemente il design che sto usando.


Innanzitutto, alcuni DAO di esempio:

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

Successivamente, definisco le classi virtuali (interfacce) pure per la lettura e la scrittura di DAO. L'idea è quella di astrarre la serializzazione dei dati dai dati stessi ( 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;
};

Infine, ecco il codice che ottiene il lettore / scrittore appropriato per il tipo di I / O desiderato. Ci sarebbero anche sottoclassi di lettori / scrittori definiti, ma questi non aggiungono nulla alla revisione del design:

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

Per gli obiettivi dichiarati del mio progetto, ho una preoccupazione specifica. I flussi C ++ possono essere aperti in modalità testo o binaria, ma non è possibile controllare un flusso già aperto. Potrebbe essere possibile tramite un errore del programmatore fornire ad esempio un flusso binario a un lettore / scrittore XML o JSON. Ciò potrebbe causare errori sottili (o non così sottili). Preferirei che il codice fallisse rapidamente, ma non sono sicuro che questo progetto lo farebbe.

Un modo per aggirare questo problema potrebbe essere quello di scaricare la responsabilità di aprire lo stream al lettore o allo scrittore, ma credo che violi SRP e renderebbe il codice più complesso. Quando si scrive un DAO, lo scrittore non dovrebbe preoccuparsi di dove sta andando lo stream: potrebbe essere un file, standard out, una risposta HTTP, un socket, qualsiasi cosa. Una volta che questa preoccupazione è incapsulata nella logica della serializzazione, diventa molto più complessa: deve conoscere il tipo specifico di flusso e quale costruttore chiamare.

A parte questa opzione, non sono sicuro di quale sarebbe un modo migliore per modellare questi oggetti che è semplice, flessibile e aiuta a prevenire errori logici nel codice che lo utilizza.


Il caso d'uso con cui la soluzione deve essere integrata è una semplice finestra di dialogo per la selezione dei file . L'utente seleziona "Apri ..." o "Salva con nome ..." dal menu File e il programma apre o salva WidgetDatabase. Ci saranno anche le opzioni "Importa ..." e "Esporta ..." per i singoli Widget.

Quando l'utente seleziona un file da aprire o salvare, wxWidgets restituirà un nome file. Il gestore che risponde a quell'evento deve essere un codice generico che accetta il nome del file, acquisisce un serializzatore e chiama una funzione per eseguire il sollevamento di carichi pesanti. Idealmente, questo progetto funzionerebbe anche se un altro pezzo di codice eseguisse operazioni di I / O senza file, come l'invio di un WidgetDatabase a un dispositivo mobile tramite un socket.


Un widget viene salvato nel suo formato? Interagisce con i formati esistenti? Sì! Tutti i precedenti. Tornando alla finestra di dialogo del file, pensa a Microsoft Word. Microsoft era libera di sviluppare il formato DOCX, tuttavia desiderava entro determinati limiti. Allo stesso tempo, Word legge o scrive anche formati legacy e di terze parti (ad es. PDF). Questo programma non è diverso: il formato "binario" di cui parlo è un formato interno ancora da definire progettato per la velocità. Allo stesso tempo, deve essere in grado di leggere e scrivere formati standard aperti nel suo dominio (irrilevante per la domanda) in modo da poter lavorare con altri software.

Infine, esiste un solo tipo di widget. Avrà oggetti figlio, ma questi saranno gestiti da questa logica di serializzazione. Il programma non caricherà mai sia Widget che Pignoni. Questo design unico ha bisogno di essere interessati con i widget e WidgetDatabases.


1
Hai preso in considerazione l'utilizzo della libreria di serializzazione Boost per questo? Incorpora tutti gli obiettivi di progettazione che hai.
Bart van Ingen Schenau,

1
@BartvanIngenSchenau non l'ho avuto, principalmente a causa della relazione amore / odio che ho con Boost. Penso che in questo caso alcuni dei formati che devo supportare potrebbero essere più complessi di quelli che Boost Serialization è in grado di gestire senza aggiungere abbastanza complessità che il suo utilizzo non mi compra molto.

Ah! Quindi non stai (de-) serializzando le istanze dei widget (sarebbe strano ...), ma questi widget devono solo leggere e scrivere dati strutturati? Devi implementare formati di file esistenti o sei libero di definire un formato ad hoc? Widget diversi utilizzano formati comuni o simili che potrebbero essere implementati come modello comune? È quindi possibile eseguire un'interfaccia utente - logica di dominio - modello - DAL anziché dividere tutto insieme come un oggetto dio WxWidget. In realtà, non vedo perché i widget siano rilevanti qui.
amon

@amon Ho modificato di nuovo la domanda. wxWidgets è rilevante solo per quanto riguarda l'interfaccia con l'utente: i widget di cui parlo non hanno nulla a che fare con il framework wxWidgets (cioè nessun oggetto god). Uso semplicemente quel termine come nome generico per un tipo di DAO.

1
@LarsViklund fai un argomento convincente e hai cambiato la mia opinione in merito. Ho aggiornato il codice di esempio.

Risposte:


7

Potrei sbagliarmi, ma il tuo design sembra essere orribilmente riprogettato. Per serializzare un solo Widget, si vuole definire WidgetReader, WidgetWriter, WidgetDatabaseReader, WidgetDatabaseWriterle interfacce che ogni hanno implementazioni per XML, JSON, e codifiche binarie, e una fabbrica per legare tutte le classi insieme. Questo è problematico per i seguenti motivi:

  • Se voglio serializzare un non Widgetdi classe, chiamiamola così Foo, devo reimplementare l'intero zoo di classi, e di creare FooReader, FooWriter, FooDatabaseReader, FooDatabaseWriterinterfacce, tre volte per ogni formato di serializzazione, oltre a una fabbrica per renderlo anche solo lontanamente utilizzabile. Non dirmi che non ci sarà nessun copia e incolla lì! Questa esplosione combinatoria sembra essere abbastanza irraggiungibile, anche se ciascuna di queste classi contiene essenzialmente un solo metodo.

  • Widgetnon può essere ragionevolmente incapsulato. O si apre a tutto ciò che dovrebbe essere serializzato al mondo aperto con metodi getter, o si deve friendogni WidgetWriter(e probabilmente anche tutti WidgetReader) implementazioni. In entrambi i casi, si introdurrà un notevole accoppiamento tra le implementazioni di serializzazione e il Widget.

  • Lo zoo lettore / scrittore invita incoerenze. Ogni volta che aggiungi un membro a Widget, dovrai aggiornare tutte le classi di serializzazione correlate per archiviare / recuperare quel membro. Questo è qualcosa che non può essere verificato staticamente per correttezza, quindi dovrai anche scrivere un test separato per ogni lettore e scrittore. Al tuo progetto attuale, sono 4 * 3 = 12 test per classe che vuoi serializzare.

    Nella direzione opposta, anche l'aggiunta di un nuovo formato di serializzazione come YAML è problematica. Per ogni classe che vuoi serializzare, dovrai ricordarti di aggiungere un lettore e uno scrittore YAML e aggiungere quel caso all'enum e alla fabbrica. Ancora una volta, questo è qualcosa che non può essere testato staticamente, a meno che non diventi (troppo) intelligente e crei un'interfaccia basata su modelli per fabbriche indipendenti Widgete assicurati che sia fornita un'implementazione per ciascun tipo di serializzazione per ogni operazione di entrata / uscita.

  • Forse Widgetora soddisfa l'SRP poiché non è responsabile della serializzazione. Ma le implementazioni del lettore e dello scrittore chiaramente no, con l'interpretazione "SRP = ogni oggetto ha un motivo per cambiare": le implementazioni devono cambiare quando cambia il formato di serializzazione o quando Widgetcambiano.

Se sei in grado di investire un minimo di tempo in anticipo, prova a elaborare un framework di serializzazione più generico rispetto a questo groviglio di classi ad hoc. Ad esempio, potresti definire una rappresentazione di interscambio comune, chiamiamola così SerializationInfo, con un modello a oggetti simile a JavaScript: la maggior parte degli oggetti può essere vista come std::map<std::string, SerializationInfo>, o come std::vector<SerializationInfo>, o come primitiva come int.

Per ciascun formato di serializzazione, avresti quindi una classe che gestisce la lettura e la scrittura di una rappresentazione di serializzazione da quel flusso. E per ogni classe che si desidera serializzare, si avrebbe un meccanismo che converte le istanze da / nella rappresentazione di serializzazione.

Ho sperimentato un tale progetto con cxxtools ( homepage , GitHub , demo di serializzazione ) ed è per lo più estremamente intuitivo, ampiamente applicabile e soddisfacente per i miei casi d'uso - gli unici problemi sono il modello a oggetti abbastanza debole della rappresentazione di serializzazione che richiede te sapere durante la deserializzazione esattamente quale tipo di oggetto ti aspetti e che la deserializzazione implica oggetti costruibili per difetto che possono essere inizializzati in seguito. Ecco un esempio di utilizzo forzato:

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

Non sto dicendo che dovresti usare cxxtools o copiare esattamente quel design, ma nella mia esperienza il suo design rende banale aggiungere la serializzazione anche per piccole classi una tantum, a condizione che non ti interessi troppo del formato di serializzazione ( ad es. l'output XML predefinito utilizzerà i nomi dei membri come nomi di elementi e non utilizzerà mai gli attributi per i tuoi dati).

Il problema con la modalità binaria / di testo per i flussi non sembra risolvibile, ma non è poi così grave. Per prima cosa, importa solo per i formati binari, su piattaforme per le quali non tendo a programmare ;-) Più seriamente, è una limitazione della tua infrastruttura di serializzazione che dovrai solo documentare e sperare che tutti usino correttamente. Aprire i flussi all'interno dei tuoi lettori o scrittori è troppo poco flessibile e C ++ non ha un meccanismo a livello di tipo incorporato per distinguere il testo dai dati binari.


Come cambierebbe il tuo consiglio dato che questi DAO sono già una classe di "informazioni di serializzazione"? Questi sono l'equivalente C ++ dei POJO . Ho intenzione di modificare anche la mia domanda con qualche informazione in più su come verranno utilizzati questi oggetti.
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.