Pull to refresh

Паттерн спецификация в .NET

Level of difficultyMedium
Reading time3 min
Views6.8K

Понимаю, что тема избитая, есть масса статей на хабре (например раз, два, три) и если с теорией все гладко, то все попавшиеся мне на глаза реализации (не только на хабре, но и на гитхабе в том числе) этого паттерна обладали теми или иными ограничениями.

Свою идею я реализовывал постепенно на основании опыта использования в реальном проекте. Требования оформились следующие:

  • Минимальный API;

  • Не использовать методы расширения;

  • Совместимость с существующим кодом;

  • Использование как с провайдерами баз данных так и просто в обычном коде;

  • Композиция;

  • Не должно быть привязано ни к какому фреймворку;

Основные ограничения существующих решений:

  • Неоправданно объемные определения (например, надо надо создавать целый отдельный класс);

  • Кучи разных методов и методов расширений (загрязняет код, усложняет отказ от библиотеки, сбивает с толку если выбраны названия мимикрирующие под LINQ);

  • Лишний функционал вроде поддержки пагинации или сортировки;

  • Фокус только на деревьях выражений либо только на варианте с методами/делегатами;

  • Лишние компиляции делегатов без намека на оптимизацию;

  • Слабые возможности по композиции, например, вложенные условия не поддерживаются;

  • Возможно использовать только в некоторых контекстах;

Может возникнуть вопрос: Почему просто не использовать набор методов расширений или даже просто выражения (Expression<Func<T, bool>>)? Разумеется, есть случаи когда и этого будет достаточно, но часто одни и те же условия необходимо проверять как при запросе в базу, так и в обычном коде, поэтому очевидно, что надо поддерживать оба сценария.

Еще один вариант - создать некий сервис(ы), в котором будут методы вроде IsUserActive, IsUserRegistered и так далее. Опять же, в каких-то случаях это тоже оправдано, но с композицией и переиспользованием у такого подхода может быть еще хуже. Могут быть условия, которые просто не возможно проверить в одном запросе или внутри спецификации, но эти проблемы можно решить или сгладить.

Перейдем к примерам объявления:

public class User
{
    public int Id { get; set; }

    public string Name { get; set; }

    public bool Active { get; set; }

    public Subscription Subscription { get; set; }

    public List<Department> Departments { get; set; }
}

public class Department
{
    public int Id { get; set; }

    public string Name { get; set; }

    public bool Active { get; set; }
}

public enum Subscription
{
    Subscribed,

    Unsubscribed
}

public static class Specifications
{
    // в базе сравнение не учитывает регистр
    public static readonly Specification<Department> CustomerServiceDepartment = new(
        x => x.Name == "Customer Service",
        x => string.Equals(x.Name?.TrimEnd(), "Customer Service", StringComparison.InvariantCultureIgnoreCase)
    );

    // делагат скомпилируется при вызове
    public static readonly Specification<Department> ActiveDepartment = new(x => x.Active);

    public static readonly Specification<User> ActiveUser = new(
        default,
        x => x.Active // передаем только делегат, выражение будет вычислено
    );

    // инвертируем
    public static readonly Specification<User> InactiveUser = !ActiveUser;
    
    // комбинируем
    public static readonly Specification<User> SubscribedUser = ActiveUser && new Specification<User>(x => x.Subscription == Subscription.Subscribed);

    public static readonly Specification<User> VasiliyUser = new(x => x.Name == "Vasiliy");

    // можем даже использовать спецификации внутри других спецификаций
    public static readonly Specification<User> UserInCustomerServiceDepartment = new(x => x.Departments.Any(CustomerServiceDepartment && ActiveDepartment));
}

Упрощенный пример использования:

public class UserController : Controller
{
    private readonly DbContext _context;

    public UserController(DbContext context)
    {
        _context = context;
    }

    // option 1: DB
    public Task<User> GetUser(int id)
    {
        return _context.Set<User>()
            .Where(Specifications.UserInCustomerServiceDepartment)
            .Where(x => x.Id == id)
            .SingleOrDefaultAsync();
    }

    // option 2: in-memory
    public async Task<User> GetUser(int id)
    {
        var user = await _context.Set<User>()
            .Include(x => x.Departments)
            .Where(x => x.Id == id)
            .SingleAsync();

        return Specifications.UserInCustomerServiceDepartment.IsSatisfiedBy(user) ? user : null;
    }
}

Основные инструменты - деревья выражений + визиторы и библиотека DelegateDecompiler.

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

Если лень читать код - можно установить пакет из nuget и попробовать у себя.

Tags:
Hubs:
Total votes 10: ↑2 and ↓8-6
Comments8

Articles