Revisión de diseño de serialización de C ++

9

Estoy escribiendo una aplicación C ++. La mayoría de las aplicaciones leen y escriben datos citas de necesarias y esta no es una excepción. Creé un diseño de alto nivel para el modelo de datos y la lógica de serialización. Esta pregunta solicita una revisión de mi diseño con estos objetivos específicos en mente:

  • Tener una manera fácil y flexible de leer y escribir modelos de datos en formatos arbitrarios: binario sin formato, XML, JSON, et. Alabama. El formato de los datos se debe desacoplar de los datos en sí, así como del código que solicita la serialización.

  • Para garantizar que la serialización esté tan libre de errores como sea razonablemente posible. La E / S es intrínsecamente riesgosa por una variedad de razones: ¿mi diseño introduce más formas de que falle? Si es así, ¿cómo podría refactorizar el diseño para mitigar esos riesgos?

  • Este proyecto usa C ++. Ya sea que lo ames o lo odies, el lenguaje tiene su propia forma de hacer las cosas y el diseño tiene como objetivo trabajar con el lenguaje, no en contra de él .

  • Finalmente, el proyecto está construido sobre wxWidgets . Si bien estoy buscando una solución aplicable a un caso más general, esta implementación específica debería funcionar bien con ese kit de herramientas.

Lo que sigue es un conjunto muy simple de clases escritas en C ++ que ilustran el diseño. Estas no son las clases reales que he escrito parcialmente hasta ahora, este código simplemente ilustra el diseño que estoy usando.


Primero, algunos DAO de muestra:

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

A continuación, defino clases virtuales puras (interfaces) para leer y escribir DAO. La idea es abstraer la serialización de datos de los datos en sí ( 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;
};

Finalmente, aquí está el código que obtiene el lector / escritor adecuado para el tipo de E / S deseado. Habría subclases de los lectores / escritores también definidos, pero estos no agregan nada a la revisión del diseño:

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

Según los objetivos establecidos de mi diseño, tengo una preocupación específica. Las secuencias de C ++ se pueden abrir en modo de texto o binario, pero no hay forma de verificar una secuencia ya abierta. A través del error del programador, podría ser posible proporcionar, por ejemplo, una secuencia binaria a un lector / escritor XML o JSON. Esto podría causar errores sutiles (o no tan sutiles). Preferiría que el código fallara rápidamente, pero no estoy seguro de que este diseño lo haga.

Una forma de evitar esto podría ser descargar la responsabilidad de abrir la transmisión al lector o escritor, pero creo que eso viola SRP y haría que el código sea más complejo. Al escribir un DAO, al escritor no le debe importar a dónde va la transmisión: podría ser un archivo, salida estándar, una respuesta HTTP, un socket, cualquier cosa. Una vez que esa preocupación se encapsula en la lógica de serialización, se vuelve mucho más compleja: debe conocer el tipo específico de flujo y a qué constructor llamar.

Aparte de esa opción, no estoy seguro de cuál sería una mejor manera de modelar estos objetos que sea simple, flexible y que ayude a evitar errores lógicos en el código que lo usa.


El caso de uso con el que debe integrarse la solución es un cuadro de diálogo de selección de archivo simple . El usuario selecciona "Abrir ..." o "Guardar como ..." en el menú Archivo, y el programa abre o guarda la WidgetDatabase. También habrá opciones "Importar ..." y "Exportar ..." para widgets individuales.

Cuando el usuario selecciona un archivo para abrir o guardar, wxWidgets devolverá un nombre de archivo. El controlador que responde a ese evento debe ser un código de propósito general que tome el nombre del archivo, adquiera un serializador y llame a una función para realizar el trabajo pesado. Idealmente, este diseño también funcionaría si otra pieza de código realiza E / S sin archivos, como enviar una WidgetDatabase a un dispositivo móvil a través de un zócalo.


¿Se guarda un widget en su propio formato? ¿Interopera con los formatos existentes? ¡Si! Todas las anteriores. Volviendo al diálogo de archivo, piense en Microsoft Word. Microsoft era libre de desarrollar el formato DOCX como quisiera dentro de ciertas limitaciones. Al mismo tiempo, Word también lee o escribe formatos heredados y de terceros (por ejemplo, PDF). Este programa no es diferente: el formato "binario" del que hablo es un formato interno aún por definir, diseñado para la velocidad. Al mismo tiempo, debe poder leer y escribir formatos estándar abiertos en su dominio (irrelevante para la pregunta) para poder trabajar con otro software.

Finalmente, solo hay un tipo de Widget. Tendrá objetos secundarios, pero estos serán manejados por esta lógica de serialización. El programa nunca cargará Widgets y Sprockets. Este diseño solo tiene que ver con Widgets y WidgetDatabases.

Comunidad
fuente
1
¿Ha considerado usar la biblioteca Boost Serialization para esto? Incorpora todos los objetivos de diseño que tienes.
Bart van Ingen Schenau
1
@BartvanIngenSchenau no tenía, principalmente debido a la relación de amor / odio que tengo con Boost. Creo que, en este caso, algunos de los formatos que necesito admitir podrían ser más complejos de lo que Boost Serialization puede manejar sin agregar la suficiente complejidad como para que usarlo no me compre mucho.
Ah! Entonces, ¿no está (des) serializando instancias de widgets (eso sería extraño ...), pero estos widgets solo necesitan leer y escribir datos estructurados? ¿Tiene que implementar formatos de archivo existentes o es libre de definir un formato ad-hoc? ¿Los diferentes widgets usan formatos comunes o similares que podrían implementarse como un modelo común? Entonces podría hacer una interfaz de usuario, lógica de dominio, modelo, división DAL en lugar de combinar todo como un objeto de dios WxWidget. De hecho, no veo por qué los widgets son relevantes aquí.
amon
@amon Edité la pregunta nuevamente. Los wxWidgets solo son relevantes en cuanto a la interfaz con el usuario: los widgets de los que hablo no tienen nada que ver con el marco de wxWidgets (es decir, no hay ningún objeto de Dios). Solo uso ese término como un nombre genérico para un tipo de DAO.
1
@LarsViklund, haces un argumento convincente y cambiaste mi opinión al respecto. Actualicé el código de ejemplo.

Respuestas:

7

Puedo estar equivocado, pero su diseño parece estar terriblemente diseñado en exceso. Para serializar sólo uno Widget, que desea definir WidgetReader, WidgetWriter, WidgetDatabaseReader, WidgetDatabaseWriterinterfaces que tienen cada una de las implementaciones XML, JSON y codificaciones binarias, y una fábrica para atar todos esos clases juntos. Esto es problemático por las siguientes razones:

  • Si quiero serializar un no Widgetclase, vamos a llamarlo Foo, tengo que volver a implementar toda esta Zoológico de clases, y crear FooReader, FooWriter, FooDatabaseReader, FooDatabaseWriterinterfaces, tres veces para cada formato de serialización, además de una fábrica para que sea aún remotamente utilizable. ¡No me digas que no habrá copiar y pegar allí! Esta explosión combinatoria parece ser bastante imposible de mantener, incluso si cada una de esas clases esencialmente solo contiene un método único.

  • Widgetno se puede encapsular razonablemente. O abres todo lo que debería ser serializado al mundo abierto con métodos getter, o tienes que realizar friendtodas y cada una de WidgetWriterlas WidgetReaderimplementaciones (y probablemente también todas ). En cualquier caso, introducirá un acoplamiento considerable entre las implementaciones de serialización y el Widget.

  • El zoológico lector / escritor invita a las inconsistencias. Cada vez que agregue un miembro Widget, deberá actualizar todas las clases de serialización relacionadas para almacenar / recuperar ese miembro. Esto es algo que no puede verificarse estáticamente para su corrección, por lo que también tendrá que escribir una prueba por separado para cada lector y escritor. En su diseño actual, eso es 4 * 3 = 12 pruebas por clase que desea serializar.

    En la otra dirección, agregar un nuevo formato de serialización como YAML también es problemático. Para cada clase que desee serializar, deberá recordar agregar un lector y escritor de YAML, y agregar ese caso a la enumeración y a la fábrica. Nuevamente, esto es algo que no se puede probar estáticamente, a menos que sea (también) inteligente y elabore una interfaz con plantilla para fábricas que sea independiente Widgety se asegure de que se proporcione una implementación para cada tipo de serialización para cada operación de entrada / salida.

  • Quizás el Widgetahora satisface el SRP ya que no es responsable de la serialización. Pero las implementaciones de lector y escritor claramente no lo hacen, con la interpretación “SRP = cada objeto tiene una razón para cambiar”: las implementaciones deben cambiar cuando cambia el formato de serialización o cuando los Widgetcambios.

Si puede invertir un mínimo de tiempo de antemano, intente elaborar un marco de serialización más genérico que esta maraña de clases ad-hoc. Por ejemplo, podría definir una representación de intercambio común, llamémosla SerializationInfo, con un modelo de objeto similar a JavaScript: la mayoría de los objetos se pueden ver como a std::map<std::string, SerializationInfo>, o como std::vector<SerializationInfo>, o como un primitivo como int.

Para cada formato de serialización, tendrá una clase que gestiona la lectura y escritura de una representación de serialización de esa secuencia. Y para cada clase que desee serializar, tendrá algún mecanismo que convierta las instancias de / a la representación de serialización.

He experimentado un diseño de este tipo con cxxtools ( página de inicio , GitHub , demostración de serialización ), y es en su mayoría extremadamente intuitivo, ampliamente aplicable y satisfactorio para mis casos de uso; los únicos problemas son el modelo de objeto bastante débil de la representación de serialización que requiere saber durante la deserialización con precisión qué tipo de objeto está esperando, y esa deserialización implica objetos construibles por defecto que se pueden inicializar más tarde. Aquí hay un ejemplo de uso artificial:

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

No estoy diciendo que deba usar cxxtools o copiar exactamente ese diseño, pero en mi experiencia su diseño hace que sea trivial agregar serialización incluso para clases pequeñas y únicas, siempre que no le importe demasiado el formato de serialización ( por ejemplo, la salida XML predeterminada usará nombres de miembros como nombres de elementos, y nunca usará atributos para sus datos).

El problema con el modo binario / texto para transmisiones no parece solucionable, pero eso no es tan malo. Por un lado, solo importa para formatos binarios, en plataformas para las que no tiendo a programar ;-) Más en serio, es una restricción de su infraestructura de serialización que solo tendrá que documentar y esperar que todos usen correctamente. Abrir las transmisiones dentro de sus lectores o escritores es demasiado inflexible, y C ++ no tiene un mecanismo de nivel de tipo incorporado para distinguir el texto de los datos binarios.

amon
fuente
¿Cómo cambiaría su consejo dado que estos DAO básicamente ya son una clase de "información de serialización"? Estos son el equivalente en C ++ de los POJO . También voy a editar mi pregunta con un poco más de información sobre cómo se usarán estos objetos.