Почему не всегда стоит следовать принципу DRY

“Не повторяйся” (Don’t Repeat Yourself, DRY)  —  один из самых распространенных принципов программирования, регулярно упоминаемый при рассмотрении запросов на включение изменений. Первоначально обоснование соблюдения принципа DRY было вполне разумным.

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

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


Что призван решить принцип DRY?

В 1999 году Энди Хант и Дэйв Томас выпустили книгу “Прагматичный программист”. С тех пор она приобрела невероятную популярность, а ряд ее положений стали неизменным руководством к действию для многих инженеров-программистов.

Одно из них  —  “не повторяйся”. Вот определение DRY из книги “Прагматичный программист”:

Каждая часть информации должна иметь единое, однозначное, авторитетное представление в системе.

Я с этим полностью согласен. Иначе говоря, бизнес-логика не должна повторяться в двух местах.

Коротко об использовании DRY

Допустим, вы работаете над системой электронной коммерции и вам нужно указать, что заказ действителен в течение 7 дней. Логика для определения того, просрочен ли заказ, должна определяться только один раз. Например, в классе Order (заказ):

class Order
def initialize(order_id:, order_timestamp:, items:)
@order_id = order_id
@order_timestamp = order_timestamp
@items = items
end

def expired?
Time.zone.now > order_timestamp + 7.days
end
end

Как видите, логика для определения того, истек ли срок действия заказа, находится в классе Order. Если предположить, что это единственное место, где эта логика определена, то код в данном репозитории соответствует принципу DRY.

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

Это неоптимально, поскольку обновление бизнес-правила  —  по истечении срока действия заказа через 7 дней  —  потребует изменений в нескольких местах. Стоит не внести изменение в одном месте (что вполне вероятно), и в разных частях приложения будет применяться разная логика.

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

Этот второй фрагмент кода должен подвергнуться рефакторингу, чтобы использовать метод expired? из Order вместо того, чтобы придерживаться принципа DRY.

Пока все верно. Описанное выше является классическим примером применения DRY. Однако в последние годы этот принцип используется слишком своевольно, причем в ущерб коду.


Проблема злоупотребления DRY

Итак, мы выяснили, для чего предназначен принцип DRY и как правильно его использовать.

Однако DRY не всегда применяется именно так. Я видел множество комментариев к запросам на включение изменений, где авторы неправильно полагали, что код должен быть обновлен, чтобы соответствовать принципу DRY, в то время как был смысл в дублировании кода.

При этом считается, что нужно применять DRY по отношению к любому дублированию, а не только к дублированию бизнес-правил и информации. Это часто усугубляется автоматизированными инструментами анализа кода, которые обычно выделяют любое дублирование как признак кода “с душком”.

Пример неуместного использования DRY

Думаю, лучше всего объяснить правильный и неправильный подходы на конкретном примере. Рассмотрим фрагмент кода с двумя классами, в котором есть дублирование. Это пример из реального проекта (я лишь упростил код, чтобы ограничить количество представленных данных).

class OrderPlacedEventPublisher
def publish(order)
publisher = EventPublisher.for_event('orer_placed')
.with_data(order_id: order.id, items: order.items)

add_location_context(location_uuid: order.location_uuid, publisher: publisher)

publisher.publish
end
def add_location_context(location_uuid:, publisher:)
return if location_uuid.blank?

publisher.with_context(
name: 'location_context',
data: {
location_uuid: location_uuid
},
version: '1-0-0'
)
end
end
class ItemViewedEventPublisher
def publish(item)
publisher = EventPublisher.for_event('item_viewed')
.with_data(item_id: item.id)

add_location_context(location_uuid: item.location_uuid, publisher: publisher)

publisher.publish
end

def add_location_context(location_uuid:, publisher:)
return if location_uuid.blank?

publisher.with_context(
name: 'location_context',
data: {
location_uuid: location_uuid
},
version: '1-0-0'
)
end
end

Классы находятся в отдельных файлах в репозитории (я просто поместил их в один блок кода для удобства просмотра). Оба класса публикуют событие в Kafka в совершенно разных частях приложения и в разных случаях использования.

Каждое событие имеет основной раздел (например, order_placed) и опционально список контекстов для предоставления дополнительной информации, которая часто относится к нескольким событиям. Например, location, т.е. данные о местоположении пользователя, не хранятся в проекте вместе с данными о товарах и заказах, но здесь это не имеет значения.

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

Повторяйтесь

В данном случае я не согласен ни с инструментом анализа кода, ни с рецензентом. Хотя здесь есть дублирование, оно не связано с бизнес-правилами и информацией.

К сожалению, не могу найти доклад, сделанный на конференции O’Reilly в Берлине, где я впервые услышал рекомендацию “повторяйтесь”. Она очень сильно меня привлекла.

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

module LocationContextHelper
def add_location_context(location_uuid:, publisher:)
return if location_uuid.blank?

publisher.with_context(
name: 'location_context',
data: {
location_uuid: location_uuid
},
version: '1-0-0'
)
end
end

class OrderPlacedEventPublisher
include LocationContextHelper
... rest of the class ...
end

class ItemViewedEventPublisher
include LocationContextHelper
... rest of the class ...
end

Вы будете правы, если скажете, что дублирование было удалено, но ценой введения взаимозависимости обоих классов. В проекте со множеством контекстов во многих частях кодовой базы для соблюдения DRY при работе с дополнительными контекстами событий может понадобиться 5 вспомогательных классов (или один большой вспомогательный класс, что еще хуже) с включением их примерно в 15 классов event publisher

Цена устранения такого дублирования, на мой взгляд, слишком высока. В методе add_location_context нет бизнес-логики  —  это простой код, который необходим в совершенно несвязанных частях кодовой базы.

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


Заключение

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

Стоит отметить, что мы обсуждали DRY только в контексте одного репозитория. Если вы работаете, например, в архитектуре микросервисов, то дублирование некоторых бизнес-правил в нескольких сервисах может быть более оправданным и целесообразным.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Ashley Peacock: The Case Against Relying Solely on DRY

Предыдущая статьяAEGIS  —  система аутентификации платформы Ankorstore
Следующая статьяЯзык R: прокачайте свои навыки до следующего уровня