Przejdź do treści

DevStyle - Strona Główna
Tworzenie obiektów poprzez Fluent Interface – dla każdego

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

Maciej Aniserowicz

15 lipca 2010

Backend

Tagi:

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:).

Zobacz również