Как писать код понятно и удобно для дальнейшей работы. Часть 2

Разработчик Яндекс Бизнеса Виктор Якимич поделился свежей порцией советов, как писать код так, чтобы в нём было меньше шансов заблудиться. Почитайте также первую часть его советов

Уменьшайте сложность проекта

В сложном проекте:

  • затруднено восприятие кода — перед решением задачи разработчик долго изучает написанное и с трудом находит место, куда внести исправление;

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

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

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

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

Разделите проект на ограниченные контексты

Ограниченный контекст, если коротко, — это способ декомпозиции путём группировки классов.

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

grafika1

Для ограниченного контекста характерны самодостаточность и чётко определённые границы. Объекты внутри контекста взаимосвязаны как структурно, так и функционально, однако они не связаны с объектами из других контекстов. Благодаря слабому влиянию на прочий код разработчики могут с большей уверенностью работать с кодом ограниченного контекста, поскольку вероятность нарушения кода вне этой области минимальна.

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

Добивайтесь низкой связности (low coupling) и высокого зацепления (high cohesion)

Это понятия, которые описывают структуру кодовой базы, где код разбит на отдельные контексты с чёткими взаимосвязями между объектами внутри каждого контекста.

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

То есть низкая связность создаётся между крупными частями проекта — ограниченными контекстами. А высокое зацепление происходит между объектами внутри одного ограниченного контекста. При этом объекты из разных контекстов не зависят друг от друга.

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

grafika2

Изолируйте доменную модель

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

Создайте отдельный проект или модуль для классов доменной модели внутри ограниченного контекста, например product-notification/model. Этот модуль не должен иметь внешних зависимостей, то есть он будет содержать только классы объектов одного бизнес-процесса, проверки и преобразования данных. У модуля не будет доступа к файлам на жёстком диске, базам данных или другим сетевым сервисам.

Изолируя доменную модель, проще контролировать её состояние и обеспечивать идемпотентность (свойство объекта или операции при повторном применении давать тот же результат, что и при первом) методов сервисов и объектов доменной модели. Это повышает надёжность кода.

Примените гексагональную архитектуру для ограниченного контекста

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

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

Гексагональная архитектура описывает приложение как набор из шести сторон (гексагонов), которые отвечают за различные аспекты. Центральный гексагон представляет бизнес-логику приложения, а остальные гексагоны — внешние интерфейсы и интеграционные слои.

Начните писать код автоматизации бизнес-процесса с изолированной доменной модели. Потом создавайте интеграционные сервисы, которые имеют возможность взаимодействовать с файловой системой, базами данных и разными внешними API. Интеграционные сервисы работают с объектами доменной модели.

grafika3

Начинайте проект, создавая модульный монолит

Крупные проекты славятся тенденцией разделять проект на множество отдельных сервисов. Сервисы взаимодействуют друг с другом синхронно, например через http-запросы, или асинхронно, например через AMQP. Создаётся впечатление, будто сервисная архитектура — это панацея и нужно её использовать всегда и везде. К тому же часто можно услышать фразу: «Мы распиливаем монолит на микросервисы».

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

Между микросервисной архитектурой и монолитом выбирайте компромисс — модульный монолит. Это монолит, который заранее разделён на ограниченные контексты с низкой связностью между собой. Отделить ограниченный контекст в отдельный сервис в будущем будет проще, чем выделить бизнес-процесс из неструктурированного классического монолита.

Научите архитектуру вашей программы кричать

Очень часто разработчики распределяют классы по каталогам исходя из их технической принадлежности: модели к моделям, контроллеры к контроллерам, dao к dao. Со временем в каталогах оказывается слишком много файлов. А если один и тот же класс начинает использоваться в разных бизнес-процессах, то выделить один такой процесс из общего приложения практически невозможно. Так рождаются классические монолиты. В начале проекта разработка идёт гладко и быстро. Со временем скорость и качество разработки снижаются. А правки могут изменить поведение программы там, где этого не ожидается.

Термин «кричащая архитектура» был введён Робертом Мартином в книге «Чистая архитектура». Кричащая архитектура позволяет создавать более гибкие и масштабируемые системы, разбивая их на отдельные компоненты, которые могут разрабатываться и поддерживаться независимо друг от друга. Это упрощает процесс разработки, тестирования и внедрения новых возможностей. Кроме того, кричащая архитектура повышает надёжность и безопасность системы, так как каждый компонент может быть обновлён или заменён без влияния на работу других компонентов.

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

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

Не дублируйте код

Есть правило DRY (don’t repeat yourself) — «не повторяйся». Не надо использовать копии кода в нескольких местах программы.

Представьте, что вы пишете курсовую работу и в нескольких разделах упоминаете один график. Если этот график дублировать везде, где о нём идёт речь, возникнет пара трудностей. Во-первых, читатель может подумать, что это разные графики. А во-вторых, при внесении изменений в график нужно не забыть обновить его во всех местах. Гораздо удобнее вынести график в раздел с приложениями и ссылаться на него.

Точно так и в коде: хотите разделить строку на массив слов так же, как уже где-то сделали, — создайте функцию и используйте её в обоих местах.

public class Main {
    public static void main(String[] args) {
        printAllWords();
        printLongWords();
    }

    public static List<String> splitOnlyLettersAndNumerics(String phrase, int minWordLength) {
        String[] words = phrase.split("[\\s]+");
        return Arrays.stream(words)
            .map(word -> word.replaceAll("[^\\p{L}\\p{N}]+", ""))
            .filter(Predicate.not(String::isEmpty))
            .filter(word -> word.length() >= minWordLength || word.matches("\\d+")) // для числа неважна длина строки
            .collect(Collectors.toList());
    }
    
    public static void printAllWords() {
        String str = " строка, из кот!орой нужно\nполуч+ить только слова, длина кот-орых больше или равна 4";
        List<String> words = splitOnlyLettersAndNumerics(str, 4);
        System.out.println(words);
    }
    
    public static void printLongWords() {
        String str = "Загадочные истории обязательно привлекут внимание многочисленных поклонников, желающих приключений";
        List<String> words = splitOnlyLettersAndNumerics(str, 10);
        System.out.println(words);
    }
}

При разделении кода на ограниченные контексты могут появиться объекты, которые присутствуют сразу в нескольких контекстах. Например, объект «пользователь». Он присутствует и в контексте регистрации, и в пополнении корзины, и в создании и оплате заказа. Как быть?

В этом случае стоит создать разные классы User, описывающие пользователя, но с разным составом полей. Противоречит ли это DRY? Кажется, что да. Но на самом деле нет. Ведь эти классы User развиваются отдельно друг от друга и над ними выполняются разные действия. А если нужно проверять корректность введённого email-адреса в нескольких ограниченных контекстах, то можно вынести функцию проверки за пределы всех контекстов и вызывать её при необходимости.

Сопровождайте свой код комментариями

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

public static double getAvgSpeed(Collection<GeoCoordinate> geoCoordinateBatch) {
  return getDistance(geoCoordinateBatch).stream()
    .map(GeoDistance::getKmValue)
    .filter(kmValue -> kmValue < 10) // игнорируем сбои в спутниковой навигации (хороший комментарий)
    .mapToInt(v -> v)
    .average()
    .orElse(0);
}


public static double getAvgSpeed(Collection<GeoCoordinate> geoCoordinateBatch) {
  return getDistance(geoCoordinateBatch).stream()
    .map(GeoDistance::getKmValue)
    .filter(kmValue -> kmValue < 10) // учитываем только расстояния меньше 10 км (плохой коммментарий)
    .mapToInt(v -> v)
    .average()
    .orElse(0);
}

По поводу комментариев у разработчиков разные мнения:

  • комментарии нужно писать для каждого класса, поля, метода, даже если всё понятно без пояснений;

  • комментарии не нужны абсолютно, так как код более точен; кроме того, при изменении кода про комментарий могут забыть — и тогда он будет только вводить в заблуждение;

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

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

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

public void sendEmail(Message message) {
  // if (message.getRecipient() == "developers@example.org") {  // этот код нужен только для локальной отладки, не удаляйте, пожалуйста
  //   writeToFile(message);
  // }
  send(message);
}

Пишите комментарии до написания кода

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

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

То есть вы прямо в комментариях пишете план вашей разработки.

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

Ведите документацию

Писать документацию могут технические писатели, аналитики, менеджеры. Если в вашем проекте нет человека, который пишет документацию, стоит сделать это самостоятельно, хотя бы на минимальном уровне, например в файле readme.md в корне ограниченного контекста.

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

Если вам кажется, что код меняется так быстро, что документация устаревает, то либо документация излишне подробна, либо проект не разделён на ограниченные контексты.

Покрывайте код тестами

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

Начинать создание автотестов стоит ещё в процессе разработки. Лучше даже до её начала, а не после написания основного кода. Тогда ваш код окажется более пригоден для тестирования, а процесс отладки будет более быстрым и предсказуемым.

Помните, что когда-нибудь ваш код будет переписан

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

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

Краткий пересказ от YandexGPT