Оволодіння патерном Fluent Builder у C#: Від основ до складних сценаріїв

Флент-Білдер (Fluent Builder) — потужний шаблон проектування, який дозволяє створювати складні об'єкти через більш читабельний та зручний інтерфейс. У цій статті ми детально розглянемо впровадження шаблону Fluent Builder на C#, досліджуючи як базові, так і більш складні сценарії, а також аналізуючи реальні приклади з бібліотек .NET Standard.

Чому варто використовувати шаблон Fluent Builder?

Перед тим як заглиблюватися в реалізації, давайте з'ясуємо, чому ви можете захотіти використовувати шаблон Fluent Builder:

  1. Покращує читабельність коду завдяки ланцюжку викликів методів
  2. Окремо розділяє побудову складних об'єктів від їхньої репрезентації
  3. Забезпечує незмінність об'єктів, одночасно зберігаючи гнучкість у їх побудові
  4. Елегантно обробляє необов'язкові параметри
  5. Забезпечує чіткий API для побудови об'єктів

Реальний приклад: конфігурація HttpClient

Один з найпоширеніших прикладів використання шаблону Fluent Builder в .NET — це HttpClientBuilder. Подивимося, як Microsoft реалізує це:

var client = new HttpClient(new SocketsHttpHandler  
{  
 PooledConnectionLifetime = TimeSpan.FromMinutes(10),  
 PooledConnectionIdleTimeout = TimeSpan.FromMinutes(5),  
 MaxConnectionsPerServer = 10  
});

Хоч це і працює, але може бути більш елегантним. Ось як ми могли б реалізувати fluent builder для HttpClient:

public class HttpClientBuilder  
{  
 private readonly SocketsHttpHandler _handler;  

 private HttpClientBuilder()  
 {  
 _handler = new SocketsHttpHandler();  
 }  

 public static HttpClientBuilder Create()  
 {  
 return new HttpClientBuilder();  
 }  

 public HttpClientBuilder WithConnectionLifetime(TimeSpan lifetime)  
 {  
 _handler.PooledConnectionLifetime = lifetime;  
 return this;  
 }  

 public HttpClientBuilder WithIdleTimeout(TimeSpan timeout)  
 {  
 _handler.PooledConnectionIdleTimeout = timeout;  
 return this;  
 }  

 public HttpClientBuilder WithMaxConnections(int maxConnections)  
 {  
 _handler.MaxConnectionsPerServer = maxConnections;  
 return this;  
 }  

 public HttpClient Build()  
 {  
 return new HttpClient(_handler);  
 }  
}

Тепер ми можемо створювати HttpClient ось так:

var client = HttpClientBuilder.Create()  
 .WithConnectionLifetime(TimeSpan.FromMinutes(10))  
 .WithIdleTimeout(TimeSpan.FromMinutes(5))  
 .WithMaxConnections(10)  
 .Build();

Складніший сценарій: Вкладені Builders з контекстом конфігурації

Розглянемо більш складний сценарій, який включає вкладені builders.
Уявімо, що ми створюємо систему конфігурації для веб-додатку:

public class WebAppConfiguration  
{  
 public DatabaseSettings Database { get; }  
 public CacheSettings Cache { get; }  
 public AuthenticationSettings Authentication { get; }  

 private WebAppConfiguration(WebAppConfigurationBuilder builder)  
 {  
 Database = builder.DatabaseBuilder.Build();  
 Cache = builder.CacheBuilder.Build();  
 Authentication = builder.AuthenticationBuilder.Build();  
 }  

 public class WebAppConfigurationBuilder  
 {  
 internal readonly DatabaseSettingsBuilder DatabaseBuilder;  
 internal readonly CacheSettingsBuilder CacheBuilder;  
 internal readonly AuthenticationSettingsBuilder AuthenticationBuilder;  

 public WebAppConfigurationBuilder()  
 {  
 DatabaseBuilder = new DatabaseSettingsBuilder(this);  
 CacheBuilder = new CacheSettingsBuilder(this);  
 AuthenticationBuilder = new AuthenticationSettingsBuilder(this);  
 }  

 public DatabaseSettingsBuilder ConfigureDatabase()  
 {  
 return DatabaseBuilder;  
 }  

 public CacheSettingsBuilder ConfigureCache()  
 {  
 return CacheBuilder;  
 }  

 public AuthenticationSettingsBuilder ConfigureAuthentication()  
 {  
 return AuthenticationBuilder;  
 }  

 public WebAppConfiguration Build()  
 {  
 return new WebAppConfiguration(this);  
 }  
 }  
}  

public class DatabaseSettings  
{  
 public string ConnectionString { get; }  
 public int MaxConnections { get; }  
 public TimeSpan CommandTimeout { get; }  

 internal DatabaseSettings(string connectionString, int maxConnections, TimeSpan commandTimeout)  
 {  
 ConnectionString = connectionString;  
 MaxConnections = maxConnections;  
 CommandTimeout = commandTimeout;  
 }  
}  

public class DatabaseSettingsBuilder  
{  
 private readonly WebAppConfiguration.WebAppConfigurationBuilder _parentBuilder;  
 private string _connectionString;  
 private int _maxConnections;  
 private TimeSpan _commandTimeout;  

 internal DatabaseSettingsBuilder(WebAppConfiguration.WebAppConfigurationBuilder parentBuilder)  
 {  
 _parentBuilder = parentBuilder;  
 }  

 public DatabaseSettingsBuilder WithConnectionString(string connectionString)  
 {  
 _connectionString = connectionString;  
 return this;  
 }  

 public DatabaseSettingsBuilder WithMaxConnections(int maxConnections)  
 {  
 _maxConnections = maxConnections;  
 return this;  
 }  

 public DatabaseSettingsBuilder WithCommandTimeout(TimeSpan timeout)  
 {  
 _commandTimeout = timeout;  
 return this;  
 }  

 public WebAppConfiguration.WebAppConfigurationBuilder Done()  
 {  
 return _parentBuilder;  
 }  

 internal DatabaseSettings Build()  
 {  
 return new DatabaseSettings(_connectionString, _maxConnections, _commandTimeout);  
 }  
}  

public class CacheSettings  
{  
 public string RedisConnection { get; }  
 public TimeSpan DefaultExpiration { get; }  

 internal CacheSettings(string redisConnection, TimeSpan defaultExpiration)  
 {  
 RedisConnection = redisConnection;  
 DefaultExpiration = defaultExpiration;  
 }  
}  

public class CacheSettingsBuilder  
{  
 private readonly WebAppConfiguration.WebAppConfigurationBuilder _parentBuilder;  
 private string _redisConnection;  
 private TimeSpan _defaultExpiration;  

 internal CacheSettingsBuilder(WebAppConfiguration.WebAppConfigurationBuilder parentBuilder)  
 {  
 _parentBuilder = parentBuilder;  
 }  

 public CacheSettingsBuilder WithRedisConnection(string redisConnection)  
 {  
 _redisConnection = redisConnection;  
 return this;  
 }  

 public CacheSettingsBuilder WithDefaultExpiration(TimeSpan defaultExpiration)  
 {  
 _defaultExpiration = defaultExpiration;  
 return this;  
 }  

 public WebAppConfiguration.WebAppConfigurationBuilder Done()  
 {  
 return _parentBuilder;  
 }  

 internal CacheSettings Build()  
 {  
 return new CacheSettings(_redisConnection, _defaultExpiration);  
 }  
}  

public class AuthenticationSettings  
{
public string JwtSecret { get; }  
 public TimeSpan TokenExpiration { get; }  

 internal AuthenticationSettings(string jwtSecret, TimeSpan tokenExpiration)  
 {  
 JwtSecret = jwtSecret;  
 TokenExpiration = tokenExpiration;  
 }  
}  

public class AuthenticationSettingsBuilder  
{  
 private readonly WebAppConfiguration.WebAppConfigurationBuilder _parentBuilder;  
 private string _jwtSecret;  
 private TimeSpan _tokenExpiration;  

 internal AuthenticationSettingsBuilder(WebAppConfiguration.WebAppConfigurationBuilder parentBuilder)  
 {  
 _parentBuilder = parentBuilder;  
 }  

 public AuthenticationSettingsBuilder WithJwtSecret(string jwtSecret)  
 {  
 _jwtSecret = jwtSecret;  
 return this;  
 }  

 public AuthenticationSettingsBuilder WithTokenExpiration(TimeSpan tokenExpiration)  
 {  
 _tokenExpiration = tokenExpiration;  
 return this;  
 }  

 public WebAppConfiguration.WebAppConfigurationBuilder Done()  
 {  
 return _parentBuilder;  
 }  

 internal AuthenticationSettings Build()  
 {  
 return new AuthenticationSettings(_jwtSecret, _tokenExpiration);  
 }  
}

Це дозволяє створити дуже виразну конфігурацію:

var config = new WebAppConfiguration.WebAppConfigurationBuilder()  
 .ConfigureDatabase()  
 .WithConnectionString("Server=myserver;Database=mydb")  
 .WithMaxConnections(100)  
 .WithCommandTimeout(TimeSpan.FromSeconds(30))  
 .Done()  
 .ConfigureCache()  
 .WithRedisConnection("localhost:6379")  
 .WithDefaultExpiration(TimeSpan.FromMinutes(10))  
 .Done()  
 .ConfigureAuthentication()  
 .WithJwtSecret("your-secret-key")  
 .WithTokenExpiration(TimeSpan.FromHours(1))  
 .Done()  
 .Build();

Реальний приклад: StringBuilder

Клас StringBuilder у .NET є ще одним відмінним прикладом патерну Fluent Builder:

var message = new StringBuilder()  
 .Append("Hello")  
 .Append(" ")  
 .Append("World")  
 .AppendLine("!")  
 .ToString();

Розширений патерн: Генеричні будівельники з обмеженнями

Іноді необхідно створювати будівельники, які працюють з генеричними типами.
Ось приклад будівельника для генеричних колекцій:

public class CollectionBuilder where T : class  
{  
 private readonly List _items = new List();  
 private readonly List>> _configurations = new List>>();  

 public CollectionBuilder Add(T item)  
 {  
 _items.Add(item);  
 return this;  
 }  

 public CollectionBuilder AddRange(IEnumerable items)  
 {  
 _items.AddRange(items);  
 return this;  
 }  

 public CollectionBuilder Configure(Action> configuration)  
 {  
 _configurations.Add(configuration);  
 return this;  
 }  

 public CollectionBuilder WithFilter(Func predicate)  
 {  
 _configurations.Add(items =>   
 {  
 var itemsToRemove = items.Where(x => !predicate(x)).ToList();  
 foreach (var item in itemsToRemove)  
 {  
 items.Remove(item);  
 }  
 });  
 return this;  
 }  

 public IReadOnlyList Build()  
 {  
 foreach (var configuration in _configurations)  
 {  
 configuration(_items);  
 }  
 return _items.AsReadOnly();  
 }  
}

Приклад використання:

var numbers = new CollectionBuilder()  
 .Add("1")  
 .Add("2")  
 .Add("3")  
 .AddRange(new[] { "4", "5", "6" })  
 .Configure(list => list.Sort())  
 .WithFilter(x => int.Parse(x) > 2)  
 .Build();

Кращі практики та рекомендації

Коли ви реалізуєте патерн Fluent Builder, звертайте увагу на наступні кращі практики:

  1. Робіть фінальний об’єкт незмінним
  2. Використовуйте описові назви методів, які починаються з "With" або подібних префіксів
  3. Повертаючи екземпляр будівельника з методів конфігурації
  4. Забезпечте чіткий метод Build()
  5. Розгляньте можливість зробити конструктор будівельника приватним та надати статичний метод створення
  6. Використовуйте ланцюжкове викликання методів для більш природного API
  7. Реалізуйте валідацію в методі Build()
  8. Розгляньте додавання методу Reset() для повторного використання будівельника

Поширені проблеми, яких слід уникати

  1. Ускладнення простого створення об’єктів
  2. Неналежне оброблення значень null
  3. Зміна будівельника після побудови об’єкта
  4. Відсутність належної валідації
  5. Створення надмірної кількості вкладених будівельників
  6. Відсутність можливості скидання стану будівельника

Коли використовувати патерн Fluent Builder

Патерн Fluent Builder є особливо корисним, коли:

  • Для створення об’єктів необхідно багато параметрів
  • Процес створення об’єкта складається з кількох етапів
  • Потрібно забезпечити незмінність об’єктів
  • Потрібно надати більш читабельний API
  • Необхідно створювати об’єкти з необов’язковими параметрами
  • Працюєте з складними вкладеними об’єктами

Висновок

Патерн Fluent Builder є потужним інструментом у вашій C# коробці інструментів. Коли він реалізований належним чином, він може значно покращити читабельність і підтримуваність вашого коду. Вивчаючи реальні приклади з бібліотек .NET Standard та розуміючи розширені сценарії, ви можете приймати обґрунтовані рішення щодо того, коли і як впроваджувати цей патерн у вашому коді.

Не забувайте, що, хоча цей патерн є потужним, просте створення об’єктів не завжди вимагає його використання. Використовуйте його, коли переваги покращеної читабельності та підтримуваності переважають додаткову складність його реалізації.

Перекладено з: Mastering the Fluent Builder Pattern in C#: From Basics to Advanced Scenarios

Leave a Reply

Your email address will not be published. Required fields are marked *