Aplikacja służy do synchronizacji danych pomiędzy dwoma rożnymi zewnętrznymi API.
Z definicji nie może to być czysto funkcyjnie: wszak zapisywanie do zewnetrznego API to będzie jakiś void
albo w najlepszym wypadku Task
.
No ale do pewnego momentu można pisać w miarę ładnie:
public async Task Synchronizuj()
{
var daneZFoo = await Foo.PobierzDane();
Bar.ZapiszDane(daneZFoo);
var daneZBar = Bar.PobierzDane();
await Foo.ZapiszDane(daneZBar);
}
(oczywiście upraszczam)
Ale teraz pojawiają się dwa wymogi, które wyraźnie utrudniają sprawy.
Wymóg 1: Aplikacja ma być odporna na błędy. Jeżeli dla przykładu w Foo mamy 10 dokumentów, z czego zapisywanie 5. w Bar rzuci wyjątkiem (jakimkkolwiek), to nie mamy prawa zrobić klasycznego "wycofania transakcji" - czyli finalnie nie są zapisane nawet te 4 dokumenty, których zapisanie się udało + wpis do logu. Wprost przeciwnie, nie tylko te 4 poprzednie dokumenty mają pozostać zapisane, ale co więcej, zamiast się crashnąć trzeba próbowac zapisywać dalej resztę dokumentów. Mało tego, potem trzeba próbować także przerzucić dane w drugą stronę, czyli z Bar do Foo. (Co z tego, że takie postępowanie może prowadzić do niespójności danych, i tak trzeba).
Wymóg 2: Z przyczyn wydajnościowych + aby uniknąć zbędnego wyścigu nie przerzucamy z powrotem z Bar do Foo danych, które przed chwilą przerzuciliśmy z Foo do Bar.
Tutaj wchodzą mutacje.
Bar.ZapiszDane musi zwrócić dokumenty, jakie zostały finalnie (w którejkolwiek podmetodzie) zapisane do Bar. A więc co? Funkcyjnie?
public async Task Synchronizuj
{
var daneZFoo await Foo.PobierzDane();
var zapisaneDoBar = Bar.ZapiszDane(daneZFoo);
var daneZBar = Bar.PobierzDane(zapisaneDoBar);
await Foo.ZapiszDane(daneZBar);
}
Nie - Bar.ZapiszDane przecież finalnie może rzucic AggregateException
, wyliczającym wyjątki, które poszły gdzieś w środku niego, ale które zignorował. Nie zwracac wyjątków tez nie możemy, Synchronizuj()
jednak musi wiedzieć, czy finalnie wszystko OK czy gdzieś sie jednak błędy pojawiły.
Rozwiązanie, do jakiego doszedłem, wygląda mniej więcej tak. W kodzie jakas metoda ze środka Bar.ZapiszDane:
private void ZapiszDokumenty(List<Dokument> doZapisania, List<string> zapisaneDokumenty)
{
List<Exception> wyjatki = new List<Exception>{};
foreach(var d in doZapisania)
{
try
{
ZapiszDokument(d, zapisaneDokumenty);
}
catch(Exception e)
{
Log.Błąd(e);
wyjatki.Add(e);
}
}
if(wyjatki.Any())
{
throw new AggregateException(wyjatki);
}
else
{
Log.Write("Wszystkie dokumenty zapisane prawidłowo.");
ZapiszDatęOstatniejSkutecznejSynchronizacjiDokumentów(DateTimeOffset.Now); // potrzebne dla celów optymizacyjnych, NIE WOLNO zapisywać jeśli były błędy
}
}
private void ZapiszDokument(Dokument doZapisania, List<string> zapisaneDokumenty)
{
// tutaj pominięte wywołania zewnętrznego API; owo zewnętrzne API jest marnie udokumentowane, może rzucać np błędami walidacji
zapisaneDokumenty.Add(doZapisania.numerDokumentu); // Jak wyjątek, to nie dojdziemy do tego punktu, i dobrze.
}
Oczywiście te try/catch się powtarzają w kółko i w kółko - wyabstrachowałem to (tylko nie chcę tu podawać jak wyłącznie z tego powodu, że post jest i tak dośc długi). Tutaj tylko wkleiłem niewyabstrachowane, by było wiadomo, jak to działa.
Bardziej mnie interesuje ta lista, przekazywana między metodami, do której rozmaite metody doklejają kolejne obiekty, by zapisać, co zrobiły. Chodzi mi o to, by nawet wyjątek nie był w stanie uniemozliwić zwrócenia tej listy - jeśli zapisalismy 9 dokumentów a 10. rzucił wyjatkiem, i tak musimy zwrócić, które dokumenty zapisalismy skutecznie.
Na upartego da sie to przerobić funkcyjnie: pewnie trzeba zbudować jakiegoś Eithera... cos na zasadzie:
public interface Either<T> {}
public Right<T> : Either<T>
{
public T Value {get; private set;}
public Right(T value)
{
Value = value;
}
}
public Left<T> : Either<T>, Exception
{
public Left(Exception inner) : base(inner.Message, inner) {}
}
I niech ZapiszDokumenty
zwraca listę takich Either
ów.
Pytanie moje brzmi: po co?
Tzn. być może od początku tak należało zrobić, ale teraz, kiedy to już wyabstrachowałem w inny sposób, czy jest sens tracić czas na przerabianie tego "funkcyjnie", byle by tylko uniknąć tego parametru - mutowalnej listy? (jest presja na jak najszybsze ukończenie wymaganej funkcjonalności = nie chcę przeznaczać czasu na przerabianie działającego kodu tak, by pasował do dogmatów takich, jak zakaz mutacji)
Jaka jest szkoda w tym, że metody dostają kolejny parametr - mutowalną listę - do której dopisują, co zrobiły?
O ile dla czytającego będzie jasne, że ten dodatkowy parametr do tego właśnie służy, wydaje mi sie, że nie ma szkody w tym, że mamy tutaj mutowanie.
To ostatnie zaś da się załatwić za pomocą konwencji nazewniczych.
Mógłby mi ktoś wytłumaczyć, dlaczego to, co zrobiłem teraz, jest szkodliwe?
Zazwyczaj starałem się unikać zbędnych mutacji, tym razem jednak musiałem sie chwile zastanowić, jak zrobić to, co było wymagane, więc w pierwszej chwili zaimplementowałem taki pomysł, jaki miałem - dopiero później przyszła refleksja z tym Eitherem.