Чистые функции
— это такие методы, при выполнении которых не возникает побочных эффектов. В функциональном программировании чистые функции — скорее правило, чем исключение. Но в большинстве объектно-ориентированных языков с ними приходится сталкиваться нечасто, или, как минимум, они редко считаются предпочтительным вариантом. В дотнет-среде серьёзный акцент делается на внедрении зависимостей и более-менее обширных абстракциях, использующих интерфейсы.
В данной статье будет продемонстрировано, как перейти от базы кода, характеризующейся значительной опосредованностью такого рода, к более простой версии, из которой большей частью удалена избыточная сложность.
Исходная ситуация
В качестве отправной точки для нашего рефакторинга рассмотрим вымышленный пример: допустим, у нас имеется интернет-магазин. Исходный код этого примера выложен на 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 и увидим, с каким количеством зависимостей между отдельными классами придётся иметь дело на данном этапе.
В данный момент в базе кода насчитывается 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.
Наряду с голыми цифрами не менее важно, что теперь код гораздо легче понимать и прослеживать. Чтобы понять, как система действует во время выполнения, больше не приходится вылавливать интерфейсы и потенциальные реализации.
Весь исходный код к этому посту выложен на GitHub.
ссылка на оригинал статьи https://habr.com/ru/articles/851716/
Добавить комментарий