Design einer Bibliothek mit austauschbarer Implementierung



  • Hi,

    ich moechte eine Bibliothek erstellen die verschiedene third party libs nutzt. Beispielsweise wird ein HTTPS Client benoetigt.
    In meiner Implementierung verwende ich dafuer libcurl mit OpenSSL um moeglichst Plattform unabhaengig zu sein. Allerding moechte ich ein Design das es den User ermoeglicht zB. WinHTTP zu verwenden.
    Ein anderes Beispiel ist Crypto/Encoding/hash. Ich verwende dafuer CryptoPP, aber anderen schwoeren vielleicht auf OpenSSL oder moechten dies verwenden da wir das fuer libcurl sowieso brauchen. Wieder anderen wollen die Windows libs nutzten, um etwas an der groesse einzusparen.

    Meine Idee waere foldendermassen:

    Jede Klasse die eine austauschbare implementierung hat, stellt eine statische 'create' Methode bereit die in der jeweiligen Implementierung erstellt werden muss:

    //httpClient.hpp
    
    typedef std::unique_ptr<Client> client_ptr;
    
    class Client : boost::noncopyable {
    
    public:
        static client_ptr create(const char* host, port_t port = 443);
    
    public:		
        Client(const char* host, port_t port);
        virtual ~Client() {};
        //normal members...
        void verbose(std::ostream* stream = &std::cerr);			
        void addHeader(const header_map_t& headers);
        //...
        virtual Response request(method_t method, const std::string& path, size_t size, upload_cb_t dataCallback) = 0;
    };
    
    //client.cpp
        void Client::verbose(std::ostream* stream){ /* ... */ }
    
    //libcurl_httpClient.cpp
    
        class LibcurlClient 
            : Client
        {
        public:
            Response request(method_t method, const std::string& path, size_t size, upload_cb_t dataCallback){ /* ... */ }
        private:
            CURL* curl_
        }
    
        client_ptr Client::create(const char* host, port_t port = 443){    
            return client_ptr( new LibcurlClient(host, port));    
        }
    

    Allerdings sieht mir das irgendwie alles nicht richtig aus. Gibt es da einen besseren Ansatz dafuer?

    Noch eine Frage zu smart_ptr typdefs:

    Was wuerdet ihr bevorzugen:

    // 1. 
    class Client;
    typedef std::unique_ptr<Client> client_ptr;
    
    // 2. 
    class Client {
       typedef std::unique_ptr<Client> ptr;   
    }
    
    //3. ganz anders
    

    Gruessle



  • 1. Soll die Implementierung zur Laufzeit einstellbar sein oder zur Compilezeit oder zu beiden Zeitpunkten? Bedenke, dass du zum Kompilieren dann allenfalls alle Abhängigkeiten benötigst.

    2. Willst du innerhalb einer Ausführung mehrere Bibliotheken gleichzeitig unterstützen?

    3. Sollen je nach ausgewählter Bibliothek Zusatzfunktionen angeboten werden?

    Egal wie die Antwort lautet, nimm eine Nicht-Virtuelle Klasse:

    // Header:
    class Client {
        class Impl;
        std::unique_ptr<Impl> pimpl;
    public:
        Client(string_ref host, port_t port);
        ~Client(); // Wird defaulted
        void verbose(std::ostream& stream = std::cerr);
        void addHeader(const header_map_t& headers);
        //...
        Response request(method_t method, string_ref path, size_t size, upload_cb_t dataCallback);
    };
    
    // Implementation
    class Client::Impl {
        void verbose(std::ostream& stream) {...}
        void addHeader(const header_map_t& headers) {...}
        Response request(method_t method, const std::string& path, size_t size, upload_cb_t dataCallback) {...}
    };
    
    Client::~Client() =default;
    Client::Client(string_ref host, port_t port) : pimpl(std::make_unique<Impl>(host, port)) {}
    void verbose(std::ostream& stream) { pimpl->verbose(stream); }
    void addHeader(const header_map_t& headers) { pimpl->addHeader(headers); }
    Response request(method_t method, string_ref path, size_t size, upload_cb_t dataCallback) { pimpl->request(method, path, size, dataCallback); }
    

    Falls die Entscheidung zum Backend erst zur Laufzeit entschieden wird, muss, machst du Impl halt virtuell.

    Warum das?
    - Binärkompatibilität
    - Einfache Benutzung: Die ganze ptr-Thematik entfällt. Nicht-unique_ptr-Benutzung ist sowieso nicht vorgesehen.
    - Mehr Kontrolle über die Instanzen. Ableiten vom Benutzer ist nicht vorgesehen, aber wäre für einzelne Klassen sowieso nicht gut.
    - Klassen, die für alle Backends gleich sind, verhalten sich gleich wie der Rest.



  • +1 zu mcpimp
    Zusätzlich würde ich noch eine zusätzliche Indirektion einführen, indem ich ein portables lowlevel Interface (nur Funktionen!) spezifiziere. Die Implementation des Client ist dann nur ein C++ Wrapper um das Interface, was dann verschiedene Implementierung bereitstellt, die via Makros gesteuert werden.
    Bsp. File API:

    // file_api.hpp
    using handle_type = ...;
    enum file_flags
    {
      ...
    };
    handle_type file_open(const char *name, file_flags flags);
    void file_close(handle_type h);
    // file_api.cpp
    #if WINDOWS
    handle_type file_open(const char *name, file_flags flags)
    {
      // map flags to WinAPI flags
      // open file
    }
    #elif LINUx
    ...
    #endif
    

    Die eigentliche C++ Klasse kann sich dann einfach auf ihre Aufgabe als RAII Wrapper konzentrieren. Und Leute, die später die API anpassen müssen, müssen nur simple Funktionen implementieren.



  • mcpimp schrieb:

    1. Soll die Implementierung zur Laufzeit einstellbar sein oder zur Compilezeit oder zu beiden Zeitpunkten? Bedenke, dass du zum Kompilieren dann allenfalls alle Abhängigkeiten benötigst.

    2. Willst du innerhalb einer Ausführung mehrere Bibliotheken gleichzeitig unterstützen?

    3. Sollen je nach ausgewählter Bibliothek Zusatzfunktionen angeboten werden?

    1. : nur zur Compilezeit
    2. : nein
    3. : nein, sollte immer genau die gleiche Funktionalitaet vorhanden sein

    Ja hatte mir das mit dem Pimpl-Pattern vorhin auch schon mal durchgelesen, aber leider wusste ich da nicht was ich mit den Backend unspezifischen Attributen anfangen sollte. Beispielsweise sollen custome Header mitgesendet werden koennen. Bis jetzt hab ich eine Attribute 'header_map_t headers_' in der Klasse Client. Diese Header werden auch da verwaltet, da sie ja Backend unabhaengig sind.

    Wie bekommt jetzt die Implementierung darauf zugriff?
    Eventuell so:

    class Client::Impl {
       Impl(Client* owner /* .. */)
    private:
       Client* owner_;
    }
    


  • Danke für die Information, dann kann ich nun konkreter werden.

    Ich würde keine Präprozessor-if/else-Kaskaden wie von Nathan vorgeschlagen benuzen. Das ist unübersichtlich und schlecht erweiterbar.

    Nimm stattdessen eine Dateistruktur in dieser Art:

    myproject/
      include/
        myproject/
          ... # die Header-Dateien
      src/
        ... # Siehe unten
      curl/
        wrapper/
           ... # curl-Wrapper
      openssl/
        wrapper/
           ... # Open-SSL-Wrapper
    

    Der Punkt ist nun, dass du je nach ausgewähltem Backend mit -Icurl/ oder -Iopenssl kompilieren kannst.

    Deine Implementierung (im Ordner src/) sieht nun so aus:

    #include <wrapper/client.h> // Wählt den richtigen wrapper aus.
                                // Ich würde gleich alles in wrapper/client.h implementieren,
                                // aber zumindest die Option für eine weitere Datei ist vorhanden
    
    struct Client::Impl { // Kapselung nicht nötig, alles public
       Impl() {...}
       wrapper::client client; // Wrapper-Daten
       header_map_t headers_; // Alles ins impl rein. Client soll nur aus einem unique_ptr bestehen
       void addHeader(const header_map_t& headers) {...} // Keine Interaktion mit dem Wrapper nötig
                                                         // Solche Methoden gleich hier implementieren, dann sparst
                                                         // du dir mehrfachen Aufwand
       Response request(method_t method) {
         client.request(method, headers_); // Hier direkt den Wrapper aufrufen mit allen benötigten Daten
       }
    }
    

    Wichtig ist, dass du alles in das Impl reinverschiebst. Für das Pimpl-Idiom zahlst du mit einem gewissen Overhead, der dynamischen Speicherallokation von Impl. Wenn du das schon tust, dann sollst du auch von den Vorteilen profitieren, nämlich schnelles moven und wenig Stackspeicher. sizeof(Client)==sizoef(void*) . Es ist effizienter als dein Vorschlag, weil nie virtual vorkommt. Und du vermeidest das angesprochene Problem. Wenn Impl alles weiss, braucht es auch keinen Pointer zu Client.

    Wie die Schnittstelle zum Wrapper aussieht, kannst du entscheiden. Ich finde es praktisch, alles in eine Klasse (struct) zu schmeissen und dann Memberfunktionen zu implementieren, da man sich so Argumente spart. Du kannst aber auch eine Art C-API benutzen, wie Nathan empfohlen hat. Oder eine Mischung. Wichtig ist nur, dass es in allen den Wrappern gleich ist.

    Und definiere den Wrapper non-copyable und non-movable. Impl und damit auch der Wrapper werden weder kopiert, noch verschoben.



  • mcpimp schrieb:

    ....

    Aber damit verschiebe ich doch das Problem nur eine Ebene weiter nach Unten.
    Die konkrete Implementation ( curl ) hat immer noch nicht den Zugriff auf die Backend unspezifischen Daten.
    Wenn ich jetzt diese Daten benoetige muss ich sie per Parameter uebergeben.
    Dann wuerde ein Aufruf der curl_wrapper Function so aussehen:

    client.request( method, path, uploadSize, host_, headers_, debugStream_, onUpload_, onDownload_, onProgress_);
    

    Und das ist ja auch nicht Sinn der Sache.

    Spricht was dagegen wenn ich deinen Ansatz nehme und die Implementation einfach von Client::Impl ableite. Klar nutze ich damit virtuelle Funktionen, aber ist das wirklich so schlimm? Von der Performance her ist doch gar nicht so der grosse unterschiede. Oder taeusche ich mich da gerade.

    Danke auf jeden Fall fuer deine Hilfe.


Anmelden zum Antworten