[Перевод] К чистому коду через рефакторинг
Чистые функции
— это такие методы, при выполнении которых не возникает побочных эффектов. В функциональном программировании чистые функции — скорее правило, чем исключение. Но в большинстве объектно-ориентированных языков с ними приходится сталкиваться нечасто, или, как минимум, они редко считаются предпочтительным вариантом. В дотнет-среде серьёзный акцент делается на внедрении зависимостей и более-менее обширных абстракциях, использующих интерфейсы.
В данной статье будет продемонстрировано, как перейти от базы кода, характеризующейся значительной опосредованностью такого рода, к более простой версии, из которой большей частью удалена избыточная сложность.
Исходная ситуация
В качестве отправной точки для нашего рефакторинга рассмотрим вымышленный пример: допустим, у нас имеется интернет-магазин. Исходный код этого примера выложен на 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 where T : IData
{
T Get(Guid id);
IEnumerable GetAll();
void Add(T entity);
...
}
public abstract class AbstractRepository : IRepository where T : IData
{
protected readonly IDatabase _database;
protected AbstractRepository(IDatabase database) => _database = database;
public abstract T Get(Guid id);
public abstract IEnumerable GetAll();
public abstract void Add(T entity);
...
}
Иногда бывает целесообразно абстрагировать конкретное обращение к базе данных через такой интерфейс как IDatabase
, поскольку таким образом можно на этапе тестирования замещать внешние объекты — ту же базу данных — имитационным объектом. Но по ходу данного поста мы найдём иное решение для этой проблемы.
В большинстве случаев конкретные реализации репозиториев основываются на переадресации вызовов к базовому классу или объекту IDatabase
.
public class CustomerRepository : AbstractRepository, 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 { orderItem1, orderItem2 };
var orderItemRepository = Substitute.For();
orderItemRepository.GetByOrderId(orderId).Returns(orderItemData);
var taxService = Substitute.For();
taxService.CalculateTax(default, default)
.ReturnsForAnyArgs(info =>
{
var netPrice = info.ArgAt(0);
var taxRate = info.ArgAt(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();
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 getOrder,
Func getCustomer,
Func> 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 GetOrdersByDate(
- DateTime startDate, DateTime endDate)
- => _database.GetAll()
- .Where(x => x.OrderDate >= startDate && x.OrderDate <= endDate);
+ public static IEnumerable GetOrdersByDate(
+ DateTime startDate, DateTime endDate, IDatabase db)
+ => db.GetAll()
+ .Where(x => x.OrderDate >= startDate && x.OrderDate <= endDate);
...
}
Если развить эту идею и в данном случае ожидать в качестве параметров метода делегаты методов, а не экземпляр IDatabase
, то можно полностью убрать из наших репозиториев зависимость от IDatabase
.
public static class OrderRepository
{
- public static IEnumerable GetOrdersByDate(
- DateTime startDate, DateTime endDate, IDatabase db)
- => db.GetAll()
- .Where(x => x.OrderDate >= startDate && x.OrderDate <= endDate);
+ public static IEnumerable GetOrdersByDate(
+ DateTime startDate, DateTime endDate,
+ Func> getAll)
+ => getAll().Where(x => x.OrderDate >= startDate &&
+ x.OrderDate <= endDate);
...
}
В качестве альтернативы можно попробовать передавать в наш сервисный метод возвращаемые значения от этих методов, а не сами методы. Таким образом устраняются все побочные эффекты, и у нас получается чистая функция
.
public static IReadOnlyCollection GetOrdersByDate(
DateTime startDate, DateTime endDate,
IEnumerable allOrderData,
IDictionary customerData,
ILookup 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();
var customerData = db.GetAll()
.ToDictionary(x => x.Id, x => x);
var orderData = db.GetAll()
.ToLookup(x => x.OrderId);
var orders = OrderService.GetOrdersByDate(startDate, endDate,
allOrderData, customerData, orderData);
При работе с внешними источниками информации, например, базами данных или файлами, такой подход обычно неуместен из-за поздней фильтрации, так как может быть загружено слишком много данных. Мы не хотим загрузить в память всю базу данных только ради того, чтобы воспользоваться несколькими записями из неё. Однако, если в памяти уже есть небольшие наборы данных, то это хороший путь к снижению сложности.
Результаты
Чего мы добились в результате проведённого рефакторинга? Наша база кода стала гораздо меньше, нам удалось убрать из неё почти все интерфейсы.
Видим, что в графе зависимостей стало гораздо меньше строк. У нас осталось всего 715 строк кода (снижение на 25%), файлов осталось 34 (снижение на 35%), а коэффициент ACD снизился с 5,3 до 3,6.
Наряду с голыми цифрами не менее важно, что теперь код гораздо легче понимать и прослеживать. Чтобы понять, как система действует во время выполнения, больше не приходится вылавливать интерфейсы и потенциальные реализации.
Весь исходный код к этому посту выложен на GitHub.