Jak wytrwałość pasuje do czysto funkcjonalnego języka?

18

W jaki sposób wzorzec używania programów obsługi poleceń do radzenia sobie z trwałością pasuje do czysto funkcjonalnego języka, w którym chcemy, aby kod związany z IO był jak najcieńszy?


Podczas implementowania projektowania opartego na domenie w języku obiektowym często stosuje się wzorzec polecenia / procedury obsługi do wykonywania zmian stanu. W tym projekcie programy obsługi poleceń znajdują się nad obiektami domeny i są odpowiedzialne za nudną logikę związaną z trwałością, taką jak używanie repozytoriów i publikowanie zdarzeń domeny. Procedury obsługi są publiczną twarzą Twojego modelu domeny; kod aplikacji, taki jak interfejs użytkownika, wywołuje programy obsługi, gdy musi zmienić stan obiektów domeny.

Szkic w C #:

public class DiscardDraftDocumentCommandHandler : CommandHandler<DiscardDraftDocument>
{
    IDraftDocumentRepository _repo;
    IEventPublisher _publisher;

    public DiscardDraftCommandHandler(IDraftDocumentRepository repo, IEventPublisher publisher)
    {
        _repo = repo;
        _publisher = publisher;
    }

    public override void Handle(DiscardDraftDocument command)
    {
        var document = _repo.Get(command.DocumentId);
        document.Discard(command.UserId);
        _publisher.Publish(document.NewEvents);
    }
}

documentObiekt domena jest odpowiedzialna za wdrażanie reguł biznesowych (takich jak „użytkownik powinien mieć uprawnienie do odrzucenia dokumentu” lub „nie można odrzucić dokument, który już został odrzucony”) oraz do generowania zdarzeń domen musimy publikować ( document.NewEventsbędzie być IEnumerable<Event>i prawdopodobnie zawiera DocumentDiscardedzdarzenie).

Jest to ładny projekt - można go łatwo rozszerzyć (można dodawać nowe przypadki użycia bez zmiany modelu domeny, dodając nowe programy obsługi poleceń) i jest agnostyczny, jeśli chodzi o sposób utrwalania obiektów (można łatwo wymienić repozytorium NHibernate dla Mongo repozytorium lub zamień wydawcę RabbitMQ na wydawcę EventStore), co ułatwia testowanie przy użyciu podróbek i prób. Przestrzega także separacji modelu / widoku - moduł obsługi poleceń nie ma pojęcia, czy jest używany przez zadanie wsadowe, interfejs GUI, czy interfejs API REST.


W czysto funkcjonalnym języku, takim jak Haskell, możesz modelować moduł obsługi poleceń mniej więcej tak:

newtype CommandHandler = CommandHandler {handleCommand :: Command -> IO Result)
data Result a = Success a | Failure Reason
type Reason = String

discardDraftDocumentCommandHandler = CommandHandler handle
    where handle (DiscardDraftDocument documentID userID) = do
              document <- loadDocument documentID
              let result = discard document userID :: Result [Event]
              case result of
                   Success events -> publishEvents events >> return result
                   -- in an event-sourced model, there's no extra step to save the document
                   Failure _ -> return result
          handle _ = return $ Failure "I expected a DiscardDraftDocument command"

Oto część, którą staram się zrozumieć. Zazwyczaj istnieje jakiś rodzaj kodu „prezentacji”, który wywołuje moduł obsługi poleceń, na przykład GUI lub interfejs API REST. Więc teraz mamy w naszym programie dwie warstwy, które muszą wykonywać operacje wejścia / wyjścia - moduł obsługi poleceń i widok - co w Haskell jest dużym zakazem.

O ile mi wiadomo, istnieją tutaj dwie przeciwstawne siły: jedna to separacja modelu od widoku, a druga to potrzeba zachowania modelu. Musi istnieć kod IO, aby gdzieś utrwalić model , ale separacja modelu / widoku mówi, że nie możemy umieścić go w warstwie prezentacji z całym innym kodem IO.

Oczywiście w „normalnym” języku IO może (i tak się dzieje) wszędzie. Dobry projekt nakazuje, aby różne typy IO były oddzielone, ale kompilator tego nie wymusza.

Więc: jak pogodzić separację modelu / widoku z chęcią przesunięcia kodu IO na samą krawędź programu, kiedy model musi zostać utrwalony? Jak utrzymywać dwa różne typy IO osobno , ale wciąż z dala od całego czystego kodu?


Aktualizacja : Nagroda wygasa za mniej niż 24 godziny. Nie wydaje mi się, aby którakolwiek z obecnych odpowiedzi w ogóle odnosiła się do mojego pytania. @ Ptharien's Flame komentarz na temat acid-statewydaje się obiecujący, ale nie jest to odpowiedź i brakuje w niej szczegółów. Nie chciałbym, żeby te punkty zmarnowały się!

Benjamin Hodgson
źródło
1
Być może pomocne byłoby przyjrzenie się projektowi różnych bibliotek trwałości w Haskell; w szczególności acid-statewydaje się być zbliżony do tego, co opisujesz .
Ptharien's Flame
1
acid-statewygląda całkiem świetnie, dzięki za ten link. Jeśli chodzi o projekt interfejsu API, nadal wydaje się, że jest to związane IO; moje pytanie dotyczy tego, jak struktura trwałości pasuje do większej architektury. Czy znasz jakieś aplikacje typu open source, które używają acid-stateobok warstwy prezentacji, i udało Ci się je rozdzielić?
Benjamin Hodgson,
W rzeczywistości Queryi Updatemonady są dość dalekie IO. Spróbuję podać prosty przykład w odpowiedzi.
Ptharien's Flame
Ryzykując, że jestem nie na temat, dla wszystkich czytelników, którzy używają wzorca Command / Handler w ten sposób, naprawdę polecam sprawdzenie Akka.NET. Model aktora wydaje się tu dobrze dopasowany. Jest na to świetny kurs Pluralsight. (Przysięgam, że jestem tylko fanboyem, a nie botem promocyjnym.)
RJB

Odpowiedzi:

6

Ogólnym sposobem oddzielania komponentów w Haskell są stosy transformatorów monadowych. Wyjaśnię to bardziej szczegółowo poniżej.

Wyobraź sobie, że budujemy system, który ma kilka dużych komponentów:

  • komponent, który komunikuje się z dyskiem lub bazą danych (submodel)
  • komponent, który dokonuje transformacji w naszej domenie (model)
  • komponent, który wchodzi w interakcję z użytkownikiem (zobacz)
  • komponent opisujący połączenie między widokiem, modelem i submodelem (kontrolerem)
  • komponent, który uruchamia cały system (sterownik)

Zdecydowaliśmy, że musimy zachować luźne połączenie tych komponentów, aby zachować dobry styl kodu.

Dlatego kodujemy każdy z naszych składników polimorficznie, wykorzystując różne klasy MTL do prowadzenia nas:

  • każda funkcja w podmodelu jest typu MonadState DataState m => Foo -> Bar -> ... -> m Baz
    • DataState to czysta reprezentacja migawki stanu naszej bazy danych lub pamięci
  • każda funkcja w modelu jest czysta
  • każda funkcja w widoku jest typu MonadState UIState m => Foo -> Bar -> ... -> m Baz
    • UIState jest czystą reprezentacją migawki stanu naszego interfejsu użytkownika
  • każda funkcja w kontrolerze jest typu MonadState (DataState, UIState) m => Foo -> Bar -> ... -> m Baz
    • Zauważ, że kontroler ma dostęp zarówno do stanu widoku, jak i stanu podmodelu
  • sterownik ma tylko jedną definicję, main :: IO ()która wykonuje prawie banalną pracę polegającą na łączeniu pozostałych komponentów w jeden system
    • widok i podmodel będą musiały zostać podniesione do tego samego typu stanu, co kontroler używający zoomlub podobny kombinator
    • model jest czysty i dlatego można go używać bez ograniczeń
    • w końcu wszystko żyje (typ zgodny z) StateT (DataState, UIState) IO, który jest następnie uruchamiany z rzeczywistą zawartością bazy danych lub pamięci do wytworzenia IO.
Płomień Pthariena
źródło
1
To doskonała rada i dokładnie tego szukałem. Dzięki!
Benjamin Hodgson,
2
Trawię tę odpowiedź. Czy mógłbyś wyjaśnić rolę „submodelu” w tej architekturze? W jaki sposób „rozmawia z dyskiem lub bazą danych” bez wykonywania operacji we / wy? Jestem szczególnie zdezorientowany, co rozumiesz przez „ DataStatejest czystą reprezentacją migawki stanu naszej bazy danych lub magazynu”. Prawdopodobnie nie masz zamiaru ładować całej bazy danych do pamięci!
Benjamin Hodgson,
1
Bardzo chciałbym zobaczyć twoje przemyślenia na temat implementacji tej logiki w języku C #. Nie sądzę, że mogę przekupić cię aprobatą? ;-)
RJB
1
@RJB Niestety, musiałbyś przekupić zespół programistów C #, aby zezwolić na wyższe rodzaje w języku, ponieważ bez nich architektura ta jest nieco płaska.
Ptharien's Flame
4

Więc: jak pogodzić separację modelu / widoku z chęcią przesunięcia kodu IO na samą krawędź programu, kiedy model musi zostać utrwalony?

Czy model powinien zostać utrwalony? W wielu programach wymagane jest zapisanie modelu, ponieważ stan jest nieprzewidywalny, każda operacja może w dowolny sposób zmutować model, więc jedynym sposobem na poznanie stanu modelu jest bezpośredni dostęp do niego.

Jeśli w twoim scenariuszu sekwencja zdarzeń (polecenia, które zostały zatwierdzone i zaakceptowane) zawsze może wygenerować stan, to zdarzenia muszą zostać utrwalone, niekoniecznie stan. Stan można zawsze wygenerować, odtwarzając zdarzenia.

To powiedziawszy, często zdarza się, że stan jest przechowywany, ale tylko jako migawka / pamięć podręczna, aby uniknąć ponownego odtwarzania poleceń, a nie jako niezbędne dane programu.

Więc teraz mamy w naszym programie dwie warstwy, które muszą wykonywać operacje wejścia / wyjścia - moduł obsługi poleceń i widok - co w Haskell jest dużym zakazem.

Po zaakceptowaniu polecenia zdarzenie jest przekazywane do dwóch miejsc docelowych (pamięci zdarzeń i systemu raportowania), ale na tej samej warstwie programu.

Zobacz także
Event Sourcing
Chętna pochodna odczytu

FMJaguar
źródło
2
Jestem zaznajomiony z pozyskiwaniem zdarzeń (używam go w powyższym przykładzie!) I aby uniknąć rozdwajania się włosów, nadal powiedziałbym, że pozyskiwanie zdarzeń jest podejściem do problemu uporczywości. W każdym razie pozyskiwanie zdarzeń nie eliminuje potrzeby ładowania obiektów domeny w module obsługi poleceń . Program obsługi poleceń nie wie, czy obiekty pochodzą ze strumienia zdarzeń, ORM, czy procedury przechowywanej - po prostu pobiera je z repozytorium.
Benjamin Hodgson
1
Twoje zrozumienie wydaje się łączyć widok i moduł obsługi poleceń w celu utworzenia wielu operacji we / wy. Rozumiem, że przewodnik generuje zdarzenie i nie ma dalszego zainteresowania. Widok w tym przypadku działa jako osobny moduł (nawet jeśli technicznie jest w tej samej aplikacji) i nie jest sprzężony z programem obsługi poleceń.
FMJaguar
1
Myślę, że możemy rozmawiać na różne sposoby. Kiedy mówię „zobacz”, mówię o całej warstwie prezentacji, która może być interfejsem API REST lub systemem kontrolera widoku modelu. (Zgadzam się, że widok powinien być oddzielony od modelu we wzorze MVC). Zasadniczo mam na myśli „jakiekolwiek wywołania do programu obsługi poleceń”.
Benjamin Hodgson
2

Próbujesz umieścić miejsce w aplikacji intensywnie korzystającej z IO na wszystkie działania inne niż IO; niestety typowe aplikacje CRUD, o których mówisz, robią niewiele poza IO.

Wydaje mi się, że rozumiesz odpowiednią separację, ale tam, gdzie próbujesz umieścić kod IO trwałości na pewnej liczbie warstw z dala od kodu prezentacji, ogólny fakt jest w twoim kontrolerze, gdzieś powinieneś zadzwonić do swojego warstwa uporczywości, która może wydawać się zbyt blisko twojej prezentacji - ale to tylko zbieg okoliczności, że w tego typu aplikacjach nie ma nic więcej.

Prezentacja i wytrwałość składają się zasadniczo na cały rodzaj aplikacji, którą myślę, że tu opisujesz.

Jeśli myślisz w myślach o podobnej aplikacji, która zawiera dużo złożonej logiki biznesowej i przetwarzania danych, myślę, że możesz sobie wyobrazić, jak to jest ładnie oddzielone od prezentacji IO i uporczywych operacji IO, takich jak nie musi też nic wiedzieć. Problem, który masz teraz, jest po prostu postrzegalny, spowodowany próbą znalezienia rozwiązania problemu w typie aplikacji, która na początku nie ma tego problemu.

Jimmy Hoffa
źródło
1
Mówisz, że system CRUD może łączyć trwałość i prezentację. Wydaje mi się to rozsądne; nie wspomniałem jednak o CRUD. Pytam konkretnie o DDD, gdzie masz obiekty biznesowe ze złożonymi interakcjami, warstwę trwałości (programy obsługi poleceń) i warstwę prezentacji. Jak utrzymywać dwie warstwy we / wy oddzielnie, zachowując cienkie opakowanie we / wy?
Benjamin Hodgson,
1
Uwaga: dziedzina, którą opisałem w pytaniu, może być bardzo złożona. Być może odrzucenie wersji roboczej dokumentu podlega sprawdzeniu niektórych zaangażowanych uprawnień lub może zajść potrzeba przetworzenia wielu wersji tego samego projektu lub powiadomienia muszą zostać wysłane lub działanie wymaga zatwierdzenia przez innego użytkownika lub wersje robocze przechodzą przez szereg etapy cyklu życia przed finalizacją ...
Benjamin Hodgson,
2
@BenjaminHodgson Zdecydowanie odradzam mieszanie DDD lub innych metodologii projektowania z natury OO w tej sytuacji w twojej głowie, to tylko się pomyli. Chociaż tak, możesz tworzyć obiekty takie jak bity i bobble w czystym FP, oparte na nich podejścia projektowe niekoniecznie powinny być twoim pierwszym zasięgiem. W opisanym przez ciebie scenariuszu wyobrażam sobie, jak wspomniałem powyżej, kontroler, który komunikuje się między dwoma IO a czystym kodem: IO prezentacji wchodzi i jest wymagane od kontrolera, kontroler przekazuje rzeczy do sekcji czystych i sekcji trwałości.
Jimmy Hoffa
1
@BenjaminHodgson możesz sobie wyobrazić bańkę, w której żyje cały twój czysty kod, ze wszystkimi warstwami i fantazją, jakiej potrzebujesz w dowolnym projekcie, który cenisz. Punktem wyjścia dla tej bańki będzie mały kawałek, który nazywam „kontrolerem” (być może niepoprawnie), który komunikuje się między prezentacją, trwałością i czystymi kawałkami. W ten sposób twoja wytrwałość nie wie nic o prezentacji ani czystym i vice versa - i to utrzymuje twoje rzeczy we / wy w tej cienkiej warstwie ponad bańką twojego czystego systemu.
Jimmy Hoffa
2
@BenjaminHodgson to podejście „inteligentnych obiektów”, o którym mówisz, jest z natury złym podejściem do FP, problemem z inteligentnymi obiektami w FP jest to, że łączą się one zdecydowanie za dużo i generalizują zdecydowanie za mało. W efekcie powstają dane i związane z nimi funkcje, przy czym FP preferuje, aby dane były luźno sprzężone z funkcjami, dzięki czemu można zaimplementować funkcje uogólnione, a następnie będą działać na wielu typach danych. Przeczytaj moją odpowiedź tutaj: programmers.stackexchange.com/questions/203077/203082#203082
Jimmy Hoffa
1

Tak blisko, jak potrafię zrozumieć twoje pytanie (czego mogę nie wiedzieć, ale pomyślałem, że wrzucę moje 2 centy), ponieważ niekoniecznie masz dostęp do samych obiektów, musisz mieć własną bazę danych obiektów, która wygasa z czasem).

Idealnie same obiekty można ulepszyć, aby zachowywały swój stan, więc gdy zostaną one „przekazane”, różne procesory poleceń będą wiedziały, z czym pracują.

Jeśli nie jest to możliwe (icky icky), jedynym sposobem jest posiadanie jakiegoś wspólnego klucza podobnego do DB, którego można użyć do przechowywania informacji w sklepie, który jest skonfigurowany do udostępniania różnych komend - i miejmy nadzieję, „otwórz” interfejs i / lub kod, aby inni pisarze poleceń przyjęli również interfejs do zapisywania i przetwarzania meta-informacji.

W obszarze serwerów plików samba ma różne sposoby przechowywania rzeczy, takich jak listy dostępu i alternatywne strumienie danych, w zależności od tego, co zapewnia system operacyjny hosta. Idealnie, samba jest hostowana w systemie plików i zapewnia rozszerzone atrybuty plików. Przykład „xfs” na „linux” - więcej poleceń kopiuje atrybuty rozszerzone wraz z plikiem (domyślnie większość programów na Linuksie „dorastała” bez atrybutów rozszerzonych).

Alternatywnym rozwiązaniem - które działa dla wielu procesów samby od różnych użytkowników działających na wspólnych plikach (obiektach), jest to, że jeśli system plików nie obsługuje dołączania zasobu bezpośrednio do pliku, jak w przypadku atrybutów rozszerzonych, używa modułu, który implementuje wirtualna warstwa systemu plików do emulacji rozszerzonych atrybutów dla procesów samby. Tylko samba o tym wie, ale ma tę zaletę, że nie obsługuje formatu obiektowego, ale nadal działa z różnymi użytkownikami samby (patrz procesory poleceń), którzy pracują nad plikiem w oparciu o jego poprzedni stan. Będzie przechowywać meta informacje we wspólnej bazie danych dla systemu plików, co pomaga kontrolować rozmiar bazy danych (i nie „

Może ci się to nie przydać, jeśli potrzebujesz więcej informacji specyficznych dla implementacji, z którą pracujesz, ale koncepcyjnie ta sama teoria może być zastosowana do obu zestawów problemów. Więc jeśli szukasz algorytmów i metod do robienia tego, co chcesz, może to pomóc. Jeśli potrzebujesz bardziej szczegółowej wiedzy w określonych ramach, być może nie jest tak pomocny ... ;-)

BTW - powód, dla którego wspominam o „wygasającym” - jest to, że nie jest jasne, czy wiesz, jakie obiekty są na zewnątrz i jak długo się utrzymują. Jeśli nie masz bezpośredniego sposobu, aby dowiedzieć się, kiedy obiekt jest usuwany, musisz przyciąć swój własny metaDB, aby zapobiec wypełnieniu go starymi lub starożytnymi meta informacjami, dla których użytkownicy już dawno usunęli te obiekty.

Jeśli wiesz, kiedy obiekty wygasają / są usuwane, oznacza to, że jesteś przed grą i możesz jednocześnie wygasić ją z metaDB, ale nie było jasne, czy masz taką opcję.

Twoje zdrowie!

Astara
źródło
1
Wydaje mi się, że jest to odpowiedź na zupełnie inne pytanie. Szukałem porady dotyczącej architektury w programowaniu czysto funkcjonalnym w kontekście projektowania opartego na domenie. Czy mógłbyś wyjaśnić swoje punkty?
Benjamin Hodgson,
Pytasz o trwałość danych w czysto funkcjonalnym paradygmacie programowania. Cytując Wikipedię: „Czysto funkcjonalny to termin obliczeniowy używany do opisywania algorytmów, struktur danych lub języków programowania, które wykluczają destrukcyjne modyfikacje (aktualizacje) bytów w środowisku uruchomionym programu”. ==== Z definicji utrwalanie danych jest nieistotne i nie ma pożytku z czegoś, co modyfikuje brak danych. Ściśle mówiąc, nie ma odpowiedzi na twoje pytanie. Próbowałem luźniej interpretować to, co napisałeś.
Astara,