Обзор дизайна сериализации C ++

9

Я пишу приложение на C ++. Большинству приложений требуется чтение и запись данных, и это не исключение. Я создал дизайн высокого уровня для модели данных и логики сериализации. Этот вопрос требует пересмотра моего дизайна с учетом этих конкретных целей:

  • Иметь простой и гибкий способ чтения и записи моделей данных в произвольных форматах: raw двоичный, XML, JSON и др. и др. Формат данных должен быть отделен от самих данных, а также от кода, который запрашивает сериализацию.

  • Чтобы обеспечить максимально безошибочную сериализацию. Ввод / вывод по своей природе сопряжен с риском по ряду причин: вводит ли мой дизайн больше способов его провала? Если так, как я мог бы реорганизовать дизайн, чтобы уменьшить эти риски?

  • Этот проект использует C ++. Любите ли вы это или ненавидите, у языка есть свой собственный способ действий, и дизайн нацелен на работу с языком, а не против него .

  • Наконец, проект построен поверх wxWidgets . Хотя я ищу решение, применимое к более общему случаю, эта конкретная реализация должна прекрасно работать с этим инструментарием.

Далее следует очень простой набор классов, написанных на C ++, которые иллюстрируют дизайн. Это не те классы, которые я частично написал до сих пор, этот код просто иллюстрирует дизайн, который я использую.


Во-первых, некоторые примеры 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;
};

Далее я определяю чисто виртуальные классы (интерфейсы) для чтения и записи DAO. Идея состоит в том, чтобы абстрагировать сериализацию данных от самих данных ( 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;
};

Наконец, вот код, который получает правильное устройство чтения / записи для нужного типа ввода / вывода. Также будут определены подклассы читателей / писателей, но они ничего не добавляют к обзору проекта:

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

В соответствии с заявленными целями моего дизайна, у меня есть одна конкретная проблема. Потоки C ++ могут быть открыты в текстовом или двоичном режиме, но нет способа проверить уже открытый поток. Возможно из-за ошибки программиста предоставить, например, двоичный поток для чтения / записи XML или JSON. Это может вызвать тонкие (или не очень) ошибки. Я бы предпочел, чтобы код не работал быстро, но я не уверен, что этот дизайн сделает это.

Одним из способов решения этой проблемы может быть снятие ответственности за открытие потока для читателя или автора, но я считаю, что это нарушает SRP и сделает код более сложным. При написании DAO автору записи не нужно заботиться о том, куда движется поток: это может быть файл, стандартный вывод, ответ HTTP, сокет, что угодно. Как только эта проблема инкапсулируется в логике сериализации, она становится гораздо более сложной: она должна знать конкретный тип потока и какой конструктор вызывать.

Помимо этой опции, я не уверен, что будет лучшим способом моделирования этих объектов, который будет простым, гибким и поможет предотвратить логические ошибки в коде, который его использует.


Вариант использования, с которым необходимо интегрировать решение, - это простое диалоговое окно выбора файла . Пользователь выбирает «Открыть ...» или «Сохранить как ...» в меню «Файл», и программа открывает или сохраняет базу данных WidgetDatabase. Также будут доступны опции «Импорт ...» и «Экспорт ...» для отдельных виджетов.

Когда пользователь выбирает файл для открытия или сохранения, wxWidgets возвращает имя файла. Обработчик, который отвечает на это событие, должен быть кодом общего назначения, который принимает имя файла, получает сериализатор и вызывает функцию для выполнения тяжелой работы. В идеале этот дизайн также будет работать, если другой фрагмент кода выполняет нефайловый ввод / вывод, например, отправку базы данных WidgetDD на мобильное устройство через сокет.


Сохраняет ли виджет свой собственный формат? Взаимодействует ли он с существующими форматами? Да! Все вышеперечисленное. Возвращаясь к диалогу файла, подумайте о Microsoft Word. Microsoft была свободна в разработке формата DOCX, однако они хотели в рамках определенных ограничений. В то же время Word также читает или пишет устаревшие и сторонние форматы (например, PDF). Эта программа ничем не отличается: «двоичный» формат, о котором я говорю, - это еще не определенный внутренний формат, разработанный для скорости. В то же время он должен уметь читать и записывать открытые стандартные форматы в своей области (независимо от вопроса), чтобы он мог работать с другим программным обеспечением.

Наконец, есть только один тип виджетов. У него будут дочерние объекты, но они будут обрабатываться этой логикой сериализации. Программа никогда не загрузит как виджеты, так и звездочки. Этот проект только нужно иметь дело с виджетами и WidgetDatabases.

Сообщество
источник
1
Рассматривали ли вы использовать библиотеку Boost Serialization для этого? Он включает в себя все цели дизайна, которые у вас есть.
Барт ван Инген Шенау
1
@BartvanIngenSchenau у меня не было, в основном из-за отношений любовь / ненависть, которые у меня есть с Boost. Я думаю, что в этом случае некоторые из форматов, которые мне нужно поддерживать, могут быть более сложными, чем Boost Serialization, которые можно обработать, не добавляя достаточной сложности, поэтому их использование не слишком выгодно для меня.
Ах! Таким образом, вы не (не) сериализуете экземпляры виджетов (это было бы странно ...), но эти виджеты просто должны читать и записывать структурированные данные? Нужно ли вам реализовывать существующие форматы файлов, или вы можете определить специальный формат? Используют ли разные виджеты общие или похожие форматы, которые могут быть реализованы как общая модель? Затем вы могли бы выполнить разделение DAL пользовательского интерфейса - доменная логика - модель - вместо того, чтобы объединять все вместе как объект бога WxWidget. На самом деле, я не понимаю, почему виджеты здесь актуальны.
Амон
@amon Я снова отредактировал вопрос. wxWidgets имеют отношение только к интерфейсу с пользователем: виджеты, о которых я говорю, не имеют ничего общего с каркасом wxWidgets (то есть без объекта бога). Я просто использую этот термин как общее имя для типа DAO.
1
@LarsViklund вы делаете убедительный аргумент, и вы изменили мое мнение по этому вопросу. Я обновил пример кода.

Ответы:

7

Я могу ошибаться, но ваш дизайн, кажется, ужасно перегружен. Для сериализации только один Widget, вы хотите определить WidgetReader, WidgetWriter, WidgetDatabaseReader, WidgetDatabaseWriterинтерфейсы, каждая из которых имеет реализаций для XML, JSON и бинарных кодировок, и завод , чтобы связать все эти классы вместе. Это проблематично по следующим причинам:

  • Если я хочу , чтобы сериализовать не- Widgetкласс, давайте назовем это Foo, я должен переопределять весь этот зоопарк классов, а также создавать FooReader, FooWriter, FooDatabaseReader, FooDatabaseWriterинтерфейсы, раз три для каждого формата сериализации, а также завод , чтобы сделать его даже удаленно использовать. Не говорите мне, что там не будет копий и вставок! Этот комбинаторный взрыв кажется довольно неосуществимым, даже если каждый из этих классов по существу содержит только один метод.

  • Widgetне может быть разумно заключен в капсулу. Либо вы открываете все, что должно быть сериализовано, в открытый мир с помощью методов-геттеров, либо у вас есть friendвсе WidgetWriter(и, вероятно, также все WidgetReader) реализации. В любом случае вы введете значительную связь между реализациями сериализации и Widget.

  • Читатель / писатель зоопарк предлагает несоответствия. Всякий раз, когда вы добавляете члена Widget, вам нужно будет обновить все связанные классы сериализации, чтобы сохранить / извлечь этого члена. Это то, что не может быть статически проверено на корректность, поэтому вам также придется написать отдельный тест для каждого читателя и писателя. В вашем текущем дизайне это 4 * 3 = 12 тестов на класс, который вы хотите сериализовать.

    В другом направлении добавление нового формата сериализации, такого как YAML, также проблематично. Для каждого класса, который вы хотите сериализовать, вы должны будете не забыть добавить читателя и писателя YAML и добавить этот случай в enum и на фабрику. Опять же, это то, что не может быть подвергнуто статическому тестированию, если вы не станете (слишком) умным и не создадите шаблонный интерфейс для фабрик, которые не зависят Widgetи не обеспечат реализацию для каждого типа сериализации для каждой операции ввода / вывода.

  • Может быть, Widgetтеперь удовлетворяет SRP, поскольку он не отвечает за сериализацию. Но реализации читателя и писателя явно не совпадают с интерпретацией «SRP = каждый объект имеет одну причину для изменения»: реализации должны измениться, когда либо изменяется формат сериализации, либо когда Widgetизменения.

Если вы можете потратить минимум времени заранее, попробуйте разработать более общую платформу сериализации, чем эта специальная путаница классов. Например, вы можете определить общее представление обмена, скажем так SerializationInfo, с помощью JavaScript-подобной объектной модели: большинство объектов можно рассматривать как std::map<std::string, SerializationInfo>, или как std::vector<SerializationInfo>, или как примитив, такой как int.

Для каждого формата сериализации у вас будет один класс, который управляет чтением и записью представления сериализации из этого потока. И для каждого класса, который вы хотите сериализовать, у вас будет какой-то механизм, который преобразует экземпляры из / в представление сериализации.

Я имел такой дизайн с cxxtools ( домашняя страница , GitHub , демонстрация сериализации ), и он в основном чрезвычайно интуитивно понятен, широко применим и удовлетворителен для моих случаев использования - единственными проблемами является довольно слабая объектная модель представления сериализации, которая требует от вас точно знать во время десериализации, какой тип объекта вы ожидаете, и что десериализация подразумевает конструируемые по умолчанию объекты, которые могут быть инициализированы позже. Вот надуманный пример использования:

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

Я не говорю, что вы должны использовать cxxtools или точно копировать этот дизайн, но, по моему опыту, его дизайн делает тривиальным добавление сериализации даже для небольших одноразовых классов при условии, что вам не слишком важен формат сериализации ( например, вывод XML по умолчанию будет использовать имена членов в качестве имен элементов и никогда не будет использовать атрибуты для ваших данных).

Проблема с двоичным / текстовым режимом для потоков не кажется разрешимой, но это не так уж плохо. С одной стороны, это имеет значение только для двоичных форматов, на платформах, для которых я не склонен программировать ;-) Более серьезно, это ограничение вашей инфраструктуры сериализации, которое вам просто нужно документировать и надеяться, что все используют правильно. Открытие потоков внутри ваших читателей или авторов слишком негибко, и в C ++ нет встроенного механизма на уровне типов, который бы отличал текст от двоичных данных.

Амон
источник
Как изменится ваш совет, учитывая, что эти DAO в основном уже являются классом «информация о сериализации»? Это С ++-эквивалент POJO . Я тоже собираюсь отредактировать свой вопрос, добавив немного больше информации о том, как эти объекты будут использоваться.