Przegląd projektu serializacji w C ++

9

Piszę aplikację C ++. Większość aplikacji odczytuje i zapisuje dane potrzebne do cytowania, a ten nie jest wyjątkiem. Stworzyłem projekt wysokiego poziomu dla modelu danych i logiki serializacji. To pytanie wymaga przeglądu mojego projektu z uwzględnieniem tych konkretnych celów:

  • Aby mieć łatwy i elastyczny sposób odczytywania i zapisywania modeli danych w dowolnych formatach: raw binary, XML, JSON i in. glin. Format danych należy oddzielić od samych danych, a także od kodu żądającego serializacji.

  • Aby zapewnić, że serializacja jest możliwie bezbłędna. We / wy jest z natury ryzykowne z różnych powodów: czy mój projekt wprowadza więcej sposobów na awarię? Jeśli tak, to w jaki sposób mogę zmienić projekt, aby ograniczyć to ryzyko?

  • Ten projekt używa C ++. Niezależnie od tego, czy go kochasz, czy nienawidzisz, język ma swój własny sposób robienia rzeczy, a projekt ma na celu współpracę z językiem, a nie przeciwko niemu .

  • Wreszcie projekt jest oparty na wxWidgets . Podczas gdy szukam rozwiązania dla bardziej ogólnego przypadku, ta konkretna implementacja powinna dobrze działać z tym zestawem narzędzi.

Poniżej znajduje się bardzo prosty zestaw klas napisanych w C ++, które ilustrują projekt. To nie są rzeczywiste klasy, które do tej pory napisałem częściowo, ten kod po prostu ilustruje projekt, którego używam.


Po pierwsze, niektóre przykładowe 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;
};

Następnie definiuję czyste wirtualne klasy (interfejsy) do odczytu i zapisu DAO. Chodzi o to, aby wyodrębnić serializację danych z samych danych ( 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;
};

Wreszcie, oto kod, który pobiera odpowiedni czytnik / moduł zapisujący dla żądanego typu wejścia / wyjścia. Zdefiniowano by również podklasy czytelników / pisarzy, ale nie dodają one niczego do recenzji projektu:

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

Zgodnie z określonymi celami mojego projektu mam jeden szczególny problem. Strumienie C ++ można otwierać w trybie tekstowym lub binarnym, ale nie ma możliwości sprawdzenia już otwartego strumienia. Błąd programisty może umożliwiać np. Dostarczenie strumienia binarnego do czytnika / zapisu XML lub JSON. Może to powodować subtelne (lub nie tak subtelne) błędy. Wolałbym, żeby kod szybko zawodził, ale nie jestem pewien, czy ten projekt by to zrobił.

Jednym z rozwiązań może być odciążenie odpowiedzialności za otwarcie strumienia dla czytelnika lub pisarza, ale uważam, że narusza to SRP i uczyniłoby kod bardziej złożonym. Podczas pisania DAO pisarz nie powinien dbać o to, dokąd zmierza strumień: może to być plik, standardowe wyjście, odpowiedź HTTP, gniazdo, cokolwiek innego. Po uwzględnieniu tego problemu w logice serializacji staje się on znacznie bardziej złożony: musi znać określony typ strumienia i wywołać konstruktora.

Oprócz tej opcji nie jestem pewien, jaki byłby lepszy sposób modelowania tych obiektów, który jest prosty, elastyczny i pomaga zapobiegać błędom logicznym w kodzie, który go używa.


Przypadek użycia, z którym należy zintegrować rozwiązanie, to proste okno dialogowe wyboru pliku . Użytkownik wybiera „Otwórz ...” lub „Zapisz jako ...” z menu Plik, a program otwiera lub zapisuje WidgetDatabase. Dostępne będą również opcje „Importuj ...” i „Eksportuj ...” dla poszczególnych widżetów.

Gdy użytkownik wybierze plik do otwarcia lub zapisania, wxWidgets zwróci nazwę pliku. Program obsługi, który reaguje na to zdarzenie, musi być kodem ogólnego przeznaczenia, który pobiera nazwę pliku, pobiera serializator i wywołuje funkcję wykonującą duże podnoszenie. Najlepiej byłoby, gdyby ten projekt działał również, jeśli inny fragment kodu wykonuje operacje we / wy bez plików, takie jak wysyłanie WidgetDatabase do urządzenia mobilnego przez gniazdo.


Czy widżet zapisuje swój własny format? Czy współpracuje z istniejącymi formatami? Tak! Wszystkie powyższe. Wracając do okna plików, pomyśl o Microsoft Word. Microsoft miał swobodę opracowywania formatu DOCX, jednak chcieli z pewnymi ograniczeniami. Jednocześnie program Word odczytuje lub zapisuje starsze formaty i formaty innych firm (np. PDF). Ten program nie jest inny: format „binarny”, o którym mówię, to jeszcze nieokreślony wewnętrzny format przeznaczony do szybkości. Jednocześnie musi być w stanie czytać i pisać otwarte formaty standardowe w swojej domenie (bez znaczenia na pytanie), aby mógł współpracować z innym oprogramowaniem.

Wreszcie istnieje tylko jeden typ widżetu. Będą miały obiekty potomne, ale będą one obsługiwane przez tę logikę serializacji. Program nigdy nie ładuje zarówno widżetów, jak i zębatek. Taka konstrukcja tylko musi być związane z widgetów i WidgetDatabases.

Społeczność
źródło
1
Czy zastanawiałeś się nad użyciem do tego biblioteki zwiększania serializacji ? Zawiera wszystkie twoje cele projektowe.
Bart van Ingen Schenau
1
@BartvanIngenSchenau Nie miałem, głównie ze względu na związek miłości / nienawiści, który mam z Boost. Myślę, że w tym przypadku niektóre formaty, które muszę obsługiwać, mogą być bardziej złożone niż jest w stanie poradzić sobie z serializacją Boost bez dodawania wystarczającej złożoności, że używanie jej nie kupuje zbyt wiele.
Ach! Więc nie (od-) serializujesz instancji widżetów (to byłoby dziwne…), ale te widżety muszą tylko odczytywać i zapisywać uporządkowane dane? Czy musisz wdrożyć istniejące formaty plików, czy masz swobodę definiowania formatu ad-hoc? Czy różne widżety używają wspólnych lub podobnych formatów, które można zaimplementować jako wspólny model? Następnie możesz wykonać podział interfejsu użytkownika - logiki domeny - modelu - DAL zamiast mungować wszystko razem jako obiekt boga WxWidget. W rzeczywistości nie rozumiem, dlaczego widżety są tutaj odpowiednie.
amon
@amon Zredagowałem pytanie ponownie. wxWidgets są istotne tylko w zakresie interfejsu z użytkownikiem: Widżety, o których mówię, nie mają nic wspólnego z frameworkiem wxWidgets (tj. nie ma obiektu Boga). Używam tego terminu jako nazwy ogólnej dla typu DAO.
1
@ LarsViklund, wysuwasz przekonujący argument i zmieniłeś moje zdanie w tej sprawie. Zaktualizowałem przykładowy kod.

Odpowiedzi:

7

Mogę się mylić, ale twój projekt wydaje się być okropnie przeprojektowany. Serializacji tylko jeden Widget, chcesz zdefiniować WidgetReader, WidgetWriter, WidgetDatabaseReader, WidgetDatabaseWriterinterfejsy z których każdy ma implementacje dla XML, JSON i kodowanie binarne i fabryki do tie wszystkich tych klas razem. Jest to problematyczne z następujących powodów:

  • Jeśli chcę serializować nie-Widget klasę, nazwijmy to Foo, muszę reimplement cały ten Zoo klas, a także tworzyć FooReader, FooWriter, FooDatabaseReader, FooDatabaseWriterinterfejsy, trzy razy dla każdego formatu serializacji plus fabryce, aby jeszcze zdalnie użytkowej. Nie mów mi, że nie będzie tam żadnych operacji kopiowania i wklejania! Ta kombinatoryczna eksplozja wydaje się dość niemożliwa do utrzymania, nawet jeśli każda z tych klas zasadniczo zawiera tylko jedną metodę.

  • Widgetnie można rozsądnie zamknąć. Albo otworzysz wszystko, co powinno być zserializowane do otwartego świata, za pomocą metod pobierających, albo masz do friendkażdej WidgetWriter(i prawdopodobnie także wszystkich WidgetReader) implementacji. W obu przypadkach wprowadzisz znaczące sprzężenie między implementacjami serializacji a Widget.

  • Zoo czytelnik / pisarz zachęca do niespójności. Za każdym razem, gdy dodajesz członka Widget, musisz zaktualizować wszystkie powiązane klasy serializacji, aby przechowywać / odzyskiwać tego członka. Jest to coś, czego nie można sprawdzić statycznie pod kątem poprawności, więc będziesz musiał napisać osobny test dla każdego czytelnika i pisarza. Przy obecnym projekcie jest to 4 * 3 = 12 testów na klasę, które chcesz serializować.

    Z drugiej strony problematyczne jest także dodanie nowego formatu serializacji, takiego jak YAML. Dla każdej klasy, którą chcesz serializować, musisz pamiętać, aby dodać czytnik i pisarz YAML oraz dodać tę skrzynkę do wyliczenia i fabryki. Ponownie, jest to coś, czego nie można przetestować statycznie, chyba że staniesz się (również) sprytny i opracujesz szablonowy interfejs dla fabryk, który jest niezależny Widgeti upewni się, że zapewniona jest implementacja dla każdego typu serializacji dla każdej operacji wejścia / wyjścia.

  • Może Widgetteraz spełnia SRP, ponieważ nie jest odpowiedzialny za serializację. Ale implementacje czytnika i programu zapisującego wyraźnie tego nie robią, z interpretacją „SRP = każdy obiekt ma jeden powód do zmiany”: implementacje muszą się zmienić, gdy zmienia się format serializacji lub gdyWidget zmiany.

Jeśli jesteś w stanie zainwestować minimum czasu, spróbuj opracować bardziej ogólne ramy serializacji niż ta plątanina klas ad-hoc. Na przykład, możesz zdefiniować wspólną reprezentację wymiany, nazwijmy ją SerializationInfo, używając modelu obiektowego podobnego do JavaScript: większość obiektów może być postrzegana jako std::map<std::string, SerializationInfo>, lub jako std::vector<SerializationInfo>, lub jako prymityw, taki jakint .

Dla każdego formatu serializacji miałabyś wtedy jedną klasę, która zarządza odczytem i zapisem reprezentacji serializacji z tego strumienia. I dla każdej klasy, którą chcesz serializować, będziesz miał jakiś mechanizm konwertujący instancje z / do reprezentacji serializacji.

Doświadczyłem takiego projektu z cxxtools ( strona główna , GitHub , demo serializacji ) i jest on w większości bardzo intuicyjny, szeroko stosowany i zadowalający dla moich przypadków użycia - jedynym problemem jest dość słaby model obiektowy reprezentacji serializacji, który wymaga ciebie aby podczas deserializacji wiedzieć dokładnie, jakiego rodzaju obiektu się spodziewasz, a deserializacja implikuje obiekty o domyślnej konstrukcji, które można później zainicjować. Oto wymyślony przykład użycia:

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

Nie twierdzę, że powinieneś używać cxxtools lub dokładnie kopiować ten projekt, ale z mojego doświadczenia wynika, że ​​jego projektowanie sprawia, że ​​dodawanie serializacji jest nawet banalne nawet dla małych, jednorazowych klas, pod warunkiem, że nie przywiązujesz zbytniej wagi do formatu serializacji ( np. domyślne wyjście XML użyje nazw członków jako nazw elementów i nigdy nie użyje atrybutów dla danych).

Problem z trybem binarnym / tekstowym dla strumieni nie wydaje się rozwiązywalny, ale nie jest tak źle. Po pierwsze, ma to znaczenie tylko dla formatów binarnych, na platformach, dla których nie programuję ;-) Co więcej, jest to ograniczenie twojej infrastruktury serializacji, którą musisz tylko udokumentować i mam nadzieję, że wszyscy używają poprawnie. Otwieranie strumieni w czytnikach lub programach zapisujących jest zbyt mało elastyczne, a C ++ nie ma wbudowanego mechanizmu na poziomie typu, który odróżnia tekst od danych binarnych.

amon
źródło
Jak zmieniłaby się twoja rada, biorąc pod uwagę, że te DAO w zasadzie są już klasą „informacji o serializacji”? Są to odpowiedniki POJO w języku C ++ . Zamierzam również edytować moje pytanie, dodając nieco więcej informacji na temat tego, jak te obiekty będą używane.