Ulasan desain serialisasi C ++

9

Saya menulis aplikasi C ++. Sebagian besar aplikasi membaca dan menulis kutipan data yang diperlukan dan yang satu ini tidak terkecuali. Saya membuat desain tingkat tinggi untuk model data dan logika serialisasi. Pertanyaan ini meminta peninjauan desain saya dengan mengingat tujuan khusus ini:

  • Untuk memiliki cara yang mudah dan fleksibel untuk membaca dan menulis model data dalam format sewenang-wenang: raw binary, XML, JSON, et. Al. Format data harus dipisahkan dari data itu sendiri serta kode yang meminta serialisasi.

  • Untuk memastikan bahwa serialisasi bebas dari kesalahan sebisa mungkin dilakukan. I / O secara inheren berisiko karena berbagai alasan: apakah desain saya memperkenalkan lebih banyak cara agar gagal? Jika demikian, bagaimana saya bisa memperbaiki desain untuk mengurangi risiko tersebut?

  • Proyek ini menggunakan C ++. Apakah Anda suka atau benci itu, bahasa memiliki cara sendiri dalam melakukan sesuatu dan tujuan desain untuk bekerja dengan bahasa, tidak menentangnya .

  • Akhirnya, proyek ini dibangun di atas wxWidgets . Sementara saya mencari solusi yang berlaku untuk kasus yang lebih umum, implementasi spesifik ini harus bekerja dengan baik dengan toolkit itu.

Berikut ini adalah seperangkat kelas yang sangat sederhana yang ditulis dalam C ++ yang menggambarkan desain. Ini bukan kelas sebenarnya yang telah saya tulis sebagian sejauh ini, kode ini hanya menggambarkan desain yang saya gunakan.


Pertama, beberapa sampel 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;
};

Selanjutnya, saya mendefinisikan kelas virtual murni (interface) untuk membaca dan menulis DAO. Idenya adalah untuk abstrak serialisasi data dari data itu sendiri ( 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;
};

Akhirnya, berikut adalah kode yang mendapatkan pembaca / penulis yang tepat untuk tipe I / O yang diinginkan. Akan ada subkelas dari pembaca / penulis yang juga didefinisikan, tetapi ini tidak menambahkan apa pun pada ulasan desain:

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

Sesuai tujuan desain saya, saya punya satu masalah khusus. C ++ stream dapat dibuka dalam mode teks atau biner, tetapi tidak ada cara untuk memeriksa aliran yang sudah dibuka. Bisa saja melalui kesalahan programmer untuk menyediakan misalnya aliran biner ke pembaca / penulis XML atau JSON. Ini dapat menyebabkan kesalahan yang halus (atau tidak terlalu halus). Saya lebih suka kode gagal cepat, tetapi saya tidak yakin desain ini akan melakukannya.

Salah satu cara mengatasi hal ini adalah melepaskan tanggung jawab membuka aliran ke pembaca atau penulis, tetapi saya percaya itu melanggar SRP dan akan membuat kode lebih kompleks. Saat menulis DAO, penulis seharusnya tidak peduli ke mana aliran akan mengalir: itu bisa berupa file, standar keluar, respons HTTP, soket, apa pun. Begitu kekhawatiran itu dirangkum dalam logika serialisasi, ia menjadi jauh lebih kompleks: ia harus mengetahui jenis aliran tertentu dan konstruktor mana yang harus dipanggil.

Selain opsi itu, saya tidak yakin apa yang akan menjadi cara yang lebih baik untuk memodelkan objek-objek ini yang sederhana, fleksibel, dan membantu untuk mencegah kesalahan logika dalam kode yang menggunakannya.


Kasus penggunaan dengan mana solusi harus diintegrasikan adalah kotak dialog pemilihan file sederhana . Pengguna memilih "Open ..." atau "Save As ..." dari menu File, dan program membuka atau menyimpan WidgetDatabase. Juga akan ada opsi "Impor ..." dan "Ekspor ..." untuk masing-masing Widget.

Ketika pengguna memilih file untuk dibuka atau disimpan, wxWidgets akan mengembalikan nama file. Pawang yang merespons peristiwa itu harus berupa kode tujuan umum yang mengambil nama file, memperoleh serializer, dan memanggil fungsi untuk melakukan pengangkatan berat. Idealnya desain ini juga berfungsi jika sepotong kode lain melakukan non-file I / O, seperti mengirim WidgetDatabase ke perangkat seluler melalui soket.


Apakah widget menyimpan ke formatnya sendiri? Apakah itu beroperasi dengan format yang ada? Iya! Semua yang di atas. Kembali ke dialog file, pikirkan tentang Microsoft Word. Microsoft bebas untuk mengembangkan format DOCX namun mereka inginkan dalam batasan tertentu. Pada saat yang sama, Word juga membaca atau menulis format lawas dan pihak ketiga (mis. PDF). Program ini tidak berbeda: format "biner" yang saya bicarakan adalah format internal yang belum ditentukan yang dirancang untuk kecepatan. Pada saat yang sama, ia harus dapat membaca dan menulis format standar terbuka di domainnya (tidak relevan dengan pertanyaan) sehingga dapat dapat bekerja dengan perangkat lunak lain.

Akhirnya, hanya ada satu jenis Widget. Ini akan memiliki objek anak, tetapi mereka akan ditangani oleh logika serialisasi ini. Program tidak akan memuat Widget dan Sprocket. Desain ini hanya perlu memperhatikan Widget dan Widget Widget.

Komunitas
sumber
1
Sudahkah Anda mempertimbangkan untuk menggunakan perpustakaan Serialization Boost untuk ini? Ini menggabungkan semua tujuan desain yang Anda miliki.
Bart van Ingen Schenau
1
@ BartvanIngenSchenau saya belum, terutama karena hubungan cinta / benci yang saya miliki dengan Boost. Saya pikir dalam hal ini beberapa format yang perlu saya dukung mungkin lebih kompleks daripada Peningkatan Serialisasi yang dapat menangani tanpa menambahkan kompleksitas yang cukup sehingga menggunakannya tidak banyak membantu saya.
Ah! Jadi Anda bukan (tidak) membuat serialisasi instance widget (itu akan aneh ...), tetapi widget ini hanya perlu membaca dan menulis data terstruktur? Apakah Anda harus menerapkan format file yang ada, atau Anda bebas menentukan format ad-hoc? Apakah widget yang berbeda menggunakan format umum atau serupa yang dapat diimplementasikan sebagai Model umum? Anda kemudian dapat melakukan antarmuka pengguna-domain-logika-model-DAL daripada membagi-bagi semuanya menjadi objek dewa WxWidget. Sebenarnya, saya tidak melihat mengapa widget relevan di sini.
amon
@amon Saya mengedit pertanyaan lagi. wxWidgets hanya relevan sejauh antarmuka dengan pengguna: Widget yang saya bicarakan tidak ada hubungannya dengan kerangka kerja wxWidgets (yaitu tidak ada objek dewa). Saya hanya menggunakan istilah itu sebagai nama umum untuk jenis DAO.
1
@ LarsViklund Anda membuat argumen yang meyakinkan dan Anda mengubah pendapat saya tentang masalah ini. Saya memperbarui kode contoh.

Jawaban:

7

Saya mungkin salah, tetapi desain Anda tampaknya terlalu direkayasa. Cerita bersambung hanya satu Widget, Anda ingin mendefinisikan WidgetReader, WidgetWriter, WidgetDatabaseReader, WidgetDatabaseWriterantarmuka yang masing-masing memiliki implementasi untuk XML, JSON, dan pengkodean biner, dan pabrik untuk mengikat semua kelas bersama-sama. Ini bermasalah karena alasan berikut:

  • Jika saya ingin cerita bersambung non Widgetkelas, sebut saja Foo, saya harus reimplement seluruh Zoo ini kelas, dan menciptakan FooReader, FooWriter, FooDatabaseReader, FooDatabaseWriterinterface, kali tiga untuk setiap format serialisasi, ditambah pabrik untuk membuatnya bahkan jauh digunakan. Jangan bilang tidak ada copy & paste yang terjadi di sana! Ledakan kombinatorial ini tampaknya cukup dapat dipelihara, bahkan jika masing-masing kelas tersebut pada dasarnya hanya berisi metode tunggal.

  • Widgettidak dapat dienkapsulasi secara wajar. Entah Anda membuka semua yang harus diserialkan ke dunia terbuka dengan metode pengambil, atau Anda harus menerapkan friendsetiap WidgetWriter(dan mungkin juga semua WidgetReader) semua . Dalam kedua kasus tersebut, Anda akan memperkenalkan banyak kaitan antara implementasi serialisasi dan Widget.

  • Kebun binatang pembaca / penulis mengundang ketidakkonsistenan. Setiap kali Anda menambahkan anggota Widget, Anda harus memperbarui semua kelas serialisasi terkait untuk menyimpan / mengambil anggota itu. Ini adalah sesuatu yang tidak dapat diperiksa kebenarannya secara statis, jadi Anda juga harus menulis tes terpisah untuk setiap pembaca dan penulis. Pada desain Anda saat ini, itu 4 * 3 = 12 tes per kelas yang ingin Anda serialkan.

    Di arah lain, menambahkan format serialisasi baru seperti YAML juga bermasalah. Untuk setiap kelas yang ingin Anda serialkan, Anda harus ingat untuk menambahkan pembaca dan penulis YAML, dan menambahkan kasing itu ke enum dan ke pabrik. Sekali lagi, ini adalah sesuatu yang tidak dapat diuji secara statis, kecuali jika Anda (terlalu) pintar dan membuat antarmuka templated untuk pabrik yang independen Widgetdan memastikan implementasi untuk setiap jenis serialisasi untuk setiap operasi masuk / keluar disediakan.

  • Mungkin Widgetsekarang memenuhi SRP karena tidak bertanggung jawab atas serialisasi. Tetapi implementasi pembaca dan penulis jelas tidak, dengan interpretasi “SRP = setiap objek memiliki satu alasan untuk berubah”: implementasi harus berubah ketika format serialisasi berubah, atau ketika Widgetperubahan.

Jika Anda dapat menginvestasikan waktu minimum sebelumnya, silakan coba untuk membuat kerangka serialisasi yang lebih umum daripada kusut kelas ad-hoc ini. Misalnya, Anda dapat mendefinisikan representasi pertukaran yang umum, sebut saja SerializationInfo, dengan model objek yang mirip JavaScript: sebagian besar objek dapat dilihat sebagai std::map<std::string, SerializationInfo>, atau sebagai std::vector<SerializationInfo>, atau sebagai primitif seperti int.

Untuk setiap format serialisasi, Anda kemudian akan memiliki satu kelas yang mengelola membaca dan menulis representasi serialisasi dari aliran itu. Dan untuk setiap kelas yang Anda ingin serialkan, Anda akan memiliki beberapa mekanisme yang mengubah instance dari / ke representasi serialisasi.

Saya telah mengalami desain dengan cxxtools ( beranda , GitHub , demo serialisasi ), dan sebagian besar sangat intuitif, berlaku luas, dan memuaskan untuk kasus penggunaan saya - satu-satunya masalah adalah model objek representasi serialisasi yang cukup lemah yang mengharuskan Anda untuk mengetahui selama deserialisasi secara tepat objek apa yang Anda harapkan, dan deserialisasi itu menyiratkan objek-objek yang dapat dibangun secara default yang dapat diinisialisasi nanti. Berikut contoh penggunaan yang dibuat-buat:

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

Saya tidak mengatakan bahwa Anda harus menggunakan cxxtools atau persis menyalin desain itu, tetapi dalam pengalaman saya desainnya membuatnya sepele untuk menambahkan serialisasi bahkan untuk kelas kecil, sekali saja, asalkan Anda tidak terlalu peduli tentang format serialisasi ( misalnya output XML default akan menggunakan nama anggota sebagai nama elemen, dan tidak akan pernah menggunakan atribut untuk data Anda).

Masalah dengan mode biner / teks untuk streaming sepertinya tidak bisa dipecahkan, tapi itu tidak terlalu buruk. Untuk satu hal, itu hanya penting untuk format biner, pada platform saya tidak cenderung memprogram untuk ;-) Lebih serius, ini adalah pembatasan infrastruktur serialisasi Anda, Anda hanya perlu mendokumentasikan dan berharap semua orang menggunakan dengan benar. Membuka aliran di dalam pembaca atau penulis Anda terlalu tidak fleksibel, dan C ++ tidak memiliki mekanisme tipe-tingkat bawaan untuk membedakan teks dari data biner.

amon
sumber
Bagaimana saran Anda berubah mengingat bahwa pada dasarnya DAO ini sudah merupakan kelas "info serialisasi"? Ini adalah C ++ yang setara dengan POJO . Saya akan mengedit pertanyaan saya juga dengan sedikit informasi lebih lanjut tentang bagaimana benda-benda ini akan digunakan.