Популярні бібліотеки

Humanizer та Guard Clauses в ASP.NET Core

Humanizer: перетворення даних у людиночитабельний текст (дати, числа, рядки, переліки). Guard Clauses: захисні перевірки для чистого коду без вкладених if-else.

Humanizer та Guard Clauses в ASP.NET Core

Дві невеликі, але надзвичайно цінні бібліотеки, що вирішують часті задачі елегантно. Humanizer перетворює "createdAt" у "Created At", 142857 у "142 857", DateTime.Now.AddMinutes(-5) у "5 хвилин тому". Guard Clauses замінюють численні if (x == null) throw new ArgumentNullException() на один читабельний рядок.

Частина 1: Humanizer

1. Що таке Humanizer?

Humanizer — бібліотека для перетворення даних у людиночитабельний текст. Її місія: зробити те, що комп'ютер представляє внутрішньо, зрозумілим для людей.

Приклади завдань, що вирішує Humanizer:

  • "camelCaseString""Camel case string"
  • DateTime.Now.AddHours(-3)"3 years ago" / "3 години тому"
  • 1500000"1.5 million" / "1 500 000"
  • UserAccountStatus.PendingVerification"Pending verification"
  • "TheQuickBrownFox""The quick brown fox"

2. Встановлення

Встановлення
dotnet add package Humanizer
dotnet add package Humanizer.Core.uk    # Українська локалізація

3. String Manipulation

Humanizer надає потужний набір extension methods для рядків:

String Extension Methods
using Humanizer;

// Гуманізація рядків
"camelCaseString".Humanize()          // → "Camel case string"
"PascalCaseString".Humanize()         // → "Pascal case string"
"UPPER_CASE_IDENTIFIER".Humanize()    // → "Upper case identifier"
"some_database_column".Humanize()     // → "Some database column"

// Зміна регістру
"some title".Pascalize()             // → "SomeTitle"
"some title".Camelize()              // → "someTitle"
"some title".Underscore()            // → "some_title"
"some title".Kebaberize()            // → "some-title"
"SomeTitle".Titleize()               // → "Some Title"

// Truncate (скорочення)
"The quick brown fox".Truncate(10)
// → "The qui..."  (10 символів з "..." наприкінці)

"The quick brown fox".Truncate(10, Truncator.FixedNumberOfCharacters)
// → "The qui..."

"The quick brown fox".Truncate(3, Truncator.FixedNumberOfWords)
// → "The quick brown..."

// Transform
"Encode this string!".Transform(
    To.LowerCase,
    To.TitleCase)
// → "Encode This String!"

Використання у серіалізації JSON / API

Humanizer корисний для генерації читабельних ключів і значень у відповідях API:

Humanizer у DTO
public static class EnumExtensions
{
    // Перетворює значення Enum у читабельний рядок для фронтенду
    public static string ToHumanReadable<TEnum>(this TEnum value)
        where TEnum : Enum
    {
        return value.ToString().Humanize(LetterCasing.Title);
    }
}

// Використання:
OrderStatus.PendingPayment.ToHumanReadable() // → "Pending Payment"
UserRole.SystemAdministrator.ToHumanReadable() // → "System Administrator"
ApiController — Enum у відповіді
[HttpGet("statuses")]
public IActionResult GetOrderStatuses()
{
    var statuses = Enum.GetValues<OrderStatus>()
        .Select(s => new
        {
            Value = (int)s,
            Label = s.ToString().Humanize(LetterCasing.Title),
            Code  = s.ToString()
        })
        .ToList();

    return Ok(statuses);
}
// → [{ "value": 0, "label": "Pending Payment", "code": "PendingPayment" }, ...]

4. DateTime: Відносний час

Перетворення дат у відносний час (timeago) — одна з найкорисніших функцій Humanizer:

DateTime.Humanize()
using Humanizer;
using System.Globalization;

// Встановлюємо культуру для усієї програми
CultureInfo.CurrentCulture   = new CultureInfo("uk-UA");
CultureInfo.CurrentUICulture = new CultureInfo("uk-UA");

// Відносний час (від поточного моменту)
DateTime.Now.AddSeconds(-45).Humanize()    // → "хвилину тому"
DateTime.Now.AddMinutes(-3).Humanize()     // → "3 хвилини тому"
DateTime.Now.AddHours(-2).Humanize()       // → "2 години тому"
DateTime.Now.AddDays(-1).Humanize()        // → "вчора"
DateTime.Now.AddDays(-3).Humanize()        // → "3 дні тому"
DateTime.Now.AddMonths(-2).Humanize()      // → "2 місяці тому"
DateTime.Now.AddYears(-1).Humanize()       // → "рік тому"

// Майбутнє
DateTime.Now.AddMinutes(10).Humanize()     // → "за 10 хвилин"
DateTime.Now.AddDays(2).Humanize()         // → "через 2 дні"

// Різниця між двома датами
var start = new DateTime(2024, 1, 1);
var end   = new DateTime(2024, 3, 15);
(end - start).Humanize()                  // → "2 місяці"
(end - start).Humanize(precision: 2)      // → "2 місяці 2 тижні"

// Форматування DateTimeOffset
DateTimeOffset.Now.AddHours(-5).Humanize() // → "5 годин тому"

Застосування у Views та API Response

DTO з Humanized датами
public class ArticleDto
{
    public int    Id        { get; set; }
    public string Title     { get; set; } = string.Empty;
    public string Content   { get; set; } = string.Empty;
    public DateTime CreatedAt { get; set; }

    // Автоматично обчислюване поле
    public string CreatedAtHuman => CreatedAt.Humanize();
    // → "5 хвилин тому", "вчора", "3 місяці тому"
}

// Або через Mapster afar mapping:
config.NewConfig<Article, ArticleDto>()
    .Map(dest => dest.CreatedAtHuman,
         src  => src.CreatedAt.Humanize());

5. Numbers: Числа у текст

Числа
using Humanizer;

// Ordinal (порядковий номер)
1.ToOrdinalWords()    // → "first" (англ.)
5.ToOrdinalWords()    // → "fifth"
42.ToOrdinalWords()   // → "forty-second"

// Ukrainian ordinals (через CultureInfo)
CultureInfo.CurrentUICulture = new CultureInfo("uk-UA");
1.ToOrdinalWords()    // → "перший"
5.ToOrdinalWords()    // → "п'ятий"

// Words (число прописом)
1234.ToWords()        // → "one thousand two hundred and thirty-four"
42.ToWords()          // → "forty-two"

// Metric (SI-префікси)
1_500_000.ToMetric()  // → "1.5M"
1_234.ToMetric()      // → "1.234k"
0.001.ToMetric()      // → "1m" (мілі)

// Bits/Bytes
10_240.Bytes().Humanize()       // → "10 KB"
1_073_741_824L.Bytes().Humanize()// → "1 GB"
(2.5).Gigabytes().Humanize()    // → "2.5 GB"

Застосування у API для розміру файлів та статистики

FileInfo DTO
public class FileDto
{
    public string FileName        { get; set; } = string.Empty;
    public long   SizeBytes       { get; set; }
    public string SizeHuman       => SizeBytes.Bytes().Humanize();
    // → "1.45 MB", "256 KB", "3.2 GB"
}

6. Pluralize та Singularize

Множина / однина
// Pluralize (однина → множина)
"user".Pluralize()          // → "users"
"category".Pluralize()      // → "categories"
"person".Pluralize()        // → "people"
"ox".Pluralize()            // → "oxen"

// Singularize (множина → однина)
"users".Singularize()       // → "user"
"categories".Singularize()  // → "category"
"people".Singularize()      // → "person"

// Conditioned pluralize (з числом)
"user".ToQuantity(1)        // → "1 user"
"user".ToQuantity(5)        // → "5 users"
"user".ToQuantity(0)        // → "0 users"

// Прописом
"user".ToQuantity(1, ShowQuantityAs.Words) // → "one user"
"user".ToQuantity(5, ShowQuantityAs.Words) // → "five users"

Частина 2: Guard Clauses

7. Проблема захисних перевірок

Guard Clause (захисне твердження) — перевірка на початку методу, що «захищає» від некоректного вхідного стану. Класичний підхід:

Захисні перевірки — класично (verbose)
public async Task<Order> CreateOrderAsync(
    int customerId,
    List<OrderItem> items,
    string? promoCode)
{
    if (customerId <= 0)
        throw new ArgumentException("CustomerId must be positive.", nameof(customerId));

    if (items == null)
        throw new ArgumentNullException(nameof(items));

    if (items.Count == 0)
        throw new ArgumentException("Items cannot be empty.", nameof(items));

    if (items.Any(i => i.Quantity <= 0))
        throw new ArgumentException("All items must have positive quantity.", nameof(items));

    // Лише тут починається реальна логіка...
    var order = new Order { CustomerId = customerId };
    // ...
    return order;
}

8 рядків перевірок до початку реальної логіки. Множте на кожен метод — і читабельність стрімко падає.


8. Ardalis.GuardClauses

Ardalis.GuardClauses від Steve Smith (Ardalis) — бібліотека, що перетворює захисні перевірки на один читабельний рядок:

Встановлення
dotnet add package Ardalis.GuardClauses

Базове використання

Guard Clauses — читабельно
using Ardalis.GuardClauses;

public async Task<Order> CreateOrderAsync(
    int customerId,
    List<OrderItem> items,
    string? promoCode)
{
    // Замість 8 рядків перевірок — 2 виклики Guard
    Guard.Against.NegativeOrZero(customerId, nameof(customerId));
    Guard.Against.NullOrEmpty(items, nameof(items));

    // Одразу реальна логіка
    var order = new Order { CustomerId = customerId };
    // ...
    return order;
}

Повний перелік вбудованих Guards

Всі вбудовані Guards
using Ardalis.GuardClauses;

// Null перевірки
Guard.Against.Null(obj, nameof(obj));
Guard.Against.NullOrEmpty(str, nameof(str));
Guard.Against.NullOrWhiteSpace(str, nameof(str));

// Числові перевірки
Guard.Against.Zero(value, nameof(value));
Guard.Against.Negative(value, nameof(value));
Guard.Against.NegativeOrZero(value, nameof(value));
Guard.Against.OutOfRange(value, nameof(value), min: 1, max: 100);

// Колекції
Guard.Against.NullOrEmpty(list, nameof(list));
Guard.Against.OutOfRange(index, nameof(index), min: 0, max: list.Count - 1);

// Рядки
Guard.Against.StringTooLong(str, nameof(str), maxLength: 255);

// Enum
Guard.Against.EnumOutOfRange(enumValue, nameof(enumValue));
Guard.Against.InvalidInput(
    input: str,
    parameterName: nameof(str),
    predicate: s => s.Contains("@"),
    message: "Must be a valid email format.");

// Generic expression
Guard.Against.InvalidInput(customerId,
    nameof(customerId),
    id => id > 0 && id < 1_000_000,
    "CustomerId must be between 1 and 999999.");

9. Кастомні Guard Clauses

Для домен-специфічних перевірок Ardalis.GuardClauses дозволяє розширювати IGuardClause:

Guards/DomainGuards.cs
using Ardalis.GuardClauses;

public static class DomainGuards
{
    // Перевірка українського номера телефону
    public static string ValidUkrainianPhone(
        this IGuardClause guardClause,
        string input,
        string parameterName)
    {
        Guard.Against.NullOrWhiteSpace(input, parameterName);

        var phoneRegex = new Regex(@"^\+380\d{9}$");
        if (!phoneRegex.IsMatch(input))
            throw new ArgumentException(
                $"'{parameterName}' повинен бути у форматі +380XXXXXXXXX. Отримано: '{input}'.",
                parameterName);

        return input;
    }

    // Перевірка ЄДРПОУ (код підприємства в Україні)
    public static string ValidEdrpou(
        this IGuardClause guardClause,
        string input,
        string parameterName)
    {
        Guard.Against.NullOrWhiteSpace(input, parameterName);

        if (input.Length != 8 || !input.All(char.IsDigit))
            throw new ArgumentException(
                $"ЄДРПОУ повинен містити рівно 8 цифр. Отримано: '{input}'.",
                parameterName);

        return input;
    }

    // Перевірка позитивної ціни
    public static decimal PositivePrice(
        this IGuardClause guardClause,
        decimal price,
        string parameterName)
    {
        if (price <= 0m)
            throw new ArgumentException(
                $"Ціна повинна бути більше 0. Отримано: {price:N2}.",
                parameterName);

        if (price > 1_000_000m)
            throw new ArgumentException(
                $"Ціна не може перевищувати 1 000 000. Отримано: {price:N2}.",
                parameterName);

        return price;
    }
}
Використання кастомних Guards
public class ProductService
{
    public async Task<Product> CreateAsync(CreateProductRequest request)
    {
        // Валідація через Guards — декларативно та стисло
        Guard.Against.NullOrWhiteSpace(request.Name, nameof(request.Name));
        Guard.Against.PositivePrice(request.Price, nameof(request.Price));
        Guard.Against.ValidUkrainianPhone(request.SupplierPhone,
            nameof(request.SupplierPhone));
        Guard.Against.NegativeOrZero(request.Stock, nameof(request.Stock));

        // Лише бізнес-логіка — жодного «шуму» перевірок
        var product = request.Adapt<Product>();
        // ...
        return product;
    }
}

10. Інтеграція: Guard + ErrorOr

Guards кидають виключення — це чудово для внутрішніх перевірок програмних інваріантів. Але для публічних API методів краще повертати ErrorOr:

Services/OrderService.cs — Guard + ErrorOr
public async Task<ErrorOr<Order>> CreateAsync(CreateOrderRequest request)
{
    // ArgumentException від Guard — несподівана ситуація (баг у коді)
    // Дозволяємо їй «спливти» до GlobalExceptionHandler
    Guard.Against.Null(request, nameof(request));

    // Бізнес-валідація — очікувана ситуація → ErrorOr
    var customer = await _db.Users.FindAsync(request.CustomerId);
    if (customer is null)
        return UserErrors.NotFound;

    if (request.Items.Count == 0)
        return Error.Validation("Order.EmptyItems",
            "Замовлення повинно містити принаймні один товар.");

    // Тут Guard використовуємо для програмних інваріантів
    // (ситуацій, що НІКОЛИ не мають траплятися якщо код правильний)
    Guard.Against.Negative(customer.Id,
        nameof(customer.Id),
        "Customer.Id must be positive after DB retrieval.");

    var order = new Order { /* ... */ };
    return order;
}

Ключове правило: Guard Clauses — для програмних інваріантів (невірний аргумент = баг у виклику). ErrorOr — для бізнес-правил (невірний стан = очікуваний сценарій).


11. Порівняння підходів

СитуаціяПідхідЧому
null аргумент (програмний баг)Guard.Against.Null()Fast fail, легко знайти в stacktrace
Негативний ID (програмний баг)Guard.Against.NegativeOrZero()Інваріант домену
Запис не знайдений у БДErrorOr → Error.NotFoundОчікуваний сценарій
Бізнес-правило (недостатньо коштів)ErrorOr → Error.ConflictОчікувана бізнес-помилка
Невалідний формат (з флронтенду)FluentValidationВалідація вхідних даних

Практичні завдання


Резюме

Humanizer: DateTime

createdAt.Humanize()"3 хвилини тому". Одна рядок замість перетворення вручну.

Humanizer: Strings

"camelCase".Humanize()"Camel case". Автоматична конвертація для UI.

Guard: Null/Range

Guard.Against.NullOrEmpty() замість 3 рядків if/throw. Декларативно та стисло.

Guard: Extensible

Кастомні Guards через extension methods. Guard.Against.ValidEmail() — ваш власний Guard.

Посилання: