К чистому коду через рефакторинг

от автора

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

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

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

В качестве отправной точки для нашего рефакторинга рассмотрим вымышленный пример: допустим, у нас имеется интернет-магазин. Исходный код этого примера выложен на 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.


ссылка на оригинал статьи https://habr.com/ru/articles/851716/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *