Revisão do design de serialização do C ++

9

Estou escrevendo um aplicativo C ++. A maioria dos aplicativos lê e grava dados citados, e este não é uma exceção. Criei um design de alto nível para o modelo de dados e a lógica de serialização. Esta pergunta está solicitando uma revisão do meu design com esses objetivos específicos em mente:

  • Ter uma maneira fácil e flexível de ler e gravar modelos de dados em formatos arbitrários: binário bruto, XML, JSON, et. al. O formato dos dados deve ser dissociado dos próprios dados, bem como do código que está solicitando a serialização.

  • Garantir que a serialização seja o mais livre de erros possível. A E / S é inerentemente arriscada por vários motivos: meu design apresenta mais maneiras de falhar? Se sim, como eu poderia refatorar o design para mitigar esses riscos?

  • Este projeto usa C ++. Se você o ama ou odeia, a linguagem tem sua própria maneira de fazer as coisas e o design visa trabalhar com a linguagem, não contra ela .

  • Finalmente, o projeto é construído sobre os wxWidgets . Enquanto estou procurando uma solução aplicável a um caso mais geral, essa implementação específica deve funcionar bem com esse kit de ferramentas.

O que se segue é um conjunto muito simples de classes escritas em C ++ que ilustram o design. Essas não são as classes reais que escrevi parcialmente até agora; esse código simplesmente ilustra o design que estou usando.


Primeiro, alguns DAOs de amostra:

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

Em seguida, defino classes virtuais puras (interfaces) para leitura e gravação de DAOs. A idéia é abstrair a serialização de dados a partir dos próprios dados ( 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, aqui está o código que obtém o leitor / gravador adequado para o tipo de E / S desejado. Haveria subclasses de leitores / escritores também definidas, mas elas não acrescentam nada à revisão do 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 */);
  }
};

Pelos objetivos declarados do meu projeto, tenho uma preocupação específica. Os fluxos C ++ podem ser abertos no modo texto ou binário, mas não há como verificar um fluxo já aberto. Poderia ser possível, por erro do programador, fornecer, por exemplo, um fluxo binário para um leitor / gravador XML ou JSON. Isso pode causar erros sutis (ou não tão sutis). Prefiro que o código falhe rapidamente, mas não tenho certeza de que esse design faria isso.

Uma maneira de contornar isso poderia ser descarregar a responsabilidade de abrir o fluxo para o leitor ou escritor, mas acredito que isso viola o SRP e tornaria o código mais complexo. Ao escrever um DAO, o gravador não deve se preocupar com a direção do fluxo: pode ser um arquivo, saída padrão, uma resposta HTTP, um soquete, qualquer coisa. Uma vez que essa preocupação é encapsulada na lógica de serialização, ela se torna muito mais complexa: ela deve conhecer o tipo específico de fluxo e qual construtor chamar.

Além dessa opção, não tenho certeza de qual seria a melhor maneira de modelar esses objetos, que é simples, flexível e ajuda a evitar erros lógicos no código que o utiliza.


O caso de uso com o qual a solução deve ser integrada é uma caixa de diálogo simples de seleção de arquivo . O usuário seleciona "Abrir ..." ou "Salvar como ..." no menu Arquivo, e o programa abre ou salva o WidgetDatabase. Também haverá opções "Importar ..." e "Exportar ..." para Widgets individuais.

Quando o usuário seleciona um arquivo para abrir ou salvar, o wxWidgets retornará um nome de arquivo. O manipulador que responde a esse evento deve ser um código de uso geral que aceite o nome do arquivo, adquira um serializador e chame uma função para realizar o trabalho pesado. Idealmente, esse design também funcionaria se outro pedaço de código estivesse executando E / S não-arquivo, como enviar um WidgetDatabase para um dispositivo móvel por um soquete.


Um widget salva em seu próprio formato? Interopera com os formatos existentes? Sim! Tudo acima. Voltando à caixa de diálogo do arquivo, pense no Microsoft Word. A Microsoft estava livre para desenvolver o formato DOCX como quisesse, dentro de certas restrições. Ao mesmo tempo, o Word também lê ou grava formatos legados e de terceiros (por exemplo, PDF). Este programa não é diferente: o formato "binário" de que falo é um formato interno ainda a ser definido, projetado para acelerar. Ao mesmo tempo, ele deve ser capaz de ler e gravar formatos padrão abertos em seu domínio (irrelevantes para a pergunta) para poder trabalhar com outro software.

Finalmente, existe apenas um tipo de widget. Ele terá objetos filhos, mas esses serão tratados por essa lógica de serialização. O programa nunca carregará Widgets e Sprockets. Este projeto única precisa se preocupar com Widgets e WidgetDatabases.

Comunidade
fonte
11
Você já pensou em usar a biblioteca Boost Serialization para isso? Ele incorpora todos os objetivos de design que você possui.
Bart van Ingen Schenau
11
@BartvanIngenSchenau Eu não tinha, principalmente por causa da relação de amor / ódio que tenho com o Boost. Acho que, neste caso, alguns dos formatos que preciso suportar podem ser mais complexos do que o Boost Serialization pode suportar sem adicionar complexidade suficiente para que usá-lo não me compre muito.
Ah! Então você não está (des-) serializando instâncias de widget (isso seria estranho ...), mas esses widgets só precisam ler e gravar dados estruturados? Você precisa implementar formatos de arquivo existentes ou pode definir um formato ad-hoc? Os widgets diferentes usam formatos comuns ou semelhantes que podem ser implementados como um modelo comum? Você poderia então fazer uma divisão de interface do usuário - lógica de domínio - modelo - DAL em vez de juntar tudo como um objeto de deus WxWidget. Na verdade, não vejo por que os widgets são relevantes aqui.
amon
@amon Eu editei a pergunta novamente. Os wxWidgets são relevantes apenas na interface com o usuário: os Widgets de que falo não têm nada a ver com a estrutura wxWidgets (ou seja, nenhum objeto divino). Eu apenas uso esse termo como um nome genérico para um tipo de DAO.
11
@LarsViklund você faz um argumento convincente e mudou minha opinião sobre o assunto. Eu atualizei o código de exemplo.

Respostas:

7

Eu posso estar errado, mas seu design parece estar terrivelmente com engenharia excessiva. Para serializar apenas um Widget, você quer definir WidgetReader, WidgetWriter, WidgetDatabaseReader, WidgetDatabaseWriterinterfaces que cada um tem implementações para XML, JSON, e codificações binárias, e uma fábrica de amarrar todas as classes juntos. Isso é problemático pelos seguintes motivos:

  • Se eu quiser serializar um não- Widgetclasse, vamos chamá-lo Foo, eu tenho que reimplementar todo este Zoo de aulas, e criar FooReader, FooWriter, FooDatabaseReader, FooDatabaseWriterinterfaces, três vezes para cada formato de serialização, além de uma fábrica para torná-lo ainda remotamente utilizável. Não me diga que não haverá copiar e colar lá! Essa explosão combinatória parece ser razoavelmente insustentável, mesmo que cada uma dessas classes contenha apenas um único método.

  • Widgetnão pode ser razoavelmente encapsulado. Ou você abrir tudo o que deve ser serializado para o mundo aberto com métodos getter, ou você tem que friendtodos e cada um WidgetWriter(e provavelmente também todos WidgetReader) implementações. Nos dois casos, você introduzirá um acoplamento considerável entre as implementações de serialização e o Widget.

  • O zoológico leitor / escritor convida a inconsistências. Sempre que você adiciona um membro Widget, você precisará atualizar todas as classes de serialização relacionadas para armazenar / recuperar esse membro. Isso não pode ser verificado estaticamente quanto à correção, portanto você também precisará escrever um teste separado para cada leitor e escritor. No seu design atual, são 4 * 3 = 12 testes por classe que você deseja serializar.

    Na outra direção, adicionar um novo formato de serialização, como o YAML, também é problemático. Para cada classe que você deseja serializar, lembre-se de adicionar um leitor e gravador YAML e adicione esse caso à enumeração e à fábrica. Novamente, isso é algo que não pode ser testado estaticamente, a menos que você seja (também) esperto e elabore uma interface de modelo para fábricas independentes Widgete garanta uma implementação para cada tipo de serialização para cada operação de entrada / saída.

  • Talvez o Widgetagora satisfaça o SRP, pois não é responsável pela serialização. Mas as implementações de leitor e gravador claramente não o fazem, com a interpretação “SRP = cada objeto tem um motivo para mudar”: as implementações devem mudar quando o formato de serialização muda ou quando as Widgetalterações.

Se você puder investir um período mínimo de tempo com antecedência, tente criar uma estrutura de serialização mais genérica que esse emaranhado de classes ad-hoc. Por exemplo, você pode definir uma representação de troca comum, vamos chamá-la SerializationInfo, com um modelo de objeto semelhante ao JavaScript: a maioria dos objetos pode ser vista como std::map<std::string, SerializationInfo>, ou como std::vector<SerializationInfo>, ou como uma primitiva como int.

Para cada formato de serialização, você teria uma classe que gerencia a leitura e gravação de uma representação de serialização desse fluxo. E para cada classe que você deseja serializar, você teria algum mecanismo que converte instâncias de / para a representação de serialização.

Eu experimentei esse design com o cxxtools ( página inicial , GitHub , demo de serialização ) e é extremamente intuitivo, amplamente aplicável e satisfatório para os meus casos de uso - os únicos problemas são o modelo de objeto bastante fraco da representação de serialização que exige que você saber durante a desserialização exatamente que tipo de objeto você espera e que a desserialização implica objetos construtíveis por padrão que podem ser inicializados posteriormente. Aqui está um exemplo 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);
}

Não estou dizendo que você deve usar cxxtools ou copiar exatamente esse design, mas, na minha experiência, seu design torna trivial adicionar serialização mesmo para classes pequenas e pontuais, desde que você não se importe muito com o formato de serialização ( por exemplo, a saída XML padrão usará nomes de membros como nomes de elementos e nunca usará atributos para seus dados).

O problema com o modo binário / texto para fluxos não parece solucionável, mas isso não é tão ruim. Por um lado, isso só importa para formatos binários, em plataformas para as quais não costumo programar ;-) Mais seriamente, é uma restrição da sua infraestrutura de serialização que você precisará documentar e esperar que todos usem corretamente. Abrir os fluxos em seus leitores ou gravadores é muito inflexível e o C ++ não possui um mecanismo interno de nível de tipo para distinguir texto de dados binários.

amon
fonte
Como seu conselho mudaria, dado que esses DAOs basicamente já são uma classe "informações de serialização"? Estes são o equivalente em C ++ dos POJOs . Também vou editar minha pergunta com um pouco mais de informações sobre como esses objetos serão usados.