В этой статье мы обсудим вопросы, с которыми столкнулись разработчики из компании «Наука» при создании Java-приложений и оформлении кода. Эти трудности, «выстраданные» через годы опыта, стали результатом многолетней работы над программным обеспечением для крупных предприятий нефтеперерабатывающей и нефтехимической отрасли.

Наша компания существует уже более 30-ти лет, и на сегодняшний день в ней работает более 100 разработчиков ПО на различных проектах. Одной из основных проблем в нашей компании, и, как мы полагаем, не только в нашей, является большая текучка кадров, в том числе и среди разработчиков. Чтобы упростить и ускорить процесс вхождения вновь пришедших разработчиков в проекты, для программистов, уже работающих в нашей компании, был рекомендован некоторый набор правил по разработке Java-приложений. Также был составлен перечень типовых ошибок при оформлении кода, подробно разобранный на примерах.

Программистам в IT-компаниях, подобных нашей, заказчики платят не за производимый ими код, а за успешную автоматизацию их (заказчиков) бизнес‑процессов. Поэтому материал статьи связан прежде всего с коммерческой разработкой enterprise-систем.

Мы надеемся, что данная статья может быть полезна back-end разработчикам enterprise-систем, работающим в других IT-компаниях.

      1. Методология разработки enterprise-систем

Основным подходом к enterprise-разработке является объектно-ориентированное программирование (далее – ООП). Плюсы и минусы данного подхода представлены на рисунке 1.

Рисунок 1 – Плюсы и минусы ООП с практической точки зрения
Рисунок 1 – Плюсы и минусы ООП с практической точки зрения

Каждому проекту соответствует свой подход к разработке. Разработка с использованием ООП оптимальна для больших проектов, проектов с длительной поддержкой и проектов, разрабатываемых большой командой. Плюсы ООП характерны именно для enterprise-разработки.

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

Если сравнить принципы SOLID, KISS и DRY c принципами ООП, то можно убедиться, что разработка в ООП автоматически соблюдает эти принципы. Деление на пакеты, классы, иерархичность пакетов и классов предназначены для выполнения требований SOLID, KISS и DRY и способствуют быстрому входу в проект новых разработчиков и быстрому поиску ошибок. Java является языком, прекрасно подходящим для ООП, т.е. и для enterprise-разработки.

   2. Особенности подхода к разработке софта в компании Nauka

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

·       ядро - базовую библиотеку SpringBoot с общим, базовым набором функционала для всех клиентов;

·       приложения с расширением или изменением функционала ядра под конкретного клиента.

          3. Структура приложений Java

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

Рисунок 2 – Структура пакетов, применяемая для разработки библиотек
Рисунок 2 – Структура пакетов, применяемая для разработки библиотек

В случае корневого пакета библиотеки используется имя «ru.ntik.xxxxx.core». В случае нового приложения – ««ru.ntik.xxxxx».

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

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

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

Рисунок 3 – Структура подпакетов
Рисунок 3 – Структура подпакетов

          4. Правила разработки классов и интерфейсов

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

1. все классы (бизнес-процессов, а не entity) должны иметь соответствующие интерфейсы;

2. методы в интерфейсах должны иметь javadoc описания;

3. интерфейсы и методы в них, при необходимости работы с объектами Entity, должны быть параметризованы базовым классом Entity, если предполагается наследование. Если же наследование не планируется (например, при разработке библиотек), интерфейсы и методы должны быть параметризованы конкретной Entity;

4. при определении функций в интерфейсах необходимо использовать принцип подстановки Барбары Лисков (поведение наследующих классов не должно противоречить поведению, заданному базовым классом, то есть поведение наследующих классов должно быть ожидаемым для кода, использующего переменную базового типа);

5. при определении параметров функций в интерфейсах обязательно использование аннотаций, ограничивающих значения, или сигнализирующих о допустимости нулевого значения (@NonNull, @Nullable);

6. в классах библиотек (исключая класс страницы UI) запрещено использовать переменные и функции с уровнем доступа private, только protected и выше. Это связано с нашей методикой разработки приложений как базовых библиотек и измененных реализаций под конкретного клиента;

7. все тексты исключений должны быть указаны как public или protected static final значения (шаблоны значений для использования в String format);

8. запрещено использовать prototype бины Spring. Вместо этого должна использоваться связка factory/builder (бин спринга) + pojo объект;

9. в методах, возвращающих коллекции запрещено возвращать нуль, необходимо использовать пустые коллекции;

10. для всех входных переменных публичных функции, которые являются коллекциями, необходимо вести разработку, исходя из предположения о немодифицируемости входящей коллекции;

11. запрещено менять входящие объекты функции (в том числе, вложенные в коллекции), если функция напрямую не предназначена для изменения переданного объекта, что должно отражаться в javadoc и наименования функции (save(), modify());

12. сохранять состояние в бинах Spring запрещено, кроме случаев, когда это требуется по бизнес-процессу (например, кеширование);

13. в случае необходимости сохранения состояния, рекомендуется использовать преимущественно потокобезопасные объекты/коллекции, менее желательно - реализовывать механизмы синхронизации в функциях.

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

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

Некоторые из этих правил имеет смысл рассмотреть подробнее. В п. 5 речь идет о трёх видах аннотаций:

·       аннотации валидации javax.validation.constraints.NotNull, используемые в entity для валидации объекта;

·       аннотации org.springframework.lang.NonNull (рисунок 4);

·       аннотации lombok.NonNull (рисунок 6).

Рисунок 4 – Аннотации NonNull.org.springframework.lang
Рисунок 4 – Аннотации NonNull.org.springframework.lang

Наиболее часто используемые аннотации (рисунок 4) в нашем случае – NonNull и Nullable. Эти аннотации ни в чём не ограничивают объект, подставляемый в функцию. Но в случае использования этого объекта SonarLint подсвечивает его голубым фоном, обращая внимание на то, что он может иметь нулевое значение. Также при разработке в IDEA он подсвечивает желтым шрифтом все объекты, использующие эту аннотацию (рисунок 5).

Аннотация lombok.NonNull меняет поведение функции. Она повесит Accept и потребует NullPointerException после генерации кода (рисунок 6).

Какие из аннотаций использовать при разработке зависит от автоматизируемого бизнес-процесса. Использование аннотаций упрощает работу нового разработчика и потенциально способно уменьшить количество ошибок.

Рисунок 5 – Работа SonarLint при разработке с использованием аннотаций
Рисунок 5 – Работа SonarLint при разработке с использованием аннотаций
Рисунок 6 – Описание lombok
Рисунок 6 – Описание lombok

Правило п.6 обязательно используется при разработке библиотек, но рекомендуется и при разработке приложений. Это связано с тем, приватные функции и приватные классы являются большой проблемой для классов, которые требуется расширить.

Исключения (п.7) лучше размещать вверху класса (рисунок 7), чтобы не искать их по тексту.

Рисунок 7 – Рекомендуемое размещение исключения
Рисунок 7 – Рекомендуемое размещение исключения

Прототип бины Spring (п.8) запрещено использовать потому что их использование требует дополнительного внимания к освобождению ресурсов для таких бинов.

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

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

Если разработчику требуется использовать модифицируемою коллекцию (п. 10), то в функции следует указать, что принимается не List, а ArrayList, либо внутри принять List и преобразовать его в модифицируемую коллекцию.

Изменять входящие объекты функции (п.11) можно, только если это модифицируемая функция. При этом эта функция должна быть void’овской.

Сохранять состояние в бинах Spring (п.12) запрещено, поскольку при этом возникает большое количество ошибок.  В основном, бины Spring используются в многопоточном режиме и при сохранении состояния требуют использования потокобезопасных объектов (п.13).  

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

Разработчикам, иcпользующим Intellij, полезно использовать сочетание клавиш <Ctrl+Shift+Alt+L>, позволяющее выбрать и отформатировать текст кода. Перед коммитом следует проверять с помощью плагина SonarLint. Это зачастую помогает определить ошибки, в том числе и серьёзного уровня, которые требуется править.

5. Приёмы разработки UI на Vaadin

5.1  Общая схема работы UI компонентов

При разработке UI компонентов следует учитывать, что основой любого элемента UI является интерфейс с функцией «нарисовать свое представление на экране пользователя». Для обеспечения принципов KISS и SOLID необходимо учесть, что отрисовка элемента функцией draw() происходит только исходя из внутреннего состояния элемента UI. Никаких обращений к другим элементам UI при этом не происходит. Компоновка же общего вида UI из отдельных компонентов обычно выполняется с помощью паттернов компоновщик (в общем случае) или порождающих паттернов (фабрика, билдер) для более специализированных случаев.

Общая схема работы компонентов UI при необходимости отрисовки / перерисовки UI представлена на рисунке 8.

Общая схема процесса работы пользователя с отдельным компонентом UI представлена на рисунке 9.

Главной особенностью данной схемы является то, что сам элемент UI – это всего лишь отображение. Он не несет функции хранения данных. Данные инкапсулируются в соответствующем data объекте компонента, и любое действие пользователя, в первую очередь, меняет именно data объект. А отображение объекта на экране пользователя перестраивается функцией draw().

5.2.  Схемы взаимодействия UI компонентов

Схема взаимодействия компонентов UI, если компоновщик входит в ядро Vaadin, должна выглядеть одним из способов, приведённых на рисунках 10, 11.

Рисунок 10 – Предпочтительная схема взаимодействия
Рисунок 10 – Предпочтительная схема взаимодействия
Рисунок 10 – Предпочтительная схема взаимодействия
Рисунок 10 – Предпочтительная схема взаимодействия

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

В данной схеме поток изменений распространяется только в одну сторону, и это важно. То есть компонент 2 ни при каких обстоятельствах не может менять Data объект, запрашивать дополнительные данные и менять состояние компонента 1. Данная схема является предпочтительной, так как имеет более простое прослеживание взаимосвязей компонентов UI при разработке.

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

Но данная схема неприменима для множественных разнонаправленных связей между компонентами. В этом случае применяется событийная схема связи между компонентами (рисунок 11). При многосвязной схеме взаимодействия компонентов никакой иерархии выстроить не удастся. Для связи между многосвязными элементами UI (когда действия во многих слабосвязанных элементах UI могут менять одни и те же данные в текущем объекте UI или когда компоненты не иерархичны), необходимо реализовывать событийную систему обновления компонентов. Так же, как и в предыдущей схеме:

·       компоненты являются независимыми и не знают о существовании друг друга;

·       прямое изменение состояния отображения компонента UI запрещено.

Слушатели событий меняют Data объект компонента UI и вызывают функцию полной или частичной перерисовки.

Особое внимание следует обратить на то, что в данном случае необходимо предусматривать отсутствие зацикливания событий. Это достигается правильной разработкой компонента 1 (рисунок 11). Не нужно забывать о том, что Data объект – это не только данные, он может содержать и флаги. Если пользователь изменил какие-либо данные в компоненте 2 или 3, то отправляется соответствующее сообщение в шину событий. Компонент 1 (если он подписан на эти события), получает это сообщение, и, поскольку это сообщение от шины событий, он производит необходимые изменения и сбрасывает флаг события. Таким образом, зацикливание предотвращается.

6. Ошибки при разработке Spring Boot приложений

1.  Разработка ООП в функциональном стиле, когда размеры функций превышают 70 строк, а размеры класса – 600 и имеют вложенные приватные классы.

2.  Общие коллекции в бинах спринга.

3.  Бесконечно хранящие объекты-кэши.

4.  Synchronized функциях, альтернативой чему являются структуры и объекты из java.util.concurrent.

5.  Некорректное использование переменных класса в многопоточном режиме.

6.  Некорректное использование переменных класса в однопоточном режиме (стандартная ошибка – та, что объект может быть Null, а во всём коде то, что он может быть Null не предусмотрено).

7.  Рассмотрение Happy Pass во время проектирования и реализации бизнес‑процессов.

7. Основные принципы оптимизации кода

1.   Основной принцип, который следует принимать в расчет при оптимизации – принцип Парето (80/20).

2.   Оптимизацию кода следует проводить, когда для этого возникают предпосылки.

3.   При оптимизации требуется понимать частоту вызовов функции.

Порядок поиска необходимых мест, требующих оптимизации:

1.   измерение времени загрузки страницы UI;

2.   измерение времени REST запросов;

3.   измерение скорости выполнения функций в бизнес-слое;

4.   анализ стек-трейса вызовов функции в бизнес-слое, последовательность анализа, АОР для упрощения анализа;

5.   анализ количества запросов к базе данных;

6.   измерение скорости выполнения запросов к базе данных;

7.   измерение нагрузки на приложение;

8.   измерение нагрузки на базу данных.

Показатели, указывающие на возможные проблемы, требующие оптимизации кода:

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

2.   время ответа REST запроса* больше 1,5 секунды;

3.   время выполнения функций* в бизнес-слое больше 1 секунды;

4.   время выполнения запросов* к базе данных больше 1 секунды;

5.   общее потребление памяти и ресурсов процессора приложением:

·       в ненагруженном режиме больше 70%;

·       в нормально нагруженном режиме больше 85% от установленных лимитов.

* - не относится к аналитическим отчётам.

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

1.   пользователь не должен чувствовать себя брошенным, и в этом случае помогает использования прогресс-баров;

2.   если нет возможности построить прогресс-бар с достаточно точными цифрами, то следует обновить строку прогресс-бара (99% бара – это круто, но только первые полчаса);

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

При оптимизации кода полезно обратить внимание на следующее:

1.  наличие вложенных циклов во вложенных циклах или почему итерирование – это дорого (рисунок 12);

2.  неоднократное итерирование по одним и тем же данным вместо использования правильных алгоритмов и структур хранения оптимизированных под процесс поиска (мапы).

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

1.  итерирование по всему списку с одновременным расчётом без выполнения дополнительных итераций. Если нужны дополнительные данные, то они раскладываются в правильные для поиска структуры;

2.  всё делается обычными итерациями в стриме.

Результат по быстродействию обоих подходов представлен на рисунке 12. Если в первом случае для расчётов требуется порядка 10-20 мс, то во втором – около 2 секунд (в 100 раз дольше!)

а)
а)
б)       Рисунок 12 – Результаты теста на быстродействие: а) – без вложенных циклов в стриме,                                                        б) – со вложенными циклами в стриме
б) Рисунок 12 – Результаты теста на быстродействие: а) – без вложенных циклов в стриме, б) – со вложенными циклами в стриме

При оптимизации SQL-запросов актуальны:

·       добавление индексов после анализа планов выполнения самых частых и самых долго выполняемых запросов;

·       добавление индексов на foreign key. В связи с тем, что индексы на Primary Key устанавливаются автоматически, данный пункт критически часто пропускается разработчиками;

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

Комментарии (3)


  1. ascjke
    17.10.2024 04:23

    Очень полезно. Спасибо за статью!


    1. zzzzveta
      17.10.2024 04:23

      Спасибо за Ваш отзыв! Готовим новые интересные статьи).


  1. Avan_es
    17.10.2024 04:23

    Есть над чем подумать! Спасибо. Буду ждать новых статей.