Zarządzanie parametrami w aplikacji OOP

15

Piszę średniej wielkości aplikację OOP w C ++ jako sposób na ćwiczenie zasad OOP.

Mam kilka klas w moim projekcie, a niektóre z nich potrzebują dostępu do parametrów konfiguracji w czasie wykonywania. Parametry te są odczytywane z kilku źródeł podczas uruchamiania aplikacji. Niektóre są odczytywane z pliku konfiguracyjnego w katalogu domowym użytkowników, niektóre są argumentami wiersza poleceń (argv).

Więc stworzyłem klasę ConfigBlock. Ta klasa odczytuje wszystkie źródła parametrów i zapisuje je w odpowiedniej strukturze danych. Przykładami są ścieżki i nazwy plików, które użytkownik może zmienić w pliku konfiguracyjnym, lub flaga --verbose CLI. Następnie można zadzwonić ConfigBlock.GetVerboseLevel(), aby odczytać ten konkretny parametr.

Moje pytanie: czy dobrą praktyką jest gromadzenie wszystkich takich danych konfiguracyjnych środowiska wykonawczego w jednej klasie?

Następnie moje klasy potrzebują dostępu do wszystkich tych parametrów. Mogę wymyślić kilka sposobów na osiągnięcie tego, ale nie jestem pewien, który wybrać. Konstruktor klasy może mieć odniesienie do mojego ConfigBlocka, na przykład

public:
    MyGreatClass(ConfigBlock &config);

Lub zawierają po prostu nagłówek „CodingBlock.h”, który zawiera definicję mojego CodingBlock:

extern CodingBlock MyCodingBlock;

Następnie tylko plik .cpp klas musi zawierać i używać rzeczy ConfigBlock.
Plik .h nie przedstawia tego interfejsu użytkownikowi klasy. Jednak interfejs do ConfigBlock wciąż tam jest, ale jest ukryty w pliku .h.

Czy dobrze jest to tak ukryć?

Chcę, aby interfejs był jak najmniejszy, ale ostatecznie wydaje mi się, że każda klasa, która potrzebuje parametrów konfiguracyjnych, musi mieć połączenie z moim ConfigBlockiem. Ale jak powinno wyglądać to połączenie?

lugge86
źródło

Odpowiedzi:

10

Jestem dość pragmatykiem, ale moim głównym problemem jest to, że możesz pozwolić, ConfigBlockaby zdominowało to twoje projekty interfejsów w możliwie zły sposób. Gdy masz coś takiego:

explicit MyGreatClass(const ConfigBlock& config);

... bardziej odpowiedni interfejs może wyglądać tak:

MyGreatClass(int foo, float bar, const string& baz);

... w przeciwieństwie do zwykłego zbierania tych foo/bar/bazpól z ogromnych rozmiarów ConfigBlock.

Leniwy interfejs

Z drugiej strony, ten rodzaj projektu ułatwia zaprojektowanie stabilnego interfejsu dla twojego konstruktora, np. Jeśli w końcu potrzebujesz czegoś nowego, możesz po prostu załadować to do ConfigBlock(prawdopodobnie bez żadnych zmian kodu), a następnie wybieraj dowolne nowe rzeczy, których potrzebujesz, bez jakiejkolwiek zmiany interfejsu, tylko zmiana implementacji MyGreatClass.

Jest to więc w pewnym sensie plus i minus, który uwalnia cię od zaprojektowania bardziej przemyślanego interfejsu, który akceptuje tylko te dane wejściowe, których faktycznie potrzebuje. Stosuje sposób myślenia: „Po prostu daj mi tę ogromną kroplę danych, wybiorę z niej to, czego potrzebuję” w przeciwieństwie do czegoś bardziej podobnego: „Te precyzyjne parametry są tym, co ten interfejs musi działać”.

Są więc na pewno niektórzy profesjonaliści, ale ich zalety mogą być znacznie większe niż ich.

Sprzęganie

W tym scenariuszu wszystkie takie klasy tworzone z ConfigBlockinstancji mają swoje zależności wyglądające tak:

wprowadź opis zdjęcia tutaj

Może to być na przykład PITA, jeśli chcesz przeprowadzić test jednostkowy Class2na tym schemacie w izolacji. Być może będziesz musiał powierzchownie zasymulować różne ConfigBlockdane wejściowe zawierające odpowiednie pola, które Class2są zainteresowane, aby móc je przetestować w różnych warunkach.

W każdym nowym kontekście (test jednostkowy lub cały nowy projekt) każda taka klasa może stać się większym obciążeniem dla (ponownego) użytkowania, ponieważ ostatecznie musimy zawsze zabrać ConfigBlockze sobą na przejażdżkę i skonfigurować ją odpowiednio.

Możliwość ponownego użycia / wdrażania / testowalności

Zamiast tego, jeśli odpowiednio zaprojektujesz te interfejsy, możemy oddzielić je od siebie ConfigBlocki otrzymać coś takiego:

wprowadź opis zdjęcia tutaj

Jeśli zauważysz na powyższym schemacie, wszystkie klasy stają się niezależne (ich połączenia aferentne / wychodzące zmniejszają się o 1).

Prowadzi to do znacznie większej liczby niezależnych klas (przynajmniej niezależnych ConfigBlock), co może być o wiele łatwiejsze do (ponownego) użycia / przetestowania w nowych scenariuszach / projektach.

Teraz ten Clientkod jest tym, który musi polegać na wszystkim i złożyć wszystko razem. Ostatecznie obciążenie jest przenoszone do tego kodu klienta, aby odczytać odpowiednie pola z ConfigBlocki przekazać je do odpowiednich klas jako parametry. Jednak taki kod klienta jest generalnie wąsko zaprojektowany dla konkretnego kontekstu, a jego potencjał do ponownego użycia zwykle będzie zilch lub zamknięty (może to być mainfunkcja punktu wejścia aplikacji lub coś w tym rodzaju).

Z punktu widzenia możliwości ponownego użycia i testowania może to uczynić te klasy bardziej niezależnymi. Z punktu widzenia interfejsu dla tych, którzy używają twoich klas, może również pomóc w jawnym określeniu, jakich parametrów potrzebują, a nie tylko jednego, ConfigBlockktóry modeluje cały wszechświat pól danych wymaganych do wszystkiego.

Wniosek

Ogólnie rzecz biorąc, tego rodzaju projektowanie klasowe, które zależy od monolitu, który ma wszystko, co potrzebne, ma zwykle takie cechy. Ich zastosowanie, możliwość zastosowania, możliwość ponownego użycia, testowalność itp. Mogą w rezultacie ulec znacznej degradacji. Mogą jednak uprościć projekt interfejsu, jeśli spróbujemy go pozytywnie zakręcić. Od Ciebie zależy, czy zmierzysz zalety i wady i zdecydujesz, czy kompromisy są tego warte. Zazwyczaj bezpieczniej jest pomylić się z tego rodzaju projektami, w których wybieramy monolit z klas, które ogólnie mają na celu modelowanie bardziej ogólnego i szeroko stosowanego projektu.

Nie mniej ważny:

extern CodingBlock MyCodingBlock;

... jest to potencjalnie jeszcze gorsze (bardziej wypaczone?) pod względem cech opisanych powyżej niż podejście polegające na wstrzykiwaniu zależności, ponieważ ostatecznie łączy swoje klasy nie tylko ConfigBlocks, ale bezpośrednio z konkretnym jego wystąpieniem . To dodatkowo obniża możliwości zastosowania / wdrażania / testowalności.

Moja ogólna rada byłaby błędna przy projektowaniu interfejsów, które nie zależą od tego rodzaju monolitów w celu zapewnienia ich parametrów, przynajmniej dla najbardziej ogólnych klas, które projektujesz. I unikaj globalnego podejścia bez wstrzykiwania zależności, jeśli możesz, chyba że naprawdę masz bardzo silny i pewny powód, aby go nie unikać.

marstato
źródło
1

Zwykle konfiguracja aplikacji jest wykorzystywana głównie przez obiekty fabryczne. Każdy obiekt zależny od konfiguracji powinien zostać wygenerowany z jednego z tych obiektów fabrycznych. Możesz użyć abstrakcyjnego wzorca fabrycznego, aby zaimplementować jedną klasę, która przyjmuje cały ConfigBlockobiekt. Ta klasa ujawniałaby publiczne metody zwracania innych obiektów fabryki i przechodziłaby tylko w części ConfigBlockodpowiedniej dla tego konkretnego obiektu fabryki. W ten sposób ustawienia konfiguracji „spływają” z ConfigBlockobiektu do jego członków oraz z fabryki Factory do fabryk.

Użyję C #, ponieważ znam język lepiej, ale powinien on być łatwo przenoszony do C ++.

public class ConfigBlock
{
    public ConfigBlock()
    {
        // Load config data and
        // connectionSettings = new ConnectionConfig();
        // connectionSettings...
    }

    private ConnectionConfig connectionSettings;

    public ConnectionConfig GetConnectionSettings()
    {
        return connectionSettings;
    }
}

public class FactoryProvider
{
    public FactoryProvider(ConfigBlock config)
    {
        this.config = config;
    }

    private ConfigBlock config;

    public ConnectionFactory GetConnectionFactory()
    {
        ConnectionConfig connectionSettings = config.GetConnectionSettings();

        return new ConnectionFactory(connectionSettings);
    }
}

public class ConnectionFactory
{
    public ConnectionFactory(ConnectionConfig settings)
    {
        this.settings = settings;
    }

    private ConnectionConfig settings;

    public Connection GetConnection()
    {
        return new Connection(settings.Hostname, settings.Port, settings.Username, settings.Password);
    }
}

Następnie potrzebujesz jakiejś klasy, która działa jak „aplikacja”, która jest tworzona w głównej procedurze:

// Your main procedure (yeah I'm bending the rules of C# a tad here,
// but you get the point).
int Main(string[] args)
{
    Application app = new Application();

    app.Run();
}

public class Application
{
    public Application()
    {
        config = new ConfigBlock();
        factoryProvider = new FactoryProvider(config);
    }

    private ConfigBlock config;
    private FactoryProvider factoryProvider;

    public void Run()
    {
        ConnectionFactory connections = factoryProvider.GetConnectionFactory();
        Connection connection = connections.GetConnection();

        connection.Connect();

        // Enter into your main loop and do what this program is meant to do
    }
}

Jako ostatnia uwaga jest to znane jako „obiekt dostawcy” w mowie .NET. Obiekty dostawców w .NET wydają się łączyć dane konfiguracyjne z obiektami fabrycznymi, co jest zasadniczo tym, co chcesz tutaj zrobić.

Zobacz także wzór dostawcy dla początkujących . Ponownie, jest to ukierunkowane na rozwój .NET, ale ponieważ C # i C ++ oba są językami obiektowymi, wzorzec powinien być w większości możliwy do przeniesienia między nimi.

Kolejna dobra lektura związana z tym wzorcem: model dostawcy .

Wreszcie, krytyka tego wzoru: dostawca nie jest wzorem

Greg Burghardt
źródło
Wszystko jest dobrze, z wyjątkiem linków do modeli dostawców. Odbicie nie jest obsługiwane przez c ++ i to nie będzie działać.
BЈовић
@ BЈовић: Poprawnie. Odbicie klas nie istnieje, jednak można wbudować ręczne obejście, które zasadniczo przechodzi do switchinstrukcji lub ifinstrukcji testujących wartość odczytaną z plików konfiguracyjnych.
Greg Burghardt,
0

Pierwsze pytanie: czy dobrą praktyką jest gromadzenie wszystkich takich danych konfiguracyjnych środowiska wykonawczego w jednej klasie?

Tak. Lepiej jest scentralizować stałe i wartości środowiska wykonawczego oraz kod do ich odczytania.

Konstruktor klasy może mieć odniesienie do mojego ConfigBlocka

To źle: większość twoich konstruktorów nie będzie potrzebować większości wartości. Zamiast tego utwórz interfejsy dla wszystkiego, co nie jest łatwe do zbudowania:

stary kod (twoja propozycja):

MyGreatClass(ConfigBlock &config);

nowy kod:

struct GreatClassData {/*...*/}; // initialization data for MyGreatClass
GreatClassData ConfigBlock::great_class_values();

utwórz instancję MyGreatClass:

auto x = MyGreatClass{ current_config_block.great_class_values() };

Oto current_config_blockinstancja twojej ConfigBlockklasy (ta, która zawiera wszystkie twoje wartości), a MyGreatClassklasa otrzymuje GreatClassDatainstancję. Innymi słowy, przekaż konstruktorom tylko te dane, których potrzebują, i dodaj udogodnienia, aby ConfigBlockje utworzyć.

Lub zawierają po prostu nagłówek „CodingBlock.h”, który zawiera definicję mojego CodingBlock:

 extern CodingBlock MyCodingBlock;

Następnie tylko plik .cpp klas musi zawierać i używać rzeczy ConfigBlock. Plik .h nie przedstawia tego interfejsu użytkownikowi klasy. Jednak interfejs do ConfigBlock wciąż tam jest, ale jest ukryty w pliku .h. Czy dobrze jest to tak ukryć?

Ten kod sugeruje, że będziesz mieć globalną instancję CodingBlock. Nie rób tego: zwykle powinieneś mieć globalnie zadeklarowaną instancję, w dowolnym punkcie wejścia, którego używa Twoja aplikacja (funkcja główna, DllMain itp.) I przekaż ją jako argument, gdziekolwiek potrzebujesz (ale jak wyjaśniono powyżej, nie powinieneś przekazywać cała klasa wokół, po prostu odsłonić interfejsy wokół danych i przekazać je).

Nie łącz też klas klienta (twojego MyGreatClass) z typem CodingBlock; Oznacza to, że jeśli MyGreatClassweźmiesz ciąg i pięć liczb całkowitych, lepiej będzie, jeśli przekażesz ten ciąg i liczby całkowite, niż przejdziesz przez CodingBlock.

utnapistim
źródło
Myślę, że dobrym pomysłem jest oddzielenie fabryk od konfiguracji. Niezadowalające jest to, że implementacja konfiguracji powinna wiedzieć, jak tworzyć instancje komponentów, ponieważ z konieczności powoduje to zależność dwukierunkową, gdzie wcześniej istniała tylko zależność jednokierunkowa. Ma to ogromne znaczenie przy rozszerzaniu kodu, szczególnie w przypadku korzystania z bibliotek współdzielonych, w których interfejsy naprawdę mają znaczenie
Joel Cornett,
0

Krótka odpowiedź:

Ci nie muszą wszystkie ustawienia dla każdego z modułów / klas w kodzie. Jeśli tak, to coś jest nie tak z twoim projektowaniem obiektowym. Zwłaszcza w przypadku testowania jednostkowego ustawienie wszystkich zmiennych, których nie potrzebujesz i przekazanie tego obiektu nie pomogłoby w czytaniu lub utrzymywaniu.

Dawid Pura
źródło
W ten sposób mogę zebrać kod analizatora składni (parsowanie wiersza poleceń i plików konfiguracyjnych) w jednej centralnej lokalizacji. Następnie każda klasa może stamtąd wybrać odpowiednie parametry. Jak oceniasz dobry projekt?
lugge86
Może po prostu źle to napisałem - to znaczy, że masz (i to jest dobrą praktykę) abstrakcję ze wszystkimi ustawieniami pobieranymi z pliku konfiguracyjnego / zmiennych środowiskowych - co może być Twoją ConfigBlockklasą. Chodzi tutaj o to, aby nie podać całego, w tym przypadku, kontekstu stanu systemu, a konkretnie wymaganych wartości, aby to zrobić.
Dawid Pura,