Как писать SOLIDно.

Чистый код — это естественно.

Artem Ptushkin
Clean Code

--

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

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

Важность интерфейсов

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

Обращу внимание, что CardServiceи AccountService являются классами, без выделенного интерфейса.

Разработчики смерджили код, ведь необходимости в интерфейсе явной не было. Мы догадываемся, что это классы какого-то REST API
сервиса, но также это могут быть классы библиотеки, немного в другом виде или классы в монолитном модуле.

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

Для любого изменения, связанного с CardService:

  • AccountService в библиотеке: придется изменить AccountService и пересобрать библиотеку или написать адаптер в текущем репозитории. А если бы был выделен интерфейс CardService — можно было бы изменить реализацию, передав в конструктор.
  • AccountService в текущем проекте: придется изменить AccountService, соответственно его тесты. Звучит несложно, но в случае реального промышленного случая таких изменений будет гораздо больше.

Кроме проблем, связанных с будущими изменениями, есть и другие причины выделить интерфейс:

  • С интерфейсом API класса выглядит нагляднее. Методы в классе со временем могут быть перемешаны (к примеру, private станет выше public), но при наличии интерфейса доступные для вызова методы класса будут выделены Overrideаннотациями и будут сразу заметны. Более того, сразу захочется убрать private методы вниз как неконсистентные:
  • С созданием интерфейса разработчик описывает основные свойства модуля. Поэтому, выделив интерфейс, можно представить ожидания от будущих реализаций в абстрактном виде
  • Смотря на интерфейс, внимание сосредоточено на том, как могут быть использованы реализации в будущем, потому что нет лишнего
  • Юнит-тесты проверяют, как реализация выполняет логику для интерфейса. При написании тестов нет сомнений, что тестируем,
    и не приходят в голову такие вещи, как “a как тестировать private методы?”, потому что всегда тестируется API, то есть интерфейс. Все private методы же будут протестированы автоматически, а мы проверяем лишь ожидания от доступного метода, имитируя поведение входящих параметров и полей реализации
  • Все паттерны программирования завязаны на интерфейсы и абстрактные классы. Если Вы просто выделите интерфейс, кому-то
    (Вам) будет удобнее рефакторить это в будущем и, возможно, подводить под определенный паттерн программирования.

Например, вы хотите сделать обертку вокруг CardService, который имеет какое-то поле.

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

Выделив интерфейс, мы дали возможность сделать wrapper по интерфейсу, т.е. оставить класс независимым от реализации.

Для других паттернов или удобных архитектурных решений это также будет актуально.

С практикой каждый разработчик приходит к этому, если задает себе вопросы про потенциальное повторное использование своего кода и его поддержку. Итак, мы объяснили принцип инверсии зависимостей (англ. Dependency inversion principle, DIP) — буква «D» в аббревиатуре SOLID:

Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций

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

Говоря о практике, IntelliJ Idea выделяет интерфейс за несколько кликов, но конечно рекомендуется начинать разработку
с интерфейсов, чтобы стремиться к чистой сигнатуре методов.

IntelliJ Idea interface extraction

Декомпозиция

Рассмотрим еще спорный момент в примере выше. Мы видим, что класс-сервис AccountService меняет значение amount, а именно его value.

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

Стоит задавать себе вопросы: может и другие поля будут изменяться в будущем, одно мы уже конвертируем? Как я буду это тестировать?

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

Ответственный ответ на эти вопросы приведет к коду, с которым комфортнее работать в будущем, а также даст возможность тестировать конвертацию/форматирование отдельно от остальной логики.

Если вспомнить про плюсы выделения интерфейса, описанные выше, можно понять, что здесь также будет удобно заимплементить интерфейс java.util.function.Function из Java 8 SDK, который
позволит быстро адаптироваться в будущем для конвертации множества объектов AccountEntity путем применения этой функции через java.util.stream.Stream #map

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

С данным рефакторингом мы избавили класс AccountService от действительно лишней ответственности в конвертации данных.

Таким образом, мы пришли к букве “S” в аббревиатуре SOLID — принцип единственной ответственности (англ. Single Responsibility).

Опять же, мы открыли для себя известный принцип, задавая себе вопросы, как сделать код лучше!

Вопрос ответственности

Про данный принцип стоит заметить, что единственная причина для изменений — достаточно широкое определение. Таким образом,
разработчики зачастую считают, что если класс имеет простой интерфейс, например, возвращает Account, принимая один объект, то он имеет единственную ответственность.

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

Классически принято считать, что если интерфейс работает с разными типами сущностей, например, account и email, то это избыточность, и его реализации нарушат принцип единственной ответственности.

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

Обзор модульных тестов

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

Предположим, реализация интерфейса CardService будет иметь следующий вид:

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

Классический юнит-тест для этого класса будет написан так:

Неточность данного теста в том, что мы ожидаем любой объект CardSearchRequest, переданным в метод нашей заглушки CardStorageService.

Но, например, если будут вызовы в цикле внутри метода#getCard, то данный стаб не подойдет — он даст одинаковое поведение для каждой итерации цикла.

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

Чтобы имитировать конкретное поведение вида when(cardStorageService.findCard(eq(cardSearchRequest))), необходимо
сделать заглушку созданного объекта CardSearchRequest.

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

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

Правильнее было бы, чтобы объект CardSearchRequest возвращался другим объектом (builder, provider), в этом случае мы могли бы легко имитировать это поведение, и код стал бы гибче для изменений.

Стоит помнить, что создание объекта, это тоже ответственность, которая должна быть протестирована отдельно.

Вопросы к своему коду

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

  • Удобно ли будет написать классический юнит-тест на этот класс?
  • Есть ли параллели с другим кодом, который я писал, можно ли выделить общее?
  • Будет ли мне понятно, как работают реализации данного интерфейса через длительный промежуток времени?
  • Сможет ли человек, не особо погружаясь в бизнес-логику, понять, что делают данные классы?

P.S.: Примеры по буквам (“D” и “S”) идут не в последовательности SOLID осознанно, так как я предлагаю думать не про аббревиатуры, а реальные полезные применения. Принципы созданы лишь для фиксации опыта и возможности делиться ими.

Join Clean Code in Telegram

--

--

Artem Ptushkin
Clean Code

Software engineer, clean code enthusiast, and contract testing expert