Czy sprawiam, że moje zajęcia są zbyt szczegółowe? Jak stosować zasadę pojedynczej odpowiedzialności?

9

Piszę dużo kodu, który obejmuje trzy podstawowe kroki.

  1. Zdobądź skądś dane.
  2. Przekształć te dane.
  3. Umieść te dane gdzieś.

Zazwyczaj używam trzech rodzajów zajęć - zainspirowanych ich wzorami projektowymi.

  1. Fabryki - aby zbudować obiekt z jakiegoś zasobu.
  2. Mediatorzy - aby skorzystać z fabryki, przeprowadzić transformację, a następnie użyć dowódcy.
  3. Dowódcy - aby umieścić te dane gdzie indziej.

Moje klasy wydają mi się być dość małe, często pojedyncza (publiczna) metoda, np. Uzyskiwanie danych, przekształcanie danych, wykonywanie pracy, zapisywanie danych. Prowadzi to do mnożenia klas, ale ogólnie działa dobrze.

Tam, gdzie walczę, kiedy przychodzę na testy, kończę na ściśle powiązanych testach. Na przykład;

  • Factory - odczytuje pliki z dysku.
  • Commander - zapisuje pliki na dysk.

Nie mogę przetestować jednego bez drugiego. Mógłbym również napisać dodatkowy kod „testowy”, aby wykonać odczyt / zapis dysku, ale potem się powtarzam.

Patrząc na .Net, klasa File ma inne podejście, łączy obowiązki (mojej) fabryki i dowódcy. Posiada funkcje tworzenia, usuwania, istnienia i odczytu wszystkich w jednym miejscu.

Czy powinienem podążać za przykładem .Net i łączyć - szczególnie w przypadku zasobów zewnętrznych - moje klasy razem? Kod nadal jest sprzężony, ale jest bardziej celowy - dzieje się to w oryginalnej implementacji, a nie w testach.

Czy mój problem polega tutaj na tym, że zastosowałem zasadę jednej odpowiedzialności w sposób nadmiernie nadgorliwy? Mam osobne klasy odpowiedzialne za czytanie i pisanie. Kiedy mogłem mieć połączoną klasę, która jest odpowiedzialna za zarządzanie konkretnym zasobem, np. Dyskiem systemowym.

James Wood
źródło
6
Looking at .Net, the File class takes a different approach, it combines the responsibilities (of my) factory and commander together. It has functions for Create, Delete, Exists, and Read all in one place.- Pamiętaj, że łączysz „odpowiedzialność” z „rzeczą do zrobienia”. Odpowiedzialność jest bardziej jak „obszar troski”. Obowiązkiem klasy File jest wykonywanie operacji na plikach.
Robert Harvey,
1
Wygląda na to, że jesteś w dobrej formie. Wszystko czego potrzebujesz to mediator testowy (lub jeden dla każdego rodzaju konwersji, jeśli bardziej Ci się to podoba). Mediator testowy może czytać pliki w celu zweryfikowania ich poprawności, używając klasy plików .net. Nie ma z tym problemu z perspektywy SOLID.
Martin Maat,
1
Jak wspomniał @Robert Harvey, SRP ma gównianą nazwę, ponieważ tak naprawdę nie dotyczy Odpowiedzialności. Chodzi o „enkapsulację i wyodrębnienie jednego trudnego / trudnego obszaru, który może się zmienić”. Myślę, że STDACMC było za długie. :-) To powiedziawszy, myślę, że twój podział na trzy części wydaje się rozsądny.
user949300,
1
Ważnym punktem w Twojej Filebibliotece z C # jest, jak wiemy, Fileklasa może być po prostu fasadą, umieszczając wszystkie operacje na plikach w jednym miejscu - w klasie, ale może wewnętrznie używać podobnych klas do odczytu / zapisu do twojej, co by faktycznie zawierają bardziej skomplikowaną logikę do obsługi plików. Taka klasa Filenadal przestrzegałaby SRP, ponieważ proces faktycznej pracy z systemem plików zostałby zaabsorbowany za inną warstwą - najprawdopodobniej z interfejsem ujednolicającym. Nie mówię, że tak jest, ale może być. :)
Andy,

Odpowiedzi:

5

Przestrzeganie zasady pojedynczej odpowiedzialności mogło być tym, co cię tu kierowało, ale to, gdzie jesteś, ma inną nazwę.

Segregacja odpowiedzialności za zapytania poleceń

Idź przestudiuj to i myślę, że znajdziesz to zgodnie ze znanym schematem i nie jesteś sam w zastanawianiu się, jak daleko się to zajmie. Test kwasowy ma miejsce, jeśli przestrzeganie tego przyniesie ci realne korzyści lub jeśli jest to tylko ślepa mantra, którą przestrzegasz, więc nie musisz myśleć.

Wyraziłeś zaniepokojenie testowaniem. Nie sądzę, aby przestrzeganie CQRS wykluczało pisanie testowalnego kodu. Być może po prostu postępujesz zgodnie z CQRS w sposób uniemożliwiający testowanie kodu.

Pomaga wiedzieć, jak używać polimorfizmu do odwracania zależności kodu źródłowego bez konieczności zmiany przepływu kontroli. Nie jestem do końca pewien, gdzie są twoje umiejętności pisania testów.

Słowo ostrzeżenia, przestrzeganie nawyków, które można znaleźć w bibliotekach, nie jest optymalne. Biblioteki mają własne potrzeby i są szczerze mówiąc stare. Więc nawet najlepszy przykład jest wtedy najlepszym przykładem z tamtych czasów.

Nie oznacza to, że nie ma idealnie poprawnych przykładów, które nie są zgodne z CQRS. Postępowanie zgodnie z nim zawsze będzie trochę uciążliwe. Nie zawsze warto zapłacić. Ale jeśli będziesz go potrzebować, będziesz zadowolony, że go użyłeś.

Jeśli go użyjesz, posłuchaj tego ostrzeżenia:

W szczególności CQRS powinien być stosowany tylko w określonych częściach systemu (BoundedContext w języku DDD), a nie jako system jako całość. W tym sposobie myślenia każdy powiązany kontekst potrzebuje własnych decyzji dotyczących sposobu modelowania.

Martin Flowler: CQRS

candied_orange
źródło
Ciekawe, że wcześniej nie widziałem CQRS. Kod można przetestować, chodzi raczej o próbę znalezienia lepszego sposobu. Używam próbnych i zastrzyków zależności, kiedy mogę (co myślę, że o to ci chodzi).
James Wood,
Po raz pierwszy czytając o tym, zidentyfikowałem coś podobnego w mojej aplikacji: obsługuj elastyczne wyszukiwania, wiele pól filtrowalnych / sortowalnych, (Java / JPA) jest uciążliwe i prowadzi do mnóstwa kodu bojlera, chyba że stworzysz podstawową wyszukiwarkę, która obsłuży to dla ciebie (używam rsql-jpa). Chociaż mam ten sam model (powiedzmy te same jednostki JPA dla obu), wyszukiwania są wyodrębniane w dedykowanej usłudze ogólnej i warstwa modelu nie musi już z tym sobie radzić.
Walfrat
3

Potrzebujesz szerszej perspektywy, aby ustalić, czy kod jest zgodny z zasadą pojedynczej odpowiedzialności. Nie można na nie odpowiedzieć, analizując sam kod, należy zastanowić się, jakie siły lub podmioty mogą spowodować, że wymagania zmienią się w przyszłości.

Załóżmy, że przechowujesz dane aplikacji w pliku XML. Jakie czynniki mogą spowodować zmianę kodu związanego z czytaniem lub pisaniem? Niektóre możliwości:

  • Model danych aplikacji może ulec zmianie po dodaniu nowych funkcji do aplikacji.
  • Do modelu można dodawać nowe rodzaje danych - np. Obrazy
  • Format pamięci można zmienić niezależnie od logiki aplikacji: powiedz z XML na JSON lub na format binarny, ze względu na problemy z interoperacyjnością lub wydajnością.

We wszystkich tych przypadkach będzie trzeba zmienić zarówno czytania i pisania logiki. Innymi słowy, nie są to osobne obowiązki.

Ale wyobraźmy sobie inny scenariusz: Twoja aplikacja jest częścią potoku przetwarzania danych. Odczytuje niektóre pliki CSV wygenerowane przez oddzielny system, przeprowadza analizę i przetwarzanie, a następnie wyprowadza inny plik do przetworzenia przez trzeci system. W takim przypadku czytanie i pisanie są niezależnymi obowiązkami i należy je oddzielić.

Konkluzja: Zasadniczo nie można stwierdzić, czy odczytywanie i zapisywanie plików to osobne obowiązki, zależy to od ról w aplikacji. Ale w oparciu o twoją wskazówkę dotyczącą testowania, sądzę, że w twoim przypadku jest to jedna odpowiedzialność.

JacquesB
źródło
2

Ogólnie masz dobry pomysł.

Zdobądź skądś dane. Przekształć te dane. Umieść te dane gdzieś.

Wygląda na to, że masz trzy obowiązki. IMO, „Mediator”, może wiele robić. Myślę, że powinieneś zacząć od modelowania swoich trzech obowiązków:

interface Reader[T] {
    def read(): T
}

interface Transformer[T, U] {
    def transform(t: T): U
}

interface Writer[T] {
    def write(t: T): void
}

Następnie program można wyrazić jako:

def program[T, U](reader: Reader[T], 
                  transformer: Transformer[T, U], 
                  writer: Writer[U]): void =
    writer.write(transformer.transform(reader.read()))

Prowadzi to do mnożenia klas

Nie sądzę, że to jest problem. Wiele małych, spójnych, testowalnych klas IMO jest lepszych niż duże, mniej spójne klasy.

Tam, gdzie walczę, kiedy przychodzę na testy, kończę na ściśle powiązanych testach. Nie mogę przetestować jednego bez drugiego.

Każdy kawałek powinien być niezależnie testowany. Wzorowany powyżej, możesz reprezentować odczyt / zapis do pliku jako:

class FileReader(fileName: String) implements Reader[String] {
    override read(): String = // read file into string
}

class FileWriter(fileName: String) implements Writer[String] {
    override write(str: String) = // write str to file
}

Możesz napisać testy integracji, aby przetestować te klasy, aby sprawdzić, czy odczytują i zapisują w systemie plików. Resztę logiki można zapisać jako transformacje. Na przykład, jeśli pliki mają format JSON, możesz je przekształcić String.

class JsonParser implements Transformer[String, Json] {
    override transform(str: String): Json = // parse as json
}

Następnie możesz przekształcić w odpowiednie obiekty:

class FooParser implements Transformer[Json, Foo] {
    override transform(json: Json): Foo = // ...
}

Każdy z nich można niezależnie przetestować. Można również testy jednostkowe programpowyżej szyderczy reader, transformeri writer.

Samuel
źródło
Właśnie tam teraz jestem. Mogę przetestować każdą funkcję osobno, jednak testując je, łączą się. Np. Aby przetestować FileWriter, wtedy coś innego musi przeczytać to, co zostało napisane, oczywistym rozwiązaniem jest użycie FileReadera. Fwiw, mediator często robi coś innego jak stosowanie logiki biznesowej lub może być reprezentowany przez podstawową funkcję główną aplikacji.
James Wood,
1
@JamesWood często tak jest w przypadku testów integracyjnych. Nie musisz jednak łączyć klas w teście. Możesz przetestować FileWriter, czytając bezpośrednio z systemu plików zamiast używać FileReader. To naprawdę zależy od Twoich celów. Jeśli go użyjesz FileReader, test zostanie przerwany, jeśli któryś z nich jest uszkodzony FileReaderlub FileWritermoże to potrwać - co może potrwać dłużej.
Samuel
Zobacz także stackoverflow.com/questions/1087351/... może to pomóc w udoskonaleniu twoich testów
Samuel
Właśnie w tym miejscu jestem teraz - to nie w 100% prawda. Powiedziałeś, że używasz wzoru Mediatora. Myślę, że to nie jest przydatne tutaj; ten wzór jest używany, gdy masz wiele różnych obiektów oddziałujących ze sobą w bardzo zagmatwany sposób; umieszczasz tam mediatora, aby ułatwić wszystkie relacje i wdrożyć je w jednym miejscu. To chyba nie jest twój przypadek; masz bardzo dobrze zdefiniowane małe jednostki. Podobnie jak w powyższym komentarzu @Samuel, powinieneś przetestować jedną jednostkę i wykonać swoje twierdzenia bez dzwonienia do innych jednostek
Emerson Cardoso,
@EmersonCardoso; W moim pytaniu nieco uprościłem scenariusz. Podczas gdy niektórzy z moich mediatorów są dość prosta, inni są bardziej skomplikowani i często korzystają z wielu fabryk / dowódców. Staram się unikać szczegółów pojedynczego scenariusza, bardziej interesuje mnie architektura projektowania wyższego poziomu, którą można zastosować do wielu scenariuszy.
James Wood,
2

W końcu będę miał ściśle powiązane testy. Na przykład;

  • Factory - odczytuje pliki z dysku.
  • Commander - zapisuje pliki na dysk.

Dlatego skupiono się na tym, co łączy je ze sobą . Czy przekazujesz obiekt między nimi (taki jak File?). Następnie jest to plik, z którym są sprzężeni, a nie siebie nawzajem.

Od tego, co powiedziałeś, oddzieliłeś swoje klasy. Pułapka polega na tym, że testujesz je razem, ponieważ jest to łatwiejsze lub „ma sens” .

Dlaczego potrzebujesz danych wejściowych Commanderz dysku? Wszystko, na czym mu zależy, to pisanie przy użyciu określonych danych wejściowych, a następnie możesz sprawdzić, czy poprawnie zapisał plik, używając tego, co jest w teście .

W rzeczywistości testujesz, czy Factory„czyta ten plik poprawnie i wypisuje właściwą rzecz”? Wyśmiewaj plik przed odczytaniem go w teście .

Alternatywnie, sprawdzanie, czy Fabryka i Dowódca działają w połączeniu, jest w porządku - zgadza się z testami Integracji całkiem szczęśliwie. Pytanie tutaj dotyczy raczej tego, czy Twoja jednostka może je przetestować osobno.

Erdrik Ironrose
źródło
W tym konkretnym przykładzie łączy je zasób - np. Dysk systemowy. W przeciwnym razie nie ma interakcji między dwiema klasami.
James Wood,
1

Zdobądź skądś dane. Przekształć te dane. Umieść te dane gdzieś.

Jest to typowe podejście proceduralne, o którym pisał David Parnas w 1972 roku. Koncentrujesz się na tym, jak się sprawy mają. Konkretne rozwiązanie problemu traktujesz jako wzorzec wyższego poziomu, co zawsze jest błędne.

Jeśli stosujesz podejście obiektowe, wolę skoncentrować się na swojej domenie . O co w tym wszystkim chodzi? Jakie są główne obowiązki twojego systemu? Jakie są główne pojęcia przedstawione w języku Twoich ekspertów w dziedzinie? Więc zrozum swoją domenę, rozłóż ją, traktuj obszary odpowiedzialności wyższego poziomu jak swoje moduły , traktuj pojęcia niższego poziomu reprezentowane jako rzeczowniki jako obiekty. Oto przykład, który podałem w ostatnim pytaniu, jest bardzo istotny.

I jest wyraźny problem ze spójnością, sam o tym wspominałeś. Jeśli dokonasz modyfikacji, wprowadzisz logikę wejściową i napiszesz na niej testy, to w żaden sposób nie dowodzi, że funkcjonalność działa, ponieważ możesz zapomnieć o przekazaniu tych danych do następnej warstwy. Zobacz, te warstwy są ze sobą sprzężone. A sztuczne odsprzęganie czyni sprawy jeszcze gorszymi. Sam to wiem: 7-letni projekt ze 100 osobolat za moimi ramionami napisany całkowicie w tym stylu. Uciekaj, jeśli możesz.

I cała sprawa SRP. Chodzi przede wszystkim o spójność zastosowaną do przestrzeni problemowej, tj. Domeny. To podstawowa zasada SRP. Powoduje to, że obiekty są inteligentne i realizują swoje obowiązki za siebie. Nikt ich nie kontroluje, nikt nie dostarcza im danych. Łączą dane i zachowanie, odsłaniając tylko te ostatnie. Więc twoje obiekty łączą zarówno surowe sprawdzanie poprawności danych, transformację danych (tj. Zachowanie) i trwałość. Może to wyglądać następująco:

class FinanceTransaction
{
    private $id;
    private $storage;

    public function __construct(UUID $id, DataStorage $storage)
    {
        $this->id = $id;
        $this->storage = $storage;
    }

    public function perform(
        Order $order,
        Customer $customer,
        Merchant $merchant
    )
    {
        if ($order->isExpired()) {
            throw new Exception('Order expired');
        }

        if ($customer->canNotPurchase($order)) {
            throw new Exception('It is not legal to purchase this kind of stuff by this customer');
        }

        $this->storage->save($this->id, $order, $customer, $merchant);
    }
}

(new FinanceTransaction())
    ->perform(
        new Order(
            new Product(
                $_POST['product_id']
            ),
            new Card(
                new CardNumber(
                    $_POST['card_number'],
                    $_POST['cvv'],
                    $_POST['expires_at']
                )
            )
        ),
        new Customer(
            new Name(
                $_POST['customer_name']
            ),
            new Age(
                $_POST['age']
            )
        ),
        new Merchant(
            new MerchantId($_POST['merchant_id'])
        )
    )
;

W rezultacie istnieje kilka spójnych klas reprezentujących pewną funkcjonalność. Zauważ, że sprawdzanie poprawności zwykle dotyczy obiektów wartości - przynajmniej w podejściu DDD .

Vadim Samokhin
źródło
1

Tam, gdzie walczę, kiedy przychodzę na testy, kończę na ściśle powiązanych testach. Na przykład;

  • Factory - odczytuje pliki z dysku.
  • Commander - zapisuje pliki na dysk.

Uważaj na nieszczelne abstrakty podczas pracy z systemem plików - zbyt często go zaniedbywałem i ma on objawy, które opisałeś.

Jeśli klasa działa na danych pochodzących z / przechodzi do tych plików, wówczas system plików staje się szczegółem implementacji (I / O) i powinien być od niego oddzielony. Klasy te (fabryka / dowódca / mediator) nie powinny być świadome systemu plików, chyba że ich jedynym zadaniem jest przechowywanie / odczyt dostarczonych danych. Klasy zajmujące się systemem plików powinny zawierać parametry specyficzne dla kontekstu, takie jak ścieżki (mogą być przekazywane przez konstruktor), więc interfejs nie ujawniał swojej natury (słowo „Plik” w nazwie interfejsu przez większość czasu pachnie).

dreszcz
źródło
„Te klasy (fabryka / dowódca / mediator) nie powinny być świadome systemu plików, chyba że ich jedynym zadaniem jest przechowywanie / odczytywanie dostarczonych danych.” W tym konkretnym przykładzie to wszystko, co robią.
James Wood
0

Moim zdaniem brzmi to tak, jakbyś zaczął podążać właściwą ścieżką, ale nie posunąłeś się wystarczająco daleko. Myślę, że podzielenie funkcjonalności na różne klasy, które wykonują jedną rzecz i robią to dobrze, jest poprawne.

Aby pójść o krok dalej, należy utworzyć interfejsy dla klas Factory, Mediator i Commander. Następnie możesz użyć wyśmiewanych wersji tych klas, pisząc testy jednostkowe dla konkretnych implementacji innych. Za pomocą prób można sprawdzić, czy metody są wywoływane we właściwej kolejności i przy poprawnych parametrach oraz czy testowany kod działa poprawnie z różnymi wartościami zwracanymi.

Możesz także spojrzeć na abstrakcję odczytu / zapisu danych. Idziesz teraz do systemu plików, ale może będziesz chciał kiedyś przejść do bazy danych lub nawet gniazda. Klasa mediatora nie powinna się zmieniać, jeśli zmienia się źródło / miejsce docelowe danych.

Richard Wells
źródło
1
YAGNI to coś, o czym powinieneś pomyśleć.
whatsisname