devstyle.pl - Blog dla każdego programisty
devstyle.pl - Blog dla każdego programisty
9 minut

Tworzenie obiektów poprzez Fluent Interface – dla każdego


15.07.2010

O Fluent Interface (“płynnym interfejsie”?:) ) jest od jakiegoś czasu dość głośno w światku .NET. Stał się… trendy. Implementują go właściwie wszystkie kontenery IoC, można za jego pomocą skonfigurować NHibernate, coraz więcej innych projektów udostępnia swoje API w ten sposób. Ale dlaczego, czy jest to naprawdę tak fajne? Moim zdaniem: TAK. Fluent Interface jest nawet czymś więcej niż “fajnym bajerem” – pozwala naprawdę bardzo uprościć pracę z naszą biblioteką.

Weźmy na przykład uproszczoną klasę reprezentującą wiadomość e-mail:

  1:  public class MailMessage
  2:  {
  3:  	public string From { get; set; }
  4:  	public ICollection<string> To { get; private set; }
  5:  	public ICollection<string> CC { get; private set; }
  6:  	public string Subject { get; set; }
  7:  	public string Body { get; set; }
  8:  	public bool IsHtml { get; set; }
  9:  
 10:  	public MailMessage()
 11:  	{
 12:  		To = new List<string>();
 13:  		CC = new List<string>();
 14:  	}
 15:  
 16:  	public MailMessage(string from, ICollection<string> to, ICollection<string> cc, string subject, string body, bool isHtml)
 17:  	{
 18:  		From = from;
 19:  		To = to;
 20:  		CC = cc;
 21:  		Subject = subject;
 22:  		Body = body;
 23:  		IsHtml = isHtml;
 24:  	}
 25:  }

Jak wyglądałaby jej “typowa” inicjalizacja? Pewnie albo musielibyśmy podać wszystko w konstruktorze:

  1:  MailMessage message = new MailMessage("from@address", new []{"first-to@address", "second-to@address"}, new string[0], "subject", "body", false);

Fu!

Albo ustawić wartości ręcznie (wersja minimalna):

  1:  MailMessage message = new MailMessage();
  2:  message.From = "from@address";
  3:  message.To.Add("first-to@address");
  4:  message.To.Add("second-to@address");
  5:  message.Subject = "subject";
  6:  message.Body = "body";

Albo skorzystać ze składni “object initializer”:

  1:  MailMessage message = new MailMessage
  2:  			            {
  3:  					From = "from@address", 
  4:  					Subject = "subject",
  5:  					Body = "body", 
  6:  					IsHtml = false,
  7:  			            };
  8:  message.To.Add("first-to@address");
  9:  message.To.Add("second-to@address");

Pamiętajmy, że wszystkie pola oprócz CC powinny być obowiązkowe. Jedynie pierwsza, najbrzydsza metoda, gwarantuje, że programista tworzący wiadomość będzie sobie z tego zdawał sprawę.

A teraz zobaczmy jak MOGŁOBY to wyglądać, gdybyśmy zastosowali fluent interface:

  1:  MailMessage message = MailMessage.Create()
  2:  	.SentBy("from@address")
  3:  	.To("first-to@address")
  4:  	.And("second-to@address")
  5:  	.WithCopySentTo("first-CC@address")
  6:  	.And("second-CC@address")
  7:  	.Entitled("subject")
  8:  	.WithHtmlContent("html body");

Lepiej, prawda? Momentami rzekłbym nawet: ślicznie.

Jak do takiego stanu doprowadzić? Szczerze mówiąc jest to o wiele prostsze niż się może na początku wydawać. Na tyle proste, że udało mi się zdefiniować kroki prowadzące do takiego efektu:

1) Zapisuję konstrukcję, którą chciałbym widzieć jako końcowy rezultat pracy. Powyższy przykład jest właśnie dokładnie taką konstrukcją (pisząc tego posta na bieżąco tworzę fluent interface do tworzenia wiadomości email, więc sam nawet teraz postępuję zgodnie z tymi krokami)

2) Definiuję przestrzeń nazw Fluent zagnieżdżoną względem przestrzeni nazw tworzonej klasy:

  1:  public class MailMessage
  2:  {
  3:  	// ...
  4:  }
  5:  
  6:  namespace Fluent
  7:  {
  8:  }

3) W nowej namespace rozpisuję każdy krok potrzebny do utworzenia poprawnego obiektu do osobnego mikroskopijnego interfej-siku; najczęściej interfejsy takie mają 1-2 metody, bardzo rzadko więcej. Zwykle jadę po prostu od góry do dołu:

  1:  namespace Fluent
  2:  {
  3:  	public interface IFrom
  4:  	{
  5:  		ITo SentBy(string from);
  6:  	}
  7:  
  8:  	public interface ITo
  9:  	{
 10:  		IAfterTo To(string to);
 11:  	}
 12:  
 13:  	public interface ICc
 14:  	{
 15:  		IAfterCc WithCopySentTo(string cc);
 16:  	}
 17:  
 18:  	public interface ITitle
 19:  	{
 20:  		IContent Entitled(string subject);
 21:  	}
 22:  
 23:  	public interface IAfterTo: ITitle, ICc
 24:  	{
 25:  		IAfterTo And(string to);
 26:  	}
 27:  
 28:  	public interface IAfterCc: ITitle
 29:  	{
 30:  		IAfterCc And(string cc);
 31:  	}
 32:  
 33:  	public interface IContent
 34:  	{
 35:  		MailMessage WithHtmlContent(string body);
 36:  		MailMessage WithPlainTextContent(string body);
 37:  	}
 38:  }

Zwróćcie uwagę jak dokładnie zdefiniowany i rozbity jest każdy krok. Na przykład do etapu nadawania tytułu możemy przejść aż z czterech miejsc, dlatego wyodrębniłem osobny interfejs z metodą Entitled(). Nie jest on nigdzie zwracany bezpośrednio, ale inne interfejsy (IAfterTo oraz IAfterCc) go implementują.

4) Najtrudniejszy element mamy już za sobą. W tym miejscu ja się zwykle zatrzymuję, aby popatrzeć czy Intellisense zachowuje się dokładnie tak jak bym tego sobie życzył:

5) Mając tak przygotowane środowisko zostały nam już same mechaniczne czynności. Zacznę od utworzenia klasy budowniczego w tej samej przestrzeni nazw Fluent:

  1:  public class MailMessageFluentBuilder
  2:  {
  3:  	private readonly MailMessage _beingConstructed;
  4:  
  5:  	public MailMessageFluentBuilder(MailMessage beingConstructed)
  6:  	{
  7:  		_beingConstructed = beingConstructed;
  8:  	}
  9:  }

6) Upewnię się, że nikt niepowołany nie zrobi nam “niepoprawnej” instancji wiadomości poprzez zostawienie tylko jednego (prywatnego) konstruktora. Dodatkowo za jednym zamachem od razu dodam metodę która rozpocznie proces budowania obiektu:

  1:  public class MailMessage
  2:  {
  3:  	// ... properties
  4:  
  5:  	private MailMessage()
  6:  	{
  7:  		To = new List<string>();
  8:  		CC = new List<string>();
  9:  	}
 10:  
 11:  	public static IFrom Create()
 12:  	{
 13:  		MailMessage message = new MailMessage();
 14:  		return new MailMessageFluentBuilder(message);
 15:  	}
 16:  }

7) Jak można się domyślić, w tym momencie implementuję wszystkie szczątkowe interfejsy w klasie budowniczego. Uwaga: zawsze używam składni “explicit interface implementation”. Uchroni mnie to przed konfliktami nazw metod jeśli zdarzą się takie same w różnych interfejsach (jak w przykładzie metoda And() z interfejsów IAfterTo oraz IAfterCc).

  1:  public class MailMessageFluentBuilder : IFrom, ITo, IAfterTo, IContent, IAfterCc
  2:  {
  3:  	private readonly MailMessage _beingConstructed;
  4:  
  5:  	public MailMessageFluentBuilder(MailMessage beingConstructed)
  6:  	{
  7:  		_beingConstructed = beingConstructed;
  8:  	}
  9:  
 10:  	ITo IFrom.SentBy(string from)
 11:  	{
 12:  		_beingConstructed.From = from;
 13:  		return this;
 14:  	}
 15:  
 16:  	IAfterTo ITo.To(string to)
 17:  	{
 18:  		_beingConstructed.To.Add(to);
 19:  		return this;
 20:  	}
 21:  
 22:  	IContent ITitle.Entitled(string subject)
 23:  	{
 24:  		_beingConstructed.Subject = subject;
 25:  		return this;
 26:  	}
 27:  
 28:  	IAfterCc ICc.WithCopySentTo(string cc)
 29:  	{
 30:  		_beingConstructed.CC.Add(cc);
 31:  		return this;
 32:  	}
 33:  
 34:  	IAfterTo IAfterTo.And(string to)
 35:  	{
 36:  		_beingConstructed.To.Add(to);
 37:  		return this;
 38:  	}
 39:  
 40:  	IAfterCc IAfterCc.And(string cc)
 41:  	{
 42:  		_beingConstructed.CC.Add(cc);
 43:  		return this;
 44:  	}
 45:  
 46:  	MailMessage IContent.WithHtmlContent(string body)
 47:  	{
 48:  		_beingConstructed.IsHtml = true;
 49:  		_beingConstructed.Body = body;
 50:  		return _beingConstructed;
 51:  	}
 52:  
 53:  	MailMessage IContent.WithPlainTextContent(string body)
 54:  	{
 55:  		_beingConstructed.Body = body;
 56:  		return _beingConstructed;
 57:  	}
 58:  }

Fanfarro, bravissimo! Skończone.


Oczywiście podana przeze mnie metoda jest tylko jedną z wielu które prowadzą do tego samego rezultatu: zrozumiałego, zwięzłego i czytelnego API. Spotkałem się z podejściami intensywnie wykorzystującymi metody rozszerzające czy tworzącymi bardzo wiele malutkich obiektów do skonstruowania jednego docelowego, jednak to co przedstawiłem wydaje mi się najwygodniejsze.

Zachęcam do eksperymentów – na pewno można ten kod jeszcze skrócić (na przykład eliminując zbędną metodę Create() na poziomie MailMessage) lub w inny sposób ulepszyć. Przyznaję, że pierwsze kilka podejść zajęło mi sporo więcej czasu niż pozostawienie “zwykłego” API, jednak opłaciło się – teraz w kilka chwil mogę podobną konstrukcję zaimplementować wszędzie tam gdzie potrzeba bez zbytniego wysiłku. Ostatnio na przykład napisałem taki mechanizm do dynamicznego budowania URLa o znanej z góry konstrukcji. Owszem, mógłbym to rozwiązać za pomocą string.Format() z kilkunastoma parametrami, jednak prawdopodobnie zemściłoby się to w przyszłości.

A jakie właściwie płyną z tego korzyści? Zastosuj, a sam się przekonasz;). Na przykład: dokumentacja jest w tym przypadku całkowicie zbędna – Intellisense wyznacza programiście prawidłowe ścieżki. Albo: eliminując settery z klasy MailMessage i dodając walidację parametrów do budowniczego możemy zagwarantować, że utworzony obiekt jest poprawny (adresy email to faktycznie adresy email, tytuł nie jest pusty, treść “plan text” nie zawiera tagów html etc).

Mam nadzieję, że zachęciłem choć kilka osób do zainteresowania się tematem i spróbowania swoich sił. Wszelkie uwagi, jak zwykle, bardzo mile widziane:).

Nie przegap kolejnych postów!

Dołącz do ponad 9000 programistów w devstyle newsletter!

Tym samym wyrażasz zgodę na otrzymanie informacji marketingowych z devstyle.pl (doh...). Powered by ConvertKit
Notify of
Michał Jaskólski

Świetny artykuł. Z miejsca zachęcił mnie do wprowadzenia tej idei w życie :)

Gutek

Ja mam klopot z Twoim podejsciem do Fluent, nie chodzi o implementacje ale z semantyka i logika uzyta w przykladzie. Builder dla mnie to builder, mowie new XBuilder().WithHtmlBody("").Build(); i to build tutaj ma znaczenie. Nie moge powiedziec XBuilder().Build().WithHtmlBody(""); bo logicznie najpierw podaje sie parametry a na koncu sie builduje obiekt. Tak samo jak Class.Create().With… dla mnie juz samo create oznacza, ze obiekt jest utworzony i ma ustawienia, czyli Create() powinno mi zwrocic obiekt a nie interfejs dla kolejnej wlasnosci. Tak to prawie niczym innym nasze podejsca sie nie roznia :) ale jak to w dev jest – kazde podejscie jest… Read more »

niao
niao

Dzieki kolejny raz za artykul. Ślędząc twoj blog systematycznie nie wypadam z obiegu z jak to określiłeś "trendy" zachowaniami w świecie .NET" ;)

reVis

Ja w podejściu fluent jako punk startowy preferuję małą klasę statyczną, w Twoim przypadku powiedzmy wyglądałoby to następująco:
MailMessageBuilder.Mail().SentBy("from@address").To("first-to@address"){…}.Create();

matma
matma

Mi się natomiast podoba takie podejście, ale jeżeli potraktujemy Create() jako stworzenie czystego obiektu (szkieletu domu) któremu będziemy nadawać właściwości (kolory ścian na przykład).

Czyli:
PanieMajster.PostawSzkieletDomu().ZbudujŚcianyZCegły().ZbudujKomin().WstawOkna().Otynkuj().Pomaluj(NaZielono);

W drugą stronę raczej niewykonalne ;)

Gutek

@matma

Jezeli chcesz tworzyc tak by odrazu zwracalo Tobie czysty obiekt pusty to nie ma sensu korzystac metody create (w koncu zadaniem PostawShkieletDomu() jest zwrocenie obiektu dom) jak masz konstruktor, new Home().With….(); ale teraz znow przychodzimy do odpowiedzialnosci. Home() do byt, glownie powinien byc on niezmienny. Byt nie jest odpowiedzialny ze jago utworzenie. To tak jakbys napisal Me.CreateSister().WithBlondHair();

procent

@Michał Jaskólski:
Git… byle nie przesadzic:) zbytnie "sfluencenie" API może być trochę nienaturalne i dziwne, ale poprobowac na pewno warto

procent

@Gutek:
Tak jak napisałeś – różni ludzie, różne podejścia, różne zestawy panwitan…

procent

@niao:
W takim razie się cieszę, i zachęcam do zgłębiania także innych źródeł:)

procent

@reVis:
Ja bym raczej poszedł w stronę zastąpienia metody Create() wywołaniem bezpośrednio metody rozpoczynającej proces budowania: MailMessage.SentBy(…)…

procent

@matma:
Fajna analogia, jestem za:)

dario-g

A jak opisywany Fluent ma się do podania wymagalnych właściwości?

Konstruktor, brzydka metoda (jak napisałeś) odrazu sugeruje co należy podać, aby obiekt był poprawnie zbudowany. Przy każdej innej metodzie musisz WIEDZIEĆ co podać, aby stworzyć obiekt poprawnie. Oznacza to ni mniej ni więcej, że powstaje miejsce na popełnienie błędu. Szczególnie przy Fluent, gdzie musisz nastukać X kolejnych wywołań.

Tutaj akurat *matma* ma rację. Na końcu Build/Create miał by zasadnicze znaczenie, gdyż niepodanie wcześniej odpowiednio właściwości nie doprowadzi do uzyskania możliwości zbudowania obiektu.

Ogólnie Fluent jest fajny i sam stosuję. :)

procent

@dario-g:
Nie rozumiem… właśnie fluent podpowiada co trzeba podać. MUSISZ podać nadawcę (do metody SentBy) aby przejść do kroku umożliwiającego podanie odbiorców. MUSISZ podać tytuł, aby przejść do kroku podania treści. Fluent ma dodatkowo tą przewagę nad konstruktorem, że patrząc na kod, widzę CO jest gdzie podane. W przypadku ctora mam po prostu X stringów i muszę domyślać się który służy do czego.

s0ck3t
s0ck3t

Ogólnie fajne, jakaś nowość ;] ale po dłuższych namysłach – za bardzo przekombinowane. Wolę jednak napisać szybko konstruktor klasy z argumentami, niż pisac najpierw wszystko dłużej w Fluent Interface. Szkoda czasu ;]

reVis

@procent
Wywołania Create() raczej bym się nie pozbywał. W końcu oficjalnie określamy: dość i zwróć zbudowany obiekt.
Ale Mail() faktycznie jest zbędne, w końcu użycie fluent buildera od razu świadczy o tym co chcemy uzyskać. Zaoferowałem się poprzednimi komentarzami ;)

Orajo
Orajo

Narzędzie może i jest fajne, ale mam wrażenie, że jest to technika programowania zorientowana na IDE, a nie na rezultat projektu. I to mnie trochę martwi. Piszesz, że kod sam z siebie stanowi dokumentację. To prawda, ale tylko po naciśnięciu Ctrl-Spacja. W innym wypadku już nie i to bardzo nie. Trudno napisać dokumentację takiego API, a jej wygenerowanie da w rezultacie istny horror. To jest technika, która wręcz uniemożliwia generowanie sensownej dokumentacji API. Po drugie zmiana takiego API przy natłoku interfejsów też nie będzie prosta, szczególnie, jeśli projekt przejmuje inny programista. W sumie ta technika, jak każda, powinna być używana… Read more »

leszcz
leszcz

Fajny post z kilkoma uwagami ode mnie: 1) [czepialsko] Interfejsy się nawzajem dziedziczą, ew. rozszerzają, ale na pewno nie implementują :> 2) Jak lubię FI, to zgadzam się z Orajo – udokumentowanie czegoś takiego jest wredne i równocześnie pozostawienie możliwości rozszerzania takiego API jest koszmarne. Wyobraź sobie teraz, że chcesz, żeby coś było dostępne po .To a przed ITitle i zrób to nie łamiąc API wstecz i nie łamiąc sensowności tych interfejsów. W zasadzie niewykonalne. 3) Gdybym nie znając modułu zobaczył po raz pierwszy klasę MailMessage, to bym napisał MailMessage m = null; m. [i tu bym oczekiwał jakiegoś "Send",… Read more »

Adult Dating

There are so many blogs around relating to dating, it’s always great to find some fresh content

Moja książka

Facebook

Zobacz również