Чистые функции — это такие методы, при выполнении которых не возникает побочных эффектов. В функциональном программировании чистые функции — скорее правило, чем исключение. Но в большинстве объектно-ориентированных языков с ними приходится сталкиваться нечасто, или, как минимум, они редко считаются предпочтительным вариантом. В дотнет-среде серьёзный акцент делается на внедрении зависимостей и более-менее обширных абстракциях, использующих интерфейсы.

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

Исходная ситуация


В качестве отправной точки для нашего рефакторинга рассмотрим вымышленный пример: допустим, у нас имеется интернет-магазин. Исходный код этого примера выложен на GitHub, причём, в репозитории предусмотрена отдельная ветка на каждый шаг рефакторинга.

Исходный код на GitHub, ветка steps/01-initial-state

Данное приложение состоит из двух проектов: собственно приложения и проекта для связанных с ним тестов. В структуре не соблюдается строгое соответствие какому-либо архитектурному стилю, но она призвана проиллюстрировать те компоненты, которые, скорее всего, встретятся в такой системе. Дополнительно мы сосредоточимся здесь на серверной стороне интернет-магазина. Вот как выглядит структура каталогов:

├───Refactor.Application
│   ├───Controllers
│   ├───CQRS
│   │   ├───Handlers
│   │   └───Requests
│   ├───Data
│   ├───Models
│   ├───Repositories
│   │   ├───Implementations
│   │   └───Interfaces
│   └───Services
└───Refactor.Application.Test
    ├───Controllers
    ├───CQRS
    │   └───Handlers
    ├───Repositories
    └───Services

Приложение написано на C# с использованием контроллеров ASP.NET. Бизнес-логика реализована в виде сервисных классов, а модели предметных областей находятся в каталоге Models. Доступ к базе данных организован по паттерну репозиторий, а классы POCO для базы данных расположены в каталоге Data. Коммуникация между контроллерами и сервисами выполняется в соответствии с паттерном CQRS (разделение ответственности на команды и запросы).

Все эти отдельные компоненты управляются и связываются друг с другом при помощи внедрения зависимостей.

Абстракции


Абстракции часто используются при разработке ПО. Однако зачастую абстракция не выполняет своей первоочередной задачи, а именно — не уменьшает сложность кода и не облегчает его поддержку. Кроме того, новые слои абстракций часто вводятся в код не потому, что могли бы принести конкретную пользу, а потому, что «именно так принято делать». Из-за этого становится сложнее не только читать код, но и понимать его поведение во время выполнения, не вдаваясь в подробный анализ зависимостей. Притом, что абстракции из нашего примера могут показаться надуманными, особенно для такого маленького демо, они в самом деле время от времени попадаются в реальных проектах.

Базовые классы и интерфейсы-маркеры


Все классы нашей модели наследуют от абстрактного базового класса или от записи под названием ModelBase, которая не предоставляет никакой реализации. POCO-классы из базы данных реализуют интерфейс IData, который хотя бы определяет свойство Id.

// ./Models
public abstract record ModelBase;

public record Customer(
    Guid Id,
    string FirstName,
    string LastName,
    string Email) : ModelBase;

// ./Data
public interface IData
{
    Guid Id { get; }
}

public record Customer(
    Guid Id,
    string FirstName,
    string LastName,
    string Email,
    bool Active) : IData;

Интерфейсы репозитория


В каталоге Repositories находится как обобщённый интерфейс , так и специфичные интерфейсы для каждой таблицы базы данных или класса POCO, например, ICustomerRepository. Кроме того, здесь есть абстрактный базовый класс , который просто по очереди реализует все методы обобщённого интерфейса.

public interface IRepository<T> where T : IData
{
    T Get(Guid id);
    IEnumerable<T> GetAll();
    void Add(T entity);
    ...
}

public abstract class AbstractRepository<T> : IRepository<T> where T : IData
{
    protected readonly IDatabase _database;

    protected AbstractRepository(IDatabase database) => _database = database;

    public abstract T Get(Guid id);
    public abstract IEnumerable<T> GetAll();
    public abstract void Add(T entity);
    ...
}

Иногда бывает целесообразно абстрагировать конкретное обращение к базе данных через такой интерфейс как IDatabase, поскольку таким образом можно на этапе тестирования замещать внешние объекты — ту же базу данных — имитационным объектом. Но по ходу данного поста мы найдём иное решение для этой проблемы.

В большинстве случаев конкретные реализации репозиториев основываются на переадресации вызовов к базовому классу или объекту IDatabase.

public class CustomerRepository : AbstractRepository<Customer>, ICustomerRepository
{
    public CustomerRepository(IDatabase database) : base(database) { }

    public override void Add(Customer entity) => _database.Add(entity);
    public override void Update(Customer entity) => _database.Update(entity);
    ...
}

Сервисы и CQRS


Все наши сервисы определяются как интерфейсы, у каждого из которых ровно одна реализация. Не требуется ни множественных реализаций, ни замены в ходе выполнения.

На примере с ITaxService проиллюстрировано, как зачастую используются интерфейсы. Этот интерфейс определяет всего один метод, у которого нет никаких зависимостей, кроме непосредственных параметров этого метода.

public interface ITaxService
{
    (decimal taxAmount, decimal grossPrice) CalculateTax(
        decimal netPrice, decimal taxRate);
}

public class TaxService : ITaxService
{
    public (decimal taxAmount, decimal grossPrice) CalculateTax(
        decimal netPrice, decimal taxRate)
    {
        var taxAmount = netPrice * taxRate / 100m;
        var grossPrice = netPrice + taxAmount;

        return (taxAmount, grossPrice);
    }
}

Тесты


Итак, как же выглядел бы (модульный) тест для такого кода? Попробовав протестировать метод GetOrderItems() сервиса OrderItemService увидим, как много кода приходится обустроить заранее, чтобы сымитировать зависимости и снабдить их данными. В случае с интерфейсом ITaxService даже бизнес-логика реализуется в имитационном объекте.

[Test]
public void Should_Return_OrderItems()
{
    // Упорядочить
    var orderId = Guid.NewGuid();

    var orderItem1 = new OrderItem(Guid.NewGuid(),
        orderId, Guid.NewGuid(), 2, 19.75m);
    var orderItem2 = new OrderItem(Guid.NewGuid(),
        orderId, Guid.NewGuid(), 3, 9.66m);
    var orderItemData = new List<OrderItem> { orderItem1, orderItem2 };

    var orderItemRepository = Substitute.For<IOrderItemRepository>();
    orderItemRepository.GetByOrderId(orderId).Returns(orderItemData);

    var taxService = Substitute.For<ITaxService>();

    taxService.CalculateTax(default, default)
        .ReturnsForAnyArgs(info =>
        {
            var netPrice = info.ArgAt<decimal>(0);
            var taxRate = info.ArgAt<decimal>(1);

            var taxAmount = netPrice * taxRate / 100m;
            var grossPrice = netPrice + taxAmount;

            return (taxAmount, grossPrice);
        });

    var sut = new OrderItemService(orderItemRepository, taxService);

    // Действовать
    var orderItems = sut.GetOrderItems(orderId);

    // Постулировать
    orderItems.Should().NotBeNullOrEmpty();
    orderItems.Should().HaveCount(2);

    var firstOrderItem = orderItems.First();
    firstOrderItem.Id.Should().Be(orderItem1.Id);
    firstOrderItem.TaxRate.Should().Be(19);
    firstOrderItem.GrossPrice.Should().Be(19.75m * 1.19m);
}

Как показано в шаге 1 нашего рефакторинга, можно без особого труда существенно сократить тот код, что требуется для подготовки тестов.

Анализ кода


Попробуем исследовать наше приложение при помощи Sonargraph и увидим, с каким количеством зависимостей между отдельными классами придётся иметь дело на данном этапе.

image

В данный момент в базе кода насчитывается 917 строк в 53 файлах, и показатель среднего количества зависимостей на компонент (ACD) равен 5.3.

Шаг 1: Заглушки для тестов


На первом этапе сосредоточимся на тестовых классах. Надёжный тестовый набор — залог безопасного рефакторинга, поэтому с него и начнём.

Следуя девизу new is glue, перейдём к созданию экземпляров тестовых данных, получаемых из тестовых методов, и занесём их в Dummies. Есть целая статья на тему фабрики заглушек Simple test setup with dummy factories, поэтому здесь мы просто вкратце затронем те изменения, которые будем вносить в код примеров.

Исходный код на GitHub, ветка steps/02-introduce-dummies

Добавим класс DataDummies, который займётся созданием объектов данных для нас. Кроме того, определим несколько статических экземпляров объектов Customer, которыми сможем пользоваться в наших тестах.

internal static class DataDummies
{
    public static Customer JohnDoe => Customer(
        new Guid("bfbffb19-cdd4-42ac-b536-606a16d03eae"), "John",
        "Doe", "john.doe@example.com");

    public static Customer JaneDoe => Customer(
        new Guid("95a6db4a-4635-4fb3-b7f6-c206ff7272f1"), "Jane",
        "Doe", "Jane.doe@example.com", false);

    public static Customer Customer(
        Guid? id = null, string firstName = "Peter", string lastName = "Parker",
        string email = "peter.parker@example.com", bool active = true)
    {
        return new Customer(id ?? Guid.NewGuid(),
            firstName, lastName, email, active);
    }

    ...
}

Того же подхода будем придерживаться и с объектами из нашей предметной области. Здесь нам может помочь то, что классы POCO и модели предметной области обычно структурируются схоже, благодаря чему мы сможем использовать объекты данных из DataDummies.

internal static class ModelDummies
{
    public static Customer JohnDoe => FromData(DataDummies.JohnDoe);
    public static Customer JaneDoe => FromData(DataDummies.JaneDoe);

    public static Customer FromData(Data.Customer data)
    {
        return Customer(id: data.Id, firstName: data.FirstName,
            lastName: data.LastName, email: data.Email);
    }

    ...
}

При таком подходе наша тестовая конфигурация упрощается и, что ещё важнее, устойчивее к тем изменениям, которые могут вноситься в объекты данных, поскольку нам нужно всего лишь отрегулировать их в одном месте.

[Test]
public void Should_Return_OrderItems()
{
    // Упорядочить
    var orderId = Guid.NewGuid();

    var orderItem1 = OrderItem(price: 19.75m);
    var orderItem2 = OrderItem(price: 9.66m);
    var orderItemData = Collection(orderItem1, orderItem2);

    var orderItemRepository = Substitute.For<IOrderItemRepository>();
    orderItemRepository.GetByOrderId(orderId).Returns(orderItemData);

    ...
}

После всех этих приготовлений можно переходить к рефакторингу продакшен-кода.

Шаг 2: Удаляем интерфейсы


На втором этапе попробуем избавиться от избыточных абстракций, применив интерфейсы и базовые классы. Зачастую утверждают, что в тестовом коде можно опираться на такие абстракции, заменяя зависимости, реализованные в виде интерфейсов, на имитационные объекты, заглушки или фиктивные объекты. Именно так и обстоит дело с внешними зависимостями, например, от базы данных или почтового сервера. Но, если речь идёт об автономных абстракциях, то такой подход обычно влечёт ненужную сложность, а тестовая конфигурация обрастает высокими издержками. Тестовые имитационные объекты сложно поддерживать, для этого требуется знать внутренние детали фактической реализации, а для этого её приходится воссоздавать.

Первым делом сосредоточимся на TaxService. Метод CalculateTax() — это уже чистая функция. Следовательно, можно удалить интерфейс ITaxService, сделать класс и метод статическими static и просто напрямую их вызывать. Внедрение зависимостей не требуется, от тестового имитационного объекта также можно избавиться.

public static class TaxService
{
    public static (decimal taxAmount, decimal grossPrice) CalculateTax(
        decimal netPrice, decimal taxRate)
    {
        ...
    }
}

Видим, что в соответствующем Git-коммите удалено 43 строки.

Далее обратим внимание на сервисные классы OrderService и OrderItemService. От зависимостей, предоставляемых через внедрение конструктора (напр., ICustomerRepository), нам потребуются только отдельные методы или просто возвращаемое значение метода. Мы не будем внедрять классы репозитория, а вместо этого станем предавать сервисным классам указатели на методы (делегаты). Так исчезает потребность в приватных свойствах, классы работают без сохранения состояния и становятся static — соответственно, мы можем избавиться от интерфейсов.

Раньше у класса OrderService было три зависимости.

public class OrderService : IOrderService
{
    private readonly ICustomerRepository _customerRepository;
    private readonly IOrderItemRepository _orderItemRepository;
    private readonly IOrderRepository _orderRepository;

    public OrderService(IOrderRepository orderRepository,
        ICustomerRepository customerRepository,
        IOrderItemRepository orderItemRepository)
    {
        _orderRepository = orderRepository;
        _customerRepository = customerRepository;
        _orderItemRepository = orderItemRepository;
    }

    public Order GetOrder(Guid id)
    {
        var orderData = _orderRepository.Get(id);
        return GetOrder(orderData);
    }

    private Order GetOrder(Data.Order orderData)
    {
        var customerData = _customerRepository.Get(orderData.CustomerId);
        var orderItemData = _orderItemRepository.GetByOrderId(orderData.Id);

        ...

        return orderModel;
    }
}

После рефакторинга класс приобретает следующий вид:

public static class OrderService
{
    public static Order GetOrder(Guid id,
        Func<Guid, Data.Order> getOrder,
        Func<Guid, Customer> getCustomer,
        Func<Guid, IReadOnlyCollection<OrderItem>> getOrderItems)
    {
        var orderData = getOrder(id);
        var customerData = getCustomer(orderData.CustomerId);
        var orderItemData = getOrderItems(id);
        return GetOrder(orderData, customerData, orderItemData);
    }

    ...
}

Теперь для вызова метода GetOrder() мы просто передаём в качестве параметров нужные методы репозиториев.

var orders = OrderService.GetOrder(
    id: id,
    getOrder: _orderRepository.Get,
    getCustomer: _customerRepository.Get,
    getOrderItems: _orderItemRepository.GetByOrderId);

Если сигнатуры методов отличаются, то мы можем легко приспособить их друг к другу при помощи лямбда-выражений.

var orders = OrderService.GetOrder(
    id: id,
    getCustomer: id => _customerRepository.Get(id: id, activeOnly: true),
    ...

Также упрощаются соответствующие модульные тесты. Нам более не требуется собирать имитационные объекты, нужно только лишь определить методы. Все эти локальные лямбда-выражения — однострочные.

var getOrder = (Guid _) => DataDummies.Order(orderId, peterPan.Id);
var getCustomer = (Guid _) => peterPan;
var getByOrderId = (Guid _) => DataDummies.Collection(orderItem1, orderItem2);

// Действовать
var order = OrderService.GetOrder(orderId, getOrder, getCustomer, getByOrderId);

В качестве альтернативы, работая с чистыми функциями, можно в качестве параметра передавать возвращаемое значение той же функции. Это называется «ссылочная прозрачность». Но при работе с методами, для которых характерны побочные эффекты (например, при обновлении баз данных) или при фильтровании больших множеств данных такой подход рекомендуется не всегда.

var order = _orderRepository.Get(id);
var customer = _customerRepository.Get(order.CustomerId);
var orderItems = _orderItemRepository.GetByOrderId(id);

var orders = OrderService.GetOrder(order, customer, orderItems);

Передавая зависимости как параметры метода, а не внося их в класс путём внедрения, мы перекладываем на вызывающий код ответственность за создание зависимостей и управление ими.

Шаг 3: Удаление CQRS


Затем удаляем из нашей базы кода паттерн CQRS, реализованный при помощи MediatR. Сама библиотека отличная, а CQRS — очень действенный инструмент в тех случаях, когда действительно требуется разделять команды и запросы. Но в нашем примере мы хотим продемонстрировать, что зачастую необходимости в этом нет, и здесь мы можем иметь дело с преждевременной оптимизацией, которая так и не пригодится.

Мы не будем распределять по множеству реализаций IRequest и IRequestHandler<> исходный код, связующий контроллер и логику предметной области, а консолидируем всё это в виде нескольких интеграционных классов.

Теперь вместо AddOrderHandler с соответствующим ему AddOrderRequest у нас будет всего один метод, получающий требуемые зависимости в виде параметров и оркеструющий вызов сервисных классов.

public static class OrdersIntegration
{
    public static void AddOrder(Order order,
        ICustomerRepository customerRepository,
        IOrderItemRepository orderItemRepository,
        IOrderRepository orderRepository)
    {
        if (!order.Items.Any())
            throw new InvalidOperationException("Order must have at least one item.");

        var customerData = customerRepository.Get(order.Customer.Id);

        if (customerData.Active is false)
            throw new InvalidOperationException("Customer is not active.");

        foreach (var orderItem in order.Items)
        {
            var orderItemData = OrderItemService.AddOrderItem(orderItem, order);
            orderItemRepository.Add(orderItemData);
        }

        OrderService.AddOrder(order, orderRepository.Add);
    }

    ...
}

На следующем этапе, примерно как было сделано при работе с сервисами, можно переключиться с внедрения репозиториев на работу с делегатами методов. Таким образом, можно избавиться от всех интерфейсов IRepository, поскольку при работе с тестами можно ничего вместо них не подставлять. Вот пример Git-коммита, в котором это продемонстрировано для IOrderRepository.

Шаг 4: Статические репозитории


Избавившись ещё от некоторых абстрактных базовых классов и интерфейсов, давайте заново взглянем на классы репозиториев. Все они зависят только от IDatabase. Если переключиться с внедрения конструктора на внедрение метода, то всё можно делать гораздо быстрее.

- public class OrderRepository
+ public static class OrderRepository
{
-    private readonly IDatabase _database;
-    public OrderRepository(IDatabase database) => _database = database;

-    public IEnumerable<OrderData> GetOrdersByDate(
-       DateTime startDate, DateTime endDate)
-        => _database.GetAll<OrderData>()
-               .Where(x => x.OrderDate >= startDate && x.OrderDate <= endDate);


+    public static IEnumerable<OrderData> GetOrdersByDate(
+        DateTime startDate, DateTime endDate, IDatabase db)
+            => db.GetAll<OrderData>()
+                .Where(x => x.OrderDate >= startDate && x.OrderDate <= endDate);
     ...
}

Если развить эту идею и в данном случае ожидать в качестве параметров метода делегаты методов, а не экземпляр IDatabase, то можно полностью убрать из наших репозиториев зависимость от IDatabase.

public static class OrderRepository
{
-    public static IEnumerable<OrderData> GetOrdersByDate(
-       DateTime startDate, DateTime endDate, IDatabase db)
-        => db.GetAll<OrderData>()
-            .Where(x => x.OrderDate >= startDate && x.OrderDate <= endDate);

+    public static IEnumerable<OrderData> GetOrdersByDate(
+        DateTime startDate, DateTime endDate, 
+        Func<IEnumerable<OrderData>> getAll)
+        => getAll().Where(x => x.OrderDate >= startDate &&
+                               x.OrderDate <= endDate);

    ...
}

В качестве альтернативы можно попробовать передавать в наш сервисный метод возвращаемые значения от этих методов, а не сами методы. Таким образом устраняются все побочные эффекты, и у нас получается чистая функция.

public static IReadOnlyCollection<Order> GetOrdersByDate(
    DateTime startDate, DateTime endDate,
    IEnumerable<OrderData> allOrderData,
    IDictionary<Guid, CustomerData> customerData,
    ILookup<Guid, OrderItemData> orderItemData)
{
    return allOrderData
        .Where(x => x.OrderDate >= startDate && x.OrderDate <= endDate)
        .Select(order => GetOrder(order,
            customerData[order.CustomerId], orderItemData[order.Id]))
        .ToList();
}

Теперь вызывающий метод отвечает за сбор данных.

var allOrderData = db.GetAll<OrderData>();

var customerData = db.GetAll<CustomerData>()
    .ToDictionary(x => x.Id, x => x);

var orderData = db.GetAll<OrderItemData>()
    .ToLookup(x => x.OrderId);

var orders = OrderService.GetOrdersByDate(startDate, endDate,
    allOrderData, customerData, orderData);

При работе с внешними источниками информации, например, базами данных или файлами, такой подход обычно неуместен из-за поздней фильтрации, так как может быть загружено слишком много данных. Мы не хотим загрузить в память всю базу данных только ради того, чтобы воспользоваться несколькими записями из неё. Однако, если в памяти уже есть небольшие наборы данных, то это хороший путь к снижению сложности.

Результаты


Чего мы добились в результате проведённого рефакторинга? Наша база кода стала гораздо меньше, нам удалось убрать из неё почти все интерфейсы.

Видим, что в графе зависимостей стало гораздо меньше строк. У нас осталось всего 715 строк кода (снижение на 25%), файлов осталось 34 (снижение на 35%), а коэффициент ACD снизился с 5,3 до 3,6.

image

Наряду с голыми цифрами не менее важно, что теперь код гораздо легче понимать и прослеживать. Чтобы понять, как система действует во время выполнения, больше не приходится вылавливать интерфейсы и потенциальные реализации.

Весь исходный код к этому посту выложен на GitHub.

Комментарии (0)