Software: Ошибки и компромиссы при разработке ПО [Томаш Лелек] (pdf) читать онлайн

Книга в формате pdf! Изображения и текст могут не отображаться!


 [Настройки текста]  [Cбросить фильтры]

Ошибки и
компромиссы
при разработке ПО

Томаш Лелек
Джон Скит

2023

ББК 32.973.2-018-02
УДК 004.415
Л43

Лелек Томаш, Скит Джон
Л43

Software: Ошибки и компромиссы при разработке ПО. — СПб.: Питер, 2023. —
464 с.: ил. — (Серия «Библиотека программиста»).
ISBN 978-5-4461-2320-9
Создание программных продуктов всегда связано с компромиссами. В попытках сбалансировать скорость, безопасность, затраты, время доставки, функции и многие другие факторы можно обнаружить, что вполне разумное дизайнерское решение на практике оказывается сомнительным. Советы экспертов и яркие примеры, представленные в этой книге, научат вас делать
правильный выбор в дизайне и проектировании приложений.
Мы будем рассматривать реальные сценарии, в которых были приняты неверные решения,
а затем искать пути, позволяющие исправить подобную ситуацию. Томаш Лелек и Джон Скит
делятся опытом, накопленным за десятки лет разработки ПО, в том числе рассказывают о собственных весьма поучительных ошибках. Вы по достоинству оцените конкретные советы
и практические методы, а также неустаревающие паттерны, которые изменят ваш подход к проектированию.

16+ (В соответствии с Федеральным законом от 29 декабря 2010 г. № 436-ФЗ.)
Права на издание получены по соглашению с Manning Publications.
Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав.
Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не
менее, имея в виду возможные человеческие или технические ошибки, издательство не может гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные ошибки, связанные с использованием книги.
В книге возможны упоминания организаций, деятельность которых запрещена на территории Российской Федерации, таких
как Meta Platforms Inc., Facebook, Instagram и др.
Издательство не несет ответственности за доступность материалов, ссылки на которые вы можете найти в этой книге. На момент подготовки книги к изданию все ссылки на интернет-ресурсы были действующими.

ISBN 978-1617299209 англ.
ISBN 978-5-4461-2320-9 рус.

©2022 by Manning Publications Co. All rights reserved.
© Перевод на русский язык ООО «Прогресс книга», 2023
© Издание на русском языке, оформление ООО «Прогресс книга», 2023
© Серия «Библиотека программиста», 2023

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

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

Краткое содержание

https://t.me/it_boooks
Предисловие . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
Благодарности . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
О книге . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
Об авторах . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
Иллюстрация на обложке . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
От издательства . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
Глава 1. Введение . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
Глава 2. Дублирование кода не всегда плохо: дублирование .
кода и гибкость . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
Глава 3. Исключения и другие паттерны обработки ошибок в коде . . . . . . 75
Глава 4. Баланс между гибкостью и сложностью . . . . . . . . . . . . . . . . . 112
Глава 5. Преждевременная оптимизация и оптимизация .
критического пути: решения, влияющие на производительность кода . . . . 137
Глава 6. Простота и затраты на обслуживание API . . . . . . . . . . . . . . . 170
Глава 7. Эффективная работа с датой и временем . . . . . . . . . . . . . . . . 198

8  Краткое содержание
Глава 8. Локальность данных и использование памяти . . . . . . . . . . . . . 258
Глава 9. Сторонние библиотеки: используемые библиотеки .
становятся кодом . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 290
Глава 10. Целостность и атомарность в распределенных системах . . . . . . 323
Глава 11. Семантика доставки в распределенных системах . . . . . . . . . . 347
Глава 12. Управление версиями и совместимостью . . . . . . . . . . . . . . . 374
Глава 13. Современные тенденции разработки и затраты .
на сопровождение кода . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 433

Оглавление
https://t.me/it_boooks
Предисловие . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
Благодарности . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 20
О книге . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
Для кого эта книга . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
Структура книги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 22
О коде . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
Форум liveBook . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
Об авторах . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 26
Иллюстрация на обложке . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 27
От издательства . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 28
Словарь паттернов проектирования . . . . . . . . . . . . . . . . . . . . . . . . 28
Глава 1. Введение . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 29
1.1. Последствия каждого решения и паттерна . . . . . . . . . . . . . . . . . 30
1.1.1. Решения в модульном тестировании . . . . . . . . . . . . . . . . . 31
1.1.2. Соотношение модульных и интеграционных тестов . . . . . . . . 32
1.2. Программные паттерны проектирования и почему .
они работают не всегда . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 34
1.2.1. Измерение скорости выполнения . . . . . . . . . . . . . . . . . . . 39

10  Оглавление
1.3. Архитектурные паттерны проектирования и почему .
они работают не всегда . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
1.3.1. Масштабируемость и эластичность . . . . . . . . . . . . . . . . . . 41
1.3.2. Скорость разработки . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
1.3.3. Сложность микросервисов . . . . . . . . . . . . . . . . . . . . . . . 43
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
Глава 2. Дублирование кода не всегда плохо: дублирование кода .
и гибкость . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
2.1. Общий код в кодовых базах и дублирование . . . . . . . . . . . . . . . . 47
2.1.1. Добавление нового бизнес-требования, для которого
дублирование кода необходимо . . . . . . . . . . . . . . . . . . . . . . . . 49
2.1.2. Реализация нового бизнес-требования . . . . . . . . . . . . . . . . 50
2.1.3. Оценка результата . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 51
2.2. Библиотеки и совместное использование кода в кодовых базах . . . . 52
2.2.1. Оценка компромиссов и недостатков .
совместно используемых библиотек . . . . . . . . . . . . . . . . . . . . . 53
2.2.2. Создание совместно используемой библиотеки . . . . . . . . . . 54
2.3. Выделение кода в отдельный микросервис . . . . . . . . . . . . . . . . . 55
2.3.1. Компромиссы и недостатки отдельного сервиса . . . . . . . . . . 58
2.3.2. Выводы о выделении отдельных сервисов . . . . . . . . . . . . . . 62
2.4. Улучшение слабой связанности за счет дублирования кода . . . . . . . 63
2.5. Проектирование API с наследованием для сокращения .
дублирования . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
2.5.1. Выделение базового обработчика запросов . . . . . . . . . . . . . 68
2.5.2. Наследование и сильная связанность . . . . . . . . . . . . . . . . . 70
2.5.3. Компромиссы между наследованием и композицией . . . . . . . 72
2.5.4. Дублирование внутреннее и ситуативное . . . . . . . . . . . . . . 73
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 74
Глава 3. Исключения и другие паттерны обработки ошибок в коде . . . . . . 75
3.1. Иерархия исключений . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
3.1.1. Универсальный и детализированный подход к обработке
ошибок . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 78

Оглавление  11
3.2. Лучшие паттерны для обработки исключений в собственном коде . . 81
3.2.1. Обработка проверяемых исключений .
в общедоступном API . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 82
3.2.2. Обработка непроверяемых исключений .
в общедоступном API . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 83
3.3. Антипаттерны в обработке исключений . . . . . . . . . . . . . . . . . . . 85
3.3.1. Закрытие ресурсов при возникновении ошибки . . . . . . . . . . 87
3.3.2. Антипаттерн использования исключений .
для управления программной логикой . . . . . . . . . . . . . . . . . . . . 89
3.4. Исключения из сторонних библиотек . . . . . . . . . . . . . . . . . . . . 90
3.5. Исключения в многопоточных средах . . . . . . . . . . . . . . . . . . . . 93
3.5.1. Исключения в асинхронной программной .
логике с API обещаний . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96
3.6. Функциональный подход к обработке ошибок с Try . . . . . . . . . . . 99
3.6.1. Использование Try в рабочем коде . . . . . . . . . . . . . . . . . 103
3.6.2. Объединение Try с кодом, выдающим исключение . . . . . . . 105
3.7. Сравнение производительности кода обработки исключений . . . . 106
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
Глава 4. Баланс между гибкостью и сложностью . . . . . . . . . . . . . . . . . 112
4.1. Мощный, но не расширяемый API . . . . . . . . . . . . . . . . . . . . . 113
4.1.1. Проектирование нового компонента . . . . . . . . . . . . . . . . 113
4.1.2. Начиная с простого . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
4.2. Возможность предоставления собственной библиотеки метрик . . . 118
4.3. Обеспечение расширяемости API с использованием .
перехватчиков . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 121
4.3.1. Защита от непредвиденного использования .
API перехватчиков . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 123
4.3.2. Влияние API перехватчиков на производительность . . . . . . 125
4.4. Обеспечение расширяемости API за счет использования
прослушивателей . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 129
4.4.1. Прослушиватели и перехватчики . . . . . . . . . . . . . . . . . . 130
4.4.2. Неизменяемость архитектуры . . . . . . . . . . . . . . . . . . . . 131

12  Оглавление
4.5. Анализ гибкости API и затраты на обслуживание . . . . . . . . . . . . 133
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136
Глава 5. Преждевременная оптимизация и оптимизация .
критического пути: решения, влияющие на производительность кода . . . . 137
5.1. Когда преждевременная оптимизация — зло . . . . . . . . . . . . . . . 138
5.1.1. Создание конвейера обработки учетных записей . . . . . . . . 139
5.1.2. Оптимизация обработки на основании ложных .
утверждений . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 140
5.1.3. Оценка оптимизации производительности . . . . . . . . . . . . 141
5.2. Критические пути в коде . . . . . . . . . . . . . . . . . . . . . . . . . . . . 144
5.2.1. Принцип Парето в контексте программных систем . . . . . . . 146
5.2.2. Настройка количества параллельных пользователей .
(потоков) для заданного уровня SLA . . . . . . . . . . . . . . . . . . . . 147
5.3. Словарный сервис с потенциальным критическим путем . . . . . . . 148
5.3.1. Получение «слова дня» . . . . . . . . . . . . . . . . . . . . . . . . . 149
5.3.2. Проверка существования слова . . . . . . . . . . . . . . . . . . . . 151
5.3.3. Предоставление доступа к WordsService .
с использованием сервиса HTTP . . . . . . . . . . . . . . . . . . . . . . 151
5.4. Обнаружение критического пути в коде . . . . . . . . . . . . . . . . . . 153
5.4.1. Применение Gatling для создания тестов .
производительности API . . . . . . . . . . . . . . . . . . . . . . . . . . . 153
5.4.2. Измерение хронометража кодовых путей .
с использованием MetricRegistry . . . . . . . . . . . . . . . . . . . . . . 157
5.5. Повышение производительности критического пути . . . . . . . . . . 159
5.5.1. Создание микротеста JMH для существующего решения . . . 160
5.5.2. Оптимизация проверки с использованием кэширования . . . 161
5.5.3. Увеличение количества входящих слов в тестах
производительности . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 167
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 169
Глава 6. Простота и затраты на обслуживание API . . . . . . . . . . . . . . . 170
6.1. Базовая библиотека, используемая другими инструментами . . . . . 171
6.1.1. Создание клиента облачного сервиса . . . . . . . . . . . . . . . . 172
6.1.2. Стратегии аутентификации . . . . . . . . . . . . . . . . . . . . . . 173

Оглавление  13
6.1.3. Понимание механизма конфигурации . . . . . . . . . . . . . . . 175
6.2. Прямое предоставление настроек зависимой библиотеки . . . . . . . 179
6.2.1. Конфигурация пакетного инструмента . . . . . . . . . . . . . . . 181
6.3. Абстрагирование настроек зависимой библиотеки . . . . . . . . . . . 183
6.3.1. Конфигурация стримингового сервиса . . . . . . . . . . . . . . . 184
6.4. Добавление новой настройки для облачной .
клиентской библиотеки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 186
6.4.1. Добавление новой настройки в пакетный инструмент . . . . . 187
6.4.2. Добавление новой настройки в стриминговый сервис . . . . . 188
6.4.3. Сравнение UX-ориентированности и удобства .
обслуживания в двух решениях . . . . . . . . . . . . . . . . . . . . . . . 189
6.5. Удаление настроек в облачной клиентской библиотеке . . . . . . . . 190
6.5.1. Удаление настройки из пакетного инструмента . . . . . . . . . 192
6.5.2. Удаление настроек из стримингового сервиса . . . . . . . . . . 194
6.5.3. Сравнение UX-ориентированности и затрат .
на обслуживание для двух решений . . . . . . . . . . . . . . . . . . . . 196
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 197
Глава 7. Эффективная работа с датой и временем . . . . . . . . . . . . . . . . 198
7.1. Концепции представления даты и времени . . . . . . . . . . . . . . . . 200
7.1.1. Машинное время: моменты времени, эпохи и интервалы . . . 200
7.1.2. Календарные системы, даты, время и периоды . . . . . . . . . . 204
7.1.3. Часовые пояса, UTC и смещения от UTC . . . . . . . . . . . . . 211
7.1.4. Концепции даты и времени, вызывающие приступ .
головной боли . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 216
7.2. Подготовка к работе с информацией о дате и времени . . . . . . . . . 219
7.2.1. Ограничение объема работ . . . . . . . . . . . . . . . . . . . . . . 219
7.2.2. Уточнение требований к дате и времени . . . . . . . . . . . . . . 221
7.2.3. Использование подходящих библиотек или пакетов . . . . . . 227
7.3. Реализация кода даты и времени . . . . . . . . . . . . . . . . . . . . . . . 229
7.3.1. Последовательное применение концепций . . . . . . . . . . . . 229
7.3.2. Отказ от значений по умолчанию в целях улучшения
тестируемости . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 232

14  Оглавление
7.3.3. Текстовое представление даты и времени . . . . . . . . . . . . . 239
7.3.4. Объяснение кода в комментариях . . . . . . . . . . . . . . . . . . 246
7.4. Граничные случаи . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 249
7.4.1. Арифметические операции с календарями . . . . . . . . . . . . 249
7.4.2. Переходы часовых поясов в полночь . . . . . . . . . . . . . . . . 250
7.4.3. Обработка неоднозначного или пропущенного времени . . . . 251
7.4.4. Изменения данных часовых поясов . . . . . . . . . . . . . . . . . 251
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 256
Глава 8. Локальность данных и использование памяти . . . . . . . . . . . . . 258
8.1. Что такое локальность данных? . . . . . . . . . . . . . . . . . . . . . . . 259
8.1.1. Перемещение вычислений к данным . . . . . . . . . . . . . . . . 260
8.1.2. Масштабирование обработки с использованием .
локальности данных . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 261
8.2. Секционирование и разбиение данных . . . . . . . . . . . . . . . . . . . 263
8.2.1. Автономное секционирование больших данных . . . . . . . . . 263
8.2.2. Секционирование и сегментирование . . . . . . . . . . . . . . . . 266
8.2.3. Алгоритмы секционирования . . . . . . . . . . . . . . . . . . . . . 267
8.3. Соединение наборов больших данных из нескольких секций . . . . . 270
8.3.1. Соединение данных на одной физической машине . . . . . . . 271
8.3.2. Соединение, требующее перемещения данных . . . . . . . . . . 273
8.3.3. Оптимизация соединения за счет широковещательной .
рассылки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 274
8.4. Обработка данных: память и диск . . . . . . . . . . . . . . . . . . . . . . 276
8.4.1. Обработка с хранением данных на диске . . . . . . . . . . . . . . 276
8.4.2. Для чего нужна парадигма MapReduce? . . . . . . . . . . . . . . 277
8.4.3. Вычисление времени обращения . . . . . . . . . . . . . . . . . . . 280
8.4.4. Обработка данных в памяти . . . . . . . . . . . . . . . . . . . . . . 281
8.5. Реализация соединений с использованием Apache Spark . . . . . . . 283
8.5.1. Реализация соединения без рассылки . . . . . . . . . . . . . . . 284
8.5.2. Реализация соединения с рассылкой . . . . . . . . . . . . . . . . 287
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 288

Оглавление  15
Глава 9. Сторонние библиотеки: используемые библиотеки .
становятся кодом . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 290
9.1. Импортирование библиотеки и ответственность за ее настройки:
берегитесь значений по умолчанию . . . . . . . . . . . . . . . . . . . . . . . 291
9.2. Модели параллельного выполнения и масштабируемость . . . . . . . 296
9.2.1. Использование асинхронных и синхронных API . . . . . . . . 298
9.2.2. Распределенная масштабируемость . . . . . . . . . . . . . . . . . 301
9.3. Тестируемость . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 303
9.3.1. Тестовая библиотека . . . . . . . . . . . . . . . . . . . . . . . . . . 304
9.3.2. Тестирование с использованием объектов fake .
(тестовых двойников) и mock . . . . . . . . . . . . . . . . . . . . . . . . 306
9.3.3. Набор инструментов интеграционного тестирования . . . . . . 311
9.4. Зависимости сторонних библиотек . . . . . . . . . . . . . . . . . . . . . 312
9.4.1. Предотвращение конфликтов версий . . . . . . . . . . . . . . . . 313
9.4.2. Слишком много зависимостей . . . . . . . . . . . . . . . . . . . . 315
9.5. Выбор и обслуживание сторонних зависимостей . . . . . . . . . . . . 316
9.5.1. Первые впечатления . . . . . . . . . . . . . . . . . . . . . . . . . . 316
9.5.2. Разные подходы к повторному использованию кода . . . . . . 317
9.5.3. Привязка к производителю . . . . . . . . . . . . . . . . . . . . . . 318
9.5.4. Лицензирование . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 319
9.5.5. Библиотеки и фреймворки . . . . . . . . . . . . . . . . . . . . . . 319
9.5.6. Безопасность и обновления . . . . . . . . . . . . . . . . . . . . . . 319
9.5.7. Список решений . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 320
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 321
Глава 10. Целостность и атомарность в распределенных системах . . . . . . 323
10.1 Источники данных с доставкой «не менее одного раза» . . . . . . . . 324
10.1.1. Трафик между сервисами с одним узлом . . . . . . . . . . . . . 324
10.1.2. Повтор попытки вызова . . . . . . . . . . . . . . . . . . . . . . . 326
10.1.3. Производство данных и идемпотентность . . . . . . . . . . . . 327
10.1.4. Паттерн CQRS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 330
10.2. Наивная реализация дедупликации . . . . . . . . . . . . . . . . . . . . 332

16  Оглавление
10.3. Типичные ошибки при реализации дедупликации .
в распределенных системах . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 335
10.3.1. Одноузловый контекст . . . . . . . . . . . . . . . . . . . . . . . . 336
10.3.2. Многоузловый контекст . . . . . . . . . . . . . . . . . . . . . . . 338
10.4. Обеспечение атомарности логики для предотвращения .
ситуации гонки . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 341
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 345
Глава 11. Семантика доставки в распределенных системах . . . . . . . . . . 347
11.1. Архитектура событийно-управляемых приложений . . . . . . . . . . 348
11.2. Производители и потребители на базе Apache Kafka . . . . . . . . . 352
11.2.1. Kafka на стороне потребителя . . . . . . . . . . . . . . . . . . . . 353
11.2.2. Конфигурация брокеров Kafka . . . . . . . . . . . . . . . . . . . 355
11.3. Логика производителя . . . . . . . . . . . . . . . . . . . . . . . . . . . . 356
11.3.1. Выбор между целостностью данных и доступностью .
для производителя . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 359
11.4. Код потребителя и разные семантики доставки . . . . . . . . . . . . . 362
11.4.1. Ручная фиксация у потребителя . . . . . . . . . . . . . . . . . . 364
11.4.2. Перезапуск от самых ранних или поздних смещений . . . . . 366
11.4.3. Семантика «фактически ровно один» . . . . . . . . . . . . . . . 369
11.5. Использование гарантий доставки для обеспечения
отказоустойчивости . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 371
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 373
Глава 12. Управление версиями и совместимостью . . . . . . . . . . . . . . . 374
12.1. Версионирование на абстрактном уровне . . . . . . . . . . . . . . . . 375
12.1.1. Свойства версий . . . . . . . . . . . . . . . . . . . . . . . . . . . . 375
12.1.2. Обратная и прямая совместимость . . . . . . . . . . . . . . . . . 377
12.1.3. Семантическое версионирование . . . . . . . . . . . . . . . . . . 378
12.1.4. Маркетинговые версии . . . . . . . . . . . . . . . . . . . . . . . . 381
12.2. Версионирование для библиотек . . . . . . . . . . . . . . . . . . . . . . 381
12.2.1. Совместимость уровня исходного кода, двоичная .
и семантическая . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 382
12.2.2. Графы зависимостей и ромбовидные зависимости . . . . . . . 391

Оглавление  17
12.2.3. Снижение последствий от критических изменений . . . . . . 397
12.2.4. Управление библиотеками только для внутреннего
пользования . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 402
12.3. Версионирование для сетевых API . . . . . . . . . . . . . . . . . . . . .403
12.3.1. Контекст вызовов сетевых API . . . . . . . . . . . . . . . . . . . 404
12.3.2. Ясность для клиента . . . . . . . . . . . . . . . . . . . . . . . . . . 405
12.3.3. Популярные стратегии версионирования . . . . . . . . . . . . 407
12.3.4. Дополнительные аспекты версионирования . . . . . . . . . . . 413
12.4. Версионирование для хранилищ данных . . . . . . . . . . . . . . . . . 417
12.4.1. Краткое введение в Protocol Buffers . . . . . . . . . . . . . . . . 418
12.4.2. Что является критическим изменением? . . . . . . . . . . . . . 420
12.4.3. Миграция данных в системе хранения . . . . . . . . . . . . . . 421
12.4.4. В ожидании неожиданного . . . . . . . . . . . . . . . . . . . . . . 425
12.4.5. Разделение API и представлений хранения данных . . . . . . 427
12.4.6. Оценка форматов хранения . . . . . . . . . . . . . . . . . . . . . 430
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 431
Глава 13. Современные тенденции разработки и затраты .
на сопровождение кода . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 433
13.1. Когда использовать фреймворки внедрения зависимостей . . . . . 434
13.1.1. Самостоятельная реализация внедрения зависимостей . . . . 435
13.1.2. Использование фреймворка внедрения зависимостей . . . . 438
13.2. Когда применяется реактивное программирование . . . . . . . . . . 441
13.2.1. Создание однопоточной обработки с блокированием . . . . . 442
13.2.2. Использование CompletableFuture . . . . . . . . . . . . . . . . . 444
13.2.3. Реализация реактивного решения . . . . . . . . . . . . . . . . . 447
13.3. Когда применяется функциональное программирование . . . . . . 449
13.3.1. Создание функционального кода .
на нефункциональном языке . . . . . . . . . . . . . . . . . . . . . . . . . 450
13.3.2. Хвостовая рекурсия . . . . . . . . . . . . . . . . . . . . . . . . . . 453
13.3.3. Использование неизменяемости . . . . . . . . . . . . . . . . . . 454
13.4. Отложенное и немедленное вычисление . . . . . . . . . . . . . . . . . 456
Итоги . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 459

Предисловие

Работа всех участников процесса создания программных продуктов полна
компромиссов. Нам часто приходится действовать в условиях ограничений времени, бюджета и информации. А это значит, что решения о продукте, принятые
сегодня, будут иметь последствия завтра: затраты на обслуживание, негибкость
продукта при необходимости внесения изменений, ограниченная производительность при масштабировании и многие другие. Важно, что каждое решение
принимается в определенном контексте. Легко оценивать прошлые шаги без
полного знания контекста, в котором они были сделаны. При этом чем больше
информации и глубже анализ, выполняемый в момент принятия решений, тем
лучше понимание компромиссов, заложенных в эти решения.
За свою карьеру мы имеем дело со многими программными продуктами и изу­
чаем компромиссы, которые в них заложены. В процессе работы Томаш начал
вести журнал с описанием обстоятельств, в которых принималось то или иное
решение. Каким был контекст? Какие существовали альтернативы? Как оценить
конкретное решение? Наконец, каким оказался результат? Предусмотрели ли
мы все возможные компромиссы конкретного решения? Столкнулись ли с неожиданностями? Как выяснилось, этот персональный список усвоенных уроков
в действительности представлял собой те проблемы и решения, с которыми
сталкиваются многие разработчики. Тогда Томаш решил, что этими знаниями
стоит поделиться с миром. Так родилась идея этой книги.
Мы хотели рассказать о том, что мы усвоили на личном опыте работы с разными
программными системами: монолитами, микросервисами, обработкой больших
данных, библиотеками и многими другими. В книге тщательно анализируются
решения, компромиссы и ошибки реальных систем. Представляя эти паттерны,
ошибки и уроки, мы надеемся расширить ваш контекст и вооружить вас более

Предисловие

19

эффективными инструментами, которые помогут принимать лучшие решения
в повседневной работе. Если вы будете сразу видеть потенциальные проблемы
и ограничения архитектуры, то сэкономите массу времени и денег в будущем.
Мы не пытаемся дать однозначные ответы. Сложные задачи часто можно решать разными способами. Мы опишем некоторые из таких задач и будем задавать вопросы без однозначных ответов. У всех решений есть свои достоинства
и недостатки, и мы проанализируем их. Каждое решение приводит к своим
компромиссам, и вы сами должны решить, какие из них лучше всего подходят
для вашего контекста.

Благодарности

Работа над книгой требует больших усилий. Тем не менее благодаря издательству
Manning она стала настоящим удовольствием.
Прежде всего я благодарю свою жену Малгожату. Ты всегда поддерживала меня,
прислушивалась к моим идеям и проблемам. Ты помогла мне сосредоточиться
на книге.
Я благодарю своего редактора в Manning Дуга Раддера (Doug Rudder). Спасибо
за совместную работу — твои комментарии и обратная связь были бесценны.
С твоей помощью я прокачал свои писательские навыки до нового уровня. Спасибо всем остальным сотрудникам Manning, которые участвовали в создании
и продвижении книги. Это действительно была командная работа. Отдельное
огромное спасибо выпускающему редактору Дейрдре Хайем (Deirdre Hiam);
литературному редактору Кристиану Берку (Christian Berk); ревьюеру Михаэле
Батинич (Mihaela Batinic) и корректору Джейсону Эверетту (Jason Everett).
Также хотелось бы поблагодарить рецензентов, которые не пожалели времени,
чтобы прочитать мою рукопись на разных стадиях ее создания, и предоставили
бесценную обратную связь — ваши предложения помогли мне улучшить книгу: Алекс Сез (Alex Saez), Александр Вейер (Alexander Weiher), Андрес Сакко
(Andres Sacco), Эндрю Эленески (Andrew Eleneski), Энди Кирш (Andy Kirsch),
Конор Редмонд (Conor Redmond), Косимо Атанаси (Cosimo Atanasi), Дэйв
Корун (Dave Corun), Джордж Томас (George Thomas), Жиль Ячелини (Gilles
Iachelini), Грегори Варгезе (Gregory Varghese), Хьюго Крус (Hugo Cruz), Иоханнес Вервийнен (Johannes Verwijnen), Джон Гатри (John Guthrie), Джон Генри
Галино (John Henry Galino), Джонни Слос (Johnny Slos), Максим Прохоренко
(Maksym Prokhorenko), Марк-Оливер Шееле (Marc-Oliver Scheele), Нельсон

Благодарности

21

Гонсалес (Nelson González), Оливер Кортен (Oliver Korten), Паоло Брунасти
(Paolo Brunasti), Рафаэль Авила Мартинес (Rafael Avila Martinez), Раджиш
Моханан (Rajesh Mohanan), Роберт Траусмут (Robert Trausmuth), Роберто Касадеи (Roberto Casadei), Со Фай Фон (Sau Fai Fong), Шон Лам (Shawn Lam),
Спенсер Маркс (Spencer Marks), Василь Борис (Vasile Boris), Винсент Делкойн
(Vincent Delcoigne), Витош Дойнов (Vitosh Doynov), Уолтер Стоунбернер
(Walter Stoneburner) и Уилл Прайс (Will Price).
Отдельное спасибо редактору-консультанту Джин Боярски (Jeanne Boyarsky)
за подробный разбор текста с технической точки зрения.
Эта книга стала результатом всех профессиональных решений, которые принимал я и те, с кем я общался в ходе работы. Я встречал многих людей, которые
сформировали меня как программиста и положительно повлияли на мою карьеру.
Мне повезло познакомиться и работать с ними в начале карьеры. Хочу поблагодарить всех своих коллег из Schibsted, Allegro, DataStax и Dremio. Некоторые
из них заслуживают особой благодарности:
Павел Волошин (Paweł Wołoszyn) — за то, что он превосходный университетский лектор, научивший меня тому, что программирование способно
кардинально изменить мир.
Анджей Гжесик (Andrzej Grzesik) — за то, что вдохновил меня на стремление
к достижению амбициозных целей.
Матеуш Квасьневский (Mateusz Kwasniewski) — за то, что разжег во мне иск­ру
жажды к знаниям.
Лукаш Банцеровский (Łukasz Bancerowski) — за то, что указал мне направление
и сформировал мою карьеру в сфере JVM.
Ярослав Палка (Jarosław Pałka) — за то, что поверил в меня и обеспечил пространство для экспериментов и обучения.
Александр Дутра (Alexandre Dutra) — за то, что подавал пример и демонстрировал высочайшую рабочую этику.
— Томаш Лелек
Спасибо всем, кому я годами досаждал рассказами о часовых поясах, особенно
моей многострадальной семье. Мои коллеги в Google, а также участники проекта с открытым кодом Noda Time и других очень помогли продумать аспекты,
которые я изложил в этой книге.
— Джон Скит

О книге

Книга «Software: Ошибки и компромиссы при разработке ПО» была задумана
как описание проблем, встречающихся в реальных системах. Мы постарались
проанализировать каждую ситуацию в разных контекстах и рассмотреть все
возможные компромиссы. Кроме того, в книге представлены некоторые неочевидные ошибки, способные значительно повлиять на системы в разных аспектах
(не только с точки зрения правильности).

ДЛЯ КОГО ЭТА КНИГА
Книга предназначена для разработчиков, которые хотят изучить паттерны,
используемые в реальных системах, и связанные с ними компромиссы. Также
она учит избегать неочевидных ошибок. Книга начинается с низкоуровневых
решений, что очень полезно для начинающих разработчиков. Затем рассматриваются более сложные темы — это пригодится даже опытному специалисту.
Большинство примеров, паттернов и фрагментов кода написаны на Java, но сами
решения не привязаны к этому языку.

СТРУКТУРА КНИГИ
Книга состоит из 13 глав. В первой главе приводится общий обзор использованных в книге методов анализа компромиссов. Остальные главы относительно
независимы друг от друга, в них рассматриваются разные вопросы разработки.
Чтобы извлечь наибольшую пользу из книги, мы рекомендуем читать ее по

Структура книги

23

порядку. Тем не менее, если вас интересует конкретный вопрос, переходите
к нужной главе.
В главе 1 представлен подход, используемый в книге для анализа компромиссов в определенном контексте. В ней приводятся примеры компромиссов на
уровнях программной архитектуры, кода и контроля качества.
Глава 2 показывает, что дублирование кода не всегда является антипаттерном.
В ней рассматриваются различные архитектуры и анализируется их влияние
на слабую связанность систем. Глава завершается вычислением затрат на
координацию внутри команд и между ними по закону Амдала.
В главе 3 описаны стратегии обработки аномальных ситуаций в коде. В ней
разобраны сценарии использования как проверяемых, так и непроверяемых
исключений. Вы научитесь разрабатывать стратегии обработки исключений
для общедоступных API (библиотек). Наконец, в ней рассматриваются
компромиссы между подходами обработки исключений, применяемыми
в объектном и функциональном программировании.
Глава 4 учит выдерживать баланс между сложностью кода и API. Она показывает, что эволюция кода в одном из направлений часто влияет на другие
направления.
Глава 5 объясняет, что преждевременная оптимизация не всегда плоха. С правильным инструментарием и определенными условиями SLA можно найти
критический путь и оптимизировать его. Кроме того, она демонстрирует, что
принцип Парето полезен, чтобы сконцентрировать усилия по оптимизации
в нужной части системы.
В главе 6 показано, как проектировать API с качественным UX. Она показывает, что удобство UX характеризует не только пользовательские, но
и программные интерфейсы: REST API, средства командной строки и т. д.
Однако глава также показывает, что за удобство UX приходится расплачиваться повышением затрат на обслуживание.
В главе 7 рассматриваются болезненные вопросы обработки информации
даты и времени. Если учесть, как часто данные содержат по крайней мере
некоторые элементы даты и времени — например, дату рождения или временную метку записи в журнале, возможностей для возникновения ошибок
более чем достаточно. С этими проблемами можно справиться, но они требуют особого внимания.
Глава 8 объясняет, почему локальность данных играет важную роль в обработке больших данных. Она демонстрирует необходимость алгоритмов
разбиения, обеспечивающих распределение данных и трафика.
Глава 9 показывает, что используемые вами библиотеки становятся вашим
кодом. В ней рассмотрены проблемы и компромиссы, которые необходимо
учитывать при импортировании сторонней библиотеки в кодовую базу. Нако-

24

О книге

нец, глава пытается ответить на вопрос, стоит ли импортировать библиотеку
или лучше заново реализовать ее отдельные части.
Глава 10 посвящена компромиссу между целостностью данных и атомарностью в распределенных системах. В ней анализируются возможные ситуации
гонки в таких системах и влияние идемпотентности на способы проектирования систем.
Глава 11 объясняет семантику доставки в распределенных системах. Она
помогает понять семантику «не менее одного», «не более одного» и «фактически ровно один».
В главе 12 вы узнаете, как программные системы, API и хранимые данные
эволюционируют со временем и как при этом сохранять совместимость
с другими системами.
Глава 13 демонстрирует, что не всегда разумно гнаться за новейшими тенденциями в IT-отрасли. В ней анализируются некоторые популярные паттерны
и фреймворки (такие, как реактивное программирование), а также обсуждается их применение в конкретных контекстах.

О КОДЕ
Книга содержит множество примеров исходного кода как в нумерованных
листингах, так и в тексте. В обоих случаях исходный код форматируется моноширинным шрифтом, в отличие от обычного текста. Иногда для кода также применяется жирный шрифт, чтобы выделить фрагменты, изменившиеся по сравнению
с предыдущими шагами, — например, при добавлении новой функциональности
в существующую строку кода.
Во многих случаях оригинальная версия исходного кода переформатируется;
добавляются разрывы строк и измененные отступы, чтобы код помещался на
странице. Иногда даже этого оказывается недостаточно и в листинги включаются маркеры продолжения строк (➥). Также из исходного кода часто удаляются
комментарии, если код описывается в тексте.
Исходный код в книге форматирован автоматизированным плагином в соответствии с рекомендациями Google по оформлению кода. Многие листинги
снабжены примечаниями, выделяющими важные концепции. Каждая глава
представлена отдельной папкой в репозитории. Для всего кода, использованного в книге, написаны многочисленные модульные и интеграционные тесты,
обеспечивающие его качество. Не все тесты приводятся в листингах книги.
Вы можете как запустить тесты, так и прочитать их код по отдельности, чтобы
лучше понять конкретную часть логики. Все инструкции по импортированию
и запуску примеров содержатся в файле README.md в репозитории.

Форум liveBook

25

Исполняемые фрагменты кода можно загрузить из версии liveBook (электронной) по адресу https://livebook.manning.com/book/software-mistakes-and-tradeoffs. Полный
код примеров книги доступен для загрузки на сайте Manning по адресам https://
www.manning.com/books/software-mistakes-and-tradeoffs и https://github.com/tomekl007/
manning_software_mistakes_and_tradeoffs.

ФОРУМ LIVEBOOK
Приобретая книгу, вы получаете бесплатный доступ к liveBook, платформе
Manning для чтения в интернете. С помощью эксклюзивных средств liveBook
можно добавлять комментарии к книгам — глобально или к отдельным разделам/
абзацам. Кроме того, можно делать заметки для себя, задавать технические вопросы и отвечать на них, получать помощь от автора и других пользователей. Чтобы
получить доступ к форуму, перейдите по ссылке https://livebook.manning.com/book/
software-mistakes-and-tradeoffs/discussion. Узнать больше о форумах Manning и правилах поведения на них можно на странице https://livebook.manning.com/discussion.
Manning соблюдает обязательство перед читателями по предоставлению площадки для содержательной дискуссии между читателями и между читателями и
автором. Со стороны автора отсутствуют обязательства о конкретной доле участия, и его вклад в работу форума остается добровольным (и неоплачиваемым).
Задавайте автору сложные вопросы, чтобы его интерес не потерялся! Форум
и архивы предыдущих обсуждений будут доступны на веб-сайте издателя, пока
книга продолжает издаваться.

Об авторах

Томаш Лелек
За свою карьеру разработчика Томаш имел дело с разными сервисами, архитектурами и языками программирования (прежде всего для JVM). У него есть
реальный опыт работы с монолитными и микросервисными архитектурами.
Томаш проектировал системы, обрабатывающие запросы десятков миллионов
уникальных пользователей и сотни тысяч операций в секунду. В частности, он
работал над следующими проектами:
Микросервисная архитектура с CQRS (на базе Apache Kafka).
Автоматизация маркетинга и обработка потоков событий.
Обработка больших данных в Apache Spark и Scala.
Сейчас Томаш работает в Dremio, где помогает создавать современные решения
для хранения данных. До этого он работал в DataStax, где строил различные
продукты для Cassandra Database. Он создавал инструменты для тысяч разработчиков, которые больше всего ценят проектирование API, производительность
и удобство UX. Он внес свой вклад в разработку Java-Driver, Cassandra Quarkus,
соединителя Cassandra-Kafka и Stargate.

Джон Скит
Джон — специалист по выстраиванию отношений с разработчиками в Google,
в настоящее время работающий над библиотеками Google Cloud Client Libraries
для .NET. Его вклад в сообщество с открытым кодом включает библиотеку
даты и времени Noda Time для .NET (https://nodatime.org). Вероятнее всего, он
известен благодаря своим публикациям на сайте Stack Overflow. Джон — автор
книги «C# in Depth» (издательство Manning). Также он участвовал в работе
над книгами «Groovy in Action» и «Real-World Functional Programming». Джон
интересуется API даты/времени и версионированием — многим эти увлечения
кажутся в лучшем случае необычными.

Иллюстрация на обложке

Иллюстрация под названием Groenlandaisse («Гренландка»), помещенная на
обложку, взята из вышедшего в 1797 году каталога национальных костюмов, составленного Жаком Грассе де Сен-Совером. Каждая иллюстрация этого каталога
тщательно прорисована и раскрашена от руки. В прежние времена по одежде
человека можно было легко определить, где он живет и какова его профессия
или положение. Manning отдает дань изобретательности и инициативности
компьютерных технологий, используя для своих изданий обложки, демонстрирующие богатое вековое разнообразие региональных культур, оживающее на
изображениях из собраний, подобных этому.

От издательства

На момент сдачи книги в печать все приведенные в ней URL-ссылки работают,
однако доступ к некоторым сайтам ограничен на территории РФ.
В книге используются паттерны проектирования. При первом упоминании
паттерна в скобках дается его английское название, чтобы читатели смогли без
труда отследить его в листингах. Для удобства ниже приведен Словарь паттернов
проектирования, встречающихся в данной книге.
Ваши замечания, предложения, вопросы отправляйте по адресу comp@piter.com
(издательство «Питер», компьютерная редакция).
Мы будем рады узнать ваше мнение!
На веб-сайте издательства www.piter.com вы найдете подробную информацию
о наших книгах.

СЛОВАРЬ ПАТТЕРНОВ ПРОЕКТИРОВАНИЯ
Декоратор
Наблюдатель
Одиночка
Заместитель
Прерыватель
Прототип
Стратегия
Строитель
Фабрика

Decorator
Observer
Singleton
Proxy
Circuit Breaker
Prototype
Strategy
Builder
Factory

1
Введение

https://t.me/it_boooks

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

При проектировании кода, API и системных архитектур приходится принимать
решения, влияющие на обслуживание, производительность, расширяемость
и многие другие факторы. Почти всегда решение пойти в одном направлении
ограничивает возможность развиваться в другом. Чем дольше живут системы,
тем сложнее изменить их архитектуру и отказаться от предыдущих решений.
Компромиссы в проектировании и программировании, представленные в этой
книге, связаны с выбором из двух и более направлений, в которых может развиваться система. Важно понимать, что, какой бы вариант вы ни выбрали,
придется иметь дело со всеми его плюсами и минусами.
Команда должна принимать эти непростые решения в зависимости от контекста,
времени выхода на рынок, соглашения об уровне обслуживания (SLA, ServiceLevel Agreement) и других факторов. Мы опишем некоторые компромиссы, на
которые приходится идти в рабочих системах, и сравним их с альтернативными
подходами. Надеемся, что после прочтения этой книги вы научитесь внимательно оценивать свои ежедневные решения в проектировании. Это позволит вам
принимать их осознанно, учитывая все «за» и «против».

30

Глава 1. Введение

Первая часть книги посвящена низкоуровневым проектным решениям, которые
вынужден принимать каждый программист при работе с кодом и API. Вторая
часть освещает системы более широко — в ней рассматривается архитектура
и потоки данных между компонентами. Также мыпоговорим о компромиссах,
на которые приходится идти при работе с распределенными системами.
В следующих разделах продемонстрирован подход к анализу компромиссов,
принятый в нашей книге. Сначала мы сосредоточимся на компромиссах,
с которыми сталкивается любой разработчик: балансе между модульными,
интеграционными, сквозными и другими видами тестов. В реальных условиях
программный продукт приносит пользу в течение ограниченного периода. Поэтому нужно решить, на какие тесты выделить больше времени — модульные,
интеграционные, сквозные или другие. Мы проанализируем плюсы и минусы
увеличения количества тестов конкретного типа.
Затем мы рассмотрим зарекомендовавший себя паттерн Одиночка (Singleton)
и объясним, как его полезность меняется в зависимости от контекста (однопоточного или многопоточного). Наконец, рассмотрим архитектурные компромиссы более высокого уровня: выбор между микросервисной и монолитной
архитектурой.
Следует заметить, что зачастую архитектуру невозможно описать как чисто
монолитную или чисто микросервисную. На практике распространен гибридный подход: часть функционала реализована в виде сервисов, тогда как
другие части системы могут существовать в монолитном виде. Например,
когда унаследованная система монолитна и лишь малая ее часть перемещена
в микросервисную архитектуру. Также в проекте, создаваемом с нуля, вполне
разумно начать с одного приложения и не разбивать его на микросервисы,
если это сопряжено с ощутимыми затратами. Мы кратко проанализируем
компромиссы между микросервисами и монолитами. Рекомендуем частично
применять эту аргументацию в актуальном контексте, даже если это гибридная архитектура.
В следующих разделах представлен подход, который используется во всех
главах: решение задачи в конкретной ситуации, затем анализ альтернативного
варианта и, наконец, добавление контекста, подразумевающего компромиссы
и окончательные решения. Плюсы и минусы каждого из них проанализированы
в конкретном контексте. В следующих главах компромиссные решения будут
рассмотрены более подробно.

1.1. ПОСЛЕДСТВИЯ КАЖДОГО РЕШЕНИЯ И ПАТТЕРНА
Цель книги — продемонстрировать различные компромиссы и ошибки в проектировании и программировании. При разборе проектных решений и компромиссов

1.1. Последствия каждого решения и паттерна

31

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

1.1.1. Решения в модульном тестировании
При написании тестов необходимо решить, какую часть кода тестировать.
Возьмем простой компонент, для которого нужно организовать модульное
тестирование. Допустим, компонент SystemComponent предоставляет один
открытый метод API: publicApiMethod(). Другие методы скрываются от клиентов приватным модификатором доступа. В следующем листинге приведен
код этого сценария.
Листинг 1.1. Компонент для модульного тестирования
public class SystemComponent {
public int publicApiMethod() {
return privateApiMethod();
}
private int privateApiMethod() {
return complexCalculations();
}
private int complexCalculations() {
// Сложная логика
return 0;
}
}

Здесь мы должны решить, стоит ли включать в модульное тестирование
complexCalculations() или же оставить приватный метод скрытым. Такой
модульный тест работает по принципу «черного ящика» и покрывает только
открытый API. Обычно этого достаточно. Но иногда приватные методы содержат сложную логику, которую также стоит протестировать. В таких ситуациях
можно понизить модификатор доступа complexCalculations(). Этот подход
продемонстрирован в следующем листинге.

32

Глава 1. Введение

Листинг 1.2. Открытый уровень видимости компонента для модульного
тестирования
@VisibleForTesting
public int complexCalculations() {
// Сложная логика
return 0;
}

Переходя на открытый уровень видимости, вы разрешаете себе написать модульный тест для части API, которая не должна быть открытой. Открытый
метод будет видимым для клиентов API, и появится риск того, что они будут
напрямую использовать этот API. В листинге аннотация @VisibleForTesting
(см.  http://mng.bz/y4wq) служит только для информационных целей. Ничто не
мешает пользователям вызвать открытый метод API. Если они не заметят эту
аннотацию, то могут проигнорировать ее.
Вы можете использовать любой из двух подходов к модульному тестированию,
рассмотренных в этом разделе. Последний обеспечивает большую гибкость, но
затраты на обслуживание могут возрасти. Возможно, вы выберете промежуточное решение. Для этого можно сделать код пакетно-приватным. Иначе говоря,
когда тесты находятся в одном пакете с рабочим кодом, делать его открытым не
нужно, но можно использовать эти методы в тестовом коде.

1.1.2. Соотношение модульных и интеграционных тестов
При тестировании логики нужно определиться с соотношением интеграционных
и модульных тестов в системе. Часто выбор одного направления снижает возможность развиваться в другом. Кроме того, такое ограничение может зависеть
от времени начала разработки системы.
Так как время разработки функционала обычно ограниченно, нужно выбрать,
чему посвятить большую его часть — модульным или интеграционным тестам.
Тестирование реальных систем должно включать обе эти разновидности, и мы
должны определить их соотношение.
Медленнее
Время
отладки

Время
получения
Интеграционные обратной
связи
тесты

Модульные тесты
Быстрее

Рис. 1.1. Интеграционные/модульные тесты и время (скорость),
необходимое для их выполнения

1.1. Последствия каждого решения и паттерна

33

У каждого вида тестов есть достоинства и недостатки, и выбор между ними становится типичным компромиссом при написании кода. Модульные тесты работают
быстрее и обеспечивают более быстрый отклик, так что процесс отладки часто
ускоряется. На рис. 1.1 представлены достоинства и недостатки обоих тестов.
Диаграмма на рис. 1.1 имеет форму пирамиды, потому что обычно в программных
системах больше модульных тестов, чем интеграционных. Модульные тесты
обеспечивают практически мгновенную обратную связь для разработчика, что
повышает производительность. Кроме того, они быстрее выполняются и сокращают время отладки кода. Если обеспечить полное покрытие кодовой базы
модульными тестами, то при появлении новой ошибки проблема с большой
вероятностью будет перехвачена одним из них. Ее можно будет обнаружить на
уровне метода, который покрывается конкретным модульным тестом.
С другой стороны, если в системе отсутствуют интеграционные тесты, исчезает возможность анализа связей между компонентами и их объединением
в систему. У вас будут хорошо протестированные алгоритмы, но без проверки
общей картины. В результате может получиться система, которая правильно
функционирует на низком уровне, но без тестирования компонентов невозможно оценить ее работу на более высоком уровне. В реальной жизни код должен
сочетать модульные и интеграционные тесты.
Важно заметить, что рис. 1.1 учитывает только один аспект тестирования: время
выполнения и, следовательно, время получения обратной связи. В реально эксплуатируемых системах существуют другие уровни тестирования. В них могут
быть сквозные тесты, которые осуществляют комплексную проверку бизнессценариев. Возможно, в более сложных архитектурах для обеспечения этой
бизнес-функциональности придется запустить N сервисов, взаимодействующих
друг с другом. У таких тестов обычно медленное время отклика из-за лишних
затрат ресурсов на подготовку тестовой инфраструктуры. С другой стороны, они
дают более высокую уверенность относительно сквозной логики и правильности
системы. Сравнение таких тестов с модульными и интеграционными тестами
можно производить по разным параметрам — например, насколько хорошо они
проверяют систему в целом, как показано на рис. 1.2.
Целостное
Количество
проверенных
компонентов

Сквозные тесты

Время,
необходимое
для создания теста

Интеграционные тесты
Изолированное

Модульные тесты

Рис. 1.2. Интеграционные/модульные тесты и время (скорость),
необходимое для их выполнения

34

Глава 1. Введение

Так как модульные тесты выполняются изолированно, они не дают сколько-нибудь значимой информации о других компонентах системы и их взаимодействии
между собой. Интеграционные тесты пытаются проверять другие компоненты
и взаимодействия между ними. Но, как правило, они не охватывают многочисленые (микро-) сервисы, обеспечивающие заданную бизнес-функциональность.
Наконец, хотя сквозные тесты проверяют целостность работы системы, количество проверяемых компонентов может быть значительным, так как придется
запускать всю инфраструктуру, которая может включать N микросервисов, баз
данных, очередей и т. д.
Другое измерение (ресурс), которое необходимо принять во внимание, — время на
создание тестов. За короткое время можно создать много модульных тестов, так
как они относительно просты в разработке. На проектирование интеграционных
тестов часто уходит больше времени. Наконец, сквозные тесты требуют значительных начальных вложений для построения необходимой для них инфраструктуры.
На практике ресурсы ограничены (например, бюджет и время), и приходится
максимизировать качество программного продукта с учетом этих условий. Однако
покрытие кода тестами позволяет создать более качественный продукт и сократить число багов в релизе. Кроме того, это упрощает дальнейшее обслуживание
продукта. Для этого нужно выбрать типы тестов и их количество, а также найти
соотношение между модульными, интеграционными и сквозными тестами из-за
ограниченности ресурсов. Анализируя разные параметры, сильные и слабые стороны конкретной разновидности тестов, можно принимать более рациональные
решения.
Важно, что тестирование увеличивает время разработки. Чем больше тестов мы
хотим провести, тем больше времени мы потратим. Иногда трудно реализовать
хорошие сквозные тесты, если не планировать их в пределах заданного срока.
Таким образом, некоторые виды тестов необходимо планировать так же, как
добавление нового функционала в программу, — заранее, а не задним числом.

1.2. ПРОГРАММНЫЕ ПАТТЕРНЫ ПРОЕКТИРОВАНИЯ
И ПОЧЕМУ ОНИ РАБОТАЮТ НЕ ВСЕГДА
Паттерны проектирования — Строитель (Builder), Декоратор (Decorator), Прототип (Prototype) и многие другие — появились много лет назад. Они предоставляют проверенные на опыте решения многих известных задач. Я настоятельно рекомендую изучить эти паттерны (см. книгу «Design Patterns: Elements
of Reusable Object-Oriented Software»1) и использовать их в коде, чтобы сделать
1

Гамма Э., Хелм Р., Джонсон Р., Влиссидес Д. «Паттерны объектно-ориентированного
проектирования». СПб, издательство «Питер».

1.2. Программные паттерны проектирования и почему они работают не всегда

35

его более простым в обслуживании и масштабировании — да и просто более
качественным. С другой стороны, их следует применять с осторожностью,
потому что реализация этих паттернов сильно зависит от контекста. Как вы
уже поняли, я пытаюсь показать, что каждое решение в программном продукте
подразумевает компромиссы и имеет последствия.
Чтобы понять компромиссы на уровне программного кода, я продемонстрирую
паттерн Одиночка (https://refactoring.guru/design-patterns/singleton). Он был введен
как механизм совместного использования состояния всеми компонентами.
Одиночка — единственный экземпляр, существующий на протяжении всего
срока жизни приложения. К нему обращаются все остальные классы. Допустим,
вам нужно создать приватный конструктор, чтобы предотвратить создание
нового экземпляра. Реализовать паттерн Одиночка для этого несложно, как
показывает следующий листинг.
Листинг 1.3. Реализация паттерна Одиночка
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

Есть только один способ получить экземпляр Одиночки: через метод
getInstance(), возвращающий единственный экземпляр, который может
безопасно использоваться разными компонентами. Предполагается, что
каждый раз, когда коду на стороне вызова нужно обратиться к одиночному экземпляру, он делает это через getInstance(). Далее мы рассмотрим
другой сценарий использования, который не требует применения этого
метода ­каждый раз ­при обращении к экземпляру. На первый взгляд паттерн
позволяет быстро добиться успеха; вы получаете возможность совместно
использовать код через глобальный одиночный экземпляр. Казалось бы,
в чем компромисс?
Рассмотрим этот паттерн в другом контексте. Что произойдет, если применить
его в многопоточной среде? Если сразу несколько потоков вызовут getInstance()
одновременно, может возникнуть состояние гонки (race condition). В этой ситуации код создаст два экземпляра Одиночки. Разумеется, это нарушит инварианты
паттерна и может привести к сбою системы. Чтобы этого избежать, необходимо
добавить синхронизацию перед выполнением логики инициализации, как показано в следующем листинге.

36

Глава 1. Введение

Листинг 1.4. Синхронизация для потоково-безопасного паттерна Одиночка
public class SystemComponentSingletonSynchronized {
private static SystemComponent instance;
private SystemComponentSingletonSynchronized() {}
public static synchronized SystemComponent getInstance() {
if (instance == null) {
instance = new SystemComponent();
}

Начинает блок
синхронизации

return instance;
}
}

Блок synchronized предотвращает обращение к этой логике из двух потоков.
Все потоки, кроме одного, блокируются и ожидают логики инициализации.
На первый взгляд все работает, как ожидалось. Но Одиночка в многопоточном
сценарии может значительно снизить производительность кода, что важно
учитывать, если она первоочередная.
Инициализация — первый процесс, в ходе которого несколько потоков должны
блокироваться и ожидать. А после создания одиночного экземпляра все обращения к нему должны синхронизироваться. Одиночка может вызвать конкуренцию
потоков (http://mng.bz/M2nn), что создаст серьезные риски для производительности. Это происходит, когда имеется общий экземпляр объекта, к которому
одновременно обращаются несколько потоков.
Синхронизированный метод getInstance() позволяет только одному потоку
вой­ти в критическую секцию кода, тогда как остальные вынуждены ожидать
снятия блокировки. Когда один поток выходит из критической секции, следующий в очереди может обратиться к ней. Недостаток такого подхода в том,
что он создает необходимость синхронизации и может значительно замедлить
программу. В двух словах, каждый раз, когда в коде выполняется синхронизируемый вызов, это может привести к дополнительным затратам.
Из примера можно сделать вывод, что существует компромисс между производительностью кода при использовании Одиночки в однопоточном или многопоточном контексте. Но для нас важен контекст, в котором код выполняется.
Если он работает без параллельного выполнения или одиночный экземпляр
не используется совместно разными потоками, компромисс не проявляется.
Но при совместном использовании Одиночки необходимо обеспечить его потоковую безопасность, что потенциально влияет на производительность. Знание
этого компромисса позволит принимать рациональные решения, касающиеся
архитектуры и кода.

1.2. Программные паттерны проектирования и почему они работают не всегда

37

Если окажется, что у выбранного варианта больше минусов, чем плюсов, решение можно изменить. Так, в примере с Одиночкой ситуацию можно улучшить,
применив один из двух паттернов.
В первом варианте используется метод блокировки с двойной проверкой. Этот
способ отличается тем, что перед входом в критическую (синхронизированную)
секцию необходимо проверить экземпляр на наличие null. Если условие выполняется, можно продолжать вход в критическую секцию, в противном случае
в этом нет необходимости — нужно просто вернуть существующий одиночный
экземпляр. Эта реализация блокировки представлена в следующем листинге.
Листинг 1.5. Блокировка с двойной проверкой для паттерна Одиночка
private volatile static SystemComponent instance;
public static SystemComponent getInstance() {
if (instance == null) {
synchronized (ThreadSafeSingleton.class) {
if (instance == null) {
instance = new SystemComponent();
}
}
}
return instance;
}

Если экземпляр не содержит null,
не входить в критическую секцию

Применение этого паттерна значительно снижает необходимость в синхронизации и уровень конкуренции между потоками. Эффект синхронизации будет
наблюдаться только при запуске, когда каждый поток пытается инициализировать одиночный экземпляр.
Второй паттерн, который можно применить, — привязка к потоку. Он позволяет
закрепить состояние за конкретным потоком. Однако при этом необходимо понимать, что паттерн Одиночка перестает существовать на уровне глобального
приложения — на каждый поток будет приходиться один экземпляр объекта.
Если в приложении существуют N потоков, то в нем будут использоваться
N экземпляров.
При использовании данного паттерна каждый поток в коде владеет экземпляром
объекта, видимым для конкретного потока и привязанным к нему. Благодаря
этому потоки не конкурируют за доступ к объекту. Объект принадлежит потоку и не используется совместно. В Java желаемого эффекта можно добиться
при помощи класса ThreadLocal (http://mng.bz/aD8B). Он позволяет обернуть
системный компонент, который будет привязан к конкретному потоку. С точки
зрения кода объект находится внутри экземпляра ThreadLocal, как показывает
следующий листинг.

38

Глава 1. Введение

Листинг 1.6. Привязка к потоку с использованием ThreadLocal
private static ThreadLocal threadLocalValue = new
ThreadLocal();
public static void set() {
threadLocalValue.set(new SystemComponent());
}
public static void executeAction() {
SystemComponent systemComponent = threadLocalValue.get();
}
public static SystemComponent get() {
return threadLocalValue.get();
}

Логика привязки SystemComponent к конкретному потоку инкапсулируется в экземпляре ThreadLocal. Когда поток A вызывает метод set(), внутри ThreadLocal
создается новый экземпляр SystemComponent. Важно, что этот экземпляр доступен
только для этого потока. Если другой поток (скажем, B) вызовет executeAction()
без предварительного вызова set(), он получит null-экземпляр SystemComponent,
потому что для этого потока компонент еще не был создан вызовом set(). Новый
экземпляр, предназначенный для этого потока, будет создан и станет доступен
только после того, как поток B вызовет метод set().
Происходящее можно упростить, передав поставщик (supplier) при вызове
метода withInitial(). Он будет вызван, если потоково-локальный объект еще
не получил значения, так что риск получить null отсутствует. В следующем
листинге показана возможная реализация.
Листинг 1.7. Привязка к потоку с исходным значением
static ThreadLocal threadLocalValue =
ThreadLocal.withInitial(SystemComponent::new);

Применение этого паттерна устраняет конкуренцию, что улучшает производительность. Обратной стороной такого решения становится усложнение системы.
ПРИМЕЧАНИЕ
Каждый раз, когда код на стороне вызова хочет обратиться к одиночному экземпляру,
обращение не обязано использовать метод getInstance(). Можно обратиться к одиночному экземпляру один раз и присвоить его переменной (ссылке). После этого дальнейшие обращения могут получить доступ к одиночному объекту по ссылке без необходимости вызывать getInstance(). Такое решение снижает конкуренцию потоков.

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

1.2. Программные паттерны проектирования и почему они работают не всегда

39

зависимостей). В таком случае паттерн Одиночка может вообще не понадобиться. Вы просто создаете один экземпляр объекта, который должен находиться в
общем доступе, и внедряете его во все зависимые сервисы (http://mng.bz/g4dE).
Альтернатива этому — использование перечисляемого типа, в основе которого
лежит паттерн Одиночка. Чтобы проверить предположения, измерим временные
характеристики кода.

1.2.1. Измерение скорости выполнения
К этому моменту мы создали три потоково-безопасные реализации паттерна
Одиночка:
с синхронизацией для всех операций;
с блокировкой с двойной проверкой;
с привязкой к потокам (с использованием ThreadLocal).
Предполагается, что первая версия будет самой медленной, но данных об этом
у нас пока нет. Создадим тест производительности, который будет проверять все
три реализации. Воспользуемся инструментарием проверки производительности
JMH (https://openjdk.java.net/projects/code-tools/jmh/), который мы еще применим
в дальнейшем для проверки быстродействия кода.
Создадим хронометражный тест, который выполняет 50 000 операций получения
(одиночного) объекта SystemComponent (листинг 1.8). В нем будут реализованы
три теста, использующих разные подходы к функциональности паттерна Одиночка. Чтобы проверить, как конкуренция потоков влияет на производительность,
код будет выполняться в 100 одновременно работающих потоках. Результаты
(среднее время выполнения) будут выведены в миллисекундах.
Листинг 1.8. Создание хронометражных тестов реализаций паттерна
Одиночка
@Fork(1)
@Warmup(iterations = 1)
@Measurement(iterations = 1)
Код выполняется в 100 одновременно
@BenchmarkMode(Mode.AverageTime)
работающих потоках
@Threads(100)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class BenchmarkSingletonVsThreadLocal {
private static final int NUMBER_OF_ITERATIONS = 50_000;
@Benchmark
public void singletonWithSynchronization(Blackhole blackhole) {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
blackhole.consume(
➥ SystemComponentSingletonSynchronized.getInstance());
В первом тесте используется
}
SystemComponent
}
SingletonSynchronized

40

Глава 1. Введение

@Benchmark
public void singletonWithDoubleCheckedLocking(Blackhole blackhole) {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
blackhole.consume(
➥ SystemComponentSingletonDoubleCheckedLocking.getInstance());
Тесты для System
}
ComponentSingleton
}
DoubleCheckedLocking
@Benchmark
public void singletonWithThreadLocal(Blackhole blackhole) {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
blackhole.consume(SystemComponentThreadLocal.get());
Получить результаты для
}
SystemComponentThreadLoc
}
}

Выполнив тест, мы получаем среднее время на 50 000 вызовов для 100 одновременных потоков. В конкретной среде числа могут быть другими, но общие
тенденции останутся теми же, как показывает следующий листинг.
Листинг 1.9. Просмотр результатов хронометражного теста реализаций
паттерна Одиночка
Benchmark

Mode

Cnt

Score

Error

Units

CH01.BenchmarkSingletonVsThreadLocal.singletonWithDoubleCheckedLocking

avgt

2.629

ms/op

CH01.BenchmarkSingletonVsThreadLocal.singletonWithSynchronization

avgt

316.619

ms/op

CH01.BenchmarkSingletonVsThreadLocal.singletonWithThreadLocal

avgt

5.622

ms/op

Из результатов видно, что реализация singletonWithSynchronization действительно оказалась самой медленной. Среднее время выполнения логики хронометража составило около 300 мс (миллисекунд). Затем идут два решения с более
высокими результатами. Реализация singletonWithDoubleCheckedLocking показывает лучший результат (около 2,6 мс), а реализация singletonWithThreadLocal
завершилась за 5,6 мс. Можно сделать вывод, что усовершенствование исходной
версии паттерна Одиночка обеспечивает примерно 50-кратное повышение производительности для потоково-локального решения и 115-кратное для решения
с блокировкой с двойной проверкой.
Проверяя предположения, можно принимать эффективные решения в многопоточном контексте. Если потребуется выбрать одно решение вместо другого при
сравнимой производительности, можно отдать предпочтение более прямолинейному решению. Тем не менее без реальных данных трудно сделать полностью
рациональный выбор.
Теперь рассмотрим компромиссы для архитектурных решений. В следующем
разделе вы узнаете о микросервисных и монолитных архитектурах и о связанных
с ними компромиссах.

1.3. Архитектурные паттерны проектирования

41

1.3. АРХИТЕКТУРНЫЕ ПАТТЕРНЫ ПРОЕКТИРОВАНИЯ
И ПОЧЕМУ ОНИ РАБОТАЮТ НЕ ВСЕГДА
Выше мы рассмотрели низкоуровневые паттерны программирования и компромиссы, приводящие к разным вариантам структуры кода. При всей их важности
вам, скорее всего, не составит труда изменить эти низкоуровневые части, меняя
контекст приложения. Вторая часть книги будет посвящена архитектурным
паттернам проектирования: их изменять сложнее, поскольку они охватывают
всю архитектуру нескольких сервисов, образующих систему. Пока мы сосредоточимся на микросервисной (см. http://mng.bz/enlv) архитектуре — одном из самых
популярных паттернов создания современных программных систем.
Микросервисная архитектура имеет ряд преимуществ перед созданием одной
монолитной системы, в которой реализована вся бизнес-логика. Тем не менее
она подразумевает усложнение системы и значительные затраты на обслуживание. Рассмотрим важнейшие преимущества микросервисной архитектуры перед
монолитной.

1.3.1. Масштабируемость и эластичность
Системы, которые мы создаем, должны справляться с высоким трафиком и при
этом адаптироваться и масштабироваться в зависимости от потребностей. Если
один узел приложения способен обрабатывать N запросов в секунду и на нем
наблюдается выброс трафика, микросервисная архитектура позволит быстро
масштабироваться по горизонтали (рис. 1.3). Конечно, приложение должно быть
написано так, чтобы поддерживать возможность простого масштабирования.
Кроме того, оно должно использовать имеющиеся компоненты.
Например, можно добавить новый экземпляр существующего микросервиса,
чтобы система могла обрабатывать ~2×N запросов в секунду (где 2 — количество
сервисов, а N — количество запросов, которые может обрабатывать один сервис).
Тем не менее этого показателя можно достигнуть только в том случае, если базовый уровень доступа к данным способен масштабироваться столь же эффективно.
Конечно, может существовать некий верхний порог масштабируемости, после
которого добавление новых узлов не сильно увеличит пропускную способность.
Он может зависеть от предела масштабируемости используемых компонентов —
базы данных, очереди, пропускной способности сети и т. д.
Тем не менее добиться общей масштабируемости микросервисной архитектуры
обычно проще по сравнению с монолитным подходом. Монолитные архитектуры не позволяют масштабироваться с нужной скоростью после достижения
верхнего порога ресурсов.

Глава 1. Введение

Больше пользователей

42

Больше экземпляров

Рис. 1.3. Горизонтальное масштабирование означает добавление большего
количества машин в пул ресурсов по мере возрастания потребности

Приложение также можно масштабировать вертикально за счет добавления
большего количества процессоров, памяти или дискового пространства в вычислительный узел, и здесь также существует предел, при достижении которого
масштабирование становится невозможным. Например, если монолитное приложение развернуто в облаке, его можно масштабировать за счет перехода на
более мощный облачный вычислительный узел (с большим количеством процессоров или памяти). Такой подход отлично работает, пока есть возможность
добавлять новые ресурсы. Однако есть риск, что провайдер облачного сервиса
не сможет предложить более мощную машину для развертывания. В таком
случае масштабирование по горизонтали окажется более гибким методом.
Если приложение написано так, чтобы иметь возможность развертывания на
N экземпля­рах, можно добавить в него новые экземпляры для повышения общей
пропускной способности.

1.3.2. Скорость разработки
В микросервисной архитектуре задачи легко распределить между несколькими
командами. Допустим, команда A работает над бизнес-функциональностью,
которая будет реализована в отдельном микросервисе. В то же время команда B
может сосредоточиться на другой части бизнес-области. Команды работают независимо друг от друга и эффективнее продвигаются вперед.
При работе с микросервисами отсутствует координация на уровне кодовой базы.
Команды сами принимают решения относительно технологий и развиваются
быстрее. Если к команде, задействованной в своей части бизнес-области, присоединяется новый участник, ему проще понять систему и приступить к работе.
Каждая команда может внедрить свою кодовую базу независимо, что делает
процесс развертывания более надежным. В результате он происходит чаще

1.3. Архитектурные паттерны проектирования

43

и с меньшим риском. Даже если команда случайно внесет баг, количество изменений в развертываемой версии будет меньше. Благодаря этому решение потенциальных проблем ускоряется. Трудности с отладкой могут появиться из-за
ошибок, возникших вследствие интеграции двух слишком детализированных микросервисов. В таком случае необходимо обратиться к трассировке для отслеживания запросов, проходящих через несколько микросервисов (http://mng.bz/p2w8).
С другой стороны, в монолитных архитектурах кодовая база часто доступна
разным командам. Если код приложения хранится в одном репозитории, а приложение достаточно сложное, несколько команд могут работать над ним одновременно. В таких ситуациях возрастает вероятность конфликтов в коде, и, как
следствие, значительная часть рабочего времени может уйти на их разрешение.
Конечно, если код продукта структурируется в модульной форме, этот эффект
можно снизить. Тем не менее всегда будет необходимость в учащенной перебазировке, так как основная кодовая база продукта меняется быстрее, если над
ней трудится много разработчиков. При сравнении монолитной архитектуры с
микросервисной легко заметить, что код выделенной бизнес-области часто значительно короче. Это значит, что конфликтов, скорее всего, также будет меньше.
В монолитных приложениях развертывание выполняется реже. Причина в том,
что больше функций объединяется в главную кодовую ветвь (потому что над
ней работает много людей). Чем шире функционал, тем дольше он тестируется.
Поскольку в одном выпуске развертывается множество функций, вероятность
внесения бага в систему возрастает.
Стоит заметить, что число подобных проблем можно сократить, создав более
надежный конвейер непрерывной интеграции (или непрерывного развертывания). Его можно запускать чаще, регулярно создавая новые версии приложения
с меньшим количеством функций. Код свежего выпуска проще анализировать
и отлаживать при обнаружении проблем. Чем меньше новых функций, тем
быстрее вы разберетесь в причинах неполадок. Если сравнить этот подход
с циклом выпуска, в котором новое приложение строится реже, очевидно, что
в таком выпуске будет больше новых функций, внедряющихся одновременно.
Чем шире функционал выпуска, тем больше в нем потенциальных проблем
и тем сложнее его отладить.

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

44

Глава 1. Введение

Отслеживание таких изменений — непростая задача. Для этого понадобится
новый компонент — реестр сервисов (рис. 1.4).
192.4.3.1:8080

Запрос

Балансировщик
нагрузки

Распределение
нагрузки

Реестр
сервисов

REST
API
Экземпляр
сервиса
Клиент A
реестра
192.4.3.99:8080
REST
API
Экземпляр
сервиса
Клиент B
реестра

Регистрация

192.4.3.20:8080
REST
API
Экземпляр
сервиса
Клиент C
реестра

Рис. 1.4. Реестр микросервисов

Каждому микросервису необходим работающий клиент реестра, отвечающий
за регистрацию его в реестре. После регистрации балансировщик нагрузки
сможет маршрутизировать трафик новому экземпляру. Отмена регистрации
обеспечивается реестром сервисов путем проверки состояния экземпляров
сервисов. Это одна из сложностей данной архитектуры, значительно затрудняющая развертывание.
Разобравшись с сильными и слабыми сторонами задачи, вы должны учесть
контекст, чтобы выбрать подходящее проектное решение. Если контекст показывает, что возможности масштабирования ограниченны, и у вас небольшая
команда разработчиков, решением может стать монолитная архитектура. Процессы оценки проектных решений, описанные в следующих главах, похожи на
представленный здесь: определение плюсов и минусов каждого из вариантов,
добавление контекста и выбор оптимального подхода для конкретной ситуации.
В этой главе были приведены примеры компромиссов проектирования, которые
рассматриваются далее в нашей книге. Вы изучили низкоуровневые компромиссы, подразумевающие выбор соотношения модульных и интеграционных тестов
в приложениях. Также вы узнали, что проверенные временем паттерны (такие,

Итоги

45

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

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

2

Дублирование кода
не всегда плохо:
дублирование кода и гибкость
https://t.me/it_boooks
В этой главе:
33 Совместное использование общего кода в независимых кодовых
базах.
33 Компромиссы между дублированием кода, гибкостью и доставкой
результата.
33 Когда дублирование кода становится грамотным решением, обес­
печивающим слабую связанность.

Принцип DRY (Don’t Repeat Yourself, «не повторяйтесь») — одно из самых
известных правил программирования. В его основе лежит идея отказа от дуб­
лирования кода, что сокращает число багов и повышает потенциал повторного
использования программного продукта. Однако фанатично следовать принципу
DRY при построении любой системы опасно, поскольку такой подход скрывает
многие сложности. Проще соблюдать принцип DRY, если создаваемая система
монолитна; это означает, что почти вся кодовая база хранится в одном репозитории.
В наши дни разработчики часто включают в распределенные системы множество
подвижных частей. В таких архитектурах решение об отказе от дублирования

2.1. Общий код в кодовых базах и дублирование

47

кода приводит к другим компромиссам — например, созданию сильной связанности между компонентами или сокращению скорости разработки в команде.
Если один фрагмент кода используется в нескольких местах, его изменение может
потребовать значительной координации, а это замедляет создание коммерческой
ценности. В этой главе подробно рассматриваются паттерны и компромиссы,
относящиеся к дублированию кода. Мы попытаемся ответить на вопрос: когда
дублирование кода может быть разумным компромиссом, а когда его следует
избегать?
Начнем с наличия дублированного кода в двух кодовых базах. Затем сократим
дублирование за счет использования общей библиотеки. После этого применим
другой подход к извлечению общей функциональности — используем микросервис, инкапсулирующий это поведение. Далее рассмотрим наследование как
паттерн для устранения дублирования в коде. Вы увидите, что такое решение
тоже сопряжено с затратами, которые нельзя просто игнорировать.

2.1. ОБЩИЙ КОД В КОДОВЫХ БАЗАХ
И ДУБЛИРОВАНИЕ
Для анализа первой задачи рассмотрим совместное использование кода в контексте микросервисной архитектуры. Представьте, что в проекте две команды.
Команда A работает над сервисом платежей, а команда B — над сервисом личных
данных. Сценарий изображен на рис. 2.1.
Сервис платежей предоставляет HTTP API с конечной точкой /payment. Сервис личных данных предоставляет свою бизнес-логику через конечную точку
/person. Будем считать, что обе кодовые базы написаны на одном языке программирования. На этой стадии обе команды продвигаются в работе и могут
быстро поставлять очередные версии продукта.
конечная точка /payment/ конечная точка /person/

Сервис платежей

Сервис личных
данных

Команда A

Команда B

Рис. 2.1. Два независимых сервиса: платежей и личных данных

48

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

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

Ускорение
20
Параллельная часть

18

50%
75%
90%
95%

16
14
12
10
8
6
4
2
0

1

2

4

8

16

32

64

128

256

512

1024

2048

4096

8192 16384 32768 65536
Количество процессоров

Рис. 2.2. Закон Амдала находит максимальное ожидаемое улучшение для всей
системы в зависимости от доли распараллеливаемой работы

Например, если задача распараллеливается 50 % времени (а другие 50 % требует
синхронизации), скорость обработки при добавлении ресурсов (количество процессоров на диаграмме) значительно не повысится. Но чем сильнее распараллелена задача и чем меньше затраты на синхронизацию, тем больший выигрыш
в скорости достигается при добавлении новых ресурсов.
Формула Амдала может использоваться для вычисления распараллеливания
одновременной обработки и выгоды от добавления новых ядер, но ее также можно адаптировать для участников команды, работающих над конкретной задачей
(http://mng.bz/OG4R). Синхронизация, снижающая степень параллелизма, может

2.1. Общий код в кодовых базах и дублирование

49

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

2.1.1. Добавление нового бизнес-требования,
для которого дублирование кода необходимо
Через какое-то время в процессе работы над сервисами возникает новое бизнес-требование: добавить авторизацию в оба HTTP API. Первое решение команд — реализовать компонент авторизации в обеих кодовых базах. На рис. 2.3
изображена обновленная архитектура.
конечная точка /payment/

конечная точка /person/

Компонент
авторизации 1

Компонент
авторизации 2

Сервис платежей

Сервис личных
данных

Команда A

Команда B

Рис. 2.3. Новый компонент авторизации

Обе команды разрабатывают и сопровождают похожие компоненты авторизации.
Однако работа двух групп все еще остается независимой.
В этом сценарии следует помнить, что в примере используется упрощенная
версия аутентификации на основе маркеров, но такое решение уязвимо для
атак повторного воспроизведения (http://mng.bz/YgYB), поэтому оно не подходит
для реальной эксплуатации. Упрощенная версия позволяет не отвлекаться от
главных аспектов, обсуждаемых в этой главе. Заметим, что обеспечить безопасность достаточно сложно. Если команды работают независимо, то вероятность,
что они обе с этим справятся, весьма низкая. Даже если на разработку общей
библиотеки уйдет больше времени, выгода от предотвращения инцидентов безопасности может того стоить.

50

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

2.1.2. Реализация нового бизнес-требования
Рассмотрим сервис платежей. Он предоставляет конечную точку HTTP /payment
и использует только один ресурс @GET для получения всех платежей от заданного
маркера, как видно из следующего листинга.
Листинг 2.1. Реализация конечной точки /payment
@Path("/payment")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class PaymentResource {

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

private final PaymentService paymentService = new PaymentService();
private final AuthService authService = new AuthService();
Создает экземпляр
AuthService
@GET
@Path("/{token}")
public Response getAllPayments(@PathParam("token") String token) {
if (authService.isTokenValid(token)) {
Проверяет маркер
return Response.ok(paymentService.getAllPayments()).build(); с использованием
} else {
AuthService
return Response.status(Status.UNAUTHORIZED).build();
}
}
}

Как видно из листинга 2.1, AuthService проверяет маркер, после чего вызывающая сторона переходит к платежному сервису, который возвращает все платежи.
Реальная логика AuthService будет сложнее. Взгляните на упрощенную версию
в следующем листинге.
Листинг 2.2. Создание сервиса авторизации
public class AuthService {
public boolean isTokenValid(String token) {
return token.equals("secret");
}
}

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

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

2.1. Общий код в кодовых базах и дублирование

51

Листинг 2.3. Реализация конечной точки /person
@Path("/person")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class PersonResource {

Предоставляет интерфейс
HTTP для микросервиса
личных данных

private final PersonService personService = new PersonService();
private final AuthService authService = new AuthService();
Создает экземпляр
AuthService
@GET
@Path("/{token}/{id}")
public Response getPersonById(@PathParam("token") String token,
@PathParam("id") String id) {
if (authService.isTokenValid(token)) {
Проверяет маркер
return Response.ok(personService.getById(id)).build();
с использованием
} else {
AuthService
return Response.status(Status.UNAUTHORIZED).build();
}
}
}

Сервис также интегрирует AuthService. Он проверяет маркер, предоставленный
пользователем, после чего получает данные Person через PersonService.

2.1.3. Оценка результата
Так как в этой точке обе команды ведут разработку независимо друг от друга,
код и работа дублируются.
Дублирование может привести к большему числу багов и ошибок. Например,
если команда Person исправит баг в своем компоненте авторизации, это не
гарантирует, что команда Payment не допустит тот же баг.
Когда один и тот же или похожий код дублируется в независимых кодовых базах,
программисты не обмениваются знаниями. Например, команда Person находит
баг в вычислениях с маркером и исправляет его в своей кодовой базе. К сожалению, эта правка автоматически не распространяется на кодовую базу Payment.
Команде Payment придется исправить баг позже, независимо от команды Person.
Работа без координации может идти быстрее. Но даже в этом случае обе
команды выполняют значительный объем похожей работы.
На практике вы, вероятно, не станете реализовывать логику с нуля, а примените
стратегии аутентификации, проверенные в условиях реальной эксплуатации,
такие как OAuth (https://oauth.net/2/) или JWT (https://jwt.io/). Эти стратегии еще
более эффективны в контексте микросервисной архитектуры. Оба способа дают
множество преимуществ, когда несколько сервисов требуют аутентификации для
обращения к ресурсам других сервисов. Мы не будем рассматривать конкретную
стратегию аутентификации или авторизации. Вместо этого сосредоточимся на

52

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

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

2.2. БИБЛИОТЕКИ И СОВМЕСТНОЕ ИСПОЛЬЗОВАНИЕ
КОДА В КОДОВЫХ БАЗАХ
Предположим, из-за того что значительная часть кода дублируется в двух независимых кодовых базах, обе команды решают выделить общий код в отдельную
библиотеку. Выделим код сервиса авторизации в специальный репозиторий.
Одной команде потребовалось создать процесс развертывания для новой библиотеки. Самый распространенный сценарий — публикация библиотеки во
внешнем менеджере репозиториев, таком как JFrog Artifactory (https://jfrog.com/
open-source/). Этот сценарий показан на рис. 2.4.
Загрузка библиотеки
во время компоновки

Менеджер
репозиториев

конечная точка /payment/

конечная точка /person/

Сервис платежей

Сервис личных
данных

Команда A

Команда B

Библиотека
авторизации

Загрузка библиотеки
во время компоновки

Рис. 2.4. Загрузка общей библиотеки из менеджера репозиториев

После того как общий код попадает в менеджер репозиториев, оба сервиса могут
загрузить библиотеку во время компоновки и использовать классы, поставляемые
с ней. Применяя такой подход, можно исключить любое дублирование кода за
счет его хранения в одном месте.
Одно из очевидных преимуществ исключения дублирования — общее качество
кода. Хранение совместно используемой библиотеки позволяет обеим командам
взаимодействовать друг с другом и повышает качество кодовой базы. Благодаря
этому исправления багов немедленно распространяются на все клиенты библио­
теки, так что работа не дублируется. Рассмотрим недостатки и компромиссы,
на которые приходится идти при выборе этого подхода.

2.2. Библиотеки и совместное использование кода в кодовых базах

53

2.2.1. Оценка компромиссов и недостатков совместно
используемых библиотек
Как только мы выделяем новую библиотеку, она становится новой сущностью
с собственным стилем программирования, процессом развертывания и практикой
кодирования. В этом контексте под библиотекой понимается код, упакованный
в некотором формате (JAR, DLL или *.so на платформах Linux и т. д.), который
может использоваться в разных проектах. Команда (или независимый разработчик) должна принять ответственность за новую кодовую базу. Кто-то должен
настраивать процесс развертывания, проверять качество кода, разрабатывать
новую функциональность и т. д. Впрочем, это можно отнести к второстепенным
фиксированным затратам.
При выборе совместно используемых библиотек нужно разработать сопутствующие процессы, включая практики кодирования, развертывания и т. д. Но
процесс создается один раз, а применять его можно многократно. Затраты на
добавление первой библиотеки могут быть значительными, но затраты на добавление второй будут гораздо меньше.
Один из самых очевидных компромиссов такого подхода в том, что язык, на
котором создается новая библиотека, должен совпадать с языком клиентов,
которые ее используют. К примеру, если сервисы платежей и личных данных
разрабатываются на разных языках (скажем, на Python или Java), создать новую библиотеку не удастся. Однако в реальности это не проблема, потому что
сервисы создаются на одном языке или семействе языков (например, JVM).
Можно создать экосистему сервисов, где они пишутся с использованием разных
технологий. Однако это значительно усложнит систему в целом. Это также означает, что вам потребуются люди с опытом в целом ряде технологий. Кроме того,
придется задействовать множество инструментов — скажем, системы сборки или
пакетные менеджеры из различных технологических стеков. В зависимости от
выбранного языка придется иметь дело с его особенной экосистемой.
УЧАСТИЕ В РАЗРАБОТКЕ С ОТКРЫТЫМ КОДОМ
В экосистеме JVM существует активное сообщество разработки с открытым кодом,
которое занимается созданием и обслуживанием различных библиотек. Прежде
чем принимать решение о выделении отдельной библиотеки, следует провести
исследование и узнать, существует ли готовая библиотека с открытым кодом,
решающая актуальную задачу. Впрочем, возможно, ее придется адаптировать или
немного расширить под насущные потребности.
Можно также сделать свой код открытым, если похожих библиотек не существует.
Участвуя в проекте с открытым кодом, вы предоставляете свой код другим потенциальным пользователям. Кроме того, вам бесплатно достается процесс развертывания и реклама. Скорее всего, другие разработчики также найдут вашу библиотеку
и используют ваш код повторно.

54

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

Зачастую можно писать библиотеку на другом языке (скажем, C) и упаковывать
ее в язык платформенного интерфейса (например, Java Native Interface) по своему выбору. Однако это может создать проблемы, потому что коду понадобится
еще один уровень перенаправления. Может оказаться, что код, упакованный
в платформенный интерфейс, не портируется между операционными системами, а вызовы его методов выполняются медленнее (по сравнению с вызовами
методов Java). Поэтому будем считать, что рассматриваемые далее экосистемы
имеют одинаковый технологический стек.
Информация о новой библиотеке должна распространяться в компании, чтобы
другие команды могли узнать о ней и использовать в случае необходимости.
Иначе мы получим гибридный подход: одни команды будут применять новую
библиотеку, а другие продолжат дублировать код.
Менеджер репозиториев — подходящее место для совместно используемой
библиотеки, но придется поддерживать качественную документацию. Обычно
хорошее покрытие тестами помогает другим разработчикам вносить вклад в библиотеку. Если у вас есть набор тестов, которым могут пользоваться и с которым
могут экспериментировать другие разработчики, к вашей библиотеке будут обращаться чаще и больше. Также отметим, что документация иногда устаревает.
Следовательно, ее необходимо регулярно обновлять.
Тесты, как и код, требуют регулярного обслуживания и обновления, что помогает
эффективно продвигать библиотеку в компании и формирует у потенциальных
пользователей уверенность в ее общем качестве. Конечно, если вы выбрали подход с дублированием, придется тестировать дублируемый код во всех местах.
А это значит, что может появиться дублируемый тестовый код.
Хорошее тестовое покрытие — это не повод игнорировать обновление документации. Непросто научиться работать с новой библиотекой, лишь изучая ее тесты, если
только они не написаны специально для этой цели. Тесты должны покрывать все
способы использования библиотеки, а не только предпочтительные. Они могут помочь с поиском ответа на конкретные вопросы, но они не настолько наглядны, как
специальная страница с учебными примерами и руководством по началу работы.

2.2.2. Создание совместно используемой библиотеки
При создании библиотеки следует стремиться к простоте. Это особенно важно, когда вы зависите от сторонней библиотеки. Предположим, что компонент авторизации
должен зависеть от популярной библиотеки Java Google Guava (https://github.com/
google/guava), поэтому вы явно объявляете эту зависимость. Когда платежный сервис
импортирует новую библиотеку авторизации, он также получает транзитивную
зависимость от Google Guava. Все отлично работает, пока платежному сервису не
понадобится импортировать другую стороннюю библиотеку с прямой зависимостью от Google Guava, но другой версии. Этот сценарий изображен на рис. 2.5.

2.3. Выделение кода в отдельный микросервис

Сервис
платежей

Импортирует

Библиотека
авторизации

Имеет зависимость

Импортирует

Сторонняя
библиотека

Имеет зависимость

55

В Google
Guava 28.0
methodA()
В Google
Guava 27.0
есть methodA()

Рис. 2.5. Транзитивные зависимости, необходимые для реализации сервиса платежей

В такой ситуации платежный сервис будет содержать две версии одной библио­
теки. Ситуация только усложняется, если основная версия библиотеки изменилась, — в этом случае двоичная совместимость двух версий возможна, но не
гарантирована. Более того, если обе библиотеки присутствуют в пути к классам,
система сборки (например, Maven или Gradle) часто автоматически берет более
новую версию, если обратное поведение не настроено в конфигурации. Например, возможна ситуация, в которой код сторонней библиотеки полагается на
более старую версию Guava и вызывает метод methodA(), отсутствующий в новой
версии. Если конфигурация не указывает, какую версию использовать, система
сборки может выбрать новую версию библиотеки. В таком случае можно получить исключение MethodNotFound или нечто похожее. Дело в том, что сторонняя
библиотека ожидает Guava 27.0 с вызываемым методом methodA(), а система
сборки загрузила Guava 28, и сторонней библиотеке приходится использовать
именно ее. Так возникают упомянутые выше проблемы.
Подобные конфликты сложно устранить, и они могут отбить желание других
команд использовать ваш код, выделенный в библиотеку. А значит, ваша библио­
тека должна иметь как можно меньше прямых зависимостей. Эта тема более
подробно рассматривается в главах 9 и 12 — в них основное внимание уделяется
решениям, которые принимаются при выборе библиотек для создаваемых систем.
В этом сценарии предполагается, что созданная библиотека будет использоваться
как в платежном сервисе, так и в сервисе личных данных. На этой стадии над
самим сервисом авторизации не работает отдельная команда, так что обе они
будут участвовать в разработке новой библиотеки. Это потребует планирования
и координации между участниками обеих команд.

2.3. ВЫДЕЛЕНИЕ КОДА В ОТДЕЛЬНЫЙ МИКРОСЕРВИС
Совместное использование кода за счет библиотек может быть хорошим стартом,
но, как видно из раздела 2.2.1, у такого решения есть несколько недостатков. Вопервых, разработчики, создающие библиотеку, должны учитывать совместимость
и множество других факторов. Они не могут свободно пользоваться сторонними

56

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

библиотеками. Кроме того, импортирование кода библиотеки означает, что между
кодом продукта и библиотекой существует сильная связанность на уровне зависимостей. Это не означает, что она отсутствует в микросервисной архитектуре;
сервисы могут связываться на уровне API, в форматах запросов и т. д. Связанность
в библиотеке образуется в другом месте, нежели в микросервисной архитектуре.
Если дублируемая функциональность представляется отдельной бизнес-областью, мы можем задуматься о создании еще одного микросервиса, который
обеспечивает ее через HTTP API. Например, можно определить отдельную
бизнес-область, которая извлекает и предоставляет функции, изначально реализованные в другом месте. Наш компонент авторизации отлично для этого
подходит, поскольку представляет ортогональный функционал проверки маркеров, а сервис авторизации имеет собственную бизнес-область. Можно найти
бизнес-сущность, которую будет обрабатывать новый сервис, — например, сущность пользователя с логином и паролем.
ПРИМЕЧАНИЕ
Приведенный пример немного упрощен, но часто логике авторизации требуется доступ
к другой информации (например, в базе данных). В таком случае, если разрешения хранятся, скажем, в базе данных, выделение логики в отдельные микросервисы еще более
оправданно. Для простоты примера авторизация не требует доступа к внешнему сервису.

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

Сервис
авторизации
Проверка
маркера

Проверка
маркера

Сервис платежей

Сервис личных
данных

конечная точка /payment/ конечная точка /person/

Команда A

Команда B

Рис. 2.6. Отношения сервиса авторизации с сервисами платежей и личных данных

2.3. Выделение кода в отдельный микросервис

57

Новая архитектура (рис. 2.6) содержит три отдельных микросервиса, соединяющихся друг с другом посредством HTTP API. А значит, сервисы личных данных
и платежей должны выполнить один дополнительный запрос для проверки своих
маркеров. Если у приложения невысокие требования к производительности, еще
один вызов HTTP не создаст проблем (предполагается, что запрос выполняется
внутри кластера или замкнутой сети и не передается случайному веб-серверу
на другом конце земли). При этом логика сервиса авторизации, которая ранее
дублировалась или выделялась в библиотеку, абстрагируется с использованием
HTTP API, доступного через конечную точку /auth. Клиенты отправляют запросы для проверки этого маркера; при неудаче возвращается код ответа HTTP
с признаком отсутствия авторизации (401). Если маркер действителен, то HTTP
API возвращает код статуса OK (200). Ниже приведен новый сервис авторизации.
Листинг 2.4. Конечная точка сервиса авторизации HTTP
@Path("/auth")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class AuthResource {
private final AuthService authService = new AuthService();
@GET
@Path("/validate/{token}")
public Response getAllPayments(@PathParam("token") String token) {
if (authService.isTokenValid(token)) {
return Response.ok().build();
} else {
return Response.status(Status.UNAUTHORIZED).build();
}
}
}

Так как AuthService все еще инкапсулирует логику проверки маркеров, вместо вызова библиотечной функции теперь выполняются запросы HTTP. Код
находится в выделенном репозитории микросервиса авторизации. Сервисам
платежей и личных данных теперь не нужно ни импортировать авторизацию
напрямую, ни реализовывать их логику в своих кодовых базах. Им требуется
лишь клиент HTTP, который отсылает запрос HTTP конечной точке /auth для
проверки маркера. Ниже приведен код отправки запроса.
Листинг 2.5. Отправка запроса HTTP сервису AuthorizationService
// Отправка запроса отдельному сервису
public boolean isTokenValid(String token) throws IOException {
CloseableHttpClient client = HttpClients.createDefault();
HttpGet httpGet = new HttpGet("http:/
/auth-service/auth/validate/" +
token);
CloseableHttpResponse response = client.execute(httpGet);
return response.getStatusLine().getStatusCode() == HttpStatus.SC_OK;
}
Отправляет запрос HTTP внешнему
сервису авторизации

58

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

В листинге 2.5 создается клиент HTTP, выполняющий запросы HTTP. В реальных системах клиент будет совместно использоваться в вызовах и компонентах,
чтобы сократить количество открытых подключений и потребление ресурсов.
HttpClient (https://hc.apache.org/) выполняет запрос HTTP GET для проверки
маркера. Если строка статуса ответа содержит код статуса OK, это означает, что
маркер действителен; в противном случае он недействителен.
ПРИМЕЧАНИЕ
Для предоставления доступа к сервису авторизации можно воспользоваться системой
доменных имен (DNS) auth-service. Также возможно использование других механизмов обнаружения сервисов, таких как Eureka (https://github.com/Netflix/eureka), Consul
(https://www.consul.io/) и т. д. Доступ к auth-service также возможен по статическому
IP-адресу.

2.3.1. Компромиссы и недостатки отдельного сервиса
Отдельный микросервис решает некоторые проблемы, с которыми мы столкнулись при выделении общего кода в отдельную библиотеку. Решение с выделением
отдельной библиотеки требует определенного изменения менталитета команды,
использующей код. Когда вы импортируете библиотеку в свою кодовую базу,
этот код становится вашим и вы несете за него ответственность. Кроме того,
использование такой библиотеки подразумевает более сильную связанность,
чем применение отдельного микросервиса.
При интеграции с другими микросервисами можно рассматривать их как «черный ящик». Единственной точкой интеграции является API, которым может
быть HTTP или другой протокол. Теоретически к библиотеке можно относиться
аналогично. К сожалению, как было показано в разделе 2.2, на практике библио­
теку нельзя рассматривать как «черный ящик» из-за зависимостей, которые она
вводит в код.
Вызов микросервиса означает, что необходимо также добавить новую зависимость
в клиентскую библиотеку, используемую для выполнения кода. Теоретически это
может привести к появлению проблемы транзитивных зависимостей, описанной
в предыдущем разделе. И снова на практике большинство микросервисов должно
использовать клиентскую библиотеку для вызовов других сервисов. Это может
быть клиент HTTP или что-то другое в зависимости от используемого протокола. А значит, когда потребуется вызвать микросервисы из сервиса, вы с большой
вероятностью будете использовать тот же клиент HTTP. Поэтому проблемы
дополнительных зависимостей для каждого вызванного сервиса не существует.
Рассмотрим сервис авторизации как отдельный микросервис с собственным API.
Вы уже видели, что такой подход решает некоторые проблемы использования

2.3. Выделение кода в отдельный микросервис

59

библиотеки. Но, с другой стороны, обслуживание отдельного микросервиса
требует значительных усилий. Придется сделать намного больше, чем просто
запрограммировать сервис и логику авторизации.
Наличие отдельного микросервиса означает, что потребуется создать процесс
развертывания, который будет публиковать код в облачной или локальной
инфраструктуре. Библиотеке также необходим процесс развертывания, но
намного более прямолинейный: достаточно упаковать файл JAR и развернуть
его в менеджере репозиториев. Кто-то должен будет отслеживать работоспособность сервиса и реагировать при возникновении проблем. Следует учесть, что
затраты на создание процесса развертывания, обслуживания, отслеживания
и т. д. представляют собой значительные начальные расходы. Но когда процесс
будет налажен, разработка последующих микросервисов станет проще и быстрее
(аналогичные начальные расходы потребуются и для библиотек). Рассмотрим
несколько важнейших факторов, которые нужно учитывать при выборе подхода
с отдельным сервисом.

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

Управление версиями
Пожалуй, управлять версиями микросервиса несколько проще, чем версионировать библиотеку (в некоторых отношениях). Библиотека должна обеспечивать семантическую версионность, и при этом совместимость API в основных
версиях не должна нарушаться. Управление версиями API микросервиса также
должно соответствовать рекомендациям по сохранению обратной совместимости. Тем не менее на практике проще отслеживать использование конечных
точек и быстро исключать их, если они больше не задействованы. Если вы
разрабатываете библиотеку и переход на новую основную версию невозможен,
будьте осторожны и не нарушайте совместимость. Нарушение будет означать,
что клиенты после обновления версии библиотеки не будут компилироваться.
Такого быть не должно.
При наличии HTTP API для количественного измерения каждой конечной точки можно воспользоваться простым счетчиком с использованием библиотеки

60

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

метрик — например, Dropwizard (https://metrics.dropwizard.io/4.1.2/). Если счетчик
конкретной конечной точки не увеличивается в течение долгого времени, а сервис
используется только внутри компании, стоит подумать об отказе от поддержки
такой точки. Если конечная точка открыта и задокументирована, возможно, ее
придется поддерживать дольше, ведь нельзя исключить, что кто-то начнет ею
пользоваться. Поэтому даже когда метрика конкретной точки не растет, это не
значит, что ее можно удалить.
Итак, мы видим, что микросервисы обеспечивают бо́льшую гибкость в отношении эволюции API. Совместимость более подробно рассматривается в главе 12.

Потребление ресурсов
Использование библиотеки клиентом означает, что объем вычислений и потреб­
ление ресурсов в клиентском коде может увеличиться. Для каждого запроса,
выполняемого платежным сервисом, маркер проверки должен обрабатываться
в коде. Если код потребляет много ресурсов, придется увеличить количество
процессоров или объем памяти в зависимости от нагрузки.
Если логика проверки скрыта за API, который предоставляется отдельным
сервисом, потребление ресурсов и масштабирование перестают быть прямой
проблемой для клиента. Обработка будет выполняться конкретным экземпляром микросервиса. Если запросов слишком много, то команда, ответственная
за сервис, должна отреагировать и масштабировать сервис в соответствии
с нагрузкой.
Следует понимать, что клиентскому коду в таком случае потребуется дополнительный вызов HTTP, потому что каждое подтверждение должно пройти путь к
микросервису и обратно. Если логика, скрытая за API микросервиса, достаточно
тривиальна, может оказаться, что дополнительные затраты на вызов HTTP превышают затраты на выполнение логики на стороне клиента. Если логика более
сложна, то затраты HTTP могут быть незначительны по сравнению с работой
микросервиса. При принятии решения о том, следует ли выделять функционал
во внешний сервис, необходимо учитывать этот компромисс.

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

2.3. Выделение кода в отдельный микросервис

61

микросервис нужно выполнять дополнительный вызов HTTP для каждого
запроса пользователя к сервису, что может быть серьезным недостатком. Необходимо вычислить, как это повлияет на задержку ответа и соглашения об
уровне обслуживания (SLA) сервиса. Один из таких сценариев представлен
на рис. 2.7.

Сервис A

Задержка M мс

Сервис B

Задержка N мс

Клиент наблюдает
задержку N+M мс

Рис. 2.7. Дополнительная задержка может повлиять на работу сервиса

Если, например, согласно SLA задержка 99-го процентиля должна быть менее n
миллисекунд, добавление вызовов в другие микросервисы может нарушить SLA.
Но если задержка 99-го процентиля меньше n, можно скрыть дополнительный
вызов HTTP, выполняя некоторые запросы параллельно, за счет повторных
попыток или упреждающего исполнения. Ситуация ухудшится, если задержка
99-го процентиля второго микросервиса больше n. В таком случае выполнить
SLA не получится. Придется повысить задержку в требованиях SLA. Если это
невозможно, уделите больше времени сокращению 99-го процентиля второго
сервиса или используйте подход с выделением библиотеки.
Если задержка не критична, все равно следует учесть возможность каскадных
сбоев (http://mng.bz/GGrv) и защититься от временной недоступности зависимого
микросервиса. Проблема каскадных сбоев присуща не только микросервисам; она
может произойти в любой внешней системе, к которой понадобится обращаться
с вызовами (базе данных, API аутентификации и т. д.).
Если бизнес-процесс требует дополнительного внешнего запроса, необходимо
решить, что делать, когда сервис недоступен. Можно реализовать повторную попытку с экспоненциальной отсрочкой, чтобы нисходящий сервис смог вернуться
в рабочее состояние без перегрузки сервиса запросами. Используя этот прием,
можно проверять состояние нисходящего сервиса каждые x миллисекунд, а когда
он восстановится — постепенно наращивать трафик. При добавлении поведения
экспоненциальной отсрочки повторные попытки должны выполняться реже:
например, первая — через 1 секунду, вторая — через 10 секунд, третья — через

62

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

30 секунд и т. д. Если это не помогает и сервис недоступен в течение долгого
времени, необходимо защититься от такой возможности при помощи паттерна
Прерыватель (Сircuit Breaker) (https://martinfowler.com/bliki/CircuitBreaker.html).
Следует предусмотреть альтернативное поведение, которое будет выполняться при недоступности нисходящей системы. Например, если у вас имеется
платежная система и провайдер платежей недоступен, можно подтверждать
оплату и списывать средства со счета через какое-то время только после
того, как нисходящая система вернется в рабочее состояние. Такой подход
необходимо реализовывать очень осторожно, и это должно быть осознанным
бизнес-решением.

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

2.3.2. Выводы о выделении отдельных сервисов
Рассмотрев все компромиссы, присущие микросервисам, мы видим, что у них
много недостатков. Необходимо реализовать много новых частей. Даже если
все сделано правильно, сбоев запросов, выполняющих внешние вызовы по
сети (которая может быть ненадежной), все равно не избежать. Выбирая между
решениями с библиотекой и микросервисами, необходимо учитывать все эти
плюсы и минусы.
ПРИМЕЧАНИЕ
Абстрагирование функциональности в отдельный сервис или библиотеку проще
передать на аутсорс. Например, делегировать реализацию логики аутентификации
сторонней компании-разработчику. Впрочем, у такого подхода много недостатков,
таких как (возможно) высокая цена, проблемы с координацией, отсутствие гибкости
к изменениям и многое другое.

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

2.4. Улучшение слабой связанности за счет дублирования кода

63

2.4. УЛУЧШЕНИЕ СЛАБОЙ СВЯЗАННОСТИ ЗА СЧЕТ
ДУБЛИРОВАНИЯ КОДА
В этом разделе проблема дублирования рассматривается на уровне кода. Конкретно — на структуре двух обработчиков запросов, имеющих дело с двумя
разновидностями запросов трассировки.
Допустим, система должна обрабатывать два типа запросов: стандартные запросы трассировки и запросы на трассировку графа. Запросы могут поступать от
разных API, использовать разные протоколы и т. д. По этой причине в программе
будут два пути, обрабатывающих оба типа запросов независимо.
Начнем с более прямолинейного подхода с двумя разными компонентами обработчиков. GraphTraceHandler обрабатывает запросы на трассировку графа,
а TraceHandler — нормальные запросы трассировки. Структура изображена на
рис. 2.8.
Trace

GraphTrace

Обрабатывается

Обрабатывается

TraceHandler

GraphTraceHandler

Рис. 2.8. Два независимых обработчика для выполнения запросов трассировки

Логика изолирована, связанность между двумя обработчиками отсутствует.
Объекты Trace и GraphTrace похожи: они содержат информацию, если трассировка включена, и оба получают реальные данные. Для класса GraphTrace
информация имеет тип int, тогда как для класса Trace это тип String, как видно
из следующего листинга.
Листинг 2.6. Несвязанные классы Trace и GraphTrace
public class Trace {
private final boolean isTraceEnabled;
private final String data;

Задает тип данных
для Trace

public Trace(boolean isTraceEnabled, String data) {
this.isTraceEnabled = isTraceEnabled;
this.data = data;
}

64

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

public boolean isTraceEnabled() {
return isTraceEnabled;
}
public String getData() {
return data;
}
}
public class GraphTrace {
private final boolean isTraceEnabled;
private final int data;

Обратите внимание: типы данных
для GraphTrace и Trace различны

public GraphTrace(boolean isTraceEnabled, int data) {
this.isTraceEnabled = isTraceEnabled;
this.data = data;
}
public boolean isTraceEnabled() {
return isTraceEnabled;
}
public int getData() {
return data;
}
}

На первый взгляд классы похожи, но у них нет общей структуры. Они полностью
отделены друг от друга.
Рассмотрим обработчики для запросов трассировки. Начнем с обработчика
TraceRequestHandler . Он отвечает за буферизацию входящих запросов. На
рис. 2.9 изображена схема работы TraceRequestHandler.
Буфер обработчика запросов
трассировки
Запрос-1

Обрабатывается
Запрос-1

Запрос-2

Запрос-3

Обрабатывается

Игнорируется,
поскольку буфер
заполнен

Запрос-2

Рис. 2.9. Обработчик TraceRequestHandler буферизует входящие запросы

2.4. Улучшение слабой связанности за счет дублирования кода

65

Как видно из диаграммы, TraceRequestHandler буферизует запросы, пока в буфере остается свободное место. Когда буфер заполняется, запрос (Запрос-3 на
рис. 2.9) игнорируется.
Параметр bufferSize ограничивает размер буфера, переданный конструктору
обработчика; он определяет, сколько элементов TraceRequestHandler сможет
обработать. Запросы буферизуются в структуре данных списка. При заполнении
буфера флагу processed присваивается значение true. В следующем листинге
приведен код выделения обработчика.
Листинг 2.7. Выделенный обработчик TraceRequestHandler
public class TraceRequestHandler {
private final int bufferSize;
private boolean processed = false;
List buffer = new ArrayList();
public TraceRequestHandler(int bufferSize) {
this.bufferSize = bufferSize;
}
public void processRequest(Trace trace) {
if (!processed && !trace.isTraceEnabled()) {
return;
}
if (buffer.size() < bufferSize) {
Если размер буфера меньше bufferSize,
buffer.add(createPayload(trace));
присоединить к нему
}
if (buffer.size() == bufferSize) {
processed = true;
}

Если буфер заполнен, присвоить
флагу processed значение true

}
private String createPayload(Trace trace) {
return trace.getData() + "-content";
}
public boolean isProcessed() {
return processed;
}
}

Обратите внимание на метод createPayload(). Он содержит единственную
логику, специфическую для класса Trace. Он получает запрос на трассировку,
извлекает данные и создает строку, которая присоединяется к буферу.
Чтобы понять этот компонент, рассмотрим модульный тест, который будет обрабатывать пять запросов. При этом лимит буфера установлен равным 4. В таком случае после получения буфером четырех запросов пятый присоединен не

66

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

будет. В следующем листинге создается новый обработчик TraceRequestHandler
с буфером размера 4 для реализации этой стратегии.
Последний запрос (со значением e) будет проигнорирован, потому что он выходит за пределы буфера.
Листинг 2.8. Создание модульного теста для TraceRequestHandler
@Test
public void shouldBufferTraceRequest() {
// Дано
TraceRequestHandler traceRequestHandler = new TraceRequestHandler(4);
// Когда
traceRequestHandler.processRequest(new
traceRequestHandler.processRequest(new
traceRequestHandler.processRequest(new
traceRequestHandler.processRequest(new
traceRequestHandler.processRequest(new

Trace(true,
Trace(true,
Trace(true,
Trace(true,
Trace(true,

"a"));
"b"));
"c"));
"d"));
"e"));

// То
assertThat(traceRequestHandler.buffer)
.containsOnly("a-content", "b-content", В содержимое буфера не
входит элемент e-content
➥ "c-content", "d-content");
assertThat(traceRequestHandler.isProcessed()).isTrue();
После обработки флаг
}
isProcessed должен
возвращать true

Как видим, буфер содержит только четыре записи. Чтобы понять, почему между
обработчиками существует дублирование, необходимо проанализировать код
GraphTraceRequestHandler. Собственно, у обработчиков трассировки графа
и обычных обработчиков трассировки различается только метод createPayload(),
который будет реализован в следующем листинге.
graphTrace извлекает данные и присоединяет к ним суффикс nodeId.

Листинг 2.9. Создание полезных данных для GraphTraceRequestHandler
private String createPayload(GraphTrace graphTrace) {
return graphTrace.getData() + "-nodeId";
}

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

2.5. Проектирование API с наследованием для сокращения дублирования

67

2.5. ПРОЕКТИРОВАНИЕ API С НАСЛЕДОВАНИЕМ
ДЛЯ СОКРАЩЕНИЯ ДУБЛИРОВАНИЯ
В этом разделе мы используем метод наследования для сокращения дублирования кода. Самый сложный метод, который мы хотим использовать совместно
в обработчиках событий, — processRequest(). Обратившись к этому методу
в листинге 2.7, заметим, что он использует метод isTraceEnabled() для проверки
того, должен ли запрос буферизоваться.
Так как между Trace и GraphTrace существует значительное сходство, общие
части можно выделить в новый класс TraceRequest, как показывает следующий
листинг.
Листинг 2.10. Создание родительского класса TraceRequest
public abstract class TraceRequest {
private final boolean isTraceEnabled;
public TraceRequest(boolean isTraceEnabled) {
this.isTraceEnabled = isTraceEnabled;
}

isTraceEnabled is shared… —
isTraceEnabled совместно используется
классами GraphTrace и Trace

public boolean isTraceEnabled() {
return isTraceEnabled;
}
}

С новой структурой оба запроса могут расширять абстрактный класс TraceRequest,
предоставляя только данные, специфические для каждой разновидности запросов. В следующем листинге показано, как GraphTrace и Trace могут расширять
TraceRequest.
Листинг 2.11. Расширение TraceRequest
public class GraphTrace extends TraceRequest {
private final int data;

GraphTrace расширяет
TraceRequest

public GraphTrace(boolean isTraceEnabled, int data) {
super(isTraceEnabled);
Передает isTraceEnabled конструктору
this.data = data;
родительского класса
}
public int getData() {
return data;
}

getData() для GraphTrace
возвращает тип int

}
public class Trace extends TraceRequest {
private final String data;

Класс Trace также
расширяет TraceRequest

public Trace(boolean isTraceEnabled, String data) {

68

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость
super(isTraceEnabled);
this.data = data;

}
public String getData() {
return data;
}

getData() для Trace отличается
от экземпляра GraphTrace

}

Рисунок 2.10 показывает, как будет выглядеть иерархия Trace и GraphTrace после выделения общих частей.
TraceRequest

GraphTrace

Расширяет

Расширяет

Trace

Рис. 2.10. Новый класс TraceRequest, который может расширяться классами
GraphTrace и Trace в целях сокращения дублирования кода

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

2.5.1. Выделение базового обработчика запросов
Целью рефакторинга было исключение дублирования кода в обработчиках. По
этой причине можно выделить новый класс BaseTraceRequestHandler, который
будет работать с классом TraceRequest. Метод createPayload(), специфический
для типа запроса, размещается в дочерних классах, предоставляющих это конкретное поведение. На рис. 2.11 изображена новая структура.

BaseTraceHandler
processRequest()

GraphTraceHandler
createPayload()

Расширяет

Расширяет

Рис. 2.11. Выделение родительского класса BaseTraceHandler

TraceHandler
createPayload()

2.5. Проектирование API с наследованием для сокращения дублирования

69

Новый класс BaseTraceRequestHandler необходимо параметризовать, чтобы
он мог работать с любым классом, расширяющим TraceRequest. В следующем
листинге приведен переработанный класс BaseTraceRequestHandler. Он будет
работать с любыми классами, которые вызывают TraceRequest или расширяют
его. Конструкция используется в Java для обеспечения этого инварианта.
Листинг 2.12. Создание родительского класса BaseTraceRequestHandler
public abstract class BaseTraceRequestHandler {
private final int bufferSize;
private boolean processed = false;
List buffer = new ArrayList();
public BaseTraceRequestHandler(int bufferSize) {
this.bufferSize = bufferSize;
}

processRequest получает в аргументе
произвольный экземпляр TraceRequest

public void processRequest(T trace) {
if (!processed && !trace.isTraceEnabled()) {
Он содержит метод
return;
isTraceEnabled(), потому что
}
является TraceRequest
if (buffer.size() < bufferSize) {
buffer.add(createPayload(trace));
Он содержит метод isTraceEnabled(),
}
потому что является TraceRequest
if (buffer.size() == bufferSize) {
processed = true;
}
}
protected abstract String createPayload(T trace);
public boolean isProcessed() {
return processed;
}

Основная логика обработки
такая же, как в решении
с дублированием

}

Теперь логика processRequest() работает с любым классом TraceRequest. Метод
isTraceEnabled() будет доступен для него, потому что он определяется классом
TraceRequest. Обратите внимание, что метод createPayload() абстрактный. Конкретная реализация будет предоставляться дочерним классом, который способен
обрабатывать запросы Trace или GraphTrace.
После рефакторинга оба обработчика могут расширить базовый класс, предоставляя только необходимые части кода для своей реализации. Классы
TraceRequestHandler и GraphTraceRequestHandler должны предоставить только
реализацию метода createPayload(). Родительский класс получает параметр
bufferSize, который используется основной логикой обработки для ограничения
размера буфера. Конструктор дочернего класса должен вызвать конструктор

70

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

суперкласса с этим аргументом. Новый класс TraceRequestHandler расширяет
выделенный базовый класс. Он параметризуется с использованием класса Trace,
как показано в следующем листинге.
Листинг 2.13. Добавление наследования к GraphTraceRequestHandler
и TraceRequestHandler
public class TraceRequestHandler extends BaseTraceRequestHandler {
public TraceRequestHandler(int bufferSize) {
super(bufferSize);
}
@Override
public String createPayload(Trace trace) {
return trace.getData() + "-content";
}
}
public class GraphTraceRequestHandler extends
BaseTraceRequestHandler {
public GraphTraceRequestHandler(int bufferSize) {
super(bufferSize);
}
@Override
public String createPayload(GraphTrace graphTrace) {
return graphTrace.getData() + "-nodeId";
}

Предоставляет алгоритм
для обработки GraphTrace

}

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

2.5.2. Наследование и сильная связанность
Теперь в коде используется наследование, а обработчики предоставляют только метод createPayload() . Допустим, поступило новое бизнес-требование:
необходимо изменить очередь GraphTraceRequestHandler, чтобы он работал
с неограниченным значением bufferSize. (Хотя в реальных системах создавать
неограниченные буферы не рекомендуется, мы рассмотрим этот сценарий для
простоты.) Это также означает, что параметр bufferSize этому обработчику
больше не нужен.

2.5. Проектирование API с наследованием для сокращения дублирования

71

Как известно, логика processRequest() находится в родительском классе и совместно используется всеми клиентскими классами. Новое бизнес-требование
означает, что метод, ответственный за обработку запросов, можно упростить,
как показано в следующем листинге.
Листинг 2.14. Упрощение processRequest
public void processRequest(T trace) {
if (!processed && !trace.isTraceEnabled()) {
return;
}
Логика ограничения количества
запросов трассировки в буфере
buffer.add(createPayload(trace));
отсутствует
}

Одна из проблем здесь — методprocessRequest() можно упростить только для
обработчиков трассировки графа. Логика стандартного обработчика должна
отслеживать буфер. Таким образом, устранение дублирования вводит в архитектуру сильную связанность. Из-за этого теряется возможность изменить метод
processRequest() для одного дочернего класса, не влияя на другие дочерние
классы. Эта потеря гибкости — вынужденный компромисс, и она существенно
ограничивает архитектуру решения.
Одно из возможных решений проблемы — определить особый случай для запросов с использованием instanceof без применения буферизации для класса
GraphTrace. Решение продемонстрировано в следующем листинге.
Листинг 2.15. Использование instanceof в обходном решении
if(trace instanceof GraphTrace){
buffer.add(createPayload(trace));
}

Такое решение получается слишком хрупким и противоречит изначальной цели
введения наследования. Оно создает сильную связанность между родительским
и дочерним классами. Внезапно оказывается, что родительский класс должен
все знать о типах запросов, которые он должен обрабатывать. Он уже работает
не только на уровне обобщенного класса TraceRequest. Теперь родительский
класс должен знать об одной из фактических реализаций — GraphTrace. Логика
из конкретного обработчика просачивается в обобщенный обработчик. Таким
образом, обработка запросов GraphTrace перестает инкапсулироваться в коде,
ответственном за обработку запроса.
Проблему можно решить возвратом к дублированию кода. Но в реальной ситуации это вряд ли возможно, потому что перерабатываемые компоненты устроены
намного сложнее и требуют приложения гораздо больших усилий.
Вдумчивый читатель увидит, что в рассматриваемом простом примере передача
Integer.MAX_INT конструктору GraphTraceRequestHandler в качестве bufferSize

72

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

решит проблему. Теоретически это означает, что бизнес-цели создания неограниченного буфера можно достичь без изменения больших фрагментов
кода. Однако в реальном мире изменения бизнес-требований, с которыми вы
можете столкнуться, будут сложнее. Возможно, вам не удастся осуществить
их без сокращения сильной связанности и отказа от наследования.
Я выбрал решение с наследованием из-за контекста, в котором работал исходный
код. Допустим, вы хотите разрешить вызывающей стороне передать некоторые
части реализации (как в BaseTraceRequestHandler). Такая схема называется
паттерном Стратегия (Strategy pattern). Возможно, в этом случае проще выбрать наследование, при котором основная логика и заготовки предоставляются
в родительском классе, а клиент реализует недостающие части.
Можно использовать другой подход к исключению дублирования — например,
применить композицию или паттерн, соответствующий вашим потребностям.
Однако у любого решения есть свои плюсы и минусы, и следует проанализировать связанные с ним компромиссы. Необходимо решить, стоит ли желаемая
гибкость проблем с обслуживанием дублируемого кода, который может развиваться в разных направлениях. В качестве альтернативы можно рассмотреть
применение композиции независимых структурных блоков вместо того, чтобы
связывать различные аспекты поведения наследованием.

2.5.3. Компромиссы между наследованием и композицией
Паттерн Стратегия хорошо подходит для нашего примера, если каждый подкласс
всегда имеет четко определенный набор требований, частично отделенных друг
от друга. Если набор требований растет, возможно, вместо наследования стоит
применить композицию. В таком случае требования придется разделить по разным зонам ответственности. В существующей системе уже есть преобразование
данных в возможный формат полезной нагрузки и буферизация.
Сейчас буферизация работает относительно прямолинейно и всегда базируется
на количестве добавленных в буфер элементов. Предположим, вы хотите применять разные стратегии: бесконечную буферизацию, полное ее отсутствие,
существующую схему буферизации с ограничением количества элементов
в буфере или, возможно, буферизацию с ограничением объема памяти, учитывающую размер каждого элемента. В решении с наследованием можно воспользоваться методом tryAddEntry(); он либо абстрактный, либо имеет реализацию
по умолчанию в BaseTraceHandler. Будет ли такая схема лучшим выбором?
Разделение функций преобразования данных и буферизации по разным абстракциям (возможно, с повторным использованием функциональных интерфейсов) позволяет коду обработчика быть связующим для абстракций. Это
повышает гибкость и дает возможность комбинировать схемы буферизации

2.5. Проектирование API с наследованием для сокращения дублирования

73

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

расширяет
GraphTraceHandler
creat ePayload()
tryAddEntry()

BaseTraceHandler
createPayload()
tryAddEntry()

Реализация через лямбда-выражения
или специализированные классы

Композиция
Предоставляется
при вызове
конструктора
(например, DI-контейнером)

Trac eHandler
- payloadT ransformer
- buff er

PayloadT ransformer
(интерфейс)

Buffer
(интерфейс)

InfiniteB uffer

ElementCountBuffer

.. .

Рис. 2.12. Композиция и наследование в TraceHandler

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

2.5.4. Дублирование внутреннее и ситуативное
В реальном мире программисты склонны подгонять решения под выбранные паттерны. Один из примеров — создание общей абстракции и последующая адаптация
кода в нескольких местах для ее распространения. Однако то, что две конструкции
выглядят одинаково, еще не значит, что они решают одну задачу. Они также могут
по-разному развиваться. Такое дублирование является ситуативным — в отличие
от внутреннего дублирования, изначально присущего коду проекта.
Обычно бывает проще объединить две концепции в одну, если они одинаковые,
чем разделить их, если они разные. Если у вас есть абстракция с несколькими
применениями, связанность компонентов может быть достаточно высокой. Это
означает, что разделить общий код по разным классам может оказаться непросто.
Порой то, что кажется дублированием, на самом деле представляет собой две
разные сущности, которые просто одинаково рассматриваются при текущих требованиях, но в дальнейшем могут измениться и не должны считаться эквивалентными. В начале работы над системой бывает сложно различить эти две ситуации.

74

Глава 2. Дублирование кода не всегда плохо: дублирование кода и гибкость

Иногда подход, при котором мы начинаем с абстракции и адаптации под нее
всех возможных применений, не оптимален. Вместо этого можно реализовать
систему, создавая независимые компоненты и позволяя им оставаться таковыми в течение определенного срока (даже если это потребует некоторого
дублирования кода). Со временем можно выделять закономерности во взаимодействиях компонентов, и тогда абстракции начнут проявляться. В этот
момент уместно исключить дублирование, создав абстракцию (вместо того,
чтобы начинать с нее).
В этой главе мы анализировали решения для сокращения дублирования в коде.
Мы начали с кода, совместно используемого двумя кодовыми базами, и выделили
его в отдельную библиотеку. Были проанализированы компромиссы и проблемы,
которые необходимо учитывать на протяжении жизненного цикла библиотеки.
Затем был представлен другой подход к совместному использованию кода через
специализированный сервис; он может иметь соответствующий API и рассматриваться по принципу «черного ящика». Выделение микросервисов позволило
избежать некоторых проблем, присущих решению с библиотекой, но добавило
новые сложности и компромиссы. Вторая часть этой главы была посвящена поиску абстракции между двумя компонентами обработчиков, не связанных друг
с другом. Мы создали решение на базе наследования, которое предполагает меньше
строк кода. Наследование решило некоторые проблемы, но при этом ограничивает
гибкость проектирования и подразумевает определенные компромиссы.
Следующая глава посвящена обработке исключений и ошибок в коде. Также вы
научитесь обрабатывать исключения из стороннего кода и изучите эффективные
приемы обработки исключений в многопоточной среде.

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

3

Исключения и другие
паттерны обработки
ошибок в коде
https://t.me/it_boooks

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

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

76

Глава 3. Исключения и другие паттерны обработки ошибок в коде

должен быть отказоустойчивым, то есть по возможности восстанавливаться
при возникновении ошибок. Прежде чем решать, как обрабатывать исключения,
необходимо спроектировать API так, чтобы они выявляли проблемы и явно
сигнализировали о них. Тем не менее, если явно выдавать сигнал о каждой возможной ошибке, это затруднит чтение и обслуживание кода.
Не каждый паттерн ошибки требует восстановления работоспособности в коде.
Согласно философии «допущения сбоев» (let-it-crash), впервые определенной
в экосистеме Erlang, лучше не восстанавливаться после критических ошибок.
В таком сценарии супервизор следит за процессом и в случае сбоя из-за неисправимой ошибки (например, нехватки памяти) просто перезапускает программу.
Эта философия не требует применять защитное программирование и пытаться
защититься от всех возможных вариантов поведения, приводящего к выдаче исключений. В экосистеме Java такой подход применяется нечасто. Тем не менее
некоторые технологии на базе Java — такие, как Akka, — следуют этому паттерну.
В стандартном приложении на базе Java подход допущения сбоев создаст проблемы, потому что обработка запросов пользователей не разделяется на независимые процессы. Типичное Java-приложение содержит n потоков, каждый из
которых обрабатывает запросы для некоторых пользователей. Но мы работаем
в пределах одного приложения, и если оно полностью будет обрушиваться из-за
запроса одного человека, это отразится на других пользователях.
В модели акторов (субъектов), применяемой в Erlang, Akka и других технологиях,
обработка имеет более высокую детализацию. Обычно приложение содержит
сотни (а то и более) акторов, каждый из которых отвечает за обработку небольшого объема пользовательского трафика. Сбой в одном акторе не повлияет на
остальные. Такой подход имеет действительные сценарии применения, но сильно
зависит от структуры приложения и его потоковой модели.
Мы рассмотрим плюсы и минусы обоих подходов, а также ситуации, в которых
их следует применять. А после разбора самых эффективных паттернов добавим
еще один уровень сложности — обработку проблем в асинхронном коде, который
работает в многопоточной среде.
Если API проектируется заранее, можно сделать его защищенным и подробным
там, где это должно быть выражено явно. Но существуют ошибки, при возникновении которых практически ничего нельзя сделать. Они должны оставаться
неявными, и включать их в контракт API не следует. К сожалению, существуют
API и сторонние библиотеки, скрывающие проблемы от нас. Мы рассмотрим
методы решения таких проблем.
Наконец, мы сравним выдачу исключений в объектно-ориентированной модели
с функциональным подходом, использующим монаду Try для решения проблем.
Наше путешествие в мир исключений начнется со знакомства с иерархией проблем, о которых может сигнализировать код и которые он может обрабатывать.

3.1. Иерархия исключений

77

3.1. ИЕРАРХИЯ ИСКЛЮЧЕНИЙ
Прежде чем погружаться в более сложные темы, такие как проектирование схемы
обработки исключений API, — вкратце рассмотрим иерархию исключений и ошибок, которые часто используются в коде. Эта иерархия изображена на рис. 3.1.
Object

Throwable
Throwable

Exceptions

Проверяемые
Checked
IOException
IOException
InterruptedException
InterruptedException

Непроверяемые
Unchecked
IllegalArgumentException
IllegalArgumentException
NullPointerException
NullPointerException

Error
Error

V irtualMachineError
VirtualMachineError

AssertionError etc
etc
AssertionError

Рис. 3.1. Иерархия исключений в Java

В Java каждое исключение представляет собой объект. Специальный тип
Throwable (расширяет Object) предоставляет полезную информацию о каждой
ошибке. Он инкапсулирует причину в сообщении, которое сигнализирует о
проблеме. Что еще важнее, он содержит трассировку стека — массив элементов,
в котором каждый элемент идентифицирует конкретную строку кода класса,
приведшую к исключению. Эта информация необходима для целей диагностики.
Она помогает отследить строку, в которой возникла проблема, для устранения
ошибки. Далее идут два класса, расширяющих Throwable (Error и Exception).
Сообщение Error указывает на возникновение критической проблемы, и чаще

78

Глава 3. Исключения и другие паттерны обработки ошибок в коде

всего не следует пытаться перехватывать или обрабатывать его. Например, это
может быть ошибка виртуальной машины, которая сигнализирует о критической
проблеме с окружением.
В этой главе мы не будем подробно рассматривать обработку ошибок в Java, потому что управлять ею почти невозможно. Впрочем, мы рассмотрим различные
стратегии обработки ошибок. Также отмечу, что в оставшейся части главы я буду
использовать термины «ошибка» и «исключение» как синонимы для обозначения
одной концепции.
В левой части рис. 3.1 изображены исключения, используемые для оповещения о проблемах в коде. Исключение также следует обрабатывать, если есть
возможность корректно восстановить нормальную работу программы. Более
того, если метод объявляет проверяемое исключение, то компилятор требует,
чтобы оно обрабатывалось на стороне вызова (где оно может перехватываться
или выдаваться заново). Это означает, что код не будет компилироваться, если
исключение не обрабатывается. Например, если при загрузке файлов выдается
исключение IOException, разумно восстановить работоспособность и попытаться
загрузить файл из другого места файловой системы. Позже эти исключения будут
использованы для явного проектирования API обработки ошибок.
С другой стороны, обрабатывать непроверяемые исключения не обязательно.
Но если код этого не делает, они распространяются в основной поток приложения
и приводят к его остановке. Они часто указывают на ошибку использования, после которой восстановиться невозможно, и лучше быстро аварийно прекратить
работу, чем пытаться это сделать. Например, если передать отрицательное число
в качестве аргумента методу, ожидающему получить положительное число, лучше выдать непроверяемое исключение, потому что попытки восстановления не
имеют смысла. Вызывающая сторона также может предпочесть непроверяемые
исключения, чтобы, к примеру, упростить их использование из API с функ­
циональным (лямбда) интерфейсом. Это неявная часть кода обработки ошибок.
Концепция проверяемых и непроверяемых исключений также присутствует в других языках, но большинство из них выбирает ту или иную стратегию. Например,
в языках программирования Scala и C# каждое исключение рассматривается как
непроверяемое; следовательно, перехватывать их не нужно. При этом необходимо
действовать осторожно, чтобы не допустить распространения исключений в главный поток приложения. В противном случае программа перестанет работать.

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

3.1. Иерархия исключений

79

Листинг 3.1. Метод, выдающий проверяемые исключения
public void methodThatThrowsCheckedException()
throws FileAlreadyExistsException, InterruptedException

Как FileAlreadyExistsException, так и InterruptedException являются проверяемыми исключениями. Это означает, что сторона вызова этого метода
должна обрабатывать их во время компиляции. Первый подход к обработке
этих исключений основан на объявлении секции catch для обоих типов, как
показывает следующий листинг.
Листинг 3.2. Обработка проверяемых исключений
public void shouldCatchAtNormalGranularity() {
try {
methodThatThrowsCheckedException();
} catch (FileAlreadyExistsException e) {
logger.error("File already exists: ", e);
} catch (InterruptedException e) {
logger.error("Interrupted", e);
}
}

Используя два блока catch, можно предоставить разное поведение обработки
ошибок в зависимости от типа. Часто этот уровень детализации хорошо подходит для обработки исключений.
Из-за того что исключения образуют иерархию, можно изменить блок catch для
перехвата более широкого типа. Например, FileAlreadyExistsException (http://
mng.bz/zQwB) расширяет IOException, так что первый блок catch может напрямую
перехватывать IOException, как видно из следующего листинга.
Листинг 3.3. Обработка проверяемых исключений более широкого типа
public void shouldCatchAtHigherGranularity() {
try {
methodThatThrowsCheckedException();
} catch (IOException e) {
logger.error("Some IO problem: ", e);
} catch (InterruptedException e) {
logger.error("Interrupted", e);
}
}

FileAlreadyExistsException
расширяет IOException —
обработать этот тип

В этом листинге есть одна проблема: мы теряем информацию о том, что было
выдано именно исключение FileAlreadyExistsException. Хотя эти сведения
будут доступны на стадии выполнения, во время компиляции можно понять
только то, что было выдано исключение IOException.
Тип исключения можно расширить до Exception или произвольного Throwable.
Однако при этом появляется риск, что будут перехвачены исключения, которые

80

Глава 3. Исключения и другие паттерны обработки ошибок в коде

изначально перехватывать было не нужно. Обработчик может перехватить критические исключения, не имеющие отношения к процессу, которые следовало
бы передать компонентам более высокого уровня.
Если вызываемый метод выдает больше одного исключения, расширяющего
IOException, можно создать один блок catch вместо пары блоков catch с меньшей
детализацией. Такое решение рационально, если логика обработки ошибок для
конкретного типа не требуется и общая обработка нас устраивает.
Если тип исключения не важен, но необходимо перехватывать все проблемы,
можно объявить catch для всех исключений. Как вы помните из предыдущего
раздела, каждое исключение, проверяемое и непроверяемое, расширяет класс
Exception, так что это решение будет перехватывать все проблемы вызываемого
метода. Описанный подход продемонстрирован в следующем листинге.
Листинг 3.4. Перехват всех исключений
public void shouldCatchAtCatchAll() {
try {
methodThatThrowsCheckedException();
} catch (Exception e) {
logger.error("Problem ", e);
}
}

Перехватываются все исключения,
проверяемые и непроверяемые

Преимущество этого решения в том, что можно писать меньше кода, но при этом
теряется большое количество информации. Кроме того, следует помнить, что
перехватываться будут все исключения — даже те, которые не объявлены как
проверяемые исключения, выдаваемые вызванным методом. Возможно, это не
то поведение, которого вы ожидали. Вы рискуете перехватить проблему, которая
должна распространяться выше по стеку вызовов.
Для сокращения дублирования и сохранения информации об ожидаемых
исключениях можно воспользоваться блоком с несколькими catch . В следующем листинге в сигнатуре catch объявляются исключения IOException
и InterruptedException.
Листинг 3.5. Обработка проверяемых исключений в блоке с несколькими
catch
public void shouldCatchUsingMultiCatch() {
try {
methodThatThrowsCheckedException();
} catch (IOException | InterruptedException e) {
logger.error("Problem ", e);
}
}

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

3.2. Лучшие паттерны для обработки исключений в собственном коде

81

RuntimeException непроверяемое, и оно не должно объявляться в сигнатуре

метода, как показывает следующий листинг.
Листинг 3.6. Выдача непроверяемого исключения
public void methodThatThrowsUncheckedException()
throws FileAlreadyExistsException, InterruptedException {
throw new RuntimeException("Unchecked exception!");
}

Листинг 3.4 перехватит эту проблему, даже если вы этого не ожидали. Если сузить
блоки catch, чтобы они перехватывали только проверяемые исключения, исключение RuntimeException не будет перехватываться и будет распространяться.
В следующем листинге показано, как исправить положение.
Листинг 3.7. Вызов метода, выдающего непроверяемые исключения
public void shouldCatchAtNormalGranularityRuntimeWillBeNotCatch()
assertThatThrownBy(
() -> {
try {
methodThatThrowsUncheckedException();
Выдает непроверяемое
} catch (FileAlreadyExistsException e) {
исключение
logger.error("File already exists: ", e);
} catch (InterruptedException e) {
logger.error("Interrupted", e);
}
})
Исключение RuntimeException
.isInstanceOf(RuntimeException.class);
не перехватывается
}
и распространяется дальше

Обратите внимание: блоки catch перехватывают только исключения, объявленные в сигнатуре methodThatThrowsUncheckedException(). Среди них нет
блока catch для Exception, поэтому непроверяемое исключение обработано
не будет.
В следующем разделе вы узнаете больше об исключениях и соответствующих
им типах Java. Затем мы разберем, как проектировать стратегии обработки исключений для API.

3.2. ЛУЧШИЕ ПАТТЕРНЫ ДЛЯ ОБРАБОТКИ
ИСКЛЮЧЕНИЙ В СОБСТВЕННОМ КОДЕ
В подавляющем большинстве случаев код API, который вы пишете для своих
продуктов, будет использовать кто-то еще. Если вы работаете в команде, то,
скорее всего, разрабатываете логику для одной части системы, а ваши коллеги
отвечают за другие ее части.

82

Глава 3. Исключения и другие паттерны обработки ошибок в коде

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

3.2.1. Обработка проверяемых исключений
в общедоступном API
Допустим, вы разрабатываете компонент, предоставляющий общедоступный
API, который будут использовать другие члены вашей команды. Когда дело доходит до проверяемых исключений, необходимо явно выразить свое намерение
и пометить методы общедоступного API проверяемыми исключениями, которые
он может выдать. Например, если вы ожидаете, что в общедоступном методе
может произойти сбой из-за проблем с вводом/выводом, объявите исключение
в сигнатуре общедоступного API.
Некоторые языки (например, Scala) склонны рассматривать все исключения
как непроверяемые, так что методы могут не объявлять их. Если вы проектируете такой API, знайте, что такой подход повышает риск ошибок, потому что
клиенты не получат информации о возможных сбоях во время компиляции.
Проблема откладывается до времени выполнения, а это значит, что в программе
может произойти неожиданный сбой на стадии реальной эксплуатации. Если
API объявляет исключения явно, то такая ситуация невозможна, потому что
это вынуждает клиента определиться со стратегией обработки исключений на
стадии компиляции (то есть при написании кода).
Часто приходится слышать мнение, что объявление пары (двух, трех и даже
более) исключений, которые может выдавать API, делает его избыточным, что
усложняет написание кода клиентом. Допустим, вы предоставляете доступ
к такому методу. Взгляните на следующий листинг.
Листинг 3.8. Метод API с парой исключений
void check() throws IOException, InterruptedException;

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

3.2. Лучшие паттерны для обработки исключений в собственном коде

83

Листинг 3.9. Распространение исключения как непроверяемого
public void wrapIntoUnchecked() {
try {
check();
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException(e);
}
}

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

В этом листинге RuntimeException перехватывается до Exception, чтобы избежать
лишней упаковки исключения в RuntimeException. Также важно упаковать используемое исключение в новое, непроверяемое. При этом вызывающая сторона
получает всю информацию о причине возникшего исключения. После этого
другие методы в коде могут пользоваться методом, выполняющим упаковку.
Я не рекомендую использовать API, которые скрывают реальные исключения
и распространяют непроверяемые исключения в код как решение всех проблем. Такой подход скрывает ожидаемые исключения и делает API менее отказоустойчивым. Тем не менее он показывает, что аргументы против излишней
перегруженности API не рациональны. Проверяемые исключения легко преобразуются в непроверяемые.
Если клиенты не хотят обрабатывать ошибки явно, они должны принять осознанное решение игнорировать эти ошибки и распространять их вверх по стеку
вызовов. Чаще всего это неверный подход. Здесь становится видно, что объявление проверяемых исключений в сигнатуре метода общедоступных API имеет
несколько значительных преимуществ:
Такие API явно объявляют свой контракт. Вызывающая сторона может делать
разумные предположения о результате вызова, не обращаясь к реализации
метода.
Непроверяемые исключения не станут неожиданностью для вызывающей
стороны. Код обработки ошибок писать проще, когда вы точно знаете, какие
возможные исключения может выдать вызываемая функция API.

3.2.2. Обработка непроверяемых исключений
в общедоступном API
В API часто приходится проверять аргументы и состояние объектов, используемых вызывающей стороной. Если состояние недействительно, можно выдать непроверяемое исключение. Как известно, непроверяемые исключения
не обязательно объявлять в сигнатуре метода. Кроме того, их не обязательно
обрабатывать в коде на стороне вызова.

84

Глава 3. Исключения и другие паттерны обработки ошибок в коде

Согласно рекомендациям по обработке ошибок (учебное руководство по непроверяемым исключениям в Java доступно по адресу http://mng.bz/0wXN), объявление
непроверяемого исключения для каждого метода делает код менее понятным.
Однако иногда такие исключения допустимо объявлять. Предположим, в API
есть метод, который готовит сервис к использованию, как показано в следующем
листинге.
Листинг 3.10. Выдача непроверяемого исключения из API
boolean running;
public void setupService(int numberOfThreads)
throws IllegalStateException,
IllegalArgumentException {
if (numberOfThreads < 0) {
throw new IllegalArgumentException(
"Количество потоков не может быть меньше 0.
");
}

}

Объявляет, что метод может
выдавать непроверяемые
исключения

Если аргумент неверен, выдается
исключение IllegalArgumentException

if (running) {
throw new IllegalStateException(
"Сервис уже выполняется."
);
Если сервис уже работает, также может быть
}
выдано исключение IllegalStateException

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

3.3. Антипаттерны в обработке исключений

85

исключениях, которые они могут выдавать. Когда компонент используется по
принципу «черного ящика», то есть только через общедоступный API, нельзя
требовать, чтобы сторона вызова знала внутреннее устройство компонента.
Хорошим решением в таких методах может быть объявление непроверяемых
исключений.
Принимая решение о том, должен ли API выдавать проверяемые или непроверяемые исключения, необходимо учитывать множество факторов. Рассмотрим
ситуацию, в которой код вызывающей стороны предполагает, что в каждой
программной ветви вызываемого API может произойти сбой и выдача исключения. Скорее всего, это означает, что приложение структурировано так, что все
исключения перехватываются на более высоком уровне стека вызовов. Можно
провести параллель с ситуацией, в которой вы пишете компоненты и API, используемые в коде. В этом случае разумно выдавать непроверяемые исключения.
Вам принадлежит как вызывающий код, так и код реализации. Вероятность того,
что в вызываемом коде возникнет что-то неожиданное, невелика.
Однако при создании общедоступного API, который может вызываться неизвестным кодом, лучше действовать более явно и объявлять потенциальные
проблемы при помощи проверяемых исключений. Такой подход предоставляет потенциальным вызывающим сторонам явную информацию о вашем API.
Они будут знать, чего ожидать от вызываемого кода, и смогут защититься от
возможных исключений. При явном объявлении исключений в контракте API
у вызывающей стороны нет необходимости защищаться от всех потенциальных
проблем и пытаться угадать возможные исключения.
Я не могу однозначно сказать, какие типы исключений использовать. Для обоих
разновидностей существуют ситуации, в которых они наиболее уместны. Здесь
я лишь рассматриваю плюсы и минусы обоих типов. Учтите эти компромиссы
и контексте, а затем решите, какие исключения лучше подойдут для вашего кода.
В следующем разделе будут рассмотрены некоторые антипаттерны, которые
могут снизить отказоустойчивость кода.

3.3. АНТИПАТТЕРНЫ В ОБРАБОТКЕ ИСКЛЮЧЕНИЙ
Допустим, вы создали мощный API, который явно сигнализирует о проблемах
и исключениях. Теперь необходимо использовать его и правильно отреагировать
на проблемы. К сожалению, в этом сценарии легко потерять информацию или обработать исключения некорректно. Если в API, который вы хотите использовать,
объявляются исключения, они должны обрабатываться на стадии компиляции.
Часто возникает искушение проанализировать код и заключить, что такое исключение не может быть выдано ни при каких обстоятельствах. И возможно, на
момент анализа это даже будет правдой. Но если метод объявляет непроверяемые

86

Глава 3. Исключения и другие паттерны обработки ошибок в коде

исключения, они должны восприниматься как часть контракта метода. Даже если
метод не выдает исключение на время написания кода вызывающей стороны,
поведение может измениться в будущем. Следующий листинг демонстрирует
этот антипаттерн.
Листинг 3.11. Поглощение исключения
try {
Используется метод check()
Вызывающая сторона думает,
из предыдущего раздела
что исключение не может произойти
check();
} catch (Exception e) { // Не выдается? Это очень опасно!
}

Поглощенное исключение не распространяется вверх по стеку вызовов. Кроме
того, вы потеряете информацию о нем, что создает риск незаметного сбоя в системе. Отлаживать такие проблемы очень трудно! Никогда не игнорируйте исключение, объявленное в сигнатуре метода. Также может возникнуть искушение
ограничиться выводом трассировки стека, как показано в следующем листинге.
Листинг 3.12. Вывод трассировки стека
try {
check();
} catch (Exception e) {
e.printStackTrace();
}

Такой вариант тоже небезопасен, потому что трассировка стека по умолчанию
направляет содержимое исключения в стандартный вывод. Однако приемником может быть что-то другое — например, FileOutputStream. Кроме того, если
стандартный вывод не захватывается и не распространяется, возникает риск
потери информации.
Необходимо решить, должно ли исключение обрабатываться на этом конкретном уровне кода. Если да, то при перехвате исключений следует извлекать как
можно больше информации. Например, для извлечения информации о Throwable
можно воспользоваться выводом в журнал, как показано в следующем листинге.
Листинг 3.13. Использование журнала для перехвата ошибки
try {
check();
} catch (Exception e) {
logger.error("Problem when check ", e);
}

Метод logger.error извлекает
необходимую информацию

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

3.3. Антипаттерны в обработке исключений

87

Если вы решите обработать определенную ошибку на более высоком уровне,
метод, вызывающий check(), не должен пытаться перехватывать ее. Вместо этого
он должен лишь объявить ее в сигнатуре метода. Явно объявляя исключение
в контракте метода, мы сигнализируем клиентам о том, чего им ожидать после
вызова метода. Использование этого паттерна позволяет клиентам разработать
собственную стратегию обработки ошибок.

3.3.1. Закрытие ресурсов при возникновении ошибки
Часто код должен взаимодействовать с методами и классами, а для этого нужны системные ресурсы. Например, создание нового файла требует открытия
дескриптора файловой системы. Создание клиента HTTP требует открытия
сокета, для которого выделяется порт из пула доступных портов. Пока проблем
нет и все работает, как ожидалось, необходимо закрыть клиент после завершения процесса.
Рассмотрим простой пример, в котором создается клиент HTTP и выполняются
некоторые запросы, после чего клиент закрывается. Код приведен в следующем
листинге.
Листинг 3.14. Закрытие клиента HTTP
Создается новый клиент,
выделяющий системные ресурсы
CloseableHttpClient client = HttpClients.createDefault();
Обработка, в которой
try {
используется клиент
processRequests(client);
Клиент закрывается после
client.close();
завершения обработки
} catch (IOException e) {
logger.error("Проблема при закрытии клиента или обработке запросов", e);
Ошибка регистрируется в журнале, если при выполнении
}
close() происходит сбой с выдачей исключения

На первый взгляд код кажется правильным, и после обработки клиент закрывается. (Здесь обработка включает логику, которая может завершиться
неудачей, если в сети будут потеряны некоторые пакеты.) К сожалению, метод
processRequests() может выдать IOException. Если исключение выдается в этой
точке кода, то метод close() вызван не будет. Возникает риск утечки ресурсов.
Это может создать проблемы, если открыть слишком много подключений через
сокеты или клиентов.
Необходимо преобразовать этот код, чтобы метод close() вызывался даже при
сбое в processRequests(). Кроме того, проблемы из processRequests() необходимо обрабатывать отдельно. Только после их обработки клиент можно закрыть.
Следующий листинг показывает, как будет выглядеть такое решение.

88

Глава 3. Исключения и другие паттерны обработки ошибок в коде

Листинг 3.15. Закрытие клиента HTTP при возникновении проблем с
обработкой запросов
CloseableHttpClient client = HttpClients.createDefault();
try {
Перехватывает проблемы
processRequests(client);
с обработкой запросов
} catch (IOException e) {
logger.error("Проблема при обработке запросов", e);
}
Метод close() вызывается только
try {
после завершения processRequests()
client.close();
} catch (IOException e) {
logger.error("Проблема при закрытии клиента", e);
}

Такой код получается слишком перегруженным и подвержен ошибкам. Первое
связано с тем, что нам приходится обрабатывать одно исключение IOException
дважды. Кроме того, необходимо защититься от сбоев с обработкой и вернуться к
вызову метода close() даже при возникновении проблем с обработкой. Об этом
легко забыть, если API выдает непроверяемое исключение. В таком случае метод
close() вызываться не будет и возникнет риск утечки ресурсов.
Чтобы немного улучшить этот код, можно воспользоваться конструкцией «try
с ресурсами» (try-with-resources), которая возьмет закрытие ресурсов на себя.
Такое решение сработает, только если используемый класс реализует интерфейс
AutoCloseable (см. http://mng.bz/QWOv). В листинге 3.16 показано, как автоматически закрыть клиент HTTP с помощью этого механизма.
Листинг 3.16. Закрытие клиента HTTP с использованием «try с ресурсами»
try (CloseableHttpClient client = HttpClients.createDefault()) {
Создает HttpClient в конструкции
processRequests(client);
«try с ресурсами»
} catch (IOException e) {
logger.error("Проблема при обработке запросов", e);
Обрабатывает исключение,
}
выданное processRequests()

Этот прием позволяет коду на стороне вызова сосредоточиться на логике, которую необходимо выполнить. Управление жизненным циклом объекта, реализующим Closeable, осуществляется за нас. Метод close() должен предоставить
необходимую логику для освобождения ресурсов. Он также выражает намерения
кода, позволяя клиентам рассуждать о типах и использовании ими ресурсов.
ПРИМЕЧАНИЕ
Если вы проектируете API так, чтобы ресурсы, выделенные для возврата объекта, освобождались после того, как объект станет не нужен, реализуйте интерфейс Closeable.

Хотя абстракция «try с ресурсами» полезна, может оказаться, что она не
поддерживается языком программирования. Главная причина для ее

3.3. Антипаттерны в обработке исключений

89

использования — закрытие ресурсов независимо от исхода обработки. Ошибка
может помешать дальнейшему выполнению кода, который выдает непроверяемое
исключение. В таком случае ресурсы необходимо закрыть после выполнения
логики. Из-за этого в некоторых языках предусмотрена возможность выполнения кода независимо от того, выдавалось ли исключение.
В Java для реализации логики, отвечающей за закрытие ресурсов, можно воспользоваться блоком finally. Код внутри этого блока выполняется всегда, даже
если код выдает исключение. Пример приведен в следующем листинге.
Листинг 3.17. Закрытие ресурсов в блоке finally
CloseableHttpClient client = HttpClients.createDefault();
try {
processRequests(client);
} finally {
System.out.println("closing");
client.close();
}

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

3.3.2. Антипаттерн использования исключений
для управления программной логикой
Другой популярный антипаттерн, встречающийся при реализации объектноориентированной обработки исключений, — использование исключений для
управления программной логикой (аналог команды goto). В таком приложении
исключения выходят за рамки нормального применения и выдаются для передачи сигнала вызывающей стороне о том, что логика должна идти по другому пути.
Также возникает соблазн воспользоваться исключением (-ями) для преодоления ограничения «один возвращаемый тип на метод». Допустим, имеется метод,
возвращающий String. Через какое-то время потребуется изменить его, чтобы
он возвращал специальное значение, если строка слишком длинная. На первый
взгляд выдача исключения в таком случае кажется правильным решением, и пока
это исключение действительно является исключительной ситуацией, оно оправданно. Проблемы начнут проявляться позже, если вызывающая сторона построит
условную логику с выполнением разных ветвей программы в зависимости от
результата метода.
Чем больше типов исключений выдает метод, тем сложнее становится логика
вызывающей стороны. Построение сложной логики на базе исключений обходится дорого (производительность рассматривается в последнем разделе этой

90

Глава 3. Исключения и другие паттерны обработки ошибок в коде

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

3.4. ИСКЛЮЧЕНИЯ ИЗ СТОРОННИХ БИБЛИОТЕК
При взаимодействии со сторонними библиотеками стратегии обработки исключений следует обдумывать очень тщательно. Рассмотрим пример создания
программного компонента, ответственного за сохранение информации о человеке
в каталоге.
API будет содержать два общедоступных метода. Первый метод получает информацию о человеке по его имени. Второй — создает информацию об имени
человека. Метод getPersonInfo() загружает файл из файловой системы, а метод createPersonInfo() создает новый файл для данного человека и сохраняет
информацию в файле. Клиентский код взаимодействует с API через два общедоступных метода, как показано на рис. 3.2.
Взаимодействует с
Файл: personName1.txt
Каталог

Клиентский код

Вызывает

getPersonInfo(personName)
createPersonlnfo(personName,

Взаимодействует с

Файл: personName2.txt

amount)

Взаимодействует с

Файл: personName3.txt

Рис. 3.2. API каталога с информацией о людях, предоставляющий два
общедоступных метода

3.4. Исключения из сторонних библиотек

91

Допустим, вы используете стороннюю библиотеку, которая предоставляет механизм сохранения и загрузки файлов в файловой системе — в данном случае будет
использоваться библиотека Apache Commons IO (http://mng.bz/9KW7). Библиотека
выдает исключения IOException или FileExistsException (http://mng.bz/jynr) при
возникновении проблем с любыми операциями, связанными с доступом к файловой системе. Как вы уже знаете, в каждом взаимодействии с файловой системой
задействован вызов метода, который может завершиться сбоем. В листинге 3.18
показано, как будет выглядеть API компонента каталогизации.
Листинг 3.18. API с объявленными исключениями
import java.io.IOException;
import org.apache.commons.io.FileExistsException;

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

public interface PersonCatalog {
PersonInfo getPersonInfo(String personName) throws IOException;
boolean createPersonInfo(String personName, int amount) throws Выдает исключение,
FileExistsException;
которое раскрывает
Выдает исключение
информацию
}
IOException из стандартной
о внутренней реализации
библиотеки Java

Самое важное, на что следует обратить внимание, — объявления обоих методов
API могут выдавать исключения. Метод getPersonInfo() выдает исключение
IOException, доступное в стандартном JDK. Метод createPersonInfo() выдает
FileExistsException — исключение, специфическое для импортированной
сторонней библиотеки. И это разумно, потому что оба метода взаимодействуют
с файловой системой через стороннюю библиотеку, которая также объявляет
эти исключения.
С одной стороны, такое решение работает, как ожидалось: клиенты должны обрабатывать IOException и FileExistsException в своем коде при взаимодействии
с компонентом PersonCatalog. С другой стороны, происходит утечка внутреннего
исключения, используемого сторонней библиотекой. Распространяя эти исключения и их типы в API, мы создаем сильное связывание между клиентским
кодом и сторонней библиотекой, которая используется во внутренней работе
компонента PersonCatalog. Это противоречит цели введенной абстракции, потому что изменить библиотеку, отвечающую за операции с файловой системой,
нельзя. Ее изменение приведет к тому, что в программе могут выдаваться другие
исключения, а сигнатура метода API перестанет отражать этот факт.
Также может оказаться, что в другой сторонней библиотеке не будет класса
FileExistsException, объявленного в сигнатуре метода. С классом IOException
меньше проблем, потому что он присутствует в JDK, доступном для клиентского
кода. Почему бы не удалить команду throws FileExistsException и не заменить ее исключением из другой сторонней библиотеки? Так как это открытый
интерфейс, изменение данного типа будет означать нарушение совместимости
библиотеки. Когда клиенты попытаются использовать новую версию этого
метода, код перестанет компилироваться!

92

Глава 3. Исключения и другие паттерны обработки ошибок в коде

Сделаем вывод, что распространение стороннего исключения в открытых методах API кода может оказаться не идеальным решением. Как решить эту проблему? Можно ввести исключение, специфическое для библиотеки, и упаковать
используемое исключение в него. Определим класс PersonCatalogException,
оборачивающий любое исключение, которое выдается сторонней библиотекой,
ответственной за взаимодействие с файловой системой. Реализация приведена
в следующем листинге.
Листинг 3.19. Создание исключения для конкретной предметной области
Приватный конструктор
public class PersonCatalogException extends Exception {
PersonCatalogException
private PersonCatalogException(String message, Throwable cause) {
super(message, cause);
}
public static PersonCatalogException getPersonException(String personName,
Throwable t) {
return new PersonCatalogException("Problem when getting person file for: " +
personName, t);
}
public static PersonCatalogException createPersonException(String personName,
Throwable t) {
return new PersonCatalogException("Problem when
➥ creating person file for: " + personName, t);
}
}

PersonCatalogException получает приватный конструктор, инкапсулирующий
реальный объект Throwable и сообщение об ошибке. Для метода getPersonInfo()
имеется фабрика getPersonException(), которая создает исключение, специфическое для предметной области. Метод API createPersonInfo() представляет
похожую ситуацию, в которой нижележащий объект Throwable преобразуется
в новое исключение PersonCatalogException.

После того как специализированное исключение PersonCatalogException будет
создано, его легко распространить в общедоступном API без раскрытия информации о реальных типах исключений используемой сторонней библиотеки. Такое
исключение продемонстрировано в следующем листинге.
Листинг 3.20. PersonCatalog без раскрытия информации о стороннем
исключении
public interface PersonCatalog {
PersonInfo getPersonInfo(String personName) throws PersonCatalogException;
boolean createPersonInfo(String personName, int amount) throws
PersonCatalogException;
}

3.5. Исключения в многопоточных средах

93

После таких изменений методы get и create объявляют, что могут выдавать
исключение PersonCatalogException. Обратите внимание: стороннее исключение уже не раскрывается, и клиентский код может использовать этот API без
сильного связывания с конкретной используемой реализацией. Такое решение
обеспечивает высокую гибкость при эволюции API и предоставляет клиентскому коду дополнительную информацию о причине исключения. По одному
взгляду на тип исключения вызывающая сторона может определить причину
и место, в котором оно было выдано. При использовании низкоуровневых исключений — таких, как IOException, — само имя исключения несет куда меньше
информации, чем могло бы.
Как видите, упаковка исключений может пригодиться в общедоступном API
и в собственном коде, потому что она дает больше контекста возникновения
ошибки. По сути, в этом случае передается больше информации с тем же объемом вывода исключений. Это не означает, что необходимо обертывать каждое
исключение, распространенное из сторонних библиотек в нашу кодовую базу, но
стоит рассмотреть затраты на обслуживание нового исключения и сравнить их
с преимуществами, которые они предоставляют. Чаще всего выгода от введения
специализированного исключения превышает затраты на его обслуживание. Если
вы проектируете приватный компонент, который не будет доступен клиентам
напрямую, ничто не мешает использовать исключения без их обертывания.
Заметим, что хотя тип исключения дает вызывающей стороне много информации,
сообщение, передаваемое с исключением, должно содержать подробное объяснение случившегося. Кроме того, в исключении сохраняется трассировка стека,
и при возникновении аномалий она также предоставляет много информации
о том, что пошло не так. Благодаря комбинации этих трех видов информации (тип
исключения, сообщение и трассировка стека) проще определить причину ошибки. Тип исключения также полезен для компилятора. Когда ошибка происходит
во время выполнения, желательно также располагать всей этой информацией.
До этого момента мы занимались проектированием синхронного кода. В следующем разделе речь пойдет о коде, работающем в многопоточной и асинхронной
модели.

3.5. ИСКЛЮЧЕНИЯ В МНОГОПОТОЧНЫХ СРЕДАХ
Обработка исключений в многопоточном и однопоточном контекстах происходит по-разному. В первом случае при отправке нового действия исполнителю
необходима обратная связь об успехе или неудаче. Без механизма получения
этой информации возникает риск того, что асинхронное действие, выполняемое
в отдельном потоке, завершится сбоем без сигналов об этом. Такие незаметные
сбои опасны, и их трудно диагностировать.

94

Глава 3. Исключения и другие паттерны обработки ошибок в коде

При взаимодействии с исполнителем возможны два способа отправки работы. Можно запланировать новое выполняемое действие при помощи метода
submit(), который возвращает экземпляр Future, а затем воспользоваться этим
экземпляром Future для получения результата действия. Во втором варианте
планирования асинхронных операций используется метод execute(). По сути,
этот метод работает по принципу «выстрелил и забыл», то есть результат из
таких действий мы не получаем.
Получение результата действия означает, что он либо успешен, либо неудачен,
если в коде выдано исключение. В следующем листинге показано, как работает
код обработки исключений при отправке действия.
Листинг 3.21. Отправка с ожиданием
Используется исполнитель с одним
отдельным рабочим потоком
ExecutorService executorService = Executors.newSingleThreadExecutor();
Runnable r =
Отправленное действие выдает
() -> {
непроверяемое исключение
throw new RuntimeException("problem");
submit() возвращает
};
Future
Future submit = executorService.submit(r);
assertThatThrownBy(submit::get)
get() подразумевает
.hasRootCauseExactlyInstanceOf(RuntimeException.class)
блокировку главного потока
.hasMessageContaining("problem");

Важно отметить, что метод get() блокирует выполнение и блокирующая операция должна завершиться при выполнении get(). Если используемое действие
завершается с исключением, оно будет распространено в главный поток. Если
действие отправлено, но результат в коде не используется («выстрелил и забыл»), то исключение не распространяется и возникает риск незаметного сбоя.
Следует помнить, что, если сервис-исполнитель возвращает Future, необходимо
проверить его правильность.
ПРИМЕЧАНИЕ
Информацию об интерфейсе Future можно найти по адресу http://mng.bz/W70a. Для
разработчиков .NET интерфейс Future аналогичен Task.

Способ исполнения несколько отличается, потому что он не возвращает никакого
результата. Его реализация представлена в следующем листинге.
Листинг 3.22. «Исполнил и забыл»
Runnable r =
() -> {
throw new RuntimeException("problem");
};
executorService.execute(r)

3.5. Исключения в многопоточных средах

95

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

Главный поток

Отправляет работу

w orker-thread-1

Выдает исключение

worker-thread-2

Если обработчика нет,
то выполнение незаметно
теряется

UncaughtExceptionHandler

Рис. 3.3. Глобальная обработка исключений в многопоточном контексте

Как показано на рис. 3.3, главный поток отправляет работу потоку workerthread-1 (рабочий поток-1) с использованием метода execute(), не получая обратно объект обещания (promise). Затем рабочий поток выполняет это действие
асинхронно. Если обещание не возвращается, главный поток не может сообщить
нам о проблемах, возникших при обработке worker-thread. К счастью, можно
зарегистрировать глобальный обработчик исключений, который активизируется
при возникновении любых исключений во время обработки. Если обработчика
нет (как в worker-thread-2 (рабочий поток-2)), появляется риск, что исключение
будет незаметно потеряно, а рабочий поток может остановиться, что приведет
к описанной ранее утечке ресурсов.
Рассмотрим модульный тест, который проверяет логику глобального обработчика исключений. Вызовем метод execute() с действием, в котором произойдет
сбой. Затем тест проверит, что обработчик UncaughtExceptionHandler был вызван
при возникновении исключения. В следующем листинге показан этот сценарий
использования.

96

Глава 3. Исключения и другие паттерны обработки ошибок в коде

Листинг 3.23. Регистрация UncaughtExceptionHandler
Присваивается true,
если очередь выполняется
// Дано
AtomicBoolean uncaughtExceptionHandlerCalled = new AtomicBoolean();
ThreadFactory factory =
Назначает глобальный
r -> {
обработчик исключений
final Thread thread = new Thread(r);
thread.setUncaughtExceptionHandler(
(t, e) -> {
uncaughtExceptionHandlerCalled.set(true);
Возникло
logger.error("Exception in thread: " + t, e);
исключение, поэтому
});
uncaughtExceptionHandlerCalled
return thread;
присваивается true
};
Назначает время
Runnable task =
ожидания до вызова
() -> {
обработчика
throw new RuntimeException("problem");
(все вызовы асинхронны)
};
ExecutorService pool = Executors.newSingleThreadExecutor(factory);
// Когда
pool.execute(task);
execute() активизирует действие
await().atMost(5,
в отдельном рабочем потоке
TimeUnit.SECONDS).until(uncaughtExceptionHandlerCalled::get);

Как видите, обработка исключений в многопоточной среде — нетривиальная
задача, особенно если используемый API разрешает или вынуждает асинхронно
инициировать действие и забыть о результатах. Но если можно получить объект
Future, обертывающий результат выполнения, следует использовать этот API,
потому что он вынуждает учитывать результат и возможность исключения.
API обещаний — хорошо известная конструкция, позволяющая создавать асинхронный код и динамично формировать асинхронные операции. В этом Java
API присутствует конструкция CompletableFuture для создания динамичных
асинхронных API, явно сохраняющих информацию о сбоях (см.  http://mng.bz/
NxMn). Аналогичные API также встречаются в других языках программирования.
Посмотрим, как обрабатывать исключения с использованием API обещаний Java.

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

3.5. Исключения в многопоточных средах

97

Допустим, необходимо вызвать какой-то внешний сервис. Метод, ответственный
за вызов внешнего сервиса, работает синхронно, так что его придется упаковать
в CompletableFuture API, позволяющий изменить последующую асинхронную
схему. Внешний вызов включает операцию ввода/вывода, поэтому он объявляет
о возможной выдаче IOException.
Так как исключение IOException является проверяемым, необходимо обработать
его асинхронным образом. Воспользуемся методом supplyAsync() — он упаковывает блокирующий вызов и возвращает неблокирующий тип. Этот тип распространяется к вызывающим сторонам, которые ожидают асинхронной операции. Первое
возможное решение — упаковка проверяемого исключения в непроверяемое для
распространения его к вызывающей стороне, как показывает следующий листинг.
Листинг 3.24. Упаковка исключения в асинхронный API
public int externalCall() throws IOException {
Методы externalCall() могут
throw new IOException("Проблема при
выдавать IOException
➥ вызове внешнего сервиса");
Выдает
новое
исключение
IOException
}
для моделирования сбоя
public CompletableFuture asyncExternalCall() {
return CompletableFuture.supplyAsync(
Упаковывает синхронный вызов
() -> {
и возвращает CompletableFuture
try {
return externalCall();
} catch (IOException e) {
throw new RuntimeException(e);
Упаковывает исключение
}
IOException в непроверяемое
});
}

Важно отметить, что мы распространяем IOException из используемой библио­
теки напрямую, без создания специализированного исключения-обертки. Это
сделано для простоты примера. Плюсы и минусы такого решения описаны
подробно в разделе 3.4.
Подход с упаковкой и распространением непроверяемого исключения далеко
не идеален. Мы смешиваем две абстракции: первая — API обещаний, инкапсулирующий результат, который может быть исполнен в будущем, или исключение, если действие завершится сбоем. Другая абстракция синхронно выдает
исключение, которое будет распространяться на вызывающую сторону. Java
API упаковывает его в исключение CompletionException — оно захватывается
в пуле потоков, где выполняется асинхронное действие.
При вызове метода asyncExternalCall() вы увидите трассировку стека, которая сообщает, что исключение было распространено на несколько уровней API
concurrent. Только после этого вы сможете понять суть проблемы. В следующем
листинге приведена трассировка стека.

98

Глава 3. Исключения и другие паттерны обработки ошибок в коде

Листинг 3.25. Трассировка стека для исключения, которое не было
правильно обработано
ava.util.concurrent.CompletionException: java.lang.RuntimeException:
java.io.IOException: Problem when calling an external service
Вызовы
at java.util.concurrent.CompletableFuture.encodeThrowable(
библиотечных
CompletableFuture.java:273)
функций, которые
at java.util.concurrent.CompletableFuture.completeThrowable(
должны
CompletableFuture.java:280)
обрабатывать
at java.util.concurrent.CompletableFuture$AsyncSupply.run(
неожиданные
CompletableFuture.java:1592)
ситуации
at java.util.concurrent.CompletableFuture$AsyncSupply.exec(
CompletableFuture.java:1582)
at java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:289)
at java.util.concurrent.ForkJoinPool$WorkQueue.runTask(
ForkJoinPool.java:1056)
at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1692)
at java.util.concurrent.ForkJoinWorkerThread.run(
ForkJoinWorkerThread.java:157)
После кода библиотеки
Caused by: java.lang.RuntimeException: java.io.IOException:
обнаруживается причина
➥ Проблема при вызове внешнего сервиса
ошибки

Такая трассировка стека указывает, что сбои были обработаны неправильно,
потому что она включает много промежуточных шагов для обработки ситуации
в библиотеке параллельного выполнения. В зависимости от языка или библиотеки такое исключение либо не распространяется, либо уничтожает поток, приводя
к утечке ресурсов. Как обработать ошибки и совместить их с API обещаний?
Для решения этой проблемы можно создать новый экземпляр CompletableFuture,
возвращающий результат или исключение. Здесь критичен второй случай.
В следующем листинге обещание заполняется исключением, но оно не выдается.
Листинг 3.26. Исполнение обещания с результатом или исключением
CompletableFuture result = new CompletableFuture();
CompletableFuture.runAsync(
Внешний вызов
() -> {
завершился успешно
try {
и исполнил обещание
result.complete(externalCall());
} catch (IOException e) {
result.completeExceptionally(e);
Если происходит исключение,
}
то оно упаковывается
});
Возврат результата, который
в обещание
return result;
будет исполнен значением
или исключением

Новое обещание
CompletableFuture,
которое еще не
исполнено

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

3.6. Функциональный подход к обработке ошибок с Try

99

ПРИМЕЧАНИЕ
Метод, представленный в этом разделе, типичен для многих асинхронных API; он будет полезен и в выбранном языке.

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

3.6. ФУНКЦИОНАЛЬНЫЙ ПОДХОД К ОБРАБОТКЕ
ОШИБОК С TRY
До сих пор мы рассматривали объектно-ориентированный подход к обработке
ошибок. Теперь рассмотрим функциональный подход к управлению ошибками.
Сосредоточимся на одном из главных аспектов функционального программирования: коде, свободном от побочных эффектов.
Когда метод выдает исключение, это значит, что у него есть побочные эффекты.
Если у вас есть простой метод, который возвращает значение и выдает исключение, можно сделать вывод, что исключение выдается в объявлении метода. Этот
паттерн использовался в объектно-ориентированном программировании, но
следует помнить, что исключение — это побочный эффект. Вызывающая сторона
должна обработать фактически возвращенное значение, но она также должна
защититься от исключений. Когда исключение явно упоминается в контракте
метода, функциональный код знает, чего следует ожидать, и может защититься
от этого побочного эффекта, упаковав его в Try (вскоре мы рассмотрим этот
вопрос подробнее).
С другой стороны, когда выдаваемое исключение является непроверяемым и не
объявляется в контракте метода, вызывающая сторона может не обработать его
и побочный эффект будет распространяться вверх по стеку вызовов. Отсутствие
обработки может быть обусловлено тем, что вызывающая сторона не ожидает
исключения и, как следствие, не защищается от него. В функциональном программировании такое поведение создает проблемы.
Главный принцип функционального программирования заключается в моделировании всех возможных результатов вызова функции в типе. Если вызываемая
функция может создать сбой, этот результат должен моделироваться возвращаемым типом функции и объявленным исключением. Выдача исключения
описывается явно при использовании проверяемого исключения, но может быть
и неявной, если метод выдает непроверяемое исключение. Такое непоследовательное поведение в функциональном программировании недопустимо. Тип,

100

Глава 3. Исключения и другие паттерны обработки ошибок в коде

возвращаемый функцией, должен моделировать все ее возможные результаты.
Именно по этой причине монада Try (также называемая монадой Error) критична
при моделировании обработки ошибок в функциональном программировании
(см. http://mng.bz/la42 и http://mng.bz/BxV1).
Рассмотрим простую конструкцию. Try может передавать одно из двух возможных состояний: успех или сбой. Тип может находиться в одном или другом
состоянии, но никогда в обоих сразу. Возможные состояния показаны на рис. 3.4.
Try

Исключение

Значение

Сбой

Успех

Рис. 3.4. Монада Try

В предыдущем разделе был показан тип обещания, использующий API
CompletableFuture. Этот тип похож на него, потому что он передает результат
асинхронного вычисления или возвращает признак сбоя, показывая, что происходит во время обработки API. У него есть одно существенное ограничение — он
должен использоваться только в контексте асинхронного программирования.
Однако монада Try может инкапсулировать состояние обработки как в синхронном, так и в асинхронном контексте. Тип Try более универсален и гибок,
благодаря чему он служит основной абстракцией для представления успеха или
сбоя в функциональном программировании. Посмотрим, как функциональный
подход к обработке ошибок будет выглядеть на языке программирования Java.
Воспользуемся библиотекой Vavr (https://www.vavr.io/), которая предоставляет
доступ к типу Try.
ПРИМЕЧАНИЕ
Если ваш метод возвращает тип Try, вызывающая сторона этого метода всегда должна
быть готова к тому, что в методе может произойти сбой. Дальнейшая обработка может
объединяться в цепочки с методами функционального программирования — filter
или подобными.

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

3.6. Функциональный подход к обработке ошибок с Try

101

вызов выполняется без сбоя, как показано в следующем листинге. В реальной
системе вызовы компонентов или внешних систем, в которых может произойти
сбой, будут упаковываться.
Листинг 3.27. Монада Try для успешного выполнения
// Дано
String defaultResult = "default";
Supplier clientAction = () -> 100;
// Если
Try response = Try.ofSupplier(clientAction);
String result = response.map(Object::toString).getOrElse(defaultResult);
// То
assertTrue(response.isSuccess());
response.onSuccess(r -> assertThat(r).isEqualTo(100));
assertThat(result).isEqualTo("100");

Следует отметить, что точка интеграции с действием, в котором может произойти сбой (клиентское действие), упаковывается в тип Try . Абстракция
Try должна возвращаться методами, в которых возможен сбой. Вызывающая
сторона может присоединить последующую обработку к типу Try вместо того,
чтобы беспокоиться об исключениях. Монада Try инкапсулирует исключение,
если оно произойдет.
Если вы хотите извлечь фактическое значение String из Try, для его получения
из монады можно воспользоваться методом getOrElse(). Однако если монада Try
содержит исключение, она не имеет возвращаемого значения. В такой ситуации
необходимо предоставить результат по умолчанию. Он будет возвращаться, если
в упакованном в Try действии происходит сбой. Эта задача решается вызовом
метода getOrElse().
Если нужно создать процесс в зависимости от того, было ли действие успешно
выполнено или нет, для проверки можно воспользоваться методом isSuccess()
(потому что абстракция Try является конструкцией функционального программирования). Функциональную обработку можно объединять в цепочки
с использованием таких методов, как map. Если Try сообщает об успехе, вызывается map. В противном случае map не вызывается. При успехе обратный вызов
выполняется, только если он содержит значение, а не ошибку.
Одно из главных преимуществ использования монады Try заключается в том,
что исключения в стандартных блоках try-catch обрабатывать не приходится.
Они все еще перехватываются, но только средствами API Try из функционального программирования. При этом код обработки исключений не загромождает
бизнес-логику. Посмотрите, как работает функциональная обработка ошибок,
если упакованное действие выдаст исключение.

102

Глава 3. Исключения и другие паттерны обработки ошибок в коде

Когда в клиентском действии возникает сбой, та же самая абстракция Try взаимодействует с системой типов. Обратите внимание, что на этот раз clientAction выдает исключение. Таким образом, мы имитируем вызов отказавшего компонента.
Это можно использовать, чтобы проверить, как сейчас выглядит абстракция Try.
В листинге 3.28 приведен тот же тип Try c упаковкой действия. На этой стадии
никаких различий в обработке нет. Сторона, работающая с API, должна взаимодействовать с компонентом, в котором может произойти сбой, только через тип
Try, если хочет создать логику, не загроможденную многочисленными блоками
try-catch.
Листинг 3.28. Монада Try для сбоя
Supplier clientAction =
() -> {
throw new RuntimeException("problem");
};
// Когда
Try response = Try.ofSupplier(clientAction);
String result = response.map(Object::toString).getOrElse(defaultResult);
Option optionalResponse = response.toOption();
// Тогда
assertTrue(optionalResponse.isEmpty());
assertTrue(response.isFailure());
assertThat(result).isEqualTo(defaultResult);
response.onSuccess(r -> System.out.println(r));
response.onFailure(ex -> assertTrue(ex instanceof RuntimeException));

Обратите внимание: функциональная обработка, которую мы хотим выполнить,
объединяется в цепочки так же, как прежде. Наша логика использует метод
map() для выполнения некоторого действия в том случае, если действие клиента завершилось успехом. Однако на этот раз map() не вызывается, потому что
действие клиента выдало исключение. В нашем случае Try содержит признак
сбоя. Следовательно, при вызове getOrElse() будет возвращено значение по
умолчанию. Метод не может вернуть обработанное значение, потому что его нет.
Try можно преобразовать в Option (конструкция библиотеки Vavr, сходная
с Optional в Java) — еще один тип функционального программирования. Он
сигнализирует о присутствии или отсутствии значения. В целом Option напоминает Try, но не содержит причины, по которой значение может оказаться
пустым. Некоторые функциональные API работают с типом Option. Преобразо-

вание позволяет легко организовать интеграцию между этими API. Вызывающая
сторона Try может проверить наличие сбоя методом isFailure() по аналогии
с предыдущим примером, в котором выполнялась проверка isSuccess(). Здесь
можно использовать обе проверки. Наконец, два функциональных процесса
объединяются в цепочку.

3.6. Функциональный подход к обработке ошибок с Try

103

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

3.6.1. Использование Try в рабочем коде
Рассмотрим пример использования Try, приближенный к действительности.
Допустим, необходимо выполнить запрос HTTP к внешнему сервису. Этот
сервис возвращает данные в формате JSON. Нужно извлечь из JSON только
идентификатор. Для этого необходимо выполнить несколько операций, в ходе
которых могут произойти сбои с выдачей исключений.
Первое действие — внешний вызов HTTP. После него необходимо извлечь
строковый контент из тела сущности HTTP. Извлечение может завершиться
с ошибкой, потому что в нем задействованы операции ввода/вывода. Наконец,
необходимо отобразить строковый контент на класс сущности Java. Эта операция
тоже может завершиться неудачей, потому что контент строки десериализуется
в JSON. Имея сущность, можно извлечь идентификатор.
Такую обработку легко объединить в цепочку с использованием API Try. Сначала
нужно упаковать клиентский вызов в монаду Try, инкапсулирующую результат
обработки. Затем каждую стадию обработки можно выразить с использованием
API Try. Если желаемое действие выдает непроверяемое исключение, это действие следует выполнить в методе mapTry(). При выдаче исключения тип Try
исполняется, а вся ветвь обработки помечается как сбойная. Результат показан
в следующем листинге.
Листинг 3.29. Вызов сервиса HTTP с использованием Try
private static final Logger logger =
LoggerFactory.getLogger(HttpCallTry.class);
Внешний вызов
public String getId() {
упаковывается
CloseableHttpClient client = HttpClients.createDefault();
в монаду Try
HttpGet httpGet = new HttpGet("http:/ /external-service/resource");
Try response = Try.of(() -> client.execute(httpGet));
return response
Используем mapTry(),
На последней
.mapTry(this::extractStringBody)
потому что extractStringBody()
стадии обработки
.mapTry(this::toEntity)
выдает исключение
извлекается
.map(this::extractUserId)
идентификатор
.onFailure(ex -> logger.error("The getId() failed.", ex))
Регистрирует
.getOrElse("DEFAULT_ID");
исключение при
}
возникновении
private String extractUserId(EntityObject entityObject) {
проблем
return entityObject.id;
Возвращает значение по умолчанию, если
}
на любой стадии обработки произошел сбой

104

Глава 3. Исключения и другие паттерны обработки ошибок в коде

private String extractStringBody(HttpResponse r) throws IOException {
return new BufferedReader(
new InputStreamReader(r.getEntity().getContent(),
StandardCharsets.UTF_8))
.lines()
.collect(Collectors.joining("\n"));
}
private EntityObject toEntity(String content) throws JsonProcessingException {
return OBJECT_MAPPER.readValue(content, EntityObject.class);
}
static class EntityObject {
String id;
public EntityObject(String id) {
this.id = id;
}
}

Только просмотр определения обработки позволяет сделать вывод, на каких
стадиях есть вероятность сбоя, а на каких нет. Вызовы extractStringBody()
и toEntity() могут завершиться сбоем. Взглянув на объявление метода
extractStringBody(), вы заметите, что он объявляет исключение IOException,
которое должно быть обработано вызывающей стороной. Аналогичным образом
метод toEntity() может выдать исключение JsonProcessingException. Когда все
действия, которые могут завершиться сбоем, совершены, извлекается идентификатор пользователя. Наконец, метод getId() должен вернуть тип String. В этой
ситуации вызывающая сторона метода ничего не знает о монаде Try, которая
используется во внутренней обработке.
Когда потребуется извлечь строку из монады Try, возможны два варианта.
Здесь используется метод getOrElse(). Если обработка завершается успехом,
она просто возвращает правильный идентификатор пользователя. Но если на
одной из стадий обработки произойдет сбой, можно предоставить вызывающей стороне разумное значение по умолчанию. Исключение (если оно происходит) регистрируется в журнале методом onFailure(). Если предоставить
разумное значение по умолчанию нельзя, можно попробовать вернуть тип
Try из getId(), чтобы вызывающая сторона сама обработала его; вероятно,
это лучшее решение.
Наконец, функциональную обработку, основанную на Try, можно преобразовать
в стандартный паттерн выдачи исключений getOrElseThrow(). Если монада Try
содержит исключение, оно выдается на сторону вызова. У последнего решения
есть несколько недостатков, которые будут рассмотрены в следующем разделе.
Тем не менее перед этим сравним подход с Try со стандартной реализацией Java
на базе исключений, как показано в следующем листинге.

3.6. Функциональный подход к обработке ошибок с Try

105

Листинг 3.30. Вызов сервиса HTTP с использованием API Exception
public String getIdExceptions() {
CloseableHttpClient client = HttpClients.createDefault();
HttpGet httpGet = new HttpGet("http:/
/external-service/resource");
try {
CloseableHttpResponse response = client.execute(httpGet);
String body = extractStringBody(response);
EntityObject entityObject = toEntity(body);
return extractUserId(entityObject);
} catch (IOException ex) {
logger.error("The getId() failed", ex);
return "DEFAULT_ID";
}
}

Используемая логика похожа на решение с Try. Единственное отличие в том, что
приходится создавать промежуточные переменные, используемые на следующем
шаге обработки. Решение с Try более функционально, и в нем можно передавать
ссылки на функции (лямбда-выражения).
Главное отличие стандартного подхода try-catch от функционального подхода — возвращаемый тип метода. С функциональным подходом можно вернуть
Try и предоставить вызывающей стороне решать, что делать со сбоем.
Возможность сбоя передается компилируемым типом (Try) и должна быть обработана; в противном случае код не будет компилироваться. Логика на базе
исключений менее явно выражена и не позволяет вернуть один тип, инкапсулирующий успех или сбой операции. Вызывающая сторона должна обработать
результат String и защититься от возможного исключения. Существуют разные
принципы обработки исключительных ситуаций. Рассмотрим наиболее частые
ловушки, с которыми сталкиваются программисты при внедрении абстракции
Try с API, использующими Exception.

3.6.2. Объединение Try с кодом, выдающим исключение
Главное, на что следует обратить внимание, — вызывающая сторона должна
взаимодействовать с компонентом, в котором могут произойти сбои, через Try.
Использование абстракции Try позволяет смоделировать каждый возможный
исход (успех или сбой) в системе типов. К сожалению, при введении функцио­
нального программирования для обработки ошибок в языках, использующих
исключения в качестве основного механизма уведомления о сбоях, возникают
проблемы. При выборе механизма обработки исключений — функционального
программирования с Try или объектно-ориентированного с исключениями —
следует придерживаться одного варианта и последовательно применять его
в кодовой базе. Объединение двух решений затрудняет анализ кода. Необходимо
обрабатывать оба состояния Try (успех или сбой), но также придется использовать паттерны try-catch для перехвата исключений.

106

Глава 3. Исключения и другие паттерны обработки ошибок в коде

Как вы помните, непроверяемые исключения могут выдаваться любым методом. Объявлять их в сигнатурах методов не нужно. Из-за этого упаковка в Try
каждого возможного метода, который может завершиться сбоем, становится
проблематичной. Представьте, что у вас имеется логика, которая взаимодействует с другими компонентами, и любой из них может выдать непроверяемое
исключение. В этом сценарии каждый вызов к каждому компоненту должен быть
упакован в тип Try. Такой код плохо читается и получается слишком длинным.
Когда мы вызываем функциональный код из нефункционального, мы должны
преобразовывать его в паттерн try-catch. При вызове нефункционального кода
из функционального необходимо перехватывать все возможные исключения
и инкапсулировать их в монады Try для устранения побочных эффектов.
В разделе, посвященном проектированию открытых API, я упоминал о том, что
часто бывает полезно объявлять все исключения (проверяемые и непроверяемые)
в сигнатурах методов. Если вы взаимодействуете с подобным компонентом, проще упаковать такой API в функциональную конструкцию Try. Все объявляется
явно, и, если выбрать функциональный подход к обработке ошибок, вам будет
проще упаковывать только те методы, которые выдают исключения. С другой
стороны, предположим, что подход функционального программирования интегрируется с API, выдающим непроверяемые исключения, не объявленные в сигнатуре метода. В итоге почти каждый вызов придется упаковывать в монаду Try,
из-за чего синтаксис кода станет слишком перегруженным и плохо читаемым.
Можно заключить, что функциональный подход к обработке ошибок лучше
всего работает при использовании системы с явной типизацией. Если такой
подход соответствует вашему стилю программирования, применение Try принесет пользу. К сожалению, создать унифицированную систему обработки исключений непросто, если вызываемые API злоупотребляют непроверяемыми
исключениями. В следующем разделе сравнивается производительность разных
стратегий обработки исключений.

3.7. СРАВНЕНИЕ ПРОИЗВОДИТЕЛЬНОСТИ
КОДА ОБРАБОТКИ ИСКЛЮЧЕНИЙ
Наконец, сравним стратегии обработки исключений с точки зрения производительности. Воспользуемся системой для создания микробенчмарков JMH
(Java Microbenchmark Harness), которая позволяет проводить детализированный
бенчмарк кода обработки исключений. Протестируем несколько возможных
стратегий.
Первая стратегия — стандартный подход try-catch. Сравним ее с решением
с монадой Try, в которую будет упакована причина исключения. Наконец, мы

3.7. Сравнение производительности кода обработки исключений

107

увидим, как потребление трассировки стека отражается на производительности. Для потребления исключения будет использоваться стандартный вывод
и регистрация Throwable в журнале.
Для начала потребуется эталонный метод, в котором не задействована обработка
ошибок. По нему можно будет оценить, как исключения влияют на производительность. Каждая операция будет выполняться 50 000 раз для получения
воспроизводимых результатов (однократное тестирование будет неинформативно). Для эмуляции этого поведения будет использован цикл for. Также
вместо ручной реализации цикла for можно воспользоваться параметром JMH.
Оба решения достаточно хорошо подходят для нашего сценария. В следующем
листинге представлен эталонный метод оценки.
Листинг 3.31. Эталонный метод оценки исключений
private static final int NUMBER_OF_ITERATIONS = 50_000;
@Benchmark
public void baseline(Blackhole blackhole) {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
blackhole.consume(new Object());
}
}

Конструкция JMH Blackhole (см. http://mng.bz/doVo) моделирует реальное применение проверочного кода. Если ее не использовать, появляется риск того, что
JIT-компилятор оптимизирует или вообще удалит код. Проверочный код делает
не так много — он только создает объект и позволяет Blackhole потребить его.
Создадим первый проверочный код, как показано в следующем листинге. Выдадим исключение и перехватим его в блоке catch.
Листинг 3.32. Тестирование выдачи исключения
@Benchmark
public void throwCatch(Blackhole blackhole) {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
try {
throw new Exception();
} catch (Exception e) {
blackhole.consume(e);
}
}
}

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

108

Глава 3. Исключения и другие паттерны обработки ошибок в коде

Листинг 3.33. Тестирование потребления трассировки стека
@Benchmark
public void getStackTrace(Blackhole blackhole) {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
try {
throw new Exception();
} catch (Exception e) {
blackhole.consume(e.getStackTrace());
}
}
}

Получает все трассировки стека,
связанные с исключением

@Benchmark
public void logError() {
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
try {
throw new Exception();
} catch (Exception e) {
logger.error("Error", e);
Передает исключение диспетчеру
}
регистрации в журнале
}
}

Важно отметить, что при использовании диспетчера регистрации в журнале метод
error получит трассировку стека, а также присоединит данные к журналу. Чтобы
завершить набор, добавим бенчмарк-тест, который использует функциональный
способ обработки сбоев, как показано в следующем листинге. Исключение будет
упаковано в монаду Try, и объект Try должен потребляться программой.
Листинг 3.34. Тестирование монады Try
@Benchmark
Упаковывает Exception в Try без
public void tryMonad(Blackhole blackhole) {
обращения к трассировке стека
for (int i = 0; i < NUMBER_OF_ITERATIONS; i++) {
blackhole.consume(Try.of(() -> { throw new Exception();}));
}
}

Рассмотрим результаты тестов производительности. Конкретные показатели
могут различаться в зависимости от компьютера, но общая тенденция останется
той же. На рис. 3.5 показаны результаты тестирования на моем компьютере.
Эталонная версия в среднем занимает менее одной миллисекунды (мс). Она
соответствует коду, не включающему обработку исключений. Далее средняя
операция throwCatch занимает менее 100 мс, и упаковка исключения в монаду
Try дает практически идентичный результат. Это означает, что при выборе подхода (функционального или объектно-ориентированного) к обработке ошибок
фактор производительности можно не учитывать. Ситуация становится более
интересной, если проанализировать трассировку стека.

3.7. Сравнение производительности кода обработки исключений

109

average ms/op

В среднем мс на операцию

бенчмарк-тест

Рис. 3.5. Результаты тестирования исключений
(результаты могут различаться в зависимости от компьютера)

Если ограничиться получением трассировки стека (это означает, что создается
и потребляется массив со всеми трассировками стека), код обработки исключения занимает около 750 мс наоперацию. Получение трассировки стека
выполняется почти в 10 раз медленнее, чем выдача и перехват исключения без
анализа трассировки стека. Самой затратной процедурой является регистрация
исключения в журнале. Она включает трассировку стека и построение строки сообщения на основе ее данных. Кроме того, она может включать логику
присоединения, которая может задействовать операцию ввода/вывода для сохранения данных в файле на диске. Быстродействие регистрации исключения
приблизительно в 30 раз ниже решения с throw-catch или с функциональной
конструкцией Try. Также оно в три раза медленнее получения трассировки
стека. Это выглядит разумно, поскольку требует значительных дополнительных усилий.
В завершение раздела, посвященного производительности, мы видим, что на
практике работают и функциональный, и объектно-ориентированный подход к обработке ошибок — при условии, что анализировать трассировку стека
не нужно. И даже если это необходимо, в большинстве случаев это нетрудно.
Проблемы с производительностью могут появиться, если код злоупотребляет
исключениями и выдает их практически в каждой программной ветви.
Конечно, в некоторых ситуациях потребуется распаковать трассировку стека
исключения и зарегистрировать его для целей отладки. Показатели производительности в этом отношении информативны. Если перехватить исключение
и выдать его заново, регистрация исключения на этом промежуточном шаге приведет к значительному ухудшению производительности. Если вы хотите заново

110

Глава 3. Исключения и другие паттерны обработки ошибок в коде

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

ИТОГИ
Иерархия исключений и ошибок существует во многих объектно-ориентированных языках. Для целей диагностики очень важно уметь в ней разбираться.
При проектировании API обработки ошибок можно выбрать между проверяемыми и непроверяемыми исключениями. Проверяемые исключения
являются явной частью таких API, а непроверяемые относятся к неявной
части кода обработки ошибок; первые должны обрабатываться, для вторых
это не обязательно.
При проектировании логики обработки исключений для общедоступных
API следует проанализировать достоинства и недостатки проверяемых
и непроверяемых исключений и сравнить их с обработкой исключений
в собственном коде.
С API обработки исключений необходимо правильно реагировать на возникающие проблемы. Часто появляется соблазн тщательно проанализировать
код и сделать вывод, что исключение не может быть выдано ни при каких
обстоятельствах. Понимание распространенных антипаттернов логики обработки исключений поможет определиться в этом вопросе.
При взаимодействии со сторонними библиотеками следует разработать
стратегию обработки исключений. При интеграции со сторонними библио-

Итоги

111

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

4

Баланс между
гибкостью и сложностью
https://t.me/it_boooks
В этой главе:
33 Гибкость и расширяемость против затрат на обслуживание и сложность API.
33 Обеспечение максимальной расширяемости с API перехватчиков
и прослушивателей.
33 Контроль над сложностью и защита от непрогнозируемого использования.

При проектировании систем и API нужен баланс между набором поддерживаемых возможностей и затратами на их обслуживание, которые проистекают из их сложности. В идеале каждое изменение API, такое как добавление
новой функции, обосновано эмпирическими исследованиями. Например,
можно проанализировать трафик на веб-сайте и, если нужно, добавить
новый инструмент или воспользоваться A/B-тестированием (http://mng.bz/
ragJ), чтобы решить, какие возможности оставить, а от каких отказаться. В зависимости от результатов A/B-тестирования можно избавиться от лишней
функциональности.
Однако стоит заметить, что иногда удалить функцию из общедоступного API
сложно или недопустимо. Например, если нужно сохранить обратную совместимость, исключение той или иной возможности может стать критическим

4.1. Мощный, но не расширяемый API

113

изменением, зачастую неприемлемым. Можно попытаться принудительно перевести клиенты на новый API без удаленных элементов, но это непросто. Тема
совместимости более подробно рассматривается в главе 12.
При проектировании общедоступного API обычно лучше начинать с малого.
Например, добавить ограниченный функционал, расширяя его на основании
информации от конечных пользователей, а не реализовывать множество функций
сразу без возможности их удаления в дальнейшем.
С другой стороны, при построении библиотек, которые будут использоваться
другими специалистами и командами в организации, необходимо предусмотреть потребность в определенном функционале. Если создать библиотеку
с минимальным набором возможностей и нерасширяемой архитектурой, может возникнуть ситуация, когда нам придется часто проводить рефакторинг
и изменять API. С другой стороны, в попытке спрогнозировать все варианты
использования кода можно создать слишком обширную кодовую базу, которая будет допускать расширение в любой точке, но сложность кода при этом
резко возрастет. Эта глава поможет отыскать баланс между гибкостью и расширяемостью кодовой базы и сопутствующей сложностью и затратностью
обслуживания.

4.1. МОЩНЫЙ, НО НЕ РАСШИРЯЕМЫЙ API
Предположим, ваша команда получила новое задание — создать программный
компонент с общим доступом для других команд и клиентов. Это означает, что
когда он будет написан, его будут использовать другие люди. Существует набор
требований, которым должен удовлетворять код.

4.1.1. Проектирование нового компонента
В данном сценарии главная задача нового компонента — выполнение клиентами
POST-запроса HTTP для заданного URL-адреса. Помимо этого, в код необходимо добавить метрики. Если запрос завершается успехом, увеличивается метрика
requests.success. В случае сбоя должна расти метрика requests.failure.
Третья функциональность, которую должен предоставлять код, — возможность
выполнить действие повторно. Вызывающая сторона задает максимальное количество повторных попыток. Если все они исчерпаны, обработка завершается
неудачей. С другой стороны, если операция завершается успешно со второй попытки, эта попытка должна быть прозрачной для клиента. Необходимо, чтобы
в коде увеличивалась метрика requests.retry, указывающая, что для выполнения
запроса понадобились повторные попытки. Обратите внимание: сбой передается

114

Глава 4. Баланс между гибкостью и сложностью

на сторону клиента только после всех этих попыток. Можно построить диаграмму
с набором функций, которые должен поддерживать API, как показано на рис. 4.1.
При проектировании такого компонента нужно отвечать на вопросы о сторонних библиотеках, которыми вы пользуетесь. Но что еще важнее, необходимо
предвидеть сценарии использования, чтобы предусмотреть потенциальные
точки расширения без чрезмерного усложнения нового компонента. Иногда
разработчики склонны начинать реализацию, применяя паттерны, позволяющие расширить код в будущем. Тем самым они рискуют ввести много уровней
абстракции, наращивающих сложность системы.
В этой главе используется другой подход. Начнем мы с самой прямолинейной
структуры без точек расширения. Затем перейдем к более гибкому с точки зрения
клиента коду. При этом вы увидите, что гибкость также повышает сложность кода.
Вернуть признак успеха
если успешно
Вызывает

Вызывающая сторона

Увеличить
метрику успехов

Execute request

если повторные
попытки исчерпаны если произошел сбой
вернуть признак сбоя

Увеличить
метрику сбоев
если повторные
попытки не исчерпаны

Повторная попытка

Увеличить метрику
повторных попыток

Рис. 4.1. Набор поддерживаемых функций совместно используемого программного
компонента

4.1.2. Начиная с простого
Наше путешествие в область рефакторинга начнется с самой прямолинейной реализации. Сначала мы поймем, как она работает, а затем обсудим ее ограничения.
Затем мы попробуем спрогнозировать отсутствующие сценарии использования
и точки расширения, которые можно предоставить.
Назовем свой новый компонент HttpClientExecution. Конструктор этого компонента получает в аргументе MetricRegistry — класс сторонней библиотеки,
используемый для работы с метриками (https://metrics.dropwizard.io/4.2.0). В следующем листинге представлена первая версия компонента.

4.1. Мощный, но не расширяемый API

115

Листинг 4.1. Параметры HttpClientExecution
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;
private
private
private
private
private

final
final
final
final
final

int maxNumberOfRetries;
CloseableHttpClient client;
Meter successMeter;
Meter failureMeter;
Meter retryCounter;

Устанавливает верхнюю
границу для повторных попыток
Создает метрики
с использованием
MetricRegistry

public HttpClientExecution(
MetricRegistry metricRegistry, int maxNumberOfRetries,
CloseableHttpClient client) {
this.successMeter = metricRegistry.meter("requests.success");
this.failureMeter = metricRegistry.meter("requests.failure");
this.retryCounter = metricRegistry.meter("requests.retry");
this.maxNumberOfRetries = maxNumberOfRetries;
this.client = client;
Клиент предоставляется вызывающей стороной,
}
которая отвечает за его настройку

В коде используется сторонняя библиотека, предоставляющая класс
MetricRegistry (http://mng.bz/Vlzy). Он нужен для построения и публикации
метрик из кода. Мы рассматриваем его по принципу «черного ящика» и используем его открытый API. Однако использование этого класса в компоненте
привязывает HttpClientExecution к конкретной библиотеке метрик. Существует
пара других библиотек метрик, и если клиент захочет переключиться на одну
из них, код не позволит ему это сделать. Мы вернемся к этой проблеме позже.
А пока сосредоточимся на алгоритмической реализации выполнения с повторными попытками. Для обработки POST-запроса метод должен получать только
один параметр — путь в формате String. Следующий листинг показывает, как
реализуются метрики и как происходят повторные попытки.
Листинг 4.2. Выполнение POST с логикой повторных попыток
public void executeWithRetry(String path) {
Продолжать, пока остаются
повторные попытки
for (int i = 0; i {
httpClientExecution.executeWithRetry("url");
})
После всех повторных попыток
.hasCauseInstanceOf(IOException.class);
распространяется исключение
IOException
// То
assertThat(getMetric(metricRegistry, "requests.success")).isEqualTo(0);
assertThat(getMetric(metricRegistry,
Задает три повторные
➥ "requests.failure")).isEqualTo(4);
попытки после первого
assertThat(getMetric(metricRegistry,
запроса
➥ "requests.retry")).isEqualTo(3);
Равно параметру, переданному
HttpClientExecution

ПРИМЕЧАНИЕ
Значение метрики requests.failure будет больше количества повторных попыток
клиента на 1. Это объясняется тем, что первый запрос не учитывается в общем количестве повторных попыток.

Наконец, если первый запрос завершается неудачей, но второй оказывается
успешным, логика повторных попыток должна обеспечить прохождение вызова.
С точки зрения клиента никакой информации о повторных попытках не будет.
Только по метрикам клиент может узнать, были повторные попытки или нет.
В следующем листинге приведен последний модульный тест, в котором первый
запрос завершается неудачей, а второй успешен.
Листинг 4.5. Проверка сбоя с последующим успехом
when(client.execute(any())).thenThrow(new
IOException("problem")).thenReturn(response);

Моделирует сценарий
со сбоем и успехом

118

Глава 4. Баланс между гибкостью и сложностью

HttpClientExecution httpClientExecution = new
HttpClientExecution(metricRegistry, 3, client);
// Когда
httpClientExecution.executeWithRetry("url");
// Первый вызов завершился неудачей, после повторной попытки
// второй вызов был успешным.
Когда компонент завершит обработку,
assertThat(getMetric(metricRegistry,
должен быть один успех
➥ "requests.success")).isEqualTo(1);
assertThat(getMetric(metricRegistry,
Также должен быть один сбой:
➥ "requests.failure")).isEqualTo(1);
первая попытка
assertThat(getMetric(metricRegistry,
➥ "requests.retry")).isEqualTo(1)
Делается одна повторная
попытка, чтобы вторая была
успешной

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

4.2. ВОЗМОЖНОСТЬ ПРЕДОСТАВЛЕНИЯ
СОБСТВЕННОЙ БИБЛИОТЕКИ МЕТРИК
Пока что наш компонент не очень гибкий. В нем существует жесткая зависимость от сторонней библиотеки, отвечающей за сбор метрик. На первый
взгляд это не создает проблем, но другие разработчики и системы будут
пользоваться нашим кодом. Применяя любой класс из сторонней библиотеки
в кодовой базе, мы ограничиваем будущие реализации своего компонента.
Более того, тем самым мы устанавливаем ограничение, требующее, чтобы
в каждой точке, в которой задействуется код, использовалась одна и та же
библиотека метрик.
Взглянув на директивы импортирования в компоненте HttpClientExecution,
можно заметить зависимости от сторонних библиотек. Эти зависимости представлены в следующем листинге.

4.2. Возможность предоставления собственной библиотеки метрик

119

Листинг 4.6. Зависимости от сторонних библиотек
import com.codahale.metrics.Meter;
import com.codahale.metrics.MetricRegistry;

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

Наш
компонент

Использует

Первая реализация
метрик

Интерфейс
метрик

Абстрагирует

Вторая реализация
метрик

Рис. 4.2. Абстрагирование сторонней библиотеки метрик с использованием
интерфейса

Новый интерфейс метрик должен определить контракт между компонентом
и сторонними библиотеками. Он может быть очень простым (см. следующий
листинг).
Листинг 4.7. Определение интерфейса метрик
public
void
void
void
}

interface MetricsProvider {
incrementSuccess();
incrementFailure();
incrementRetry();

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

При этом нам не нужно беспокоиться о подробностях реализации — клиент

120

Глава 4. Баланс между гибкостью и сложностью

предоставляет их. Допустим, клиент хочет обеспечить реализацию для библиотеки Dropwizard. Что еще важнее, он должен внедрить интерфейс MetricsProvider.
Реализация приведена в следующем листинге.
Листинг 4.8. Реализация провайдера метрик
public class DefaultMetricsProvider implements MetricsProvider {
private final Meter successMeter;
private final Meter failureMeter;
Эта реализация предоставляет
private final Meter retryCounter;
внутренние подробности
public DefaultMetricsProvider(MetricRegistry metricRegistry) {
this.successMeter =
➥ metricRegistry.meter("requests.success");
this.failureMeter = metricRegistry.meter("requests.failure");
this.retryCounter = metricRegistry.meter("requests.retry");
}
@Override
public void incrementSuccess() {
successMeter.mark();
}

Методы интерфейса становятся
единственными точками интеграции
для компонента

@Override
public void incrementFailure() {
failureMeter.mark();
}
@Override
public void incrementRetry() {
retryCounter.mark();
}
}

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

4.3. Обеспечение расширяемости API с использованием перехватчиков

121

С точки зрения клиента необходимость совершать множество дополнительных
шагов при использовании компонента — например, предоставление собственной
реализации метрик — может быть трудозатратной, и клиент в итоге выберет другую систему или компонент. Хорошим промежуточным решением может стать
выделение интерфейса метрик с предоставлением реализации по умолчанию,
которая подходит для большинства пользователей. Тем самым обеспечивается
расширяемость, но сложность перемещается в систему. Она не выносится в клиентский код. Если клиент захочет предоставить другую реализацию, он легко
реализует нужную функциональность и предоставит ее нашему компоненту.
В реальной жизни системы зависят от множества внешних компонентов, и иногда
создать абстракцию невозможно. Даже в нашем простом примере существует
зависимость от фактической реализации клиента HTTP. Клиент HTTP предоставляет другие методы, которые может быть трудно скрыть от абстракции.
Разработка абстракции, скрывающей клиент HTTP, с попыткой предвидеть все
возможные сценарии использования такой библиотеки повысит сложность кода.
В следующем разделе рассмотрен один из самых гибких и расширяемых механизмов для предоставления точек расширения в случае, если известно, что
сложность архитектуры значительно возрастет. Мы рассмотрим механизм API
перехватчиков, позволяющий клиентам предоставлять свое поведение в различных точках жизненного цикла компонента. В примерах задействован другой
сценарий использования (который не имеет отношения к метрикам).

4.3. ОБЕСПЕЧЕНИЕ РАСШИРЯЕМОСТИ API
С ИСПОЛЬЗОВАНИЕМ ПЕРЕХВАТЧИКОВ
У каждого фреймворка и системы существует жизненный цикл, состоящий из
нескольких этапов. Вместо того чтобы пытаться предвидеть все возможные сценарии использования для клиентов, можно разрешить клиентам предоставлять
нужное поведение и внедрять его в компонент. Тогда нам больше не придется
менять свой API и код на основании новых запросов функций. Клиенты могут
предоставлять собственную логику, и для кода это не должно иметь значения.
Теоретически такой подход значительно улучшает расширяемость, и о ней не
придется беспокоиться. На практике код в систему необходимо внедрять осторожно. О нем трудно что-либо предположить, потому что мы ничего не знаем
о поведении, предоставляемом вызываемой стороной.
Каждую фазу жизненного цикла можно сделать расширяемой и гибкой с применением различных паттернов, включая абстрагирование (описанное в предыдущем
разделе) и наследование. Когда требуется обеспечить максимальную гибкость кода
для клиентов, часто используется механизм перехватчиков (hooks). Этот паттерн
позволяет клиентам подключать собственный код между фазами жизненного

122

Глава 4. Баланс между гибкостью и сложностью

цикла конкретного компонента. В API из примера нужно разрешить клиентам
подключать код после подготовки запроса HTTP, но перед отправкой фактического запроса HTTP конечной точке REST. Эта схема изображена на рис. 4.3.

Вызывает executeWithRetry()

Фаза жизненного
цикла 1

API перехватчиков

Создает запрос HTTP

Перехватывает

Фаза жизненного
цикла 2

Исполнение внешнего
REST-вызова

Рис. 4.3. Интеграция API перехватчиков с кодовой базой

Сначала клиент выполняет метод executeWithRetry(). Он запускает жизненный
цикл компонента, который создает метод запроса HTTP. Обычно после завершения этой фазы жизненного цикла метод выполняет внешний REST-вызов,
и цикл завершается. Добавление API перехватчиков разрешает клиентам перехватывать конкретный вызов. Клиенту остается только реализовать интерфейс
перехватчика.
Затем перехватчик вызывается в соответствующем жизненном цикле компонента. Так обеспечивается гибкий механизм, избавляющий от бремени предвидения
конкретных функций, которые могут понадобиться клиентам в будущем.
Первый шаг поддержки API перехватчиков — создание интерфейса, позволяющего коду вызвать перехватчик в конкретной фазе жизненного цикла кода.
Новый интерфейс очень прост: он состоит всего из одного метода, как показывает следующий листинг. Мы вызываем этот метод, передавая HttpRequestBase
в аргументе, который создается в первой фазе цикла.
Листинг 4.9. Реализация интерфейса перехватчиков
public interface HttpRequestHook {
void executeOnRequest(HttpRequestBase httpRequest);
}

Клиент компонента внедряет реализацию перехватчика (клиенты могут внед­
рять сразу несколько). Следовательно, конструктор должен получать список
перехватчиков, как показывает следующий листинг.

4.3. Обеспечение расширяемости API с использованием перехватчиков

123

Листинг 4.10. Использование конструктора перехватчиков
public HttpClientExecution(
MetricRegistry metricRegistry,
int maxNumberOfRetries,
Код клиента внедряет
CloseableHttpClient client,
перехватчики
List httpRequestHooks) {
this.metricRegistry = metricRegistry;
this.successMeter = metricRegistry.meter("requests.success");
this.failureMeter = metricRegistry.meter("requests.failure");
this.retryCounter = metricRegistry.meter("requests.retry");
this.maxNumberOfRetries = maxNumberOfRetries;
Необходимо сохранить
this.client = client;
для использования в будущем
this.httpRequestHooks = httpRequestHooks;
}

Теперь разберемся, как API перехватчиков подключается к существующему
жизненному циклу компонента. Так как код клиента выполняется между фазами
цикла, он перебирает все внедренные перехватчики и передает объект HttpPost
вызывающему коду. Схема показана в следующем листинге.
Листинг 4.11. Выполнение без обработки ошибок
В первой фазе жизненного цикла
private void execute(String path) throws IOException {
создается объект HttpPost
HttpPost httpPost = new HttpPost(path);
for (HttpRequestHook httpRequestHook : httpRequestHooks) {
Выполняет клиентский код между
httpRequestHook.executeOnRequest(httpPost);
фазами жизненного цикла
}
CloseableHttpResponse execute = client.execute(httpPost);
if (execute.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
successMeter.mark();
Вторая фаза жизненного цикла
}
выполняет внешний вызов REST
}

Этот механизм позволяет клиентам внедрять свой код в промежуточных стадиях
обработки. Здесь клиентскому коду передается POST-запрос HTTP, с которым
вызывающая сторона может выполнить любое действие. Таким образом, данное
решение обладает исключительно высокой гибкостью.

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

124

Глава 4. Баланс между гибкостью и сложностью

К сожалению, следует понимать, что на код клиента повлиять невозможно. Интерфейс перехватчиков не объявляет исключений. Но как вы узнали в главе 3,
клиенты все равно могут выдавать непроверяемые исключения из своего кода.
Это означает, что непредвиденное поведение в клиентском коде может привести
к непроверяемому исключению.
ДОКУМЕНТИРОВАНИЕ КОНТРАКТОВ ПЕРЕХВАТЧИКОВ, НЕ ВЫДАЮЩИХ
ИСКЛЮЧЕНИЯ, И ЗАЩИТА ОТ ПЕРЕХВАТЧИКОВ, КОТОРЫЕ МОГУТ ИХ
ВЫДАВАТЬ
В идеальном мире разработчик, предоставляющий клиентам пользовательский
API, должен документировать его контракт. Например, можно объявить, что
все реализации перехватчиков не должны выдавать исключения. Тем не менее
установить это требование для всех клиентов достаточно трудно. Кто-то непременно
забудет (или не прочитает документацию), кто-то зависит от другого кода, который
выдает непроверяемые исключения, хотя и не должен этого делать. А значит, если вы
утверждаете, что исключения выдаваться не должны, разумно защищаться от любой
возможности их появления. В противном случае в приложении, использующем ваш
код, могут возникать трудновыявляемые ошибки (или незаметные сбои).

Чтобы проверить это предположение, напишите модульный тест с перехватчиком, выдающим непроверяемое исключение. Пример теста представлен
в следующем листинге.
Листинг 4.12. Тестирование непредвиденных ошибок в перехватчике
HttpClientExecution httpClientExecution =
new HttpClientExecution(
metricRegistry,
3,
client,
Непредвиденная ошибка выдается в коде,
Collections.singletonList(
который вы не контролируете
httpRequest -> {
throw new RuntimeException("Непредвиденная ошибка!");
}));

Такая ситуация повлияет на жизненный цикл компонента; обеспечивая гибкость
для клиента, мы вводим сложность в код. Чтобы защититься от подобных проблем, необходимо упаковать код, который нам не принадлежит, в блок try-catch,
как показано в следующем листинге.
Листинг 4.13. Защита от сбоев
for (HttpRequestHook httpRequestHook : httpRequestHooks) {
try {
Может произойти что угодно,
httpRequestHook.executeOnRequest(httpPost);
поэтому может быть выдана любая
разновидность исключений
} catch (Exception ex) {
logger.error("HttpRequestHook выдал исключение. Проверьте
логику перехватчика", ex);
Выдача исключения клиентом не должна
}
завершать жизненный цикл компонента
}

4.3. Обеспечение расширяемости API с использованием перехватчиков

125

Ошибку можно зарегистрировать, чтобы предоставить обратную связь клиенту
в том случае, если исключение не было критическим с точки зрения базовой
бизнес-обработки (кода, вызывающего перехватчики). Другими словами, независимо от типа логики, внедряемой вызывающей стороной, эти проблемы не
должны влиять на обработку. Если в коде, предоставленном перехватчиком,
происходит сбой, его можно зарегистрировать для отладки, но выполнение все
равно может продолжиться.
Если разрешить дальнейшее распространение исключения, это повлияет на
логику библиотеки. Делать этого не следует, поскольку код библиотеки работает согласно ожиданиям, но ситуация может еще сильнее усложниться. Если
передать API перехватчиков объект с состоянием — такой, как клиентский
объект HTTP, — вы не сможете повлиять на то, как он будет использоваться.
Код API перехватчиков способен выполнять код, влияющий на внутренние
механизмы клиента. Например, он может быть использован для выполнения
дополнительных запросов HTTP. Это создаст проблемы, если вы оптимизировали клиент HTTP для своего трафика (настроили размер очереди, тайм-ауты
и другие параметры).
Логика, предоставленная компоненту, потребляет ресурсы, необходимые для ее
нормальной работы. Это может привести к нарушению SLA сервиса и даже всего
жизненного цикла. В худшем случае код клиента выполнит логику, которая приведет к сбою клиента HTTP. Тогда в вашем компоненте также произойдет сбой.
ПРИМЕЧАНИЕ
При передаче любого внутреннего состояния коду, который вам не принадлежит, необходимо действовать осторожно. Документирование предположений API перехватчиков может стать хорошим первым шагом, но оно не предотвратит непредвиденное
использование в реальной жизни.

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

4.3.2. Влияние API перехватчиков на производительность
С точки зрения правильности наш код должен обрабатывать непредвиденные
сбои в клиентском коде. Однако следует понимать, что логика, предоставленная клиентом, может быть блокирующей. А это значит, что на время, которое
занимает вызов API перехватчиков, повлиять нельзя.
Предположим, вызывающая сторона предоставляет логику перехватчиков, которая выполняет функцию ввода/вывода — например, сетевую функцию или
функцию файловой системы, подразумевающую блокирование. Каждый вызов
ввода/вывода может быть непредсказуемым и может привести к более высокой

126

Глава 4. Баланс между гибкостью и сложностью

задержке. Допустим, задержка вызова API перехватчиков составляет 1000 мс.
Если первая фаза жизненного цикла занимает 100 мс, а вторая — 200 мс, общее
время одного вызова составит 1300 мс вместо 300. В четыре раза медленнее!
Это значительно влияет на производительность компонента. На рис. 4.4 представлена эта ситуация.

Вызывающая сторона
Наблюдаемая задержка: 1300 мс
Фаза жизненного цикла 1
занимает 100 мс

Блокирование
длится 1000 мс

Фаза жизненного цикла 2
занимает 200 мс

Рис. 4.4. Блокирующий вызов API перехватчиков

Вызывающая сторона заметит высокую задержку, и при текущей структуре
компонента сделать с этим ничего не удастся. Синхронный вызов внутри перехватчика использует тот же поток, что и наш компонент, из-за чего в некоторых
ситуациях даже возникает риск взаимной блокировки.
Представьте сценарий, в котором имеется минимальное количество потоков,
а клиентский код, предоставленный в API перехватчиков, блокируется или
ожидает освобождения некоторого внешнего ресурса. Даже если ресурс будет недоступен хотя бы один раз, поток, совместно используемый нашим компонентом
и клиентским кодом, заблокируется. Если это произойдет несколько раз, может
возникнуть проблема нехватки потоков для обработки запросов.
Можно потребовать, чтобы клиенты выполняли неблокирующие вызовы в коде
обратного вызова. Это означает, что каждому клиенту придется управлять пулом
потоков, которые будут управлять действиями API перехватчиков. И снова,
даже если документировать это требование, невозможно легко обеспечить его
соблюдение. Блокирующий вызов можно обнаружить в потоке, но это существенно усложнит структуру.

4.3. Обеспечение расширяемости API с использованием перехватчиков

127

Ситуация усугубляется, если имеются несколько независимых действий перехватчиков и каждый из них выполняет логику, которая подразумевает блокирующее поведение. Если есть два блокирующих перехватчика и каждый из них
занимает 1000 мс, общее время обработки возрастает до 2300 мс, что в 8 раз
медленнее кода без перехватчиков (рис. 4.5).

Вызывающая сторона
Наблюдаемая задержка: 1300 мс
Фаза жизненного цикла 1
занимает 100 мс
Блокирование
длится 1000 мс
Отправка в пул потоков
(блокирует на 1000 мс)
Блокирование
длится 1000 мс
Фаза жизненного цикла 2
занимает 200 мс

Рис. 4.5. API перехватчиков распараллеливает блокирующие вызовы

Одно из возможных решений — считать, что код API перехватчиков небезопасен
и всегда может быть блокирующим. Вводя это утверждение, можно упаковать
каждый вызов API перехватчиков в действие, выполняемое в отдельном потоке.
С текущей архитектурой каждый API перехватчиков передается отдельному
пулу потоков, причем управление и обслуживание пула происходит в нашем
коде. Нужно определиться с количеством необходимых потоков, размером
очереди и другими факторами (например, разрешать динамическое добавление
потоков или нет). Также мы должны наблюдать за использованием пула и потреблением потоков.
Управление отдельным пулом потоков увеличивает сложность кодовой базы. Пул
содержит очередь задач, которые могут обрабатываться, и нужно контролировать
эту очередь, чтобы избежать риска исчерпания памяти из-за незавершенных
задач. Кроме того, необходимо проследить, чтобы потоки не уничтожались незаметно при возникновении непредвиденных исключений.
Можно отправить n параллельных выполнений перехватчиков, где n — количество потоков в пуле. Допустим, вызывающая сторона предоставила два

128

Глава 4. Баланс между гибкостью и сложностью

перехватчика. Каждый из них выполняет блокирующий вызов, который
продолжается 1000 мс. В идеале эти перехватчики должны выполняться по
отдельности, без ожидания между стадиями. В этом случае они не повлияют
на задержку вызова компонента. Но как вы помните, новый API позволяет
клиентам подключать свой код между фазами жизненного цикла. Из-за этого
возникает отношение типа «происходит до» между завершением всех вызовов
API перехватчиков и переходом к следующей фазе цикла. Даже если вызовы
удастся распараллелить, необходимо дождаться, когда все они завершатся.
Таким образом, добавляемая задержка будет как минимум не меньше времени
выполнения самой медленной операции, предоставляемой через API перехватчиков. Гибкость архитектуры приводит к снижению производительности
из-за увеличения задержки.
Рассмотрим структуру компонента HttpClientExecution, в которой учитываются
улучшения, относящиеся к правильности и производительности. Необходимо
создать специализированный пул потоков для API перехватчиков, что приводит к увеличению сложности и затрат на обслуживание. В следующем листинге
каждый вызов API перехватчиков передается пулу потоков.
Листинг 4.14. Улучшение параллелизма
private final ExecutorService executorService =
Executors.newFixedThreadPool(8);
private void executeWithErrorHandlingAndParallel(String path) throws
Exception {
HttpPost httpPost = new HttpPost(path);
Требует n задач, где n равно количеству
List tasks =
перехватчиков
➥ new ArrayList(httpRequestHooks.size());
for (HttpRequestHook httpRequestHook : httpRequestHooks) {
tasks.add(
Executors.callable(
Конструирует callable для каждого
() -> {
действия перехватчика
try {
httpRequestHook.executeOnRequest(httpPost);
} catch (Exception ex) {
logger.error(
"HttpRequestHook выдал исключение. Проверьте
логику перехватчика", ex);
}
Активизирует все задачи
}));
}
Перебор списка
List responses =
незавершенных задач
➥ executorService.invokeAll(tasks);
for (Future response : responses) {
Ожидание каждого
response.get();
асинхронного действия перед
}
переходом к следующему шагу

}

CloseableHttpResponse execute = client.execute(httpPost);
if (execute.getStatusLine().getStatusCode() == HttpStatus.SC_OK) {
successMeter.mark();
Последняя стадия выполняется после
}
завершения всех перехватчиков

4.4. Обеспечение расширяемости API за счет использования прослушивателей

129

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

4.4. ОБЕСПЕЧЕНИЕ РАСШИРЯЕМОСТИ API ЗА СЧЕТ
ИСПОЛЬЗОВАНИЯ ПРОСЛУШИВАТЕЛЕЙ
На первый взгляд может показаться, что API прослушивателей похож на API
перехватчиков, однако между ними есть различия, которые следует объяснить
отдельно. Возможно, вы помните, что архитектура API перехватчиков синхронна,
поскольку нужно дождаться завершения всех перехватчиков перед переходом на
следующий шаг. Как показано на рис. 4.6, паттерн Наблюдатель (Observer), предоставляющий API прослушивателей, использует другой подход к созданию точек
расширения для клиента. Наш компонент (в паттерне Наблюдатель он называется
субъектом) позволяет клиентам регистрировать наблюдатели. Зарегистрированные
наблюдатели получают уведомления при возникновении события в компоненте.
Наблюдатель
Субъект()

Наблюдатель B

Субъект

Субъект
изменился…

Наблюдатель A

Субъект()

Наблюдатель C

Рис. 4.6. Паттерн проектирования Наблюдатель позволяет
клиентам регистрировать наблюдатели

130

Глава 4. Баланс между гибкостью и сложностью

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

4.4.1. Прослушиватели и перехватчики
При отправке события (например, обозначающего, что компонент завершил фазу
жизненного цикла) уведомление происходит полностью асинхронно. Никаких
отношений типа «происходит до» между событиями и переходом к следующей
стадии компонента не происходит. Это означает, что при условии выполнения
прослушивателей в отдельном пуле потоков риск снижения производительности
отсутствует. Единственное отличие в том, что не нужно ожидать завершения
действий API прослушивателя.
Идея предоставить доступ к внутреннему состоянию или сигнализировать
о возникновении некоторого события с использованием API прослушивателей
выглядит соблазнительно. Это гибкая абстракция, ведь можно разрешить клиентам предоставить собственное поведение без изменения нашего кода или API.
Допустим, вы решили, что можете предвидеть новый сценарий использования,
который требует, чтобы при завершении выполнения компонента отправлялось
уведомление с состоянием повторных попыток (рис. 4.7).

Если произошел
сбой и количество
повторных попыток
{
assertThat(statuses.size()).isEqualTo(1);
});

Клиент сбрасывает или изменяет статус,
что приводит к побочному эффекту!
Должен быть только один
статус повторной попытки

Список статусов повторных попыток, переданный OnRetryListener, представляет
собой ссылку на реальный список. Ничто не помешает клиенту вызвать clear(),
удалить или добавить элемент в этот список. Если первый прослушиватель очистит статусы, то второй не увидит изменений. Это значит, что код вызывающей
стороны создает побочный эффект, вследствие чего API становится недетерминированным, а риск ошибок возрастает. Чтобы избежать этого, можно создать
копию реального списка, которая передается клиенту. Этот прием показан
в следующем листинге.
Листинг 4.19. Копирование объекта, распространяемого на сторону
прослушивателей
retryListeners.forEach(l -> l.onRetry(new ArrayList(retryStatuses)));

Для каждого прослушивателя создается копия статусов повторных попыток,
которая отправляется всем прослушивателям. Даже если вызывающая сторона
изменит объект, это не повлияет на другие прослушиватели кода API. Тем не
менее у такого подхода есть недостатки.
Во-первых, при глубоком копировании исходного объекта придется создать
множество копий данных. В этом случае все значения копируются из исходного
в новый объект, что существенно повышает затраты памяти приложения. Для n
прослушивателей реальные данные придется скопировать n раз, что повысит затраты памяти на соответствующую величину. Во-вторых, в коде прослушивателя
возникает вероятность незаметных сбоев. Клиент может изменить список непреднамеренно, и иногда лучше явно сигнализировать о том, что такие операции
запрещены, выдав исключение.
Обе проблемы можно решить упаковкой статуса в неизменяемую обертку. Для
списка можно воспользоваться конструкцией ImmutableList (http://mng.bz/xvzd),
как показано в следующем листинге.
Листинг 4.20. Оборачивание в неизменяемый объект
retryListeners.forEach(l -> l.onRetry(ImmutableList.copyOf(retryStatuses)));

4.5. Анализ гибкости API и затраты на обслуживание

133

Оборачивание реального состояния в неизменяемую абстракцию не приводит
к созданию копии. Это создает класс, который выдает исключение при любых
попытках изменения используемого списка. Отпадает необходимость в копировании реального содержимого списка каждый раз, когда оно распространяется
на сторону прослушивателя. Запрещены только методы, вносящие изменения.
Второе преимущество такого решения — явное выражение и обеспечение
быстрого сбоя. Если код в API прослушивателя случайно меняет содержимое
списка, то обратная связь предоставляется немедленно, а риск незаметного сбоя
отсутствует.
Если вы распространяете любое состояние в код, который вам не принадлежит,
всегда следует исходить из того, что оно неизменяемо. Можно сделать его таковым изначально за счет соответствующей архитектуры, применив неизменяемые
классы и финальные поля. Если используемый API неизменяем, создавать защитную копию не обязательно. Затраты памяти при этом заметно снижаются.
В реальности часто приходитсяиспользовать библиотеки, не обеспечивающие
неизменяемость. Многие коллекции — списки, множества и т. д. — изменяемы.
Распространять их на сторону клиента следует очень осторожно. В таком случае
состояние упаковывается в неизменяемый класс, который скрывает или запрещает модификацию используемых данных.
Другая проблема заключается в том, что распространение состояния в API прослушивателей создает риск их перегрузки трафиком. Если при каждом действии
будут активизироваться n прослушивателей, потребление памяти приложением
заметно возрастет. Есть риск, что код вызывающей стороны не справится с трафиком и будет заблокирован, что повлияет на основную функцию приложения.
Подумайте о введении обратного давления (back pressure) или буферизации
дополнительных событий статуса и их пакетной отправки. Любой вариант весьма усложнит архитектуру. Если вы решите отправлять уведомления из своего
компонента, будьте внимательны.
Как видите, даже простое распространение состояния с использованием API
прослушивателей усложняет код: необходимо следить, чтобы данные были
неизменяемыми и чтобы код вызывающей стороны справлялся с трафиком.
Попытки спрогнозировать сценарии использования на первый взгляд кажутся
простыми, однако они значительно повышают сложность кода.

4.5. АНАЛИЗ ГИБКОСТИ API И ЗАТРАТЫ
НА ОБСЛУЖИВАНИЕ
Самый главный вывод из примеров этой главы — каждая новая функция
в той или иной степени повышает сложность. Например, иногда требуется

134

Глава 4. Баланс между гибкостью и сложностью

абстрагировать конкретную библиотеку, от которой зависит код. Подобный пример уже встречался при абстрагировании библиотеки метрик. Абстрагирование
реализации снижает сложность в компоненте, но повышает ее в коде клиента.
Каждый клиент должен предоставить реализацию для конкретной библиотеки
метрик. По нашему опыту, для большинства сценариев использования оптимально гибридное решение — абстрагирование с предоставлением реализации
по умолчанию, которая применяется чаще других.
Если попытаться предугадать конкретный сценарий использования, может
возникнуть искушение включить относительно общие паттерны — такие, как
API прослушивателей или перехватчиков. На первый взгляд они добавляют
гибкости без значительного усложнения. И возможно, это действительно так,
но за расширяемость приходится расплачиваться повышением сложности.
Применяя API перехватчиков, необходимо защититься от любого непредвиденного использования. Это значит, что код должен быть готов к любому исключению. Кроме того, нужно иметь в виду потоковую модель выполнения точек
расширения API. Если архитектура допускает синхронные клиентские вызовы,
следует исходить из того, что у некоторых клиентов они будут блокироваться,
что повлияет на условия SLA компонента и использование ресурсов (например, потоков). Однако введение асинхронной логики, которая должна работать
в коде параллельно, создает дополнительную сложность. Придется поддерживать
специализированный пул потоков и отслеживать его работу.
Также необходимо обратить внимание на точки расширения компонента
и отношение «происходит до» между фазами обработки. Если эти отношения
существуют, то даже переход на параллельную обработку не снизит дополнительную задержку до нуля. Это сложность, которая неизбежно возникает при
предоставлении API перехватчиков вызывающим сторонам.
API прослушивателей похож на API перехватчиков, но он не подразумевает
блокировку между фазами выполнения компонента. Выдаваемые сигналы
асинхронны, поэтому они не должны влиять на общую задержку компонента.
Но с распространением состояния следует быть осторожными. Когда вы передаете код компоненту вызывающей стороны, вы не знаете, будет ли он изменен
клиентом. Поэтому чрезвычайно важна неизменяемость состояния, распространяемого в API прослушивателей.
Как правило, чем выше гибкость API, тем больше сложности вводится в архитектуру. Это может быть сложность либо кода, либо модели выполнения, если
нужно ввести асинхронную обработку. На рис. 4.8 сравниваются эти два подхода:
гибкость и сложность.
Если сравнить эти решения, абстрагирование библиотеки метрик обеспечивает некоторую гибкость, но остается явный контракт API, который должен

4.5. Анализ гибкости API и затраты на обслуживание

135

выполняться клиентами. С другой стороны, API перехватчиков или прослушивателей намного более гибкие, но они раскрывают внутренние события или
состояние API, с которым клиент может сделать все что угодно. Этот вариант
обеспечивает отличную гибкость. Но при этом нет возможности делать предположения о клиентском коде; необходимо защищаться от непредсказуемых
сбоев, для чего используется неизменяемое состояние. Другой недостаток в том,
что невозможно делать предположения относительно модели одновременного
выполнения клиента (если оно блокирующее или асинхронное). Из-за этого
стремление к гибкости системы иногда создает слишком много хлопот. Необходимо найти оптимальную точку на оси «гибкость — сложность» и проектировать
систему соответствующим образом.

API перехватчиков/
прослушивателей
Гибкость

Абстрагирование метрик

Сложность

Рис. 4.8. Гибкость и сложность

Мы рассмотрели небольшое подмножество паттернов и способов улучшения
гибкости API. Можно использовать и другие паттерны — Декоратор, Фабрика
(Factory), Заместитель (Proxy) и т. д. В этой главе мы постарались показать
преимущества и недостатки рассмотренных паттернов. Если какое-то решение
обеспечивает значительную гибкость, стоит проанализировать присущую ему
сложность. Эти общие правила распространяются на все паттерны программирования.
В следующей главе мы покажем, что преждевременная оптимизация — не всегда
плохо. Также вы узнаете, в каких случаях оптимизация критического пути может быть рациональной, и научитесь находить критические пути в коде, чтобы
принимать верные решения об оптимизации его частей.

136

Глава 4. Баланс между гибкостью и сложностью

ИТОГИ
Для повышения гибкости API можно абстрагировать стороннюю логику.
Введение API перехватчиков и прослушивателей обеспечивает максимальную гибкость кода. Эти API помогают сделать код расширяемым с точки
зрения клиента.
Стремление к универсальности и гибкости повышает сложность системы.
Сложность может затрагивать не только код и его обслуживание, но и другие
части системы.
Если вы стремитесь к повышению расширяемости кода за счет использования
API перехватчиков, следует тщательно продумать обработку сбоев и сложность модели выполнения, обусловленную этим API.
Неизменяемость делает поведение системы предсказуемым.
Использование разных паттернов ведет к различным компромиссам между
сложностью и гибкостью.

5

Преждевременная
оптимизация и оптимизация
критического пути:
решения, влияющие
на производительность кода
https://t.me/it_boooks
В этой главе:
33 Когда преждевременная оптимизация — зло.
33 Поиск критического пути в коде с помощью измерений и тестирования производительности.
33 Оптимизация критического пути.

В компьютерной теории существует поговорка: «Преждевременная оптимизация — корень всех зол». И это зачастую справедливо на практике. Не имея
данных об объеме ожидаемого трафика и SLA, трудно сделать предположения
относительно кода и необходимой производительности. В такой ситуации
оптимизацию случайных путей в коде можно сравнить с пальбой вслепую. Вы
только усложняете код без разумных на то причин.
ПРИМЕЧАНИЕ
SLA определяет объем трафика, который должен обрабатываться сервисом. В нем
также может указываться количество запросов к выполнению, а также число запросов, которые должны обрабатываться с задержкой ниже заданного порогового
значения. Аналогично существуют нефункциональные требования (non-functional
requirements, NFR), задающие ожидаемую производительность системы.

138

Глава 5. Преждевременная оптимизация и оптимизация критического пути

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

5.1. КОГДА ПРЕЖДЕВРЕМЕННАЯ
ОПТИМИЗАЦИЯ — ЗЛО
Часто мы пишем код приложения, не имея значимых входных данных относительно ожидаемого трафика. В идеальном мире информация о требованиях
к ожидаемой пропускной способности и максимальной задержке доступна всегда.
На практике часто приходится адаптироваться к конкретной ситуации. Мы начинаем с написания продукта, который просто обслуживать и легко изменять.
Однако у нас еще нет жестких требований к производительности. В таком случае попытки оптимизировать код заранее сталкиваются со слишком большим
количеством неизвестных.
При оптимизации производительности программного пути мы часто усложняем
его. Впрочем, иногда его части приходится писать по конкретной схеме. В таких
частях системы производительность достигается за счет сложности. Это может
быть сложность кода либо сложность обслуживания или системы используемых
компонентов. Без входных данных о трафике может оказаться, что имеющийся
код не влияет на общую эффективность основного рабочего процесса. Из-за

5.1. Когда преждевременная оптимизация — зло

139

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

5.1.1. Создание конвейера обработки учетных записей
Возьмем простой сценарий: имеется класс, представляющий учетную запись.
Требуется построить конвейер обработки, который находит учетную запись
с заданным идентификатором. Этот класс представлен в следующем листинге.
Листинг 5.1. Построение сущности, представляющей учетную запись
public class Account {
private String name;
private Integer id;
// Конструкторы, методы чтения и записи свойств опущены.
}

Код работает со списком учетных записей и получает искомый идентификатор
в аргументе. Следующий листинг содержит логику фильтрации учетных записей.
Листинг 5.2. Исходная логика фильтрации
public Optional account(Integer id) {
return accounts.stream().filter(v -> v.getId().equals(id)).findAny();
}

Этот простой код использует API потоков данных; он уже скрывает многие оптимизации производительности. Абстракция потоков работает по ускоренной
схеме, а значит, операция фильтрации, проверяющая совпадение идентификатора учетной записи с аргументом, выполняется только в том случае, если эта
запись еще не найдена.
МЕТОДЫ FINDANY() И FINDFIRST()
Стоит сказать пару слов о функциях findAny() и findFirst(), которые часто
используются в неправильном контексте. Ускоренная обработка достигается при
использовании findAny(). Этот метод прекращает обработку при обнаружении
любого элемента. findFirst() же воспроизводит поведение последовательной
обработки. Если обработка разбита на части, findAny() может оказаться
эффективнее, потому что нас не интересует порядок обработки. С другой стороны,
findFirst() означает, что обработка должна выполняться последовательно, что
замедляет конвейер обработки. Эти различия становятся более важными при
использовании параллельных потоков данных.

140

Глава 5. Преждевременная оптимизация и оптимизация критического пути

Данный код был создан как отправная точка для дальнейшей доработки. Важно
заметить, что никакой информации о производительности у нас еще нет. Обрабатываемый список может ограничиваться парой элементов, а может содержать
миллионы учетных записей. Без этой информации трудно оптимизировать
производительность обработки.
Для нескольких учетных записей код будет достаточно хорош. Но для миллионов
элементов лучше распределять работу по разным потокам. Одно из возможных
решений — создать потоки вручную, разбить работу на пакеты и распределить их
по потокам. Также можно воспользоваться существующими механизмами — например, параллельными потоками данных, которые скрывают процесс создания
потока и распределяют работу.
Проблема в том, что здесь используются некоторые утверждения о коде, которые могут оказаться ложными. Можно смело предположить, что код обработает
максимум N элементов, где N равно 10 000. Пока системный анализ базируется
на этих числах, можно заняться оптимизацией соответствующей части кода.
К сожалению, таких входных данных часто нет. Оптимизировать код в таком
контексте проблематично, потому что это создает дополнительную сложность
без очевидных преимуществ. Посмотрим, как ошибочные утверждения усложняют код.

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

Поток 1
Поток 2

N элементов

Разбиение

Разбиение

1/2 N элементов

1/4 N элементов

1/2 N элементов

1/4 N элементов

Поток 3

1/4 N элементов

Поток 4

1/4 N элементов

Рис. 5.1. Перехват работы как средство оптимизации производительности

5.1. Когда преждевременная оптимизация — зло

141

Сначала объем работы делится на два потока. На этом этапе каждый поток отвечает за обработку половины от N элементов. Затем код выполняет еще одно
разбиение, поскольку задействованы не все потоки, так что работа будет разделена на четверти от N элементов. Теперь каждый поток может начать фактическую обработку. Фаза разбиения должна разбивать элементы на части, соответствующие количеству доступных потоков или ядер. В следующем листинге
показано, как компактно записать предлагаемую логику с использованием API
потоков данных.
Листинг 5.3. Перехват работы с использованием parallelStream()
public Optional accountOptimized(Integer id) {
return accounts.parallelStream().filter(v ->
v.getId().equals(id)).findAny();
}

Метод parallelStream() разбивает работу на N частей. Он использует внутренний пул параллельной обработки (http://mng.bz/Axro) с количеством потоков, равным количеству ядер, — 1. Решение выглядит просто, но скрывает
значительную сложность. Самое важное изменение заключается в том, что код
становится многопоточным, а это означает, что обработка не должна обладать
состоянием (например, нельзя изменять состояние в методе обработки, применяемом в качестве фильтра). Так как мы задействуем пул потоков, необходимо
контролировать его использование и загруженность.
Другая скрытая сложность, которую создает алгоритм перехвата работы, — фаза разбиения работы. Данный этап занимает дополнительное время
и снижает производительность кода. Потери могут превышать выгоду от
распараллеливания.
А поскольку работа по оптимизации базируется на ложных утверждениях (или
вообще ни на чем), невозможно предсказать, как этот код поведет себя в реальных условиях. Чтобы убедиться в том, что оптимизация производительности
эффективна, нужно написать бенчмарк-тест, проверяющий производительность
обоих методов.

5.1.3. Оценка оптимизации производительности
Как вы, возможно, помните, обработка применяется к N элементам, где N равно
10 000. Как бы то ни было, если это число основано на эмпирических данных или
наших утверждениях, следует как минимум написать тест производительности
для проверки результатов оптимизации.
Код тестирования генерирует N случайных учетных записей с идентификаторами от 0 до 10 000. Для этого создается случайная строка с использованием

142

Глава 5. Преждевременная оптимизация и оптимизация критического пути

класса UUID . Параметр fork указывает, что все тесты должны выполняться
на одной JVM. Для этого требования воспользуемся программой JMH (Java
Microbenchmark Harness) (https://github.com/openjdk/jmh). На других платформах
имеются инструменты для правильной организации тестирования кода — например, BenchmarkDotNet (https://benchmarkdotnet.org/) для .NET. Тест производительности полон нюансов; рекомендуем потратить время и изучить проверенные средства для вашей платформы, а не пытаться соорудить собственную
реализацию.
До запуска логики тестирования необходимо провести фазу разогрева (warmup),
которая позволяет JIT оптимизировать кодовые пути. Она настраивается аннотацией @Warmup. Мы проведем 10 итераций измерений, чего вполне достаточно —
чем больше итераций, тем более повторяемыми будут результаты. Нас интересует
среднее время выполнения метода, а результаты выдаются в миллисекундах
(мс). Логика инициализации приведена в следующем листинге.
Листинг 5.4. Инициализация теста производительности
import
import
import
import
import
import
import
import

org.openjdk.jmh.annotations.Benchmark;
org.openjdk.jmh.annotations.BenchmarkMode;
org.openjdk.jmh.annotations.Fork;
org.openjdk.jmh.annotations.Measurement;
org.openjdk.jmh.annotations.Mode;
org.openjdk.jmh.annotations.OutputTimeUnit;
org.openjdk.jmh.annotations.Warmup;
org.openjdk.jmh.infra.Blackhole;

@Fork(1)
@Warmup(iterations = 1)
@Measurement(iterations = 10)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class AccountsFinderPerformanceBenchmark {
private static final List ACCOUNTS =
IntStream.range(0, 10_000)
.boxed()
.map(v -> new Account(UUID.randomUUID().toString(), v))
.collect(Collectors.toList());
Генерирует N учетных
private static final Random random = new Random();
записей, которые
// Методы тестирования
Вызывает генератор
будут использоваться
случайных чисел
в тесте
для получения искомого
производительности
идентификатора

Метод baseline() выполняет первую версию логики поиска учетных записей.
Затем метод parallel() выполняет улучшенную версию, в которой используется
класс parallelStream, как показано в следующем листинге.

5.1. Когда преждевременная оптимизация — зло

143

Листинг 5.5. Реализация логики теста производительности
@Benchmark
public void baseline(Blackhole blackhole) {
Optional account =
Поиск учетной записи со случайным
new AccountFinder(ACCOUNTS)
идентификатором
➥ .account(random.nextInt(10_000));
blackhole.consume(account);
Потребляет результат, чтобы
}
JIT-компилятор знал, что он
где-то используется
@Benchmark
public void parallel(Blackhole blackhole) {
Optional account =
Логика параллельной
new AccountFinder(ACCOUNTS)
версии полностью
➥ .accountOptimized(random.nextInt(10_000))
совпадает
blackhole.consume(account);
}

Выполним логику тестирования и просмотрим результаты. На вашем компьютере точные значения могут быть другими, но общая тенденция останется
неизменной. В следующем листинге показаны результаты выполнения логики
на моем компьютере. Обратите внимание: производительность двух решений
почти одинакова.
Листинг 5.6. Просмотр результатов тестирования
CH05.premature.AccountsFinderPerformanceBenchmark.baseline
➥ avgt
10 0.027 ± 0.002 ms/op
CH05.premature.AccountsFinderPerformanceBenchmark.parallel
➥ avgt
10 0.030 ± 0.002 ms/op

Параллельная обработка может быть чуть медленнее из-за затрат на разбиение,
предшествующих реальной работе. Однако при увеличении количества элементов можно заметить, что параллельная версия работает чуть быстрее. В целом
различия между двумя решениями пренебрежимо малы.
Из этого простого теста видно, что результаты производительности параллельного решения не добавляют сложности, обусловленной его многопоточностью. Однако сложность кода не возрастает независимо от того, выбран ли
parallelStream или стандартный поток данных. Сложность скрывается во
внутреннем устройстве метода parallelStream(). Кроме того, результаты оптимизации могут быть другими в условиях реальной эксплуатации, потому что
мы выбрали усовершенствования производительности на основании ложных
утверждений.
В таком сценарии преждевременная оптимизация кода (до получения информации о том, как он будет использоваться в реальных условиях) может создать
проблемы.

144

Глава 5. Преждевременная оптимизация и оптимизация критического пути

Подведем итог: мы приложили усилия для оптимизации конкретных частей
кода системы. Как выяснилось, это не принесло реальной пользы. В сущности,
мы только потратили время из-за ложных утверждений. Предполагалось, что
код будет выполняться для конкретного количества элементов. В таком контексте вторая версия кода не показала заметных улучшений. Проблема в том,
что числа для тестирования были выбраны наугад. Возможно, в реальной
системе количество обрабатываемых элементов будет заметно отличаться
(в большую или меньшую сторону). Таким образом, у нас будет больше эмпирических данных для оптимизации кода, на этот раз основанных на реальных
утверждениях. В этом случае к оптимизации можно будет вернуться позже,
но уже с верными данными.
Если вы заранее знаете, что данные учетных записей будут расширяться, необходимо адаптировать код тестирования. При достижении некоторого порога
можно заметить, что parallelStream() работает лучше, чем стандартный метод
stream(). В таком случае оптимизация уже не будет преждевременной.
Мы рассмотрели только один из аспектов входных данных, необходимых для эффективной оптимизации производительности. В коде реальных систем существует
множество путей. Даже если предположить, что нам известно значение N для
обработки всего ввода, оптимизация всех путей может оказаться невыполнимой.
Мы должны знать, насколько часто будет выполняться тот или иной путь, чтобы
решить, стоит ли тратить время на его оптимизацию. Некоторые пути (например,
инициализация) выполняются относительно редко. С другой стороны, существуют
пути, выполняемые для каждого пользовательского запроса. Они называются критическими. Оптимизация их кода часто эффективна и обеспечивает значительное
повышение производительности всей системы. В следующем разделе вы узнаете,
как рассуждать при поиске критических путей в коде.

5.2. КРИТИЧЕСКИЕ ПУТИ В КОДЕ
В предыдущем разделе был приведен пример оптимизации, основанной на ложных предположениях. Также вы увидели, что одна из важнейших характеристик
данных, полезных при оптимизации кода, — число входящих элементов (N).
Это может быть количество запросов в секунду или файлов, которые нужно
прочитать. Как вы знаете, сложность алгоритма может вычисляться по количеству входящих элементов (N). Можно не только выбрать наиболее подходящий
алгоритм, но и оценить использование памяти.
Знать N очень важно, но в реальных системах не весь код приложения имеет
одинаковое значение. Например, рассмотрим простое приложение HTTP с несколькими конечными точками, выполняемыми с разной частотой. Частота
запросов представлена на рис. 5.2.

5.2. Критические пути в коде

10 000 запросов в секунду
10
Клиент

145

process-request

запросов в секунду
modify-schema

Рис. 5.2. Конечные точки с разными частотами запросов

Первая конечная точка представляет основной функционал приложения. Она
отрабатывает почти для каждого вызова со стороны клиента и выполняет основную работу приложения. Допустим, эта точка активизируется клиентами 10 000
раз в секунду. Также можно предположить, что N для обеих конечных точек
вычисляется на основании эмпирических данных или SLA, предоставляемых
сервисом. В этом примере используемые данные базируются на утверждениях,
которые в свою очередь основаны на реальных данных.
С другой стороны, есть метод, выполняющий большую часть «черной работы».
Метод modify-user-details обрабатывает структуру данных в основной базе
данных, используемой приложением HTTP. Он вызывается достаточно редко,
потому что изменение пользовательских данных — нетипичная задача для
клиента. После внесения изменений данные пользователя хранятся в прежней
структуре данных в течение долгого времени.
Теперь допустим, что вы измеряете 99-й процентиль задержки для обеих конечных точек (то есть 99 % запросов, обрабатываемых быстрее заданного числа).
Через какое-то время вы получаете результаты и заключаете, что задержка p99
точки process-request составляет 200 мс, а задержка p99 для modify-user-details
равна 500 мс. Если рассмотреть эти показатели вне контекста (числа запросов в
секунду), можно сделать вывод, что оптимизацию следует начинать с конечной
точки modify-user-details. Но после добавления контекста о количестве запросов легко заметить, что оптимизация конечной точки process-request обеспечит
большую экономию ресурсов и времени.
Например, при сокращении задержки p99 для обработки запросов до 20 мс (10 %)
достигается общее сокращение задержки в 200 000 мс:
(10 000 × 200) – (10 000 × 180) = 200 000
Но если оптимизировать конечную точку modify-user-details вдвое до 250 мс,
достигается существенно меньшее общее сокращение задержки в 2500 мс:
(10 × 500) – (10 × 250) = 2500.

146

Глава 5. Преждевременная оптимизация и оптимизация критического пути

На основании этих вычислений можно сделать следующий вывод. Затраты
времени на оптимизацию конечной точки, вызываемой чаще, дают в 80 раз большую выгоду, чем в случае точки, оптимизация которой выполняется дольше:
200 000 ÷ 2500 = 80.
Как уже говорилось, путь, выполняемый для большинства запросов, называется
критическим. Его поиск и оптимизация исключительно важны, если вы хотите
оптимизировать производительность каждого приложения.
Оказалось, что в реальных системах трафик достаточно часто неравномерно
распределяется между кодовыми путями в приложении. Многие эмпирические
исследования показали, что принцип Парето способен упростить анализ систем.
Этот принцип рассматривается в следующем разделе.

5.2.1. Принцип Парето в контексте программных систем
В ходе исследования различных систем (организаций, эффективности работы,
программных систем) были выявлены присущие большинству из них интересные
характеристики. Проанализируем их в контексте программных систем.
Как выяснилось, небольшая доля кода обеспечивает значительную долю ценности
программного продукта. На практике чаще всего обнаруживалось соотношение
80 % к 20 %. Другими словами, 80 % ценности и работы, выполняемой системой,
производят всего 20 % кода. На рис. 5.3 это соотношение изображено в виде графа.
100
90

Правило 80/20

80
70
20 % усилий

60
50
40

80 % результатов

Линейное поведение

30
20
10
Ценность
Усилия 10
Правило 80/20

20

30

40

50

60

Рис. 5.3. Правило 80/20, представленное принципом Парето

70

80

90

100

5.2. Критические пути в коде

147

При линейном поведении все пути в коде важны одинаково. В таких сценариях
добавление нового компонента в систему означает, что ценность, доставляемая
клиенту, пропорционально растет. На практике в каждой системе существует
базовая функциональность, несущая наибольшее значение для коммерческих
целей. Остальной функционал (проверка данных, обработка граничных случаев
и сбоев и т. д.) не критичен, а создаваемая им ценность незначительна (скажем,
20 %). Однако на его реализацию уходит 80 % времени и усилий.
Конечно, реальные пропорции различаются в зависимости от предметной области и системы. Соотношение может быть 30 % к 70 % или даже 10 % к 90 %.
Реальное значение роли не играет.
Какой самый важный вывод из этого следует? Оптимизация небольшой части
кодовой базы отразится на значительной части клиентов.
При создании новой системы нужны требования SLA с предполагаемой верхней границей трафика, который способна обработать система. Владея этими
данными, можно создать тесты производительности, моделирующие реальный
трафик.

5.2.2. Настройка количества параллельных
пользователей (потоков) для заданного уровня SLA
Допустим, сервис должен обеспечивать уровень SLA для обработки 10 000 запросов в секунду. Средняя задержка составляет 50 мс. Если вы хотите рассмотреть такую систему в инструменте анализа производительности, важно задать
правильное количество потоков (параллельных пользователей) для выполнения
запросов к тестируемой системе.
Если выбрать один поток, можно обрабатывать не более 20 запросов в секунду
(1000 мс ÷ 50 мс = 20). Такая конфигурация не позволит проанализировать SLA
системы. Но зная, что один поток обрабатывает 20 запросов в секунду, можно
вычислить общее количество необходимых потоков. Ожидаемое количество
запросов в секунду делится на число запросов, которые могут обрабатываться
одним потоком: 10 000 ÷ 20 = 500.
Результат показывает, что для насыщения системы или сетевого трафика потребуется 500 потоков. Теперь можно настроить инструмент согласно этой характеристике. Если инструмент нагрузочного тестирования не сможет создать
столько потоков в одном узле, трафик можно разделить на N узлов тестирования,
где каждый узел обрабатывает часть трафика. Например, можно выполнять
запросы от четырех узлов. Значит, каждый узел должен выполнять запросы
125 параллельных пользователей (500 потоков ÷ 4 узла = 125). Вычисления
могут немного отличаться в зависимости от инструмента.

148

Глава 5. Преждевременная оптимизация и оптимизация критического пути

Если инструмент использует цикл событий (неблокирующий ввод/вывод),
можно выполнять больше запросов из одного потока. В таком случае сначала
нужно измерить количество запросов, с которыми может справиться один
поток, и адаптировать остальные вычисления к этому числу. Затем следует
создать чуть больше потоков, чем показал расчет, поскольку он основан на
средней задержке. При этом могут существовать выбросы, замедляющие параллельные потоки. Чтобы посчитать выбросы, можно рассмотреть задержку
высоких процентилей (например, p90, p95, p99). Общее количество потоков,
необходимое для среднего SLA, умножается на некий коэффициент (скажем,
1,5), чтобы создать дополнительные потоки на случай временного замедления
тестируемой системы.
Наконец, можно измерить критические пути для некоторого количества
активизаций и определить затраченное время. С этими числовыми данными
можно обнаружить критический путь и вычислить, насколько значительную
выгоду мы получим от оптимизации небольшого фрагмента кода. Благодаря
характеристикам многих систем, следующим принципу Парето в отношении
оптимизации критического пути, можно внести усовершенствования, влияющие на большинство клиентов. В следующем разделе мы применим эту схему
для оптимизации системы с определенным уровнем SLA: построим новую
систему с предметной областью. Используя новые знания, мы оптимизируем
ее критический путь.

5.3. СЛОВАРНЫЙ СЕРВИС С ПОТЕНЦИАЛЬНЫМ
КРИТИЧЕСКИМ ПУТЕМ
Допустим, вы хотите построить словарный сервис, который предоставляет
две функциональности по двум конечным точкам API. На рис. 5.4 изображена
архитектура сервиса.

Получает слово дня

Word-of-the-day

Находит слово
по смещению

Клиент

Словарь
Передает слово в виде запроса

Word-exists

Проверяет,
существует ли слово

Клиент

Рис. 5.4. Архитектура словарного сервиса с двумя видами функциональности

5.3. Словарный сервис с потенциальным критическим путем

149

Первая предоставляемая функциональность — получение «слова дня» (wordof-the-day). Система вычисляет смещение, соответствующее текущей дате,
и возвращает слово с индексом, равным смещению.
Вторая функциональность проверяет, что слово существует (word-exists). Пользователь передает слово в параметре запроса, а сервис ищет его в словаре, возвращая в теле ответа информацию о его существовании. В следующем листинге
представлен словарный сервис, являющийся базовым компонентом системы;
в его основе лежит интерфейс WordsService.
Листинг 5.7. Реализация интерфейса WordsService
public interface WordsService {
String getWordOfTheDay();
boolean wordExists(String word);
}

Метод getWordOfTheDay() не принимает аргументы. Он просто возвращает
запрошенное слово. Метод wordExists() получает проверяемое слово и возвращает признак, сообщающий, есть оно в словаре или нет. Первая реализация
WordsService не пытается применять преждевременные оптимизации, так как
у нас еще нет числовых данных, относящихся к SLA или трафику.

5.3.1. Получение «слова дня»
Базовая функциональность для получения «слова дня» вычисляет индекс для
заданной даты. В следующем листинге представлена простая логика вычислений — в ней используется год и день года, а также коэффициент для лучшего
распределения возвращаемых слов.
Листинг 5.8. Получение «слова дня»
private static final int MULTIPLY_FACTOR = 100;
private static int getIndexForToday() {
LocalDate now = LocalDate.now();
return now.getYear() + now.getDayOfYear() * MULTIPLY_FACTOR;
}

ПРИМЕЧАНИЕ
Мы выбрали множитель 100, но это может быть любое произвольное число.

Реализация словарного сервиса должна получать в аргументе путь к фактическому файлу словаря, чтобы загрузить файл и сканировать его содержимое.
Функцию вычисления индекса сегодняшнего дня можно макетировать передачей
функции-поставщика, как показано в следующем листинге. Это может пригодиться для модульного тестирования, потому что тест не должен базироваться
на состоянии, возвращаемом вызовом LocalDate.now().

150

Глава 5. Преждевременная оптимизация и оптимизация критического пути

Листинг 5.9. Добавление конструктора DefaultWordsService
public class DefaultWordsService implements WordsService {

DefaultWordsService
реализует
WordsService

private static final int MULTIPLY_FACTOR = 100;
private static final IntSupplier DEFAULT_INDEX_PROVIDER =
DefaultWordsService::getIndexForToday;
Поставщик вызывает функцию,
private Path filePath;
которая использует локальную дату
private IntSupplier indexProvider;
public DefaultWordsService(Path filePath) {
this(filePath, DEFAULT_INDEX_PROVIDER);
}
@VisibleForTesting
public DefaultWordsService(Path filePath,
➥ IntSupplier indexProvider) {
this.filePath = filePath;
this.indexProvider = indexProvider;
}

В аргументе должен
передаваться путь к словарю

Второй конструктор нужен только
для модульного тестирования
Использует значение Int,
полученное от поставщика,
как индекс «слова дня»

Логика вычисления «слова дня» использует класс Scanner (http://mng.bz/ZzjR),
который позволяет выполнить отложенное сканирование файла. Если вам нужна
следующая строка, вызовите метод, который ее получает. Когда обработка будет
завершена, загружать дополнительные строки уже не нужно.
Логика примера весьма проста. Она перебирает файл, пока не будет найден
индекс, обозначающий «слово дня». Если остались строки, логика продолжает
выполняться. Если текущий обрабатываемый индекс равен индексу искомого
слова, мы возвращаем слово и завершаем обработку. Логика получения «слова
дня» приведена в следующем листинге.
Листинг 5.10. Добавление метода getWordOfTheDay()
@Override
Получает индекс
public String getWordOfTheDay() {
текущего дня
int index = indexProvider.getAsInt();
try (Scanner scanner = new Scanner(filePath.toFile())) {;
Передает сканеру
int i = 0;
путь к файлу
while (scanner.hasNextLine()) {
словаря
String line = scanner.nextLine();
Получает следующую
if (index == i) {
строку в виде String
return line;
}
i++;
}
} catch (FileNotFoundException e) {
throw new RuntimeException("Ошибка в getWordOfTheDay для индекса: " +
filePath, e);
}
return "Сегодня слово отсутствует.";
}

5.3. Словарный сервис с потенциальным критическим путем

151

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

5.3.2. Проверка существования слова
Вторая функциональность, которую предоставляет сервис, — проверка присутствия конкретного слова в словаре. Логика получения этой информации
напоминает логику «слова дня», но, чтобы проверить существование слова,
необходимо перебрать весь файл. Метод wordExists() ищет слово, переданное
в аргументе. Если строка, загруженная из файла, равна аргументу word, возвращается true; это значит, что слово существует в словаре. Наконец, если слово не
найдено после перебора всего файла, возвращается false. Функциональность
проверки представлена в следующем листинге.
Листинг 5.11. Добавление метода wordExists()
@Override
public boolean wordExists(String word) {
try (Scanner scanner = new Scanner(filePath.toFile())) {
while (scanner.hasNextLine()) {
String line = scanner.nextLine();
if (word.equals(line)) {
return true;
}
}
} catch (FileNotFoundException e) {
throw new RuntimeException("Ошибка в wordExists для слова: " + word, e);
}
return false;
}

Логика wordExists() не оптимизирована, потому что мы не определили условия
SLA. Тестов для определения производительности текущего решения еще нет,
но теперь можно предоставить доступ к логике через конечную точку API.

5.3.3. Предоставление доступа к WordsService
с использованием сервиса HTTP
Класс WordsController предоставляет две конечные точки, как видно из листинга 5.12. Первая точка, /word-of-the-day, использует GET-запрос HTTP,
который не получает параметров. Запрос запускает загрузку файла в словарь,
а после загружает файл words.txt из папки resources. Функциональность первой
конечной точки предоставляется по пути API /word-of-the-day API. (Все
пути в этом примере используют префикс /words.) Вторая функциональность
предоставляется через конечную точку /word-exists. Она использует слово,
переданное в параметре запроса, и проверяет, существует ли оно.

152

Глава 5. Преждевременная оптимизация и оптимизация критического пути

Листинг 5.12. Добавление класса WordsController
@Path("/words")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class WordsController {
private final WordsService wordsService;
public WordsController() {
java.nio.file.Path defaultPath =
Конструирует реализацию
Paths.get(
WordService по умолчанию
Objects.requireNonNull(
getClass().getClassLoader().getResource("words.txt")).getPath());
wordsService = new DefaultWordsService(defaultPath);
}
@GET
Упаковывает «слово дня»
@Path("/word-of-the-day")
в тело ответа HTTP
public Response getAllAccounts() {
return Response.ok(wordsService.getWordOfTheDay()).build();
}
@GET
@Path("/word-exists")
public Response validateAccount(@QueryParam("word") String word) {
boolean exists = wordsService.wordExists(word);
Упаковывает
return Response.ok(String.valueOf(exists)).build();
информацию
}
о существовании
слова в ответе HTTP
}

Наконец, приложение HTTP запускается с использованием встроенного сервера
HTTP Dropwizard (http://mng.bz/REpZ). Приложение должно расширять класс
io.dropwizard.Application , предоставляющий функциональность запуска
сервера HTTP, как показано в листинге 5.13. Из-за этого класс Application
необходимо расширить с типом по умолчанию Configuration. Таким образом
создается экземпляр WordsController, предоставляющий бизнес-функциональность, который затем регистрируется как конечная точка API. Наконец, приложение запускает веб-сервер HTTP, доступный по адресу http://localhost:8080/words.
Листинг 5.13. Запуск сервера HTTP
public class HttpApplication extends Application {
@Override
public void run(Configuration configuration, Environment environment) {
WordsController wordsController = new WordsController();
environment.jersey().register(wordsController);
}
public static void main(String[] args) throws Exception {
new HttpApplication().run("server");
}
}

5.4. Обнаружение критического пути в коде

153

ПРИМЕЧАНИЕ
Если запустить эту функцию main, приложение Words с обоими контроллерами будет
работать на локальном компьютере.

В следующем разделе мы применим информацию об ожидаемом трафике, чтобы
обнаружить критический путь. Для этого воспользуемся бенчмарк-тестами Gatling
(https://gatling.io/open-source/) для моделирования трафика и классом Dropwizard
MetricsRegistry, чтобы измерить хронометраж кодовых путей. Посмотрим, подчиняется ли структура приложения принципу Парето, описанному в предыдущем
разделе.

5.4. ОБНАРУЖЕНИЕ КРИТИЧЕСКОГО ПУТИ В КОДЕ
Допустим, наши оценки трафика и SLA показывают, что конечная точка word-ofthe-day обслуживает один запрос в секунду. С другой стороны, конечная точка
word-exists будет вызываться чаще 20 запросов в секунду. Прямолинейные
вычисления показывают, что это превышает соотношение принципа Парето
(правило 80/20):
1 ÷ (20 + 1) = ~5 %
20 ÷ (20 + 1) = ~95 %
Формула показывает, что функциональность word-exists обслуживает 95 %
запросов пользователя и не обслуживает только 5 %. Но прежде чем оптимизировать эту конечную точку, следует создать тест производительности для обеих
точек, чтобы получить данные о задержках. Зная количество запросов и величину задержек, можно вычислить общую выгоду от оптимизации той или иной
функциональности. Воспользуемся инструментом Gatling для тестирования
производительности.

5.4.1. Применение Gatling для создания тестов
производительности API
Мы хотим смоделировать два сценария тестирования производительности.
Первый предназначен для конечных точек word-of-the-day и выполняет один
запрос в секунду. Продолжительность измерений составляет 1 минуту для
быстрого получения обратной связи. Этого достаточно для нашего случая, в котором сравниваются исходная и оптимизированная версии. При тестировании
реальных систем измерения длятся намного дольше.

154

Глава 5. Преждевременная оптимизация и оптимизация критического пути

Модели с использованием Gatling пишутся на языке Scala, при этом каждая
модель должна расширять класс Simulation. Сценарий получения «слова дня»
прямолинеен. Требуется выполнить GET-запрос для заданной конечной точки,
и каждый запрос будет выполняться в контексте URL http://localhost:8080/words.
Если вы захотите развернуть приложение Words на отдельном сервере, этот URL
нужно будет изменить. Конечная точка API получает и генерирует формат JSON.
Сценарий теста выполняет GET-запрос HTTP для конечной точки /word-ofthe-day. Ожидается, что результатом будет код ответа HTTP 200. Любой другой
код рассматривается как ошибка. Реализация приведена в следующем листинге.
Листинг 5.14. Проверка производительности word-of-the-day
class WordsSimulation extends Simulation {
val httpProtocol =http
.baseUrl("http:/
/localhost:8080/words")
.acceptHeader("application/json")
val wordOfTheDayScenario = scenario("word-of-the-day")
.exec(WordOfTheDay.get)

Этот сценарий
используется для
генерирования трафика

object WordOfTheDay {
val get = http("word-of-the-day").get("/word-of-the-day").check(status is
200)
}

Второй сценарий похож на первый, но GET-запрос HTTP должен отправить
проверяемое слово как параметр HTTP, поэтому сценарию необходимо передать список проверяемых слов. В следующем листинге приведены слова файла
из примера words.csv.
Листинг 5.15. Слова, используемые для тестирования производительности
word
1Abc
bigger
presence
234
zoo

В список входит слово bigger, находящееся в начале словаря. Также имеется
слово presence из середины. Наконец, слово zoo находится в конце словаря.
Также имеются два несуществующих слова, для которых отработает полное
сканирование файла.
Сценарий проверки использует файл words.csv и передает его в параметре запроса конечной точке API. feeder получает слова из words.csv и использует их
случайным образом. Наконец, сценарий выполняет GET-запрос с параметром
word. Код этого сценария приведен в следующем листинге.

5.4. Обнаружение критического пути в коде

155

Листинг 5.16. Проверка производительности word-exists
val validateScenario = scenario("word-exists")
.exec(ValidateWord.validate)

Сценарий word-exists выполняет
логику проверки

object ValidateWord {
val feeder = csv("words.csv").random
val validate = feed(feeder).exec(
http("word-exists")
.get("/word-exists?word=${word}").check(status is 200)
)
}

Когда сценарии будут заданы, их следует внедрить в исполнительное ядро
и указать ожидаемый трафик. В листинге 5.17 приведен пример, как это сделать.
Первый сценарий обрабатывает один запрос в секунду. Второй сценарий (проверка), отвечающий за 95 % клиентских запросов, выполняет 20 запросов в секунду.
Листинг 5.17. Настройка профиля трафика
setUp(
wordOfTheDayScenario.inject(
constantUsersPerSec(1) during (1 minutes)
),
validateScenario.inject(
constantUsersPerSec(20) during (1 minutes)
)).protocols(httpProtocol)

Теперь можно приступать к тестированию. Сначала необходимо запустить
HttpApplication. Когда приложение заработает на локальном хосте, можно начать тесты Gatling с помощью команды mvn gatling:test. Она запускает тестирование производительности приложения. Через некоторое время результаты
будут доступны в виде веб-страницы в формате HTML.
Проанализируем результаты производительности для обоих сценариев. Как
видно на рис. 5.5, результаты для сценария word-of-the-day довольно неплохие.

Рис. 5.5. Просмотр исходных результатов производительности word-of-the-day

156

Глава 5. Преждевременная оптимизация и оптимизация критического пути

Все запросы к конечной точке /word-of-the-day успешно завершаются быстрее
800 мс. Задержка p99 равна 361 мс.
Перейдем к результатам сценария проверки слов. Как показано на рис. 5.6, эта
конечная точка выполнила большинство запросов.

Рис. 5.6. Просмотр исходных результатов производительности word-exists

Большинство запросов к конечной точке /words имеет задержку, превышающую
1200 мс. Здесь значение p99 составляет почти 5 с.
Сравнивая результаты, видим проблемы с производительностью word-exists. Ее
улучшение затронет 95 % клиентов. Преждевременно оптимизировать word-ofthe-day не нужно, так как производительность этой конечной точки достаточно
хороша и затрагивает всего 5 % клиентов.
Чтобы вычислить влияние обеих конечных точек на производительность,
воспользуемся формулой из второй главы. У word-of-the-day задержка p99 составляет 360 мс, но выполняется всего один запрос в секунду: .
(1 × 360) = 360. С другой стороны, у word-exists задержка p99 составляет почти .
5000 мс: (20 × 5000) = 100 000. Можно вычислить, что word-of-the-day отвечает менее чем за 1 % работы по обработке запросов: 360 / (100 000 + 360) = .
= 0,003 = 0,3 %.
После этих вычислений становится очевидно, где сосредоточить усилия по оптимизации. Логика word-exists занимает 99,7 % общей рабочей нагрузки системы.
Когда вы знаете, что логика word-exists создает проблемы, необходимо получить
информацию с нижнего уровня кода. Нужно понимать, на какие части кодового
пути приходится большая часть времени обработки. Чтобы это понять, можно
измерить хронометраж кода на критическом пути; займемся этим в следующем
разделе.

5.4. Обнаружение критического пути в коде

157

5.4.2. Измерение хронометража кодовых путей
с использованием MetricRegistry
В разделе 5.3 код, проверяющий существование слова в словаре, был простым,
и в нем не применялась оптимизация. На тот момент мы еще не знали, нужна
ли она. Теперь у нас есть входные данные о количестве запросов, которые будет
обрабатывать сервис.
Тесты производительности показали, что проблема с задержкой возникает с конечной точкой /word-exists, обслуживающей 95 % пользовательских запросов.
Тесты Gatling работали по принципу «черного ящика»; это означает, что у нас
была информация о поведении конкретных конечных точек, но мы не знали, на
какие части системы приходятся основные затраты времени. Посмотрим, как
обстоит дело сейчас.
Метод wordExists() включает два основных аспекта функциональности. Первый загружает файл с проверяемыми словами. Второй — фаза сканирования —
определяет, существует ли фактическое слово. Обе стадии можно упаковать
в отдельные таймеры, чтобы измерить продолжительность каждого вызова этих
методов и получить более подробную информацию об их производительности.
В листинге 5.18 создаются два таймера. Первый таймер измеряет время, необходимое для загрузки файла. Второй таймер измеряет время сканирования (то
есть время, необходимое для проверки того, есть ли слово в словаре).
Листинг 5.18. Измерение хронометража логики word-exists
@Override
public boolean wordExists(String word) {
Timer loadFile = metricRegistry.timer("loadFile");
try (Scanner scanner = loadFile.time(() -> new
Scanner(filePath.toFile()))) {
Измеряет время создания нового
сканера для обращения к файлу
Timer scan = metricRegistry.timer("scan");
return scan.time(
Измеряет время выполнения
() -> {
основной логики метода
while (scanner.hasNextLine()) {
String line = scanner.nextLine();
if (word.equals(line)) {
return true;
}
}
return false;
});
} catch (Exception e) {
throw new RuntimeException("Ошибка в wordExists для слова: " + word, e);
}
}

158

Глава 5. Преждевременная оптимизация и оптимизация критического пути

Таймер выполняется для каждой операции. В выходных данных будут получены
процентили, среднее значение и количество вызовов. Измерение можно проводить на любом уровне детализации, соответствующем актуальным потребностям.
Измерения всех кодовых путей могут повлиять на общую производительность
логики обработки, так что проводить их следует с осторожностью. Когда логика
будет оптимизирована, можно принять решение об удалении некоторых (или
всех) измерений.
Последний шаг перед повторным выполнением тестов производительности —
использование нового класса MeasuredDefaultWordsService в WordsController.
Код его создания приведен в следующем листинге.
Листинг 5.19. Использование MeasuredDefaultWordsService
wordsService = new MeasuredDefaultWordsService(defaultPath);

При перезапуске приложения будет измеряться каждый запрос к конечной точке
API /word-exists. После завершения теста производительности Gatling можно
обратиться к конечной точке http://localhost:8081/metrics?pretty=true, чтобы увидеть
все метрики, предоставляемые приложением. Найдите раздел, посвященный
loadFile; в нем приводятся данные процентилей. Для нас наиболее интересен
99-й процентиль; см. следующий листинг.
Листинг 5.20. Просмотр данных о производительности loadFile
loadFile": {
"count": 1200,
"p99": 0.000730684,
"duration_units": "seconds"
}

Результаты выводятся в секундах, и видим, что 99-й процентиль действия загрузки файла равен 7 мс. Операция загрузки файла не вызывает проблем с производительностью, обнаруженных с помощью тестов Gatling.
Счетчик count содержит количество вызовов конкретного кода. По этим данным можно сравнить разные кодовые пути и определить, где тратится большая часть времени. Такая возможность полезна, когда заранее определенная
информация об ожидаемом трафике или SLA отсутствует. Если эти сведения
доступны, можно воспользоваться метриками для проверки утверждений.
В таком сценарии мы развертываем приложение в рабочей конфигурации
с метриками и вычисляем, какие кодовые пути активизируются большую
часть времени. Узнав это, можно найти критический путь и сосредоточиться
на повышении его производительности. В следующем листинге приведен
хронометраж сканирования файла.

5.5. Повышение производительности критического пути

159

Листинг 5.21. Хронометраж сканирования
"scan": {
"count": 1200,
"p99": 4.860273076,
"duration_units": "seconds"
}

Видим, что 99-й процентиль составляет почти 5 с. Похоже, мы нашли причину
проблем с производительностью. Операция сканирования происходит долго,
и именно на нее приходится большая часть времени обработки запроса.
После выявления причины можно переходить к оптимизации критического пути.
Сделаем это в следующем разделе и определим, привело ли усовершенствование
к повышению производительности.
ПРИМЕЧАНИЕ
Если добавить код хронометража в приложение, производительность которого тестируется, нельзя, попробуйте использовать профилирование, чтобы получить более подробную информацию о времени, затрачиваемом в конкретных частях кода.
Например, в JVM можно воспользоваться Java Flight Recorder (http://mng.bz/2jYg).

5.5. ПОВЫШЕНИЕ ПРОИЗВОДИТЕЛЬНОСТИ
КРИТИЧЕСКОГО ПУТИ
Мы хотим оптимизировать кодовый путь word-exists. Когда вы экспериментируете с методом wordExists() и пытаетесь применить другой подход, необходимо
получить обратную связь о его производительности. Можно воспользоваться
существующими тестами Gatling, но они работают на высоком уровне, а их выполнение занимает больше времени. Для этого необходимо запустить веб-сервер
и тесты Gatling и получить результаты. Так как мы знаем конкретный кодовый
путь, который необходимо оптимизировать, можно написать низкоуровневые
микротесты для этого пути. Это позволит быстрее получить обратную связь
и найти более производительное решение.
Стоит заметить, что при наличии высокоуровневых тестов производительности
создавать микротесты для каждого изменения, скорее всего, не обязательно.
Микротесты требуют усилий, но, с другой стороны, обеспечивают более быструю обратную связь. Если вы хотите протестировать N решений для одной
низкоуровневой проблемы, микротесты могут оказаться более эффективными.
Далее я покажу, как реализовать микротесты для учебных целей. Впрочем, вы
можете разработать свое решение или написать другой микротест и сравнить
его с представленным в этом разделе.

160

Глава 5. Преждевременная оптимизация и оптимизация критического пути

5.5.1. Создание микротеста JMH для существующего
решения
Прежде чем оптимизировать кодовый путь, напишем тест JMH для существующего кода. Будем считать его эталонным и использовать как отправную точку
для дальнейшего улучшения кода. Тест покрывает логику критического пути,
который занимает большую часть времени обработки запросов.
В листинге 5.22 приведена логика подготовки бенчмарк-теста. Он выполняет
10 итераций для тестового прогона (чем больше итераций, тем точнее результаты). Требуется измерить среднее время, занимаемое методом теста. Один тест
проводит измерения вызова wordExists NUMBER_OF_CHECKS * WORDS_TO_CHECK.
size() раз. Каждая итерация выполняет 100 проверок для моделирования более
реалистичного сценария использования. Словарный сервис будет использоваться
100 раз, после чего начнется следующая итерация.
Листинг 5.22. Создание теста word-exists
@Fork(1)
@Warmup(iterations = 1)
@Measurement(iterations = 10)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class WordExistsPerformanceBenchmark {
private static final int NUMBER_OF_CHECKS = 100;
private static final List WORDS_TO_CHECK =
Arrays.asList("made", "ask", "find", "zones", "1ask", "123");

Обратите внимание: мы выбираем слова из начала, середины и конца словарного
файла. Также в список включены некоторые несуществующие слова.
Эталонный тест создает DefaultWordsService (текущая логика без оптимизации). Проверка существования слова будет выполнена 100 раз, и каждое слово
из списка будет проверено один раз за итерацию.
Экземпляр WordsService создается один раз для каждой итерации измерений JMH. Он повторно используется 100 * WORDS_TO_CHECK.size(). Метод
wordExists() вызывается для каждого слова, как показано в следующем листинге.
Листинг 5.23. Получение эталонных данных
@Benchmark
public void baseline(Blackhole blackhole) {
WordsService defaultWordsService = new DefaultWordsService(getWordsPath());
for (int i = 0; i < NUMBER_OF_CHECKS; i++) {
for (String word : WORDS_TO_CHECK) {
blackhole.consume(defaultWordsService.wordExists(word));

5.5. Повышение производительности критического пути

161

}
}
}
Получает путь
к файлу словаря

private Path getWordsPath() {
try {
return Paths.get(
Objects.requireNonNull(getClass().getClassLoader()
➥ .getResource("words.txt")).toURI());
} catch (URISyntaxException e) {
throw new IllegalStateException("Invalid words.txt path", e);
}
Benchmark
CH05.WordExistsPerformanceBenchmark.baseline

Mode
avgt

Cnt

Score Error
55440.923

Units
ms/op

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

5.5.2. Оптимизация проверки с использованием
кэширования
Будем считать, что файл со словами для проверки существования слов статичен и не изменяется. Это утверждение важно в контексте нашей логики. Оно
означает, что результат однократной проверки существования слова не будет
меняться в будущем.
Можно построить статическую карту, в которой ключом является слово,
а значением — его существование в словаре. Строить ее необходимо на стадии
инициализации приложения. На рис. 5.7 изображена теоретическая карта для
рассматриваемого примера.
Ask
Запускает
приложение

Оператор

True

Made

True

Zones

True

Откладывает
запуск

Рис. 5.7. Немедленная инициализация и вычисление

Word-exists

162

Глава 5. Преждевременная оптимизация и оптимизация критического пути

Файл словаря может содержать миллионы записей, и построение карты с опережением существенно увеличивает время запуска приложения; в данном случае
применяется немедленная оптимизация. Это также означает, что нам придется
использовать значительные ресурсы для предварительного вычисления данных,
которые могут не понадобиться в будущем.
Некоторая часть оперативной памяти будет задействована независимо от
фактического использования сервиса. Может оказаться, что проверяется лишь
небольшая часть слов, а другие остаются невостребованными. Ненужные слова
занимают место без всякой пользы, а программа использует больше памяти,
чем необходимо.
Другое возможное решение — отложенное конструирование кэша. Это означает,
что кэш изначально пуст и строится при поступлении запросов. Мы предполагаем, что словарный файл статичен и не изменяется. Если он содержит небольшой
объем данных, его можно кэшировать в течение неопределенного времени без
вытеснения. Тем не менее в реально используемых системах, которые должны
загружать гораздо больший объем данных, ситуация может быть другой. Например, приложение, которое проверяет существование слов в большем количестве
языков (например, английском, испанском, китайском и т. д.), должно загружать
все словари. В таких случаях избыточные затраты памяти необходимо сократить,
поэтому можно воспользоваться вытеснением данных, которые находятся в кэше
в течение некоторого времени.
Время вытеснения может вычисляться на основании данных трафика. Например, регистрация запросов в журнале может дать статистику по запрашиваемым
словам. На основании времени запросов можно определить интервалы между
ними. Следующий шаг — построение статического распределения временных
интервалов. Имея такие данные, например, можно определить 90-й процентиль и
задать время вытеснения для данного значения. Это гарантирует, что кэш будет
обслуживать 90 % запросов. А если 99-й процентиль будет не слишком большим,
то для вытеснения также можно выбрать это значение.
Наше решение предназначено для ситуации, в которой приложение еще не было
развернуто, и у нас нет достаточно данных о распределении трафика, кроме SLA
и ожидаемого количества запросов в секунду. В таком случае можно выбрать
прогнозное значение и сохранить статистику по содержимому кэша. Когда
приложение будет выпущено в эксплуатацию, можно собрать информацию
о попаданиях в кэш и промахах, а также другую статистику, чтобы оценить эффективность работы кэша. Если доля промахов слишком велика, стоит подумать
об увеличении времени вытеснения.
Реализуем решение на базе кэширования и измерим его производительность
(листинг 5.24). Необходимо сконструировать кэш, который вызывает существующий метод word-exists, если заданное слово отсутствует в кэше. Для этого

5.5. Повышение производительности критического пути

163

устанавливается время вытеснения по умолчанию (5 минут). При поступлении
дополнительных данных о распределении трафика в рабочей среде это значение
адаптируется. В кэше ключом будет слово, а значением — его наличие в словаре.
Для этой цели воспользуемся классом LoadingCache из Guava (http://mng.bz/1jOX).
ПРИМЕЧАНИЕ
Мы используем библиотеку Google Guava, потому что это одна из самых популярных
библиотек кэширования для Java. Можно выбрать и другую библиотеку кэширования (например, Caffeinate) — общие выводы этой главы останутся справедливыми.

Если к конкретному слову не было обращений за период вытеснения, оно
удаляется из кэша. Следующий листинг показывает, как конструируется кэш.
Листинг 5.24. Конструирование кэша для word-exists
public static final Duration DEFAULT_EVICTION_TIME = Duration.ofMinutes(5)
LoadingCache wordExistsCache =
CacheBuilder.newBuilder()
.ticker(ticker)
.expireAfterAccess(DEFAULT_EVICTION_TIME)
.recordStats()
.build(
new CacheLoader() {
@Override
public Boolean load(@Nullable String
if (word == null)
return false;
}
return checkIfWordExists(word);
}
});

Сохранение статистики
для получения информации
об эффективности кэширования

word) throws Exception {
Если word содержит null,
немедленно возвращается false
В противном случае
выполняется реальный
метод проверки

Прежде чем тестировать производительность улучшенного решения, проверим
его на правильность. Метод FakeTicker() будет использоваться для моделирования течения времени без потока приостановки, как показано в листинге 5.25.
Первая проверка существования слова запускает реальную операцию проверки.
После нее в кэше должен появиться один элемент.
Листинг 5.25. Модульное тестирование кэша word-exists
@Test
public void shouldEvictContentAfterAccess() {
// Дано
FakeTicker ticker = new FakeTicker();
Path path = getWordsPath();
CachedWordsService wordsService = new CachedWordsService(path, ticker);
// Если

164

Глава 5. Преждевременная оптимизация и оптимизация критического пути

assertThat(wordsService.wordExists("make")).isTrue();







// То
Первый запрос
assertThat(wordsService.wordExistsCache.size()).isEqualTo(1); инициирует
реальную загрузку
assertThat(wordsService.wordExistsCache.stats()
данных
.missCount()).isEqualTo(1);
assertThat(wordsService.wordExistsCache.stats()
.evictionCount()).isEqualTo(0);
Элемент не был вытеснен;
он находится в кэше
// Если
ticker.advance(
Продвижение времени
CachedWordsService.DEFAULT_EVICTION_TIME);
для моделирования вытеснения
assertThat(wordsService
.wordExists("make")).isTrue();
Вызывает wordExists() для активизации

вытеснения
// То
assertThat(wordsService.wordExistsCache.stats()
➥ .evictionCount()).isEqualTo(1);
После операции элемент вытесняется:
}
счетчик вытеснений = 1

Наконец, в листинге 5.26 показано, как написать микротест с помощью JMH для
проверки того, улучшает ли новая архитектура производительность wordExists().
Единственное отличие в том, что мы используем реализацию, основанную на кэше.
Листинг 5.26. Написание микротеста для кэша word-exists
@Benchmark
public void cache(Blackhole blackhole) {
WordsService defaultWordsService = new CachedWordsService(getWordsPath());
for (int i = 0; i < NUMBER_OF_CHECKS; i++) {
for (String word : WORDS_TO_CHECK) {
blackhole.consume(defaultWordsService.wordExists(word));
}
}
}

Повторим тест и сравним результаты первой эталонной версии и улучшенной
версии с кэшем. Результаты представлены в следующем листинге.
Листинг 5.27. Результаты бенчмарк-теста для эталонной версии и версии
с кэшем
Benchmark
Mode
CH05.WordExistsPerformanceBenchmark.baseline avgt
CH05.WordExistsPerformanceBenchmark.cache
avgt

Cnt Score
Error Units
55440.923
ms/op
557.029
ms/op

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

5.5. Повышение производительности критического пути

165

Листинг 5.28. Использование CacheWordsService в WordsController
wordsService = new CachedWordsService(defaultPath);

Все готово к выполнению тестов производительности Gatling. Чтобы провести
тестирование, выполните действия, описанные в разделе 5.4. Откройте результаты теста производительности (рис. 5.8).

Рис. 5.8. Тесты производительности для улучшенной версии word-exists

Видим, что производительность решения существенно возросла. Задержка
99-го процентиля составляет 65 мс — это почти в 80 раз быстрее, чем в начальной версии!
После оптимизации следует пересчитать объем работы этих конкретных частей
кода во время выполнения. Задержка p99 сократилась до 65 мс. Для вычисления эффекта логики word-of-the-day и word-exists можно воспользоваться
формулой из раздела 5.2:
Трафик word-exists составляет 20 запросов в секунду, задержка p99 равна
65 мс:
(20 × 65) = 1300
Конечная точка word-of-the-day обрабатывает один запрос в секунду, задержка p99 равна ~360 мс:
(1 × 360) = 360
Наконец, можно вычислить процент трафика, генерируемый обеими конечными
точками. Для этого вычислим трафик word-of-the-day:
360 ÷ (360 + 1300) ~= 0,21 == 21 %
Как видно из рис. 5.9, вычисления показывают, что трафик word-of-the-day
генерирует 21 % рабочей нагрузки системы, а трафик word-exists отвечает за

166

Глава 5. Преждевременная оптимизация и оптимизация критического пути

остальные 79 %. Нагрузка word-exists сократилась с 99,7 %, хотя она все еще
отвечает за бо́льшую часть работы. Тем не менее, как следует из расчетов выше,
это влияет на 95 % запросов пользователей. После оптимизации функциональность получения «слова дня» (затрагивающая 5 % запросов пользователей)
получает 21 % вычислительных ресурсов. При необходимости дальнейшей
оптимизации можно рассчитать возможную экономию времени по формулам
из раздела 5.2. Допустим, производительность обеих конечных точек можно
улучшить еще на 10 %. Для «слова дня» эти формулы дают 36 мс экономии
времени, потому что эта конечная точка получает всего один запрос в секунду:
0,1 × 360 × 1 = 36.

Word-of-the-day
21.7%

Word-exists
78.3%

Рис. 5.9. Процентное соотношение трафика word-of-the-day и word-exists

Улучшение производительности word-exists на дополнительные 10 % дает экономию 130 мс, так как обрабатываются 20 запросов в секунду: 0,1 × 65 × 20 = 130.
Однако может оказаться, что оптимизация производительности word-exists еще
на 10 % непрактична или труднодостижима. Можно вычислить это только путем
оптимизации word-of-the-day на 40 %, что сэкономит нам больше времени, чем
оптимизация word-exists на 10 %: 0,4 × 360 × 1 = 144.
Если оптимизация этой конечной точки на 40 % предпочтительнее, чем оптимизация word-exists на 10 %, можно выбрать вариант оптимизации фрагментов
кода, не находящихся на критическом пути. Но как вы заметили, оптимизация
критического пути дает больше преимуществ при меньших усилиях. В реальных
условиях проще и эффективнее оптимизировать кодовый путь на 10 %, а не на
40 %.
ПРИМЕЧАНИЕ
При оптимизации производительности одной конечной точки часто требуется выделить больше ресурсов для обработки трафика. При этом снижаются задержки и/
или увеличивается пропускная способность конкретной конечной точки, но может

5.5. Повышение производительности критического пути

167

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

5.5.3. Увеличение количества входящих слов в тестах
производительности
Осталось сделать еще одно важное замечание. В окончательной версии решения
логика построена на основе кэширования. Значит, тесты всего шести входящих
слов недостаточно эффективны для проверки производительности. Решение,
в основе которого лежит механизм кэширования, следует тестировать с большим
количеством входящих слов. Это позволяет удостовериться, что данные из кэша
не вытесняются слишком рано. Кроме того, такой подход гарантирует, что кэш
не расходует чрезмерный объем памяти в системе.
Возьмем 100 случайных слов из словаря и поместим их в файл words.csv, используемый симуляцией Gatling. Количество слов должно соответствовать ожидаемому трафику. Наш тест выполняет 20 запросов в секунду в течение 60 с, итого
1200 запросов. Если использовать еще более случайные слова (например, 1000),
почти каждый запрос будет попадать в кэш с не загруженным ранее значением
и наблюдаемого прежде улучшения производительности не будет.
Можно делать иначе: выбирать более случайные слова с увеличением времени
тестирования. Тем самым мы заполним кэш данными. Тогда последующие запросы будут чаще приходиться на элементы, которые уже существуют в кэше.
Чтобы получить N случайных слов из файла words.txt, можно воспользоваться
командой Linux sort. В следующем листинге приведен код получения случайных слов.
Листинг 5.29. Получение случайных слов
sort -R words.txt | head -n 100 > to_check.txt

Наконец, необходимо скопировать слова из файла to_check.txt в файл words.csv,
используемый Gatling, и снова запустить симуляцию. Как видно из рис. 5.10,
результаты заметно отличаются.

168

Глава 5. Преждевременная оптимизация и оптимизация критического пути

Рис. 5.10. Увеличение количества входящих слов

Производительность все еще намного выше, чем у исходного решения, но и задержки увеличились. Это объясняется тем, что почти 10 % (100 ÷ 1200) запросов
приходится на пустой кэш.
Экспериментируйте с разными паттернами трафика и временем тестирования.
Главное, что необходимо усвоить из этого раздела: при изменении деталей
решения (например, параметров кэша) стоит адаптировать к ним тесты производительности, чтобы представить более реалистичное распределение трафика.
Если вы проводите анализ и собираете данные перед выпуском продукта, то такую оптимизацию уже нельзя назвать преждевременной. Бенчмарк-тесты дают
много полезной информации о коде. Если у вас есть тесты с трафиком, близким
к реальному, вы получите правдоподобные данные о производительности. Имея
такие сведения, можно оптимизировать нужные части кода и быть уверенными,
что это даст хорошие результаты.
В этой главе нам удалось сократить задержку и повысить производительность
приложения до его развертывания. Если у вас достаточно данных об SLA
и ожидаемом объеме трафика, преждевременная оптимизация может дать отличные результаты. Главное — не забывать следовать стратегии поиска узких
мест и не увлекаться оптимизацией случайных кодовых путей. Если ваше
приложение реализует функциональность и следует принципу Парето в отношении распределения трафика в коде, найти критический путь относительно
просто. А когда он будет найден, область оптимизации можно сократить при
помощи микротестов. Они позволят повысить эффективность оптимизации
кода за счет более быстрого цикла получения обратной связи. Не забудьте,
что при оптимизации критического пути узкое место может переместиться
в другие части кода.

Итоги

169

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

ИТОГИ
Даже если код не на критическом пути, его выполнение может занимать
много времени. Это часто происходит, когда некритический кодовый путь на
порядки медленнее критического; такой код может требовать оптимизации.
Для поиска критических путей, на которых необходима оптимизация, можно
воспользоваться формулами из второго раздела этой главы.
Бенчмарк-тесты, созданные с применением инструмента Gatling, позволяют
обнаружить критический путь на основании ожидаемого трафика.
Отдельные части кода можно измерять с использованием метрик.
Чтобы сузить область действия тестов производительности, можно воспользоваться микротестами и JMH.
Кэширование часто позволяет оптимизировать критический путь.
Данные тестов Gatling можно использовать для проверки и сравнения результатов оптимизации производительности.

6

Простота и затраты
на обслуживание API
https://t.me/it_boooks
В этой главе:
33 UX и компромиссы обслуживания при интеграции со сторонними
библиотеками.
33 Эволюция настроек, предоставляемых клиентам.
33 Плюсы и минусы абстрагирования кода, который вам не принадлежит.

При построении систем для конечных пользователей на первый план выходит
простота API и удобный опыт взаимодействия (UX, User Experience). Важно
заметить, что понятие UX применимо ко всем интерфейсам. Можно спроектировать графический интерфейс (GUI), элегантный и удобный для пользователя.
Также можно создать REST API в UX-ориентированном стиле. На более глубоком уровне даже средства командной строки могут быть (или не быть) UXориентированными. По сути, любой программный продукт, взаимодействующий
с пользователем, требует обсуждения и планирования в области UX.
Механизм конфигурации системы — точка входа, которую необходимо предоставить клиентам, и жизненно важная часть UX-ориентированности компонента.
Часто системы зависят от нескольких компонентов и используют их для получения результата обработки. Каждый из зависимых компонентов предоставляет
свои настройки конфигурации, которые необходимо каким-то образом задавать.
Все последующие компоненты (элементы, используемые системой, для которой создается UX) можно абстрагировать и не предоставлять их настройки

6.1. Базовая библиотека, используемая другими инструментами

171

непосредственно в инструменте, взаимодействующем с пользователем. Такой
подход улучшит UX нашей системы, но потребует значительных затрат на
обслуживание.
Противоположный подход — напрямую предоставить доступ ко всем зависимым
настройкам. Такое решение не потребует значительных усилий по обслуживанию, но у него есть свои недостатки. Во-первых, клиенты жестко привязываются
к компонентам, используемым сервисом или программой. Это усложняет возможную модификацию компонента. Кроме того, вносить UX-ориентированные
изменения в такой компонент (как и те, что подразумевают обратную совместимость) станет сложно или вовсе невозможно.
Оба варианта имеют свои плюсы и минусы, и с позиции этих решений можно обсуждать связи между простотой API и затратами на обслуживание. Далее в этой
главе мы рассмотрим эти компромиссы подробнее, а начнем с представления
и анализа облачного компонента, в котором уже есть собственный механизм
конфигурации. Затем мы используем этот компонент в двух инструментах
с разным соотношением затрат на обслуживание и простоты UX.

6.1. БАЗОВАЯ БИБЛИОТЕКА, ИСПОЛЬЗУЕМАЯ
ДРУГИМИ ИНСТРУМЕНТАМИ
Чтобы система создавала ценность для бизнеса, она должна интегрироваться
с другими программными инструментами и использовать их в своей работе.
Например, приложение может интегрироваться с базами данных, очередями или
провайдерами облачных сервисов. Возможно, понадобится интеграция с такими
частями операционной системы, как файловая система, сетевые интерфейсы
или диск. Большинство систем, от которых зависят приложения, предоставляют
собственные пакеты SDK (Software Development Kit) или клиентские библиотеки. Они позволяют легко интегрироваться с системами без разработки полной
интеграции с нуля. Рисунок 6.1 иллюстрирует сказанное.
Приложение
Облачная внешняя
система

Вызывает

Клиентский SDK,
обеспечивающий
интеграцию с облаком

Клиентский SDK,
Вызывает
обеспечивающий
интеграцию с очередью
Клиентский SDK,
обеспечивающий
интеграцию с БД

Вызывает

Внешняя система
очереди

Внешняя
система БД

Рис. 6.1. SDK с клиентом, обеспечивающим интеграцию с внешними системами

172

Глава 6. Простота и затраты на обслуживание API

Как уже было отмечено, у реального приложения может возникнуть необходимость в интеграции с базами данных, очередями (например, Apache Kafka
или Pulsar) и облачными сервисами (например, EC2 и GCP). Все эти сервисы
предоставляют клиентские библиотеки. Использование этих библиотек позволяет создать более надежный продукт по сравнению с тем, в котором каждую
интеграцию приходится писать с нуля.
Почти каждый клиент нуждается в настройке конфигурации, прежде чем взаимодействовать со сторонними системами. Например, это могут быть учетные данные
аутентификации, продолжительность тайм-аута, размер буфера или другие настройки. Конфигурация может предоставляться, например, через системные свойства,
переменные окружения или файлы конфигурации, и все пользователи клиентского
компонента должны передать свой вариант конфигурации клиентской библиотеке.
Для примеров этой главы ради ясности и доступности объяснения построим
простой компонент облачного клиента, который нужно настроить перед использованием. Далее задействуем этот компонент из двух программ, с которыми
работают конечные пользователи. Начнем с создания компонента, а также изучим
его применение.

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

Отправляет

Запрос с данными
аутентификации
и настройками

Вызывающая
сторона

Клиент облачного
сервиса

Отправляет
данные

Облачный сервис

Аутентификация
Стратегия аутентификации
Использует одну
из этих стратегий
TokenAuthStrategy

UsernamePassword
AuthStrategy

Рис. 6.2. Компонент облачного клиента и две возможные стратегии аутентификации

6.1. Базовая библиотека, используемая другими инструментами

173

Рассмотрим эти компоненты. Первая точка входа облачного компонента для
вызывающей стороны — класс Request. Он содержит список элементов данных
и информацию, необходимую для выполнения аутентификации: имя пользователя, пароль или маркер. В следующем листинге представлена эта реализация
(если аннотация @Nullable вам незнакома, см. http://mng.bz/PWgw).
Листинг 6.1. Создание облачного запроса
public class Request {
@Nullable private final String token;
@Nullable private final String username;
@Nullable private final String password;
private final List data;
// Конструкторы, hashCode, равенство,
// методы чтения и записи не приводятся
}

Сообщает пользователям, что маркер
может принимать значение null

Компонент CloudServiceClient обрабатывает запрос (можно рассматривать
компонент как облачную клиентскую библиотеку с облачным провайдером —
AWS, Azure, GCP и т. д.). Интерфейс компонента достаточно прост, как показано
в следующем листинге. Он предоставляет всего один открытый метод, который
должен использоваться клиентами этого компонента.
Листинг 6.2. Создание интерфейса CloudServiceClient
public interface CloudServiceClient {
void loadData(Request request);
}

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

6.1.2. Стратегии аутентификации
Наш облачный компонент поддерживает две стратегии аутентификации. Первая — простая аутентификация с именем пользователя и паролем, как показано в
листинге 6.3. Она требует, чтобы во входящем запросе имя пользователя и пароль
были отличны от null. Объект создается на базе этой конфигурации и проверяет,
соответствует ли имя пользователя/пароль в Request заданной конфигурации.
Листинг 6.3. Стратегия аутентификации с именем пользователя/паролем
public interface AuthStrategy {
boolean authenticate(Request request);
}

AuthStrategy реализуется обеими
стратегиями аутентификации

174

Глава 6. Простота и затраты на обслуживание API

public class UsernamePasswordAuthStrategy implements AuthStrategy {
private final String username;
private final String password;
public UsernamePasswordAuthStrategy
➥ (String username, String password) {
this.username = username;
this.password = password;
}

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

@Override
public boolean authenticate(Request request) {
if (request.getUsername() == null
➥ || request.getPassword() == null) {
Если имя пользователя или пароль в запросе
return false;
содержит null, возвращается false
}
return request.getUsername().equals(username) &&
request.getPassword().equals(password);
Проверяет совпадение имени
}
пользователя и пароля
}

Обе стратегии реализуют интерфейс AuthStrategy . Если запрос содержит
имя пользователя или пароль с неопределенным значением null , то метод
authenticate() возвращает false. Заметим, что хранение пароля в формате
String может создать проблемы из-за риска утечки данных из приложения
(и их возможного похищения злоумышленниками). Более эффективный способ
хранения будет рассмотрен ниже.
Вторая стратегия аутентификации похожа на первую, но использует маркер
безопасности для проверки запроса. Если маркер совпадает со значением, предоставленным конструктором, то authenticate() возвращает true.
Листинг 6.4. Маркер AuthStrategy
public class TokenAuthStrategy implements AuthStrategy {
public TokenAuthStrategy(String token) {
this.token = token;
}

Реализует
AuthStrategy

private final String token;
@Override
public boolean authenticate(Request request) {
if (request.getToken() == null) {
return false;
}
return request.getToken().equals(token);
}
}

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

6.1. Базовая библиотека, используемая другими инструментами

175

Логика не изменилась по сравнению с механизмом аутентификации в листинге 6.4, но в ней используется маркер из запроса.

6.1.3. Понимание механизма конфигурации
Клиент предоставляет конфигурацию облачного сервиса в файле формата YAML.
ПРИМЕЧАНИЕ
Многие реальные фреймворки и библиотеки используют очень похожий механизм
конфигурации на базе YAML (например, Spring Boot), и примеры этой главы можно
сопоставить с некоторыми из них. Но чтобы не привязывать главу к конкретным
механизмам, напишем отдельный код вместо использования готовых решений.

На основании конфигурации в этом файле создадим класс CloudServiceConfiguration,
используемый реализацией облачного сервиса. На этой стадии построения клиентской библиотеки конфигурация содержит только класс AuthStrategy, который
будет использоваться в механизме аутентификации. В следующем листинге
приведен код создания конфигурации облачного сервиса.
Листинг 6.5. Реализация CloudServiceConfiguration
public class CloudServiceConfiguration {
private final AuthStrategy authStrategy;
public CloudServiceConfiguration(AuthStrategy authStrategy) {
this.authStrategy = authStrategy;
}
public AuthStrategy getAuthStrategy() {
return authStrategy;
}
}

Загрузка конфигурации из YAML должна быть абстрагирована от реализации CloudServiceClient. Такая абстракция может пригодиться, если вы решите поддерживать другой вариант файлов конфигурации — JSON, Hocom
и т. д. Для этого создадим класс DefaultCloudServiceClient, внедряющий
CloudServiceConfiguration через конструктор. Метод loadData() сначала проверяет, требует ли запрос аутентификации. В нем используются данные CloudS
erviceConfiguration#authStrategy, предоставленные в объекте конфигурации,
как показано в следующем листинге.
Листинг 6.6. Создание CloudServiceClient по умолчанию
public class DefaultCloudServiceClient implements CloudServiceClient {
private CloudServiceConfiguration cloudServiceConfiguration;
public DefaultCloudServiceClient(CloudServiceConfiguration

176

Глава 6. Простота и затраты на обслуживание API

cloudServiceConfiguration) {
this.cloudServiceConfiguration = cloudServiceConfiguration;
}
@Override
public void loadData(Request request) {
if (cloudServiceConfiguration.getAuthStrategy().authenticate(request)) {
insertData(request.getData());
После проверки вставляет
}
данные в облачный сервис
}

Остается последний шаг — прочитать файл конфигурации YAML и сконструировать облачный клиент. Файл конфигурации YAML должен содержать раздел
с конфигурацией аутентификации. В следующем листинге показано, как файл
конфигурации должен определять стратегию с именем пользователя/паролем.
Листинг 6.7. Конфигурация облачного сервиса для имени пользователя/
пароля
auth:
strategy: username-password
username: user
password: pass

Используем значение strategy (username-password) для создания правильной
реализации AuthStrategy. При использовании стратегии с маркером конфигурация YAML выглядит немного иначе. Для целей тестирования в качестве
значения маркера можно использовать любой идентификатор UUID. В реальной
системе маркеры не будут жестко фиксироваться в коде. Они генерируются
динамически и обновляются с некоторым временным интервалом. Стратегия
с маркером безопасности представлена в следующем листинге.
Листинг 6.8. Маркер для облачного сервиса
auth:
strategy: token
token: c8933754-30a0-11eb-adc1-0242ac120002

Наконец, сконструируем класс-строитель, отвечающий за загрузку конфигурации и конструирование DefaultCloudServiceClient. Используем ObjectMapper
(http://mng.bz/J14o) для чтения файла конфигурации YAML и его разбора. Так как
мы используем YAML, файл конфигурации имеет структуру, которую можно
описать как «карту карт». Первая внешняя карта включает все настройки, необходимые для секции аутентификации. Вторая содержит другие настройки.
На рис. 6.3 изображена высокоуровневая структура «карты карт».
На иллюстрации ключом внутренней карты является имя свойства (например, strategy ), а значением может быть любой объект (например, token ).
Другая внешняя карта имеет профильный раздел в конфигурации. Раздел auth

6.1. Базовая библиотека, используемая другими инструментами

177

представляет профильную внешнюю карту. Если позже вы захотите добавить
раздел конфигурации, для него будет создан новый профильный раздел (например, other settingsection).

Auth

Other setting
section

Strategy: token
token: token-value

Key: value

Рис. 6.3. Структура конфигурации в файле YAML

На рис. 6.9 конструктор CloudServiceClientBuilder создает карту карт для чтения файла YAML. Также имеются константы с идентификаторами стратегии
(например, USERNAME_PASSWORD_STRATEGY) для создания правильной стратегии
­аутентификации. В нашем примере используется класс YAMLFactory из библиотеки
Jackson (http://mng.bz/wnGO). ObjectMapper читает конфигурацию из файла YAML.
Листинг 6.9. Конструктор CloudServiceClientBuilder
public class CloudServiceClientBuilder {
private static final String USERNAME_PASSWORD_STRATEGY = "username-password";
private static final String TOKEN_STRATEGY = "token";
private final ObjectMapper mapper;
Используется для чтения
private final MapType yamlConfigType;
файла конфигурации
public CloudServiceClientBuilder() {
Так как файл содержит YAML,
mapper = new ObjectMapper(new YAMLFactory());
используется YAMLFactory
MapType mapType =
mapper.getTypeFactory().constructMapType(HashMap.class, String.class,
Object.class);
Внутренняя карта имеет строковый ключ
yamlConfigType =
и объектное значение
mapper
.getTypeFactory()
.constructMapType(
Внешняя карта содержит
HashMap.class, mapper.getTypeFactory()
тип внутренней карты
➥ .constructType(String.class), mapType);
}
// ...

Осталось рассмотреть последнюю часть клиента облачного сервиса. Как видно из листинга 6.10, за создание объекта отвечают два метода. Мы разрешаем

178

Глава 6. Простота и затраты на обслуживание API

вызывающей стороне этого кода передать путь к файлу конфигурации YAML.
При этом мы предоставляем возможность обеспечить CloudServiceConfiguration
на программном уровне без использования механизма конфигурации YAML. Сам
факт предоставления двух механизмов конфигурации позволяет вызывающим
сторонам настроить клиент двумя способами. У обоих вариантов есть свои достоинства и недостатки, которые мы разберем ниже.
Листинг 6.10. Создание DefaultCloudServiceClient на основе конфигурации
public DefaultCloudServiceClient
➥ create(CloudServiceConfiguration cloudServiceConfiguration) {
return new DefaultCloudServiceClient(cloudServiceConfiguration);
}

Передает
конфигурацию
в конструктор

public DefaultCloudServiceClient create(Path configFilePath) {
try {
Map config =
Читает файл YAML
mapper
с использованием
➥ .readValue(configFilePath.toFile(), yamlConfigType);
configFilePath
AuthStrategy authStrategy = null;
Извлекает раздел
Map authConfig = config.get("auth");
конфигурации
аутентификации
if (authConfig.get("strategy")
➥ .equals(USERNAME_PASSWORD_STRATEGY)) {
Если используется стратегия
authStrategy =
USERNAME_PASSWORD_STRATEGY
new UsernamePasswordAuthStrategy(
(String) authConfig.get("username"),
... создается Username➥ (String) authConfig.get("password"));
Password-AuthStrategy
Аналогичная логика применяется
} else if (authConfig.get("strategy")
для TOKEN_STRATEGY
➥ .equals(TOKEN_STRATEGY)) {
authStrategy = new TokenAuthStrategy((String) authConfig.get("token"));
}
return new DefaultCloudServiceClient(new
CloudServiceConfiguration(authStrategy));
} catch (IOException e) {
throw new UncheckedIOException("Ошибка при загрузке файла из: " +
configFilePath, e);
}
}

Метод create() на базе YAML читает из конфигурации раздел аутентификации.
Затем он проверяет, соответствует ли стратегия CloudServiceConfiguration.
Если проверка дает положительный результат, логика create() пытается сконструировать класс UsernamePasswordAuthStrategy. В противном случае, если
используется стратегия TOKEN_STRATEGY, создается TokenAuthStrategy.
Когда библиотека облачного клиента будет готова, можно внедрить два инструмента, которые будут ее использовать. В них применяются разные подходы
к интеграции. В первом случае настройки облачного клиента предоставляются

6.2. Прямое предоставление настроек зависимой библиотеки

179

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

6.2. ПРЯМОЕ ПРЕДОСТАВЛЕНИЕ НАСТРОЕК
ЗАВИСИМОЙ БИБЛИОТЕКИ
Начнем с инструмента, использующего облачный клиент как пакетный сервис.
Его главная функция — построение пакета входящих запросов до того момента,
когда размер буфера превысит параметр размера пакета. Когда буфер заполняется, программа вызывает облачный клиент, который выполняет аутентификацию
и передает данные в облачный сервис (рис. 6.4).
Перед работой клиент должен настроить пакетный сервис. Этот сервис использует облачный клиент, в который также необходимо передать конфигурацию
клиента. Она должна быть предоставлена конечным пользователем для построения объекта CloudServiceClient, используемого BatchService.
Конфигурация пакетного сервиса достаточно проста, так как содержит всего
один параметр (размер пакета). Она приведена в листинге 6.11.
Пакетный сервис
Пакет входящих запросов
Отправляет запрос
Клиент

При заполнении пакета вызывается
Клиент облачного
сервиса

Отправляет
данные

Рис. 6.4. Архитектура пакетного сервиса для облачного клиента

Листинг 6.11. Реализация BatchServiceConfiguration
public class BatchServiceConfiguration {
public final int batchSize;
public BatchServiceConfiguration(int batchSize) {
this.batchSize = batchSize;
}
public int getBatchSize() {
return batchSize;
}
}

Облако

180

Глава 6. Простота и затраты на обслуживание API

Пакетный сервис использует свою конфигурацию для ограничения количества
агрегированных событий. Когда пакет содержит достаточно элементов (их
количество равно параметру batchSize или превышает его), облачный клиент
используется для отправки данных. BatchService работает с классом Request,
представленным в предыдущем разделе. Вся логика BatchService приведена
в следующем листинге.
Листинг 6.12. Логика BatchService
public class BatchService {
private final BatchServiceConfiguration batchServiceConfiguration;
private final CloudServiceClient cloudServiceClient;
private final List batch = new ArrayList();
Буферизует данные

в списке
public BatchService(
BatchServiceConfiguration batchServiceConfiguration, CloudServiceClient
cloudServiceClient) {
Использует
this.batchServiceConfiguration = batchServiceConfiguration;
внедренный
this.cloudServiceClient = cloudServiceClient;
CloudServiceClient
}
public void loadDataWithBatch(Request request) {
batch.addAll(request.getData());
if (batch.size() >=
➥ batchServiceConfiguration.getBatchSize()) {
cloudServiceClient.loadData(withBatchData(request));
}
}

Когда размер пакета
достигает порога
или превышает его…

private Request withBatchData(Request request) {
return new Request(request.getToken(), request.getUsername(),
request.getPassword(), batch);
}
}

… используется
облачный сервис
и происходит
загрузка данных

Важно, что при выполнении этих запросов пакетный сервис использует облачный клиент напрямую. Внедрение CloudServiceClient в конструктор является
точкой интеграции между пакетным инструментом и облачным клиентом.
ИНКАПСУЛЯЦИЯ CLOUDSERVICECLIENT
В этом примере мы работаем с интерфейсом CloudServiceClient, обобщенным
в контексте конкретной облачной библиотеки. Если нужно повысить гибкость,
можно подумать о создании отдельного класса, инкапсулирующего конкретную
разновидность CloudServiceClient. Это упростит переключение используемых
библиотек без воздействия на код вызывающей стороны (потому что этот код
будет использовать облачный клиент через слой абстракции).

6.2. Прямое предоставление настроек зависимой библиотеки

181

6.2.1. Конфигурация пакетного инструмента
Самое важное решение, касающееся UX и обслуживания пакетного сервиса, —
способ передачи настроек в используемый облачный клиент. Мы решили,
что конечный пользователь пакетного инструмента должен предоставить
конфигурацию в файле YAML. Раздел auth этого файла передается напрямую
в загрузчик конфигурации облачного клиента, как показано на рис. 6.5.
Batch-service-config.yaml

Auth:
Strategy: username-password
Username: u
Password: p
Batch:
Size: 100.

Batch-service

Использует
напрямую

Облачный клиент

Рис. 6.5. Прямая передача настроек облачного клиента из файла
конфигурации YAML

Важно: раздел auth конфигурации пакетного сервиса должен иметь такую же
структуру, как YAML-разметка, настроенная в облаке. Это также означает, что
клиентам пакетного сервиса предоставляется доступ к внутренним подробностям конфигурации облачного клиента. Вследствие этого обслуживание с нашей
стороны при конструировании облачной конфигурации не требуется. Клиент
пакетного сервиса предоставляет конфигурацию, а пакетный сервис передает
ее как есть.
Пакетный сервис использует раздел batch конфигурации. Строитель пакетного
сервиса получает файл YAML в аргументе и загружает файл. Затем он извлекает
раздел batch и использует его для конструирования BatchServiceConfiguration.
Наконец, весь файл YAML передается в строитель клиента облачного сервиса.
Как вы, возможно, помните из предыдущего раздела, CloudServiceClientBuilder
извлекает раздел auth из файла и конструирует клиент. Процесс показан в следующем листинге.

182

Глава 6. Простота и затраты на обслуживание API

Листинг 6.13. Передача файла YAML в облачный клиент
public class BatchServiceBuilder {
public BatchService create(Path configFilePath) {
try {
Извлекает
Map config =
mapper.readValue(configFilePath.toFile(), yamlConfigType); раздел batch
конфигурации
Map batchConfig = config.get("batch");
BatchServiceConfiguration batchServiceConfiguration =
new BatchServiceConfiguration
➥ ((Integer) batchConfig.get("size"));
Использует BatchConfig для
построения BatchServiceConfiguration
CloudServiceClient cloudServiceClient =
Передает
в
строитель
путь к файлу YAML
new CloudServiceClientBuilder()
(необработанные данные конфигурации)
➥ .create(configFilePath);
return new BatchService(batchServiceConfiguration, cloudServiceClient);
} catch (IOException e) {
throw new UncheckedIOException("Ошибка при загрузке файла из: " +
configFilePath, e);
}
}
}

При правильной структуре конфигурации, передаваемой в строитель облачного
клиента, пакетному сервису не требуется выполнять специальную обработку.
Если возникает ошибка, выдается исключение.
Проанализируем, как создание формата конфигурации YAML и встраивание
в него структуры облачной клиентской библиотеки влияет на продукт. Первая проблема заключается в том, что мы создаем сильную связанность между
сервисом и используемой облачной клиентской библиотекой, передавая путь
к файлу конфигурации напрямую загрузчику облачной конфигурации. Тот
ожидает, что в файле присутствует раздел auth. При отсутствии этого раздела
выдается исключение. Если в будущем вы захотите перейти на другую облачную
библиотеку, возникнут проблемы. Раздел auth, предоставляемый инструментом,
становится контрактом (API). Если инструмент используется напрямую, клиенты программных систем должны предоставить файл YAML с конфигурацией
аутентификации. Удалить или изменить раздел auth невозможно, если он стал
не нужен или имеет другой формат.
Вторая проблема возникает, когда облачный клиент изменяется, устаревает
или удаляет настройку конфигурации. Мы рассмотрим такие ситуации далее
в этой главе.
Впрочем, у такого подхода есть свои преимущества. Если вы интегрируетесь
с нижележащей системой, предоставляющей десятки и сотни настроек, прямая
передача конфигурации может быть хорошим вариантом. Также важно, чтобы вызывающая сторона знала формат конфигурации следующей системы и применяла

6.3. Абстрагирование настроек зависимой библиотеки

183

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

6.3. АБСТРАГИРОВАНИЕ НАСТРОЕК ЗАВИСИМОЙ
БИБЛИОТЕКИ
Перейдем ко второму сервису, в котором используется иной подход к настройке
зависимого облачного клиента. Стриминговый сервис, создаваемый в этом разделе, теперь предоставляет только принадлежащие ему настройки. Он использует
их для конструирования облачного клиента, создание и конфигурация которого
абстрагируются от пользователя. Конечные пользователи стримингового инструмента ничего не знают об облачном клиенте, используемом во внутренней
реализации. В следующем листинге показана специфическая конфигурация
стримингового сервиса, содержащая только одну настройку: maxTimeMs.
Листинг 6.14. Построение StreamingServiceConfiguration
public class StreamingServiceConfiguration {
private final int maxTimeMs;
public StreamingServiceConfiguration(int maxTimeMs) {
this.maxTimeMs = maxTimeMs;
}
public int getMaxTimeMs() {
return maxTimeMs;
}
}

Значение maxTimeMs используется для хранения времени запроса в миллисекундах. Если время запроса превышает это значение, регистрируется преду­
преждение. В этом случае стриминговый сервис не объединяет запросы в пакеты, потому что низкая задержка обработки критична. Метод loadData() (как
и предыдущий пакетный сервис) использует объект Request . Общее время
обработки вычисляется вычитанием времени после загрузки облачного сервиса
из начального времени. Рассмотрим логику из следующего листинга, которая
проверяет, превышает ли общее время максимальное время из объекта конфигурации стримингового инструмента.

184

Глава 6. Простота и затраты на обслуживание API

Листинг 6.15. Логика стримингового инструмента
public void loadData(Request request) {
long start = System.currentTimeMillis();
cloudServiceClient.loadData(request);
long totalTime = System.currentTimeMillis() - start;
if (totalTime > streamingServiceConfiguration.getMaxTimeMs()) {
logger.warn(
"Превышено время запроса! Время: {}, но оно должно быть
меньше чем: {}",
totalTime,
streamingServiceConfiguration.getMaxTimeMs());
}
}

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

6.3.1. Конфигурация стримингового сервиса
Стриминговый сервис также использует файл YAML для хранения конфигурации. Самое значительное отличие ее формата от конфигурации пакетной программы из раздела 6.2 состоит в том, что стриминговый сервис предоставляет все
настройки в разделе streaming. Важно отметить, что стриминговый инструмент
поддерживает только аутентификацию «имя пользователя/пароль». В следующем листинге приведен соответствующий раздел файла YAML.
Листинг 6.16. Конфигурация стримингового сервиса
streaming:
username: u
password: p
maxTimeMs: 100
maxTimeMs: 100

Профильный раздел streaming определяет все настройки стримингового сервиса, которому принадлежит конфигурация. Соответственно, нашим клиентам
ничего не известно об используемом клиенте облачного сервиса. Иначе говоря, конфигурация облачного сервиса абстрагируется от пользователя. Раздел
streaming определяет четкий контракт, владельцем которого является стриминговый сервис. Такое решение выглядит проще с точки зрения UX, но требует
обслуживания для отображения настроек из стримингового формата в формат
облачного клиента.
В листинге 6.17 приведена логика создания стримингового сервиса. Все настройки, относящиеся к сервису, извлекаются из раздела streaming: инструмент
читает значение maxTimeMs и конструирует StreamingServiceConfiguration. Самое
важное — конструирование облачного клиента. Внутренняя облачная библиотека

6.3. Абстрагирование настроек зависимой библиотеки

185

и ее стратегия UsernamePasswordAuthStrategy абстрагируются от пользователя.
Клиент StreamingService ничего не знает о механизме конфигурации. Кроме
того, настройки имени пользователя и пароля применяются для конструирования UsernamePasswordAuthStrategy. Затем стратегия создает клиент облачного
сервиса, используя его API программной конфигурации.
Листинг 6.17. Конструирование стримингового сервиса
public StreamingService create(Path configFilePath) {
try {
Map config =
mapper.readValue(configFilePath.toFile(), yamlConfigType);
Map streamingConfig =
Этот раздел извлекается стриминговым
➥ config.get("streaming");
сервисом и принадлежит ему
StreamingServiceConfiguration streamingServiceConfiguration =
new StreamingServiceConfiguration((Integer)
Использует maxTimeMs
➥ streamingConfig.get("maxTimeMs"));
для создания конфигурации

}

CloudServiceConfiguration cloudServiceConfiguration =
new CloudServiceConfiguration(
new UsernamePasswordAuthStrategy(
Имя пользователя
(String) streamingConfig.get("username"),
и пароль
(String) streamingConfig.get("password")));
используются для
return new StreamingService(
конструирования
streamingServiceConfiguration,
конфигурации
new CloudServiceClientBuilder().create(cloudServiceConfiguration));
} catch (IOException e) {
Строитель
throw new UncheckedIOException
с программным
➥ ("Ошибка при загрузке файла из: " + configFilePath, e);
API, который
}
создает облачный
клиент

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

186

Глава 6. Простота и затраты на обслуживание API

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

6.4. ДОБАВЛЕНИЕ НОВОЙ НАСТРОЙКИ
ДЛЯ ОБЛАЧНОЙ КЛИЕНТСКОЙ БИБЛИОТЕКИ
Допустим, клиентский сервис изменяется и предоставляет новую настройку, отвечающую за тайм-аут. Она имеет профильный раздел timeouts в конфигурации
YAML, как показано в следующем листинге.
Листинг 6.18. Добавление новой настройки тайм-аута
auth:
strategy: username-password
username: user
password: pass
timeouts:
connection: 1000

Новая настройка также добавляется в CloudServiceConfiguration. Реализация
приведена в следующем листинге.
Листинг 6.19. Новая настройка тайм-аута для CloudServiceConfiguration
public class CloudServiceConfiguration {
private final AuthStrategy authStrategy;
private final Integer connectionTimeout;
// Конструкторы, hashCode, равенство, методы чтения и записи не приводятся
}

Строитель облачного клиента извлекает раздел timeouts из конфигурации YAML
и использует его для конструирования клиента. В следующем листинге приведена
часть процесса добавления новой настройки для облачной клиентской библиотеки.
Листинг 6.20. Извлечение тайм-аута в CloudServiceClientBuilder
Map timeouts = config.get("timeouts");
// ...
return new DefaultCloudServiceClient(
new CloudServiceConfiguration(authStrategy, (Integer)
timeouts.get("connection")));

6.4. Добавление новой настройки для облачной клиентской библиотеки

187

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

6.4.1. Добавление новой настройки в пакетный инструмент
Пакетный инструмент напрямую передает настройки от вызывающей стороны
в строитель облачного клиента. Это означает, что клиент должен предоставить
новый раздел timeouts для запуска пакетного инструмента. В следующем листинге показано, как должна выглядеть новая пакетная конфигурация YAML.
Листинг 6.21. Добавление нового раздела timeouts
auth:
strategy: username-password
username: u
password: p

timeouts:
connection: 1000
batch:
size: 100

Добавляет новый
раздел конфигурации

Чтобы пакетный инструмент конструировал облачный клиент с новыми значениями конфигурации, все клиенты должны добавить этот раздел. Если они
этого не сделают, то облачный клиент и конструирование пакетного сервиса,
в котором он используется, завершится сбоем.
По поводу новой настройки необходимо сделать одно важное замечание. Как говорилось в разделе 6.2, BatchServiceBuilder передает файл YAML непосредственно
в облачный клиент. Поэтому не требуется менять код для обработки нового параметра тайм-аута в пакетном инструменте. Необработанная конфигурация передается используемой облачной клиентской библиотеке, как показано на рис. 6.6.
Можно сделать вывод, что UX решения заметно не изменяется. Клиентам все
равно придется конструировать облачный клиент и синхронизировать его с конфигурацией пакетного инструмента. Затраты на обслуживание инструмента
близки к нулю, потому что для поддержки нового параметра изменений в коде
не требуется. Передается необработанный файл, а CloudServiceClientBuilder
извлекает из файла YAML разделы auth и timeouts.

188

Глава 6. Простота и затраты на обслуживание API

Batch-service-config.yaml

Auth:
Strategy: username-password
Username: u
Password: p
Timeouts:
Connection: 1000
Batch:
Size: 100.

Batch-service

Использует
напрямую

Облачный клиент

Рис. 6.6. Прямая передача настроек облачного клиента с новым разделом timeouts

Если предполагается, что изменения производятся довольно часто и накап­
ливаются, то схема, представленная в этом разделе, эффективна. Кроме того,
представьте, что несколько сервисов интегрируются с одним нижележащим
облачным клиентом. Это означает, что при добавлении новой настройки ничего
менять в этих сервисах не придется. Обязанность клиента — обеспечить обработку новых настроек. Бремя обслуживания распространяется на вызывающие
стороны инструментов, поэтому можно считать, что в этом контексте UX будет
не идеальным. Посмотрим, как добавить новые настройки облачного клиента
в стриминговом сервисе.

6.4.2. Добавление новой настройки в стриминговый сервис
Стриминговый сервис применяет другой подход к UX и конфигурации используемой библиотеки: свои собственные настройки он предоставляет в специальном разделе streaming. Чтобы иметь возможность передать новую настройку
тайм-аута соединения, мы должны добавить этот раздел в конфигурацию YAML
стримингового сервиса, как показано в следующем листинге.
Листинг 6.22. Новая настройка тайм-аута для конфигурации стримингового
сервиса
streaming:
username: u
password: p
maxTimeMs: 100
connectionTimeout: 1000

Новая настройка предоставляется
под именем connectionTimeout

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

6.4. Добавление новой настройки для облачной клиентской библиотеки

189

этого значение connectionTimeout извлекается из файла YAML и передается
CloudServiceConfiguration, как показано в следующем листинге.
Листинг 6.23. Новое значение тайм-аута в StreamingServiceBuilder
new CloudServiceConfiguration(
new UsernamePasswordAuthStrategy(
(String) streamingConfig.get("username"),
(String) streamingConfig.get("password")),
(Integer) streamingConfig.get("connectionTimeout"));

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

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

190

Глава 6. Простота и затраты на обслуживание API

В пакетном сервисе клиент обязан предоставить новый профильный раздел с
настройкой, которую ожидает получить облачный клиент. Важнейшее преимущество этого решения в том, что оно не предполагает затрат на обслуживание с
нашей стороны. Изменять код не нужно, потому что файл конфигурации передается напрямую в строитель облачного клиента. Однако необходимо помнить,
что затраты на обслуживание распространяются на конечного пользователя.
Всем пользователям ваших сервисов и программ придется адаптировать свои
конфигурации в соответствии с новыми настройками облачного клиента.
Так как стриминговый сервис абстрагирует свое использование облачного
сервиса, он должен отобразить настройки, предоставляемые конечными пользователями, на конфигурацию облачного клиента. Добавление новой настройки
в облачный клиент также требует добавления этой настройки в стриминговый
сервис, который является ее владельцем. Конечный пользователь ничего не
знает о скрытой от него конструкции облачного сервиса. И это имеет свою
стоимость. С другой стороны, каждую новую настройку используемой системы
необходимо сделать доступной и сопоставить с ожидаемым форматом стримингового сервиса. Это требует затрат на обслуживание: приходится изменять код
стримингового сервиса.
В табл. 6.1 приведена краткая сводка двух сценариев. Важно отметить, что UX
и затраты на обслуживание представлены в контексте одного сервиса.
Таблица 6.1. Добавление новой настройки в клиент и ее влияние на сервисы
Название

Затраты на обслуживание

Пакетный сервис

Затраты отсутствуют

Стриминговый сервис

Растущие затраты

UX

Пользователь должен
добавить новую настройку
Пользователь должен
добавить новую настройку

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

6.5. УДАЛЕНИЕ НАСТРОЕК В ОБЛАЧНОЙ
КЛИЕНТСКОЙ БИБЛИОТЕКЕ
В этом разделе анализируется сценарий, в котором может возникнуть необходимость в удалении настроек облачного клиента. Как вы, должно быть, помните,

6.5. Удаление настроек в облачной клиентской библиотеке

191

облачный клиент использует определенную стратегию аутентификации при подключении к облачному сервису. Допустим, через некоторое время вы видите, что
текущая стратегия UsernamePasswordAuthStrategy небезопасна, потому что пароль
в виде простого текста хранится в конфигурации YAML и в памяти, что довольно
рискованно. Злоумышленник может похитить пароли, используемые в коде.
Стоит разработать новую стратегию, UsernamePasswordHashedAuthStrategy, где
применяется хешированная версия пароля при проведении аутентификации. Для
этого используется алгоритм SHA-256 из класса, реализующего хеширование
(http://mng.bz/q2aA). При аутентификации запроса сравниваются хешированные
версии паролей. В следующем листинге приведен код новой стратегии аутентификации с хешированием.
Листинг 6.24. Новая стратегия UsernamePasswordHashedAuthStrategy
public class UsernamePasswordHashedAuthStrategy implements AuthStrategy {
private final String username;
private final String passwordHash;
public UsernamePasswordHashedAuthStrategy(String username, String
passwordHash) {
Пароль сохраняется
this.username = username;
в хешированной форме
this.passwordHash = passwordHash;
}
@Override
public boolean authenticate(Request request) {
if (request.getUsername() == null || request.getPassword() == null) {
return false;
}
Выполняет
return request.getUsername().equals(username)
аутентификацию
&& toHash(request.getPassword()).equals(passwordHash);
с хешированной
}
версией пароля
public static String toHash(String password) {

}

return Hashing.sha256().hashString(password,
StandardCharsets.UTF_8).toString();
Использует алгоритм SHA-256
}
для хеширования пароля

Как видно из следующего листинга, новой стратегии аутентификации присваивается идентификатор username-password-hashed. Клиенты облачной конфигурации должны использовать его вместо прежнего значения username-password,
которое конструирует версию с простым текстовым паролем.
Листинг 6.25. Запрет прежней стратегии аутентификации
public class CloudServiceClientBuilder {
private static final String USERNAME_PASSWORD_STRATEGY = "username-password";
private static final String TOKEN_STRATEGY = "token";
private static final String
➥ USERNAME_PASSWORD_HASHED_STRATEGY = "username-password-hashed";
Реализует новую стратегию username-password-hashed

192

Глава 6. Простота и затраты на обслуживание API

// ...
public DefaultCloudServiceClient create(Path configFilePath) {
// ...
if (authConfig.get("strategy").equals(USERNAME_PASSWORD_HASHED_STRATEGY))
{
Конструирует
authStrategy =
UsernamePasswordHashedAuthStrategy
new UsernamePasswordHashedAuthStrategy(
(String) authConfig.get("username"), (String)
authConfig.get("password"));
} else if (authConfig.get("strategy").equals(TOKEN_STRATEGY)) {
authStrategy = new TokenAuthStrategy((String) authConfig.get("token"));
} else if (authConfig.get("strategy").equals(USERNAME_PASSWORD_STRATEGY))
{
throw new UnsupportedOperationException(
"The " + USERNAME_PASSWORD_STRATEGY + " strategy is no longer
supported.");
}
return new DefaultCloudServiceClient(
new CloudServiceConfiguration(authStrategy, (Integer)
timeouts.get("connection")));
}
Если задана стратегия
}
username-password,
выдается исключение

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

6.5.1. Удаление настройки из пакетного инструмента
Как вы уже знаете, пакетный сервис передает файл YAML, полученный от клиента, непосредственно в облачный клиент. До сих пор все клиенты применяли
стратегию с именем пользователя/паролем или маркером безопасности. Если
клиент задает устаревшую стратегию username-password, конфигурация пакетного сервиса выдает исключение. Теперь все клиенты должны переключиться
на новый тип, если они хотят использовать пакетный сервис. Это значительно
влияет на UX решения.
Все клиенты, настраивающие пакетное решение, сталкиваются с проблемой
аутентификации облачного клиента. Это поведение можно наблюдать в модульном тесте, который использует файл batch-service-config-timeout.yaml со стратегией
username-password. Код теста приведен в следующем листинге.

6.5. Удаление настроек в облачной клиентской библиотеке

193

Листинг 6.26. Выдача исключения для неподдерживаемой стратегии
аутентификации
@Test
public void shouldThrowIfUsingNotSupportedAuthStrategy() {
// Дано
Path path =
Paths.get(
Objects.requireNonNull(
getClass().getClassLoader().getResource("batch-serviceconfig-timeout.yaml"))
.getPath());
// Когда
assertThatThrownBy(() -> new BatchServiceBuilder().create(path))
.isInstanceOf(UnsupportedOperationException.class)
.hasMessageContaining("Стратегия username-password strategy больше
не поддерживается.");
}

Теперь все клиенты видят исключение UnsupportedOperationException. Это означает, что все клиенты пакетного инструмента должны будут преобразовать свою
конфигурацию YAML на новый режим user-name-password-hashed! UX такого
решения оставляет желать лучшего. Мы раскрываем внутреннее устройство
сторонней библиотеки, так что каждое изменение этой конфигурации потребует
адаптации клиентского кода.
Представьте сценарий, в котором пакетный сервис используется несколькими
клиентскими системами. Мы публикуем новый пакетный сервис, который не
позволяет конечным пользователям применять стратегию с именем пользователя
и паролем. Когда конечные пользователи изменят свое ПО для нового пакетного
сервиса, они не смогут развернуть его без изменений в конфигурации YAML.
Каждый клиент, оставивший режим аутентификации без изменений, получит
исключение во время выполнения при использовании новой версии пакетного
сервиса. Для обеспечения обратной совместимости и уменьшения количества
проблем с UX приходится создавать неуклюжее обходное решение.
Сначала необходимо загрузить файл конфигурации из configFilePath. Мы
просматриваем содержимое файла и ищем запись для auth.strategy. Найдя
ее, мы меняем стратегию конфигурации, поставив username-password-hashed
вместо username-password. Затем необходимо извлечь простой текстовый пароль
и вручную хешировать его, заменяя запись в карте. Обходное решение представлено в следующем листинге.
Листинг 6.27. Обходное решение BatchServiceBuilder
// НЕ ДЕЛАЙТЕ ТАК
public BatchService create(Path configFilePath) {

194

Глава 6. Простота и затраты на обслуживание API

try {
Первая утечка абстракции конфигурации
облачного сервиса
Map config =
mapper.readValue(configFilePath.toFile(), yamlConfigType);
Map batchConfig = config.get("batch");
BatchServiceConfiguration batchServiceConfiguration =
new BatchServiceConfiguration((Integer) batchConfig.get("size"));

}

Map authConfig = config.get("auth");
if (authConfig.get("strategy").equals(USERNAME_PASSWORD_STRATEGY)) {
authConfig.put("strategy",
Переопределение настроек может
➥ USERNAME_PASSWORD_HASHED_STRATEGY);
привести к трудноустранимым
}
ошибкам!
String password = (String) authConfig.get("password");
Еще одна утечка
String hashedPassword = toHash(password);
конфигурации
authConfig.put("password", hashedPassword);
Переопределяется другая настройка
Создает временный файл
Path tempFile = Files.createTempFile(null, null);
Files.write(tempFile, mapper.writeValueAsBytes(config));
Сохраняет модифицированную
CloudServiceClient cloudServiceClient = new
конфигурацию
CloudServiceClientBuilder().create(tempFile);
return new BatchService(batchServiceConfiguration, cloudServiceClient);
} catch (IOException e) {
throw new UncheckedIOException
➥ ("Проблема при загрузке файла из: " + configFilePath, e);
}
Передает измененный файл
конфигурации (не тот, который
передается вызывающей стороной)

Наконец, нужно сохранить измененную конфигурацию в новом временном файле
и передать путь к этому файлу CloudServiceClientBuilder. Такое решение просто ужасно: оно изменяет исходный файл и параметр конфигурации (без ведома
пользователя) и может ввести баги, которые очень трудно отладить. Кроме того,
при каждом построении клиента приходится создавать временный файл.
Важно заметить, что реальные имена параметров конфигурации утекают из CloudServiceClientBuilder в новое неуклюжее обходное решение
BatchServiceBuilder, что приводит к появлению сильной связанности между
компонентами. Вдруг оказывается, что строитель службы, который обычно отвечает только за загрузку разделов конфигурации из файла YAML, должен знать
точную структуру конфигурации облачного клиента и изменять ее.
Стриминговый сервис использует другой подход к конфигурации. В следующем
разделе показано, как в таком сервисе решается проблема удаления настроек.

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

6.5. Удаление настроек в облачной клиентской библиотеке

195

сервиса ничего не известно о его механизме конфигурации. Можно прозрачно
изменить стратегию аутентификации, при этом пользователь об этом не узнает,
а совместимость не будет нарушена. На новые стратегии можно переходить, не
создавая проблем для пользователей, а конфигурация YAML стримингового
сервиса никак не изменится.
В листинге 6.28 используется то же имя пользователя и пароль, что и ранее,
при этом пароль передается в виде простого текста. StreamingServiceBuilder
конструирует объект UsernamePasswordHashedAuthStrategy, а затем передает
ему хешированную версию пароля.
Листинг 6.28. Абстрагирование конструирования стратегии с хешированием
CloudServiceConfiguration cloudServiceConfiguration =
Конструирует стратегию
new CloudServiceConfiguration(
с хешированием (вместо
new UsernamePasswordHashedAuthStrategy(
стратегии с простым текстом)
(String) streamingConfig.get("username"),
Пароль
toHash((String) streamingConfig.get("password"))),
хешируется
(Integer) streamingConfig.get("connectionTimeout"));
до передачи
в стратегию
с хешированием

Изменение поведения скрыто от пользователей стримингового инструмента.
Такое решение обеспечивает более высокий уровень UX, потому что оно не
требует смены конфигурации стримингового сервиса. Конечные пользователи
сервиса смогут легко использовать новую версию, ничего не меняя на своей
стороне.
Стриминговый сервис может сменить облачную клиентскую библиотеку незаметно для конечных пользователей. Команда разработчиков сервиса легко
переведет его на другую библиотеку при необходимости. Отображение настроек
уже реализовано, так что в такой ситуации к новому формату конфигурации
нужно будет адаптировать только слой отображения.
Незаметное для конечного пользователя отображение прежней стратегии
­аутентификации на новую стратегию UsernamePasswordHashedAuthStrategy может
быть хорошим временным решением. Тем не менее в долгосрочной перспективе
следует мигрировать на UsernamePasswordHashedAuthStrategy, потому что она
обеспечивает более высокую безопасность для пользователей.
В какой-то момент придется заняться реализацией миграции, но поскольку стриминговый инструмент инкапсулирует нижележащую облачную конфигурацию,
процесс перехода упрощается. Например, можно добавить в конфигурацию новое
свойство с хешированным паролем. Если конечный пользователь предоставляет
такой пароль, уже не требуется вручную преобразовывать простой текстовый
пароль в хешированный. Вместо этого можно использовать новую стратегию .
UsernamePasswordHashedAuthStrategy.

196

Глава 6. Простота и затраты на обслуживание API

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

6.5.3. Сравнение UX-ориентированности и затрат
на обслуживание для двух решений
Из всего сказанного можно сделать вывод, что UX-ориентированность и затраты
на обслуживание для двух решений будут разными в случае удаления настроек
в нижележащей системе. Рассмотрим эти различия.
Во-первых, стриминговый инструмент является владельцем всей конфигурации,
и миграция всех нижележащих компонентов в нем создает меньше проблем. Если
вы захотите убрать облачный клиент и заменить его другим, в стриминговом
сервисе это сделать проще.
Для этого сценария прямая передача пакетным сервисом настроек от клиентов
к облачному клиенту совсем не подходит. Удаление зависимых настроек означает, что всем клиентам придется одновременно переходить на новое значение.
Скрыть это обстоятельство от них не удастся. Кроме того, UX системы получается слишком хрупким. Чтобы справиться с проблемой, придется сконструировать
неуклюжее обходное решение с высоким риском ошибок.
Дополнительная абстракция конфигурации, введенная в стриминговом сервисе,
дает возможность совершенствовать инструменты так, чтобы это было удобно
для пользователя. Завершим обсуждение табл. 6.2, в которой сравниваются два
подхода.
Таблица 6.2. Удаление настроек из клиента и его влияние на два инструмента
Название

UX

Низкий уровень; значительное
Пакетный сервис
влияние на пользователей
Высокий уровень; не влияет на
Стриминговый сервис
пользователей

Затраты на обслуживание

Высокие/неприемлемые
Очень низкие

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

Итоги

197

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

ИТОГИ
Технические решения могут влиять на UX.
Добавление новых настроек в используемые библиотеки может происходить
с нулевыми затратами на обслуживание.
Дополнительная абстракция позволяет развивать программные инструменты
без нарушения совместимости. С другой стороны, она увеличивает затраты
на обслуживание.
С дополнительной абстракциейкаждое изменение в используемом компоненте требует большей работы кода.
Если продукт ориентирован на потребителя и уровень UX для вас важен, лучше не раскрывать внутренние подробности библиотек, используемых в коде.

7

Эффективная
работа с датой и временем
https://t.me/it_boooks
В этой главе:
33 Данные даты и времени в определенных концепциях.
33 Ограничение области действия и документирование точных требований к продукту.
33 Выбор лучших библиотек для использования в коде с датой
и временем.
33 Последовательное применение концепций даты и времени в коде
и обеспечение тестируемости кода времени и даты.
33 Выбор текстовых форматов для даты и времени.
33 Граничные случаи, связанные с календарными вычислениями
и часовыми поясами.

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

Глава 7. Эффективная работа с датой и временем

199

Такие инструменты делятся на две категории:
Концепции помогают обдумать и четко описать информацию, с которой вы
работаете.
Библиотеки помогают преобразовать концепции в код.
Иногда используемые библиотеки являются частью платформы (например,
java.time, появившаяся в Java 8), иногда это сторонние библиотеки, которые
должны устанавливаться явно, как Noda Time для .NET (абсолютно случайно
выбранный пример, а может и не совсем случайно. Джон является основным
автором Noda Time).
В зависимости от того, с какой платформой и библиотеками вы работаете,
может оказаться, что между концепциями, описанными в этой главе, и типами,
которые вы используете для их представления, нет однозначного соответствия.
Это нормально. Такое несоответствие немного усложняет жизнь, но концепции
все равно применимы к проекту; просто нужно тщательнее прописывать свои
намерения в комментариях при выборе имен или написании документации
(или везде и сразу).
Помимо описания концепций и возможностей их практического применения
в коде, эта глава содержит рекомендации о том, как организовать эффективное
тестирование кода, связанного с датой или временем. Прочитав эту главу, вы
сможете грамотно и уверенно проектировать и реализовывать логику даты
и времени.
Для максимальной наглядности возьмем пример интернет-магазина. На рис. 7.1
представлены требования к продукту в том виде, в котором их можно передать
команде разработчиков.

Покупатели
могут вернуть
товары в течение
3 месяцев

Рис. 7.1. Высокоуровневые требования для сайта интернет-магазина

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

200

Глава 7. Эффективная работа с датой и временем

7.1. КОНЦЕПЦИИ ПРЕДСТАВЛЕНИЯ ДАТЫ И ВРЕМЕНИ
Как это часто бывает, в тему даты и времени можно погружаться бесконечно,
находя все более интересные примеры нетипичного поведения. Но так можно
никогда не вернуться на поверхность. Некоторые платформы и библиотеки
выбирают иной (и тоже неверный) курс — пытаются сделать вид, что все очень
просто, и упускают действительно важные случаи. Мы адаптировали концепции,
представленные в этом разделе, для достижения «золотой середины»: они достаточно подробны для большинства коммерческих приложений, но не настолько
глубоки, чтобы эта глава заняла целую книгу.
ПРИМЕЧАНИЕ
Если вы работаете в узкоспециализированных областях, вам придется искать информацию где-то еще, но даже в этом случае рассмотренных концепций может быть
достаточно для большинства ваших приложений. Если вы проектируете устройства
GPS, занимаетесь представлением данных античной истории или пишете клиент NTP
(Network Time Protocol), изложенных концепций также будет достаточно в большинстве случаев. Старайтесь по возможности ограничить и локализовать наиболее
сложные аспекты, требующие усилий.
Кроме того, если вы отлично разбираетесь в принципах работы корректировочных
секунд, некоторые натяжки в рассуждениях вам могут не понравиться. Я вас понимаю, но это одна из тех областей, в которых абсолютная точность только мешает
ясности изложения.

Там, где концепции имеют прямое представление в библиотеках java.time и Noda
Time, будут приводиться соответствующие типы, чтобы при желании вы могли
продолжить экспериментировать с ними. Начнем с некоторых базовых концепций: момента времени, эпохи и интервалов.

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

Момент времени
Тип в java.time: java.time.Instant. Тип в Noda Time: NodaTime.Instant.

7.1. Концепции представления даты и времени

201

Момент времени — универсальная метка времени. Два человека, находящихся
в любых точках мира (или за его пределами!), могут договориться о том, что
означает сейчас как момент времени. Они могут посмотреть на часы и увидеть разное местное время из-за разных часовых поясов или не согласиться
с названием месяца из-за культурных особенностей, но момент времени они
все равно поймут одинаково. Момент можно рассматривать как своего рода
машинное время, которое не зависит от суетных человеческих концепций:
дней, лет и т. д.
Моменты можно рассматривать как точки временной оси, неделимые на меньшие
части. Пример этой концепции показан на рис. 7.2.
Сейчас
Время

10 секунд назад

Через 5 секунд

Рис. 7.2. Ось неделимых моментов времени

Представление момента времени может стать невероятно сложным (и даже
невозможным), если учитывать теорию относительности и другие заковыристые понятия физики. Это один из тех случаев, в которых стремление добиться абсолютного соответствия законам физической Вселенной ошибочно
почти всегда.
Моменты времени естественным образом подходят для описания ситуации, когда
что-то произошло — например, была закреплена транзакция базы данных или
создана запись в журнале. Возможно, вам интересно внутреннее представление
момента времени. Хотя оно является деталью реализации, его все равно полезно
рассмотреть, но для этого потребуется новая концепция.

Эпоха
Ось времени на рис. 7.2 не имеет числовых меток; точки размещаются относительно друг друга. Стандартное решение — договориться об искусственной нулевой
точке, называемой началом эпохи, и отсчитывать все значения от нее. Добавим
начало эпохи в существующий пример, в котором эпоха начинается за 15 с до
текущего момента. На этой стадии каждый момент времени можно выразить
количеством секунд от начала эпохи. На рис. 7.3 приведено графическое представление этой концепции: к рис. 7.2 добавляется начало эпохи и относительные
промежутки времени от начала эпохи.

202

Глава 7. Эффективная работа с датой и временем

Начало эпохи
(t = 0 с)

Сейчас
(t = 15 с)

Время

10 секунд назад
(t = 5 c)

Через 5 секунд
(t = 20 c)

Рис. 7.3. Ось времени с началом эпохи

Здесь важно то, что для всех, кто использует одно представление, начало эпохи
одинаково, так что в некоторых отношениях мы просто немного сместили проблему. Все равно необходимо договориться об одном моменте времени, но это
позволит представить любой момент на оси.
В большинстве систем используется начало эпохи Unix — момент времени,
приходящийся на полночь 1 января 1970 года по времени UTC. UTC, месяцы
или годы мы еще не обсуждали; у всех описаний даты и времени есть одна проблема — концепции часто кажутся циклическими.
Впрочем, это не единственное начало эпохи, часто встречающееся на практике.
Начало эпохи .NET приходится на полночь 1 января 1 года нашей эры (AD 1),
хотя AD 1 — пролептический григорианский календарь, добавляющий еще
больше сложности, о которой мы еще не говорили.
В Excel и представлении Microsoft COM начало эпохи приходится на начало
1900 года, хотя такие эпохи сложнее обсуждать из-за ошибок в программных
продуктах, которые считают 1900 год високосным.
В хорошо инкапсулированных библиотеках даты и времени не нужно знать, какая
эпоха используется во внутреннем представлении, хотя многие из них содержат
функции для преобразования между представлением библиотеки и, например,
количеством секунд от начала эпохи Unix. По этой причине в библиотеках даты
и времени обычно даже не виден тип, инкапсулирующий концепцию эпохи.
В предыдущих примерах рассматривалось время от начала эпохи в секундах,
но, конечно, в реальной жизни часто требуются более точные измерения времени. Вместо того чтобы всегда задавать конкретную единицу времени, полезно
инкапсулировать концепцию продолжительности прошедшего времени в виде
промежутка.

Промежуток времени
Тип в java.time: java.time.Duration. Тип в Noda Time: NodaTime.Duration.

7.1. Концепции представления даты и времени

203

Промежуток определяет прошедшее время, а не точку во времени. Он рассчитывается как разность между двумя точками на оси времени. Если запустить
секундомер, то на нем будет отображаться промежуток. Он может быть как
положительным, так и отрицательным (например, момент, наступивший до
начала эпохи, во внутренней реализации может представляться отрицательным
промежутком). На логическом уровне поддерживаются следующие операции
с моментами и промежутками:
Момент – Момент => Промежуток
Промежуток + Промежуток => Промежуток
Момент + Промежуток => Момент
Момент – Промежуток => Момент

На рис. 7.4 эти операции представлены графически. В частности:
Результатом сейчас – x является промежуток продолжительностью 10 секунд.
Результатом 10 секунд + 5 секунд является промежуток продолжительностью 15 секунд.
Промежутки можно складывать и вычитать, чтобы получить 10 секунд до
текущего момента или 5 секунд после текущего момента.
Начало эпохи

Сейчас

d1 = 10 s

d2 = 5s
Время

x = сейчас – d1

y = сейчас + d2

Рис. 7.4. Вычисления с моментами и промежутками на временной оси

Точность внутреннего представления промежутка обычно ограниченна. Как
правило, на практике задается точность до миллисекунд, микросекунд или
наносекунд, а также тактов; такт используется в Windows и .NET, один такт
равен 100 нс.
Важно то, что промежуток всегда жестко фиксирован продолжительностью измеряемого времени. Таким образом, 1 секунда, 5 микросекунд и 3 часа являются
допустимыми промежутками, а 2 месяца — нет, потому что продолжительность
месяца меняется. Является ли 1 день допустимым промежутком? Это зависит

204

Глава 7. Эффективная работа с датой и временем

от того, как именно понимать «1 день». Если рассматривать его как время, прошедшее от полуночи одного дня до полуночи следующего — нет, поскольку здесь
мы заходим на территорию часовых поясов, где продолжительность дня может
составлять 23 часа или 25 часов. Но если рассматривать 1 день как синоним для
24 часов, тогда он является допустимым промежутком.
ПРИМЕЧАНИЕ
Если так будет проще, можно рассматривать момент времени как аналог точки в геометрии, а промежуток — как аналог вектора. Все операции, поддерживаемые для
моментов времени и промежутков, соответствуют операциям с точками и векторами.

В прошлом некоторые библиотеки избегали инкапсуляции концепции промежутков; вместо этого числа и единицы хранились раздельно. Это приводило
к появлению сигнатур функций следующего вида (из java.util.concurrent.
locks.Lock):
boolean tryLock(long time, TimeUnit unit)

Хотя в некоторых случаях такое разделение полезно, обычно оно получается
слишком громоздким по сравнению с типом Duration, который может использоваться везде, где актуальна концепция прошедшего времени.
Несмотря на то что моменты времени и промежутки являются важнейшими
концепциями машинного времени (а начало эпохи — своего рода вспомогательной концепцией), библиотеки часто предоставляют дополнительные типы для
большего удобства. Самый популярный из них — интервал, инкапсулирующий
два момента времени: начало и конец. Библиотеки по-разному определяют,
могут ли интервалы быть открытыми (без начального или конечного момента)
и может ли начало приходиться на более позднее время, чем конец (своего рода
отрицательный интервал). Желательно знать, какие возможности поддерживает
используемая библиотека, но мы не будем углубляться в подробности, потому
что интервал не настолько фундаментальная концепция, как момент времени
или промежуток.
Машинное время часто бывает полезным, но его формат очень неудобен для конечных пользователей и даже разработчиков. Представьте, что вы читаете файл
журнала; что бы вы предпочли увидеть: 1605255526 или 2020-11-13T08:19:46Z?
Вычисления с участием человека часто заметно усложняются, и это особенно
справедливо для даты и времени. Посмотрим, как люди привыкли делить время.

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

7.1. Концепции представления даты и времени

205

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

Календарные системы: деление времени на дни, месяцы и годы
Типы в java.time: java.time.chrono.Chronology, java.time.LocalDate и java.
time.chrono.ChronoLocalDate. Типы в Noda Time: NodaTime.CalendarSystem
и NodaTime.LocalDate.
Один из универсальных аспектов человеческой жизни заключается в том, что
она делится на дни. В каждой цивилизации существует понятие дня и ночи,
обычно мы работаем днем и спим ночью. Таким образом, деление оси времени
на дни выглядит абсолютно естественно.
Времена года исторически важны для большей части человечества, и хотя в наши
дни земледельцев стало меньше, годичный цикл все еще влияет на нашу жизнь,
поэтому деление оси времени на годы тоже разумно.
Месяцы — скорее удобство, чем полезный уровень детализации. Цикл фаз Луны
продолжительностью 29,5 дня, вероятно, когда-то много значил для цивилизаций и разработки календарных систем, но сейчас его влияние на жизнь человека
гораздо меньше.
Таким образом, календарная система представляет собой способ обозначения
конкретного дня в контексте года, месяца этого года и дня этого месяца. Если
бы существовала только одна календарная система, то задача решалась бы относительно просто, но на практике это не так.
ПРИМЕЧАНИЕ
Дата и время, рассматриваемые с бытовой точки зрения — с днями, месяцами и годами, — называются гражданским временем (civil time). Гражданское время сильно
зависит от культуры в отличие от машинного времени, которое рассматривалось
в предыдущем разделе.

Вернемся к вопросу, который привел нас к этому разделу: я пишу эти строки
20 ноября 2020 года (по крайней мере, в Великобритании). На первый взгляд
это вполне однозначное утверждение, но даже в нем содержится неявное утверждение, ведь я также могу достаточно точно утверждать, что сегодня 7 ноября 2020 года. Как одному дню могут соответствовать две даты одновременно?
Дело в том, что сегодня 20 ноября 2020 года по григорианскому календарю
и 7 ноября — по юлианскому. Также сегодня 4-й день месяца кислев 5781 года
по древнееврейскому календарю и 4-й день месяца раби ас-сани 1442 года по
исламскому календарю хиджры. И это лишь некоторые из календарных систем,
используемых в мире. В табл. 7.1 приведены некоторые дни, близкие к сегодняшней дате в этих системах.

206

Глава 7. Эффективная работа с датой и временем

Таблица 7.1. Даты в четырех календарных системах
Григорианский
календарь

Юлианский
календарь

Древнееврейский
календарь

16 ноября 2020 года

3 ноября 2020 года

29 хешван 5781 года

17 ноября 2020 года

4 ноября 2020 года

1 кислев 5781 года

18 ноября 2020 года

5 ноября 2020 года

2 кислев 5781 года

19 ноября 2020 года

6 ноября 2020 года

3 кислев 5781 года

20 ноября 2020 года

7 ноября 2020 года

4 кислев 5781 года

21 ноября 2020 года

8 ноября 2020 года

5 кислев 5781 года

22 ноября 2020 года

9 ноября 2020 года

6 кислев 5781 года

Календарь хиджры

30 раби аль-авваль
1442 года
1 раби ас-сани
1442 года
2 раби ас-сани
1442 года
3 раби ас-сани
1442 года
4 раби ас-сани
1442 года
5 раби ас-сани
1442 года
6 раби ас-сани
1442 года

Календарные системы могут очень сильно различаться. Григорианский календарь
почти идентичен юлианскому; отличие лишь в том, какие годы считаются високосными. Сравните с древнееврейской календарной системой, в которой длина
месяцев хешван и кислев изменяется от года к году, а високосным считается год,
содержащий не лишний день, а лишний месяц (месяц адар разбивается на адар I
и адар II). С исламом связано много календарных систем, из-за чего довольно
трудно определить, о какой из них идет речь, если кто-то говорит просто об исламском календаре.
Но больше всего с точки зрения кода меня удивил календарь Бади, используемый
у бахаистов. В нем каждый год содержит 19 месяцев, которые состоят из 19 дней,
и 4 или 5 дней, приходящихся между 18-м и 19-м месяцем. Эти дни вообще не
принадлежат никакому месяцу.
Наконец, все вышесказанное предполагает, что все согласны с тем, когда заканчивается один день и начинается другой — в полночь, не так ли? Но это
справедливо не для всех календарных систем. В древнееврейских и исламских
календарях (среди прочего) границей между днями считается закат, а не полночь.
Я понимаю, что все это звучит ужасно, но, как было показано в разделе 7.2.1,
в большинстве случаев вам почти не придется беспокоиться об этих различиях.
Это была хорошая новость; а плохая в том, что, даже придерживаясь григорианского календаря, нужно соблюдать осторожность. Но если ось времени (еще
раз: без часовых поясов) разделена на годы, месяцы и дни, сослаться на время
суток будет относительно несложно.

Время суток
Тип в java.time: java.time.LocalTime. Тип в Noda Time: NodaTime.LocalTime.

7.1. Концепции представления даты и времени

207

И хотя существуют системы, в которых используются разные единицы времени,
скорее всего, в большинстве случаев их можно игнорировать. Если вы захотите
узнать больше, начать можно со статьи «Internet Time» на сайте Swatch (https://
www.swatch.com/en-us/internet-time.html).
Если вынести все это за скобки и отложить в сторону часовые пояса и корректировочные секунды, можно принять, что день состоит из 24 часов, каждый
час состоит из 60 минут, а каждая минута — из 60 секунд. Секунды можно
и дальше делить на нужные единицы: миллисекунды, микросекунды или
наносекунды.
Да, все почти так просто, поэтому этот подраздел будет самым коротким в
главе. Единственная проблема в том, можно ли рассматривать 24:00 как время
суток — эта запись представляет исключающий конец дня в отличие от записи
00:00, представляющей включающее начало дня. Значение 24:00 используется
не так широко, но иногда его необходимо учитывать.
Но вернемся к более сложным материям и поразмыслим об арифметических
операциях с гражданским временем. С машинным временем все просто: всегда
можно сложить промежутки, прибавить их или вычесть из момента времени
или вычислить разность между двумя моментами для получения промежутка.
Все достаточно предсказуемо. Арифметические же операции с гражданским
временем могут преподнести немало сюрпризов.

Периоды: арифметические операции с гражданским временем
Тип в java.time: java.time.Period. Тип в Noda Time: NodaTime.Period.
Обычно в арифметике существует правильный ответ. Если в начальных классах школы вас спрашивают, сколько будет 5 + 6, то правильным ответом будет
определенно 11. Учитель скажет «правильно» или «неправильно», но никогда
не скажет «может быть».
С календарной арифметикой дело обстоит иначе (по крайней мере, в граничных
случаях, но они встречаются довольно часто, и их нельзя просто проигнорировать). Если вас спрашивают: «Какой день наступит через один месяц после
31 мая 2021 года?», ответы «30 июня 2021 года» и «1 июля 2021 года» выглядят
одинаково разумно. Эта неоднозначность представлена на рис. 7.5.
Впрочем, можно определить полезное понятие: период. Период напоминает вектор значений для разных календарных единиц: определенного количества лет,
месяцев и т. д. Таким образом, 3 года, 1 месяц и 2 дня — период. В библиотеках
даты и времени нет единого мнения о том, должны ли периоды останавливаться
на днях или переходить к меньшим единицам (например, часы, минуты, секунды
и доли секунд).

208

Глава 7. Эффективная работа с датой и временем

Июнь 2021 г.

Май 2021 г.
26
3
10
17
24
31

27
4
11
18
25
1

28
5
12
19
26
2

29
6
13
20
27
3

30 1 2
7 8 9
14 15 16 + 1 месяц = ?
21 22 23
28 29 30
4 5 6

31 1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 1 2 3 4
Июль 2021 г.
28
5
12
19
26

29
6
13
20
27

30 1 2 3 4
7 8 9 10 11
14 15 16 17 18
21 22 23 24 25
28 29 30 31 1

Рис. 7.5. Прибавляя месяц к дате, вы не всегда получите очевидный ответ

ПРИМЕЧАНИЕ
Промежуток всегда представляет фиксированную продолжительность времени независимо от контекста. Три секунды — всегда три секунды. Продолжительность периода
может изменяться. Самый очевидный пример — 1 месяц, длительность которого
меняется в зависимости от месяца (а в случае февраля — еще и года).

У периодов есть свои странности; например, ничего не мешает создать период
из 16 месяцев или 35 дней. Хотя 16 месяцев можно с достаточной уверенностью
нормализовать в 1 год и 4 месяца (если вы работаете в григорианском календаре), 35 дней определенно не удастся нормализовать в эквивалентный период
из 1 месяца и x дней, потому что при использовании периода значение x будет
зависеть от конкретного месяца.
Арифметические вычисления только между периодами достаточно прямолинейны: например, если сложить 2 месяца и 3 дня с 1 годом и 2 днями, получим 1 год, 2 месяца и 5 дней. Вопрос о том, всегда ли вычитание имеет смысл,
по-разному решается в разных библиотеках; вычитание этих двух периодов
с результатом 1 год, 2 месяца и 1 день нормально сработает на уровне программного кода, но вопрос, будет ли этот период иметь смысл и быть полезным, остается открытым. На более общем уровне вопрос заключается в том,
хороши ли периоды со смешанными знаками — с учетом того, что они редко
встречаются на практике.
В общем и целом можно назвать следующие типичные операции:
Дата + Период => Дата
Дата – Период => Дата
Дата – Дата => Период (возможно, с указанием используемых единиц)

7.1. Концепции представления даты и времени

209

Период + Период => Период
Период – Период => Период

Хотя арифметические операции, выполняемые только с периодами, просты, при
введении операций с датой и периодом (первые две операции в приведенном
списке) возникают две возможные проблемы. Если вернуться к граничным
случаям, упомянутым выше, библиотеки будут давать разные результаты для
некоторых вычислений; да и люди тоже могут отвечать по-своему. Дело не в том,
что в библиотеках ошибка (хотя и такая возможность всегда есть); просто очевидного правильного ответа не существует. Но как бы библиотека ни ответила
на ваш вопрос, не исключено, что она нарушит ваши простые ожидания. В частности, многих удивят два аспекта.
Во-первых, сложение в календарных арифметических операциях не ассоциативно. Допустим, вы хотите сложить значения 31 января 2021 года, 1 месяц
и 2 месяца. Есть два варианта группировки операций:
(31 января 2021 года + 1 месяц) + 2 месяца
31 января 2021 года + (1 месяц + 2 месяца)
В java.time и Noda Time первая операция дает результат 28 апреля 2021 года,
а вторая — 30 апреля 2021 года. К этим результатам приводят следующие шаги:
(31 января 2021 года + 1 месяц) + 2 месяца
• 31 января 2021 года + 1 месяц => 28 февраля 2021 года
• 28 февраля 2021 года + 2 месяца => 28 апреля 2021 года
31 января 2021 года + (1 месяц + 2 месяца)
• 1 месяц + 2 месяца => 3 месяца
• 31 января 2021 года + 3 месяца => 30 апреля 2021 года
Остальные библиотеки могут давать другие результаты, последовательные
в одних случаях, но непоследовательные в других.
Во-вторых, сложение даты и периода необратимо. Иначе говоря, для даты d
и периода p можно ожидать, что результат (d + p) - p всегда будет равен d,
но это не так. Например, какие бы правила ни использовались в библиотеке,
если прибавить месяц к 31 января, а затем вычесть месяц из результата, вы не
вернетесь к 31 января.
Если вам кажется, что в реальном мире все это неважно, рассмотрите гипотетическую ситуацию, представленную на рис. 7.6: 28 февраля 2022 года проходят
выборы. Избиратели, которым исполняется 18 лет в день выборов, имеют право
в них участвовать. Нужно ли разрешить участие избирателям, родившимся
29 февраля 2004 года?

210

Глава 7. Эффективная работа с датой и временем

Февраль 2004 г.
26
2
9
16
23

27
3
10
17
24

28
4
11
18
25

29
5
12
19
26

30
6
13
20
27

31 1
7 8
14 15
21 22
28 29

Февраль 2022 г.
31 1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 1 2 3 4 5 6
Голосование!
Кандидат 1
Кандидат 2
Кандидат 3
Кандидат 4
Кандидат 5

Рис. 7.6. Реальные последствия решений в календарных
арифметических операциях

Хотя это был гипотетический пример, такие ситуации происходят. Например,
в Великобритании всеобщие выборы проводились 28 февраля 1974 года. По
возможности избегайте подобных неоднозначностей, если вы контролируете
эти даты.
Как выразить это требование в арифметических операциях? Следующие два
варианта кажутся разумными:
Вычесть 18 лет из дня выборов. Каждый, кто уже родился в этот день, может
участвовать в выборах.
Прибавить 18 лет к дате рождения. Избиратель может голосовать, если выборы проходят в этот день или позже.
java.time и Noda Time ведут себя одинаково, но дают разные результаты для
обоих случаев. В первом варианте предполагается, что избиратель голосовать
не сможет, потому что 28 февраля 2004 года он еще не родился. Во втором считается, что избиратель сможет проголосовать, потому что прибавление 18 лет
к 29 февраля 2004 года вернет 28 февраля 2022 года. Другая библиотека может
решить «перенести» результат на 1 марта 2022 года.
Лично я предполагаю, что здесь правилен второй вариант, а результат java.time
и Noda Time, вероятнее всего, будет юридически корректным. Но я не уверен,
что все страны мира формулируют свои законы именно таким образом, и нельзя
исключать, что в каких-то странах существуют неоднозначные или непоследовательные законы.
Я не пытаюсь вас запугать. Скорее призываю хорошенько подумать каждый раз,
когда вы выполняете календарные арифметические операции, и убедиться, что
ожидания всех вовлеченных сторон одинаковы.

7.1. Концепции представления даты и времени

211

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

7.1.3. Часовые пояса, UTC и смещения от UTC
Типы в java.time: java.time.ZoneId и java.time.ZoneOffset. Типы в Noda Time:
NodaTime.DateTimeZone и NodaTime.Offset.
Скорее всего, вы уже знаете, что такое часовые пояса — хотя бы в общих чертах.
К сожалению, с ними связан ряд распространенных заблуждений, которые я попытаюсь здесь опровергнуть. В этом разделе я буду считать, что григорианский
календарь — единственная календарная система. На самом деле расширить
описание часовых поясов на другие системы нетрудно, но это сильно усложнит
объяснения.
Обычно люди представляют себе время так, что полдень наступает примерно
тогда, когда солнце находится прямо над головой. В разных точках мира это
происходит в разное время. Часовые пояса стараются учесть это обстоятельство.
Например, я пишу эти строки в 15:53 в Великобритании. Я знаю, что для жителей
Сан-Франциско сейчас 7:53, а для жителей Индии — 21:23.
Часовой пояс, по сути, содержит три блока данных:
идентификатор или название;
часть земной поверхности, которая, как считается, находится в этом часовом
поясе;
функцию, связывающую любой момент времени с гражданской датой и временем.
Если представить, что все носят точные наручные часы с правильной настройкой
часового пояса, в котором они находятся, то все, кто живет в одном часовом поясе,
будут видеть одну дату и время в любой момент времени, поскольку относятся
к конкретному часовому поясу. Два человека в разных часовых поясах могут
видеть одинаковую дату и время. Но даже если прямо сейчас эти значения совпадают, через минуту они могут разойтись.
Когда мы вводили концепцию начала эпохи, я упоминал о том, что начало
эпохи Unix было моментом времени, соответствующим полуночи 1 января
1970 года UTC. Что такое UTC? Это нулевой часовой пояс; базовая линия, используемая для описания других часовых поясов. Строго говоря, это вообще не
часовой пояс (потому что на земле нет области, которая бы относилась к часовому поясу UTC), но его часто используют в качестве часового пояса — самого

212

Глава 7. Эффективная работа с датой и временем

простого из возможных. Я говорю здесь об UTC, потому что это своего рода промежуточный шаг для работы с реальными, более сложными часовыми поясами.
Соотнести момент времени с гражданской датой и временем с использованием
UTC несложно. Вы уже знаете дату и время UTC, представленную началом
эпохи (1 января 1970 года 00:00:00), и момент времени — всего лишь результат
прибавления промежутка к началу эпохи. В UTC каждый день состоит из 24 часов, каждый час — из 60 минут и т. д. Здесь нет раздражающих особенностей
других часовых поясов, о которых вы вскоре узнаете. Вам все равно придется
иметь дело с високосными годами, но это не так трудно. Моменты времени до
начала эпохи также работают достаточно очевидно; например, если вы используете начало эпохи Unix, а момент представлен промежутком –10 секунд, то он
соответствует 31 декабря 1969 года 23:59:50.
Теперь, когда вы поняли в общих чертах, что такое UTC, мы можем рассматривать
функцию, связывающую любой момент времени с гражданской датой и временем
в часовом поясе, в качестве эквивалента функции, связывающей любой момент
со смещением UTC. Это значение сообщает, насколько опережает или отстает от
UTC данный часовой пояс на текущий момент.
Рассмотрим конкретный пример. В момент времени, соответствующий 20 ноября 2020 года 15:53 UTC, смещение UTC для часового пояса Сан-Франциско
составляет –8 часов. Считается, что Сан-Франциско на 8 часов отстает от UTC,
поэтому там 7:53. В Индии смещение UTC в этот момент составляет 5 часов
и 30 минут, а значит, там 21:23.
Но эта функция соотнесения момента времени со смещением UTC не обязана
выдавать одинаковые результаты для всех моментов, и в большинстве часовых
поясов она этого не делает. Таким образом, например, на 20 июня 2020 года в 15:53
смещение UTC для часового пояса Сан-Франциско составляет –7 часов, и по
местному времени будет 8:53. При этом смещение UTC для Индии по-прежнему
составляет 5 часов 30 минут — оно постоянно используется с 1945 года.
Хотя соотнесение момента времени с гражданской датой и временем выполняется недвусмысленно, об обратном преобразовании этого сказать нельзя.
Некоторые значения гражданской даты и времени неоднозначны (с конкретной
гражданской датой и временем соотносятся сразу несколько моментов), а некоторые пропускаются (не существует ни одного момента, соотносящегося
с гражданской датой и временем). Например, в часовом поясе Сан-Франциско
смещение поменялось с UTC-7 на UTC-8 1 ноября 2020 года в 2 часа ночи по
местному времени (9:00 UTC), когда произошел возврат с летнего времени. Это
означает, что для любого жителя Сан-Франциско с точными часами четкая последовательность моментов времени будет выглядеть так:
01:59:58
01:59:59

7.1. Концепции представления даты и времени

213

01:00:00 ← здесь происходит возврат с летнего времени
01:00:01
01:00:02
Таким образом, 1 ноября 2020 года гражданское время 1:45 наступает дважды.
Два жителя Сан-Франциско смогут сказать, что кошка разбудила их в 1:45, тогда
как на самом деле они проснулись с интервалом в 1 час.
С другой стороны, 8 марта 2020 года в Сан-Франциско время смещается на час
вперед в момент, соответствующий 2 часам по местному времени (10:00 UTC);
происходит переход c UTC-8 на UTC-7. Таким образом, в этот день для жителей
Сан-Франциско последовательность выглядит так:
01:59:58
01:59:59
03:00:00 ← здесь происходит переход на летнее время
03:00:01
03:00:02
Это означает, что гражданская дата и время 8 марта 2020 года 2:45 вообще не
наступает. Любой житель Сан-Франциско, утверждающий, что кошка разбудила
его в 2:45 этой ночью, что-то путает.
На рис. 7.7 представлена диаграмма смещений UTC для четырех часовых поясов (Европа/Москва, Европа/Париж, Америка/Асунсьон и Америка/ЛосАнджелес) в 2020 году. Часовой пояс Америка/Асунсьон действует в Парагвае,
а пояс Америка/Лос-Анджелес действует в Сан-Франциско. Следует заметить,
что Парагвай находится в Южном полушарии, поэтому переход на летнее время
в нем происходит в октябре, а возврат — в марте.
Смещение UTC (в часах)
+3
+2
+1
0
–1
–2
–3
–4
–5
–6
–7
–8

2020-03-29T06:00:00Z
Переход на UTC+2

2020-03-28T03:00:00Z
Возврат к UTC-4

2020-03-14T10:00:00Z
Переход на UTC-7

2020-10-25T01:00:00Z
Возврат к UTC+1

2020-10-03T04:00:00Z
Переход на UTC–3
2020-1 1-01T09:00:00Z
Возврат к UTC-8

Европа/Москва
Европа/Париж
Время/момент
Америка/Асунсьон

Америка/Лос-Анджелес

Рис. 7.7. Четыре часовых пояса со смещениями UTC на оси времени

214

Глава 7. Эффективная работа с датой и временем

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

Что не является часовым поясом?
В приведенных выше примерах я намеренно не использовал термины «Тихоокеанское стандартное время» или «Тихоокеанское летнее время» для часового
пояса Сан-Франциско. Хотя эти термины часто рассматриваются как своего
рода альтернатива для смещения UTC, они не являются часовыми поясами
как таковыми. Точнее сказать, что часовой пояс, включающий Сан-Франциско,
переключается между Тихоокеанским стандартным временем и Тихоокеанским
летним временем. Другие часовые пояса также могут иногда использовать
Тихоокеанское стандартное время, а иногда использовать время, отличное от
Сан-Франциско. Таким образом, Тихоокеанское стандартное время и другие
аналогичные описания в общем случае не являются названиями часовых поясов.
ПРИМЕЧАНИЕ
К сожалению, в базе данных часовых поясов Windows название «Тихоокеанское
стандартное время» используется для обозначения часового пояса, включающего
Сан-Франциско; аналогичные обозначения применяются и для многих других часовых поясов. Таким образом, можно запросить описание времени по Тихоокеанскому
стандартному времени и получить результат в Тихоокеанском летнем времени. Вместо
базы данных часовых поясов Windows я рекомендую использовать часовые пояса
IANA, описанные далее.

Если учесть, что описания неполных часовых поясов в действительности не
являются названиями часовых поясов, вполне логично, что произведенные от
них сокращения (такие, как PST и PDT) тоже не являются такими названиями.
Впрочем, сокращения еще хуже описаний, потому что они с большей вероятностью могут оказаться неоднозначными. Совершенно ужасный пример: BST
является сокращением как от British Summer Time (Британское летнее время), так
и от British Standard Time (Британское стандартное время), причем последнее
обозначение использовалось между 1968 и 1971 годом. Сокращения удобно выводить для пользователей, но для любых других целей их лучше не использовать.
Наконец, смещения UTC сами по себе не являются часовыми поясами. К сожалению, даже ISO-8601 (стандарт текстовых представлений даты и времени)
содержит ошибочную трактовку. Значение, описанное как обозначение зоны
в ISO-8601, представляет только смещение UTC. Это важно, потому что смещение UTC в один момент времени мало что говорит о смещении UTC в другой
момент времени в том же часовом поясе. И снова смещения UTC могут быть

7.1. Концепции представления даты и времени

215

очень полезными и более простыми, чем попытки описать реальный часовой
пояс, но важно различать эти два понятия.
Например, рассмотрим дату/время и смещение 2021-06-19T14:00:00-04 — иначе
говоря, 19 июня 2021 года в 14:00 местного времени в часовом поясе, на данный
момент отстающем на 4 часа от UTC. Каким будет смещение UTC 19 декабря
в том же местном времени? В Нью-Йорке оно будет равно –5; в Асунсьоне
(столица Парагвая) оно будет равно –3, хотя в обоих местах в июне смещение
UTC равно –4. Исходная информация содержит смещение UTC, но это не
указывает на часовой пояс.

Откуда берется информация о часовых поясах?
Тип в java.time: java.time.zone.ZoneRuleProvider. Типы в Noda Time: NodaTime.
DateTimeZoneProviders и NodaTime.IDateTimeZoneProvider.
В примечании выше упоминается база данных часовых поясов Windows, установленная на всех компьютерах Windows и изменяемая при обновлениях Windows.
Тем не менее это не самый популярный источник информации о часовых поясах. Вместо нее почти во всех системах, не входящих в семейство Windows,
используется функционирующая на добровольной основе база данных под
управлением IANA (Internet Assigned Numbers Authority — Администрация
адресного пространства интернета)1. Из-за долгой истории существования она
известна под рядом других названий. Возможно, вы слышали о часовых поясах
Olson, zoneinfo, tz или tzdb. Все они относятся к одному источнику данных; новые
названия появляются и исчезают со временем.
ПРИМЕЧАНИЕ
У каждой платформы разработки свой подход к получению данных о часовых поясах.
Например, в Java по умолчанию используются часовые пояса IANA даже при выполнении в системе Windows. .NET работает с платформенными часовыми поясами,
поэтому при выполнении в Linux используются часовые пояса IANA, а в Windows —
часовые пояса Windows. В .NET 6 были внесены доработки. Поинтересуйтесь, какая
информация о часовых поясах будет использоваться в вашем коде с учетом всех
операционных систем, в которых он будет выполняться.

Часовые пояса IANA «обычно идентифицируются по названию континента или
океана, а затем по названию самого большого города в регионе» (https://data.iana.
org/time-zones/tz-link.html).
Часовые пояса, которые до сих пор использовались в примерах:
Сан-Франциско: Америка/Лос-Анджелес;
Москва: Европа/Москва;
1

База данных часовых поясов доступна по адресу https://www.iana.org/time-zones.

216

Глава 7. Эффективная работа с датой и временем

Парагвай: Америка/Асунсьон;
Великобритания: Европа/Лондон;
Индия: Азия/Калькутта.
Правила часовых поясов меняются несколько раз в год. Здесь я не имею в виду
переход часового пояса Америка/Лос-Анджелес с UTC-8 на UTC-7 и обратно;
я говорю об изменении в правилах, управляющих этими переходами. Например, Закон об энергетической политике 2005 года поменял правила соблюдения
летнего времени в Соединенных Штатах, которые вступили в силу в 2007 году.
Правила часовых поясов — предмет политики и определяются государством.
Когда группа добровольцев базы данных IANA узнает о пересмотре правил
(с однозначным подтверждением того, что изменения действительно были
ратифицированы правительством, а не просто предложены), в базу данных
вносятся нововведения и она публикуется. Иногда несколько изменений объединяют в один выпуск. Названия выпусков содержат год выпуска и буквенный
суффикс (например, в 2020 году первым был выпуск 2020a, за ним последовал
выпуск 2020b и т. д.).
Способ передачи этой информации на компьютер, где выполняется программа,
сильно зависит от среды. Мы вернемся к этому позже, в разделе 7.4.4, когда будем
выяснять возможные последствия этого для кода.
Итак, мы рассмотрели три группы понятий:
Машинное время — моменты времени, начало эпохи и промежутки.
Гражданское время — календарные системы, даты, периоды и время суток.
Часовые пояса — UTC и смещения UTC.
На их основе можно вывести другие понятия, и хорошие библиотеки даты и
времени часто предоставляют широкий диапазон типов, при помощи которых
код может ясно и точно выразить нужный смысл. Но прежде чем переходить
к анализу кода, я кратко коснусь моментов, которые мы не затронули в приведенных выше описаниях.

7.1.4. Концепции даты и времени, вызывающие приступ
головной боли
В технических книгах я стараюсь быть точен. Стоит ли читать такую литературу,
если информация в ней приблизительная? Но иногда стремление к абсолютной
точности противоречит пользе. Я уже упоминал пару аспектов работы с датой и
временем, которые не буду детально рассматривать в этой главе, но сейчас расскажу о них подробнее. Этот раздел можно полностью пропустить — он никак
не повлияет на дальнейший материал. Но было бы неплохо после тяжелого дня

7.1. Концепции представления даты и времени

217

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

Относительность
В одном из моих любимых эпизодов сериала «Доктор Кто» доктор говорит:
«Люди полагают, что время — это четкая последовательность причин и следствий,
но с нелинейной объективной точки зрения оно больше похоже на огромный
шар колеблющегося, волнующегося… вещества». Эта формулировка хорошо
описывает мой уровень понимания теории относительности. Я понимаю ее
достаточно, чтобы ее опасаться, особенно концепциитого, что мы (люди и компьютеры) воспринимаем время по-разному в зависимости от системы координат,
скорости и ускорения.
Мы начали с определения понятия момента времени как чего-то, что мы все
представляем одинаково. Два человека в разных часовых поясах и календарных
системах одинаково ответят, что такое сейчас. Теория относительности предполагает, что все не так просто, и возможно, даже концепция «сейчас» не имеет
особого смысла.
Некоторым инфраструктурам (например, GPS) приходится учитывать этот
факт. К счастью, в коде бизнес-приложений этого делать не нужно.

Корректировочные секунды
Время — не единственное, что колеблется и волнуется. Вращение Земли тоже не
идеально, оно понемногу замедляется (хотя и очень медленно). Это значит, что
между «наблюдаемым солнечным временем» (по которому Солнце в полдень
на гринвичском меридиане находится прямо над головой) и временем, которое
сообщают атомные часы, существуют небольшие расхождения. Корректировочные секунды вводятся для учета этого обстоятельства. Они добавляются (или
удаляются, по крайней мере, теоретически) в ось времени UTC для согласования
UTC и наблюдаемого солнечного времени.
Корректировочные секунды добавляются или удаляются только для изменения
длины последней минуты в конце июня или декабря. Это значит, что, хотя минута
обычно состоит из 60 секунд, она может состоять из 61 или 59 секунд. Например, корректировочная секунда, добавленная в конце 2016 года, была вставлена
31 декабря 2016 года в 23:59:60. На момент написания книги отрицательных
корректировочных секунд еще не было (когда секунда удаляется с оси времени,
а не добавляется на нее), но они возможны.
Системы по-разному выводят информацию о корректировочных секундах либо
просто делают вид, что их не существует. Например, в некоторых системах используется размытая корректировка, при которой лишняя секунда фактически

218

Глава 7. Эффективная работа с датой и временем

распределяется по более длинному периоду времени. Таким образом, вблизи
от момента вставки корректировочной секунды продолжительность секунды
может немного превышать одну секунду. Да, я понимаю, как странно это звучит.
Если всего этого недостаточно, чтобы у вас заболела голова: корректировочные
секунды непредсказуемы. Они объявляются заранее, что значительно лучше
некоторых изменений часовых поясов, но даже в этом случае это означает, что
вам придется тщательно продумывать корректность данных, которые вы будете
хранить в будущем. Проблема более подробно рассматривается в разделе 7.4.4.
Как и прежде, некоторые инфраструктуры (например, NTP) должны учитывать
корректировочные секунды, но в большинстве других программ этого делать
не нужно.

Который час на Марсе?
Если вам кажется, что трудно организовать встречу с участниками из разных часовых поясов на Земле, представьте, что на ней должен присутствовать участник
с Марса (где продолжительность дня составляет 24 часа 37 минут), с Юпитера
(где день немного короче 10 часов) и с Венеры (где продолжительность дня
составляет 5832 часа, что длиннее венерианского года). Представьте, что когда
вы все-таки устроили встречу, в конце кто-то говорит: «Завтра в то же время?»
Идея о том, что новые библиотеки даты и времени должны поддерживать внеземное время, предлагалась вполне серьезно. Надеюсь, к тому моменту, когда
она станет важной для широкой коммерческой разработки, я уже буду на пенсии.

Смена календарных систем
В 1582 году в Риме за 4 октября последовало 15 октября. В 1572 году в Лондоне
за 2 сентября последовало 14 сентября. Это примеры перехода с юлианского
календаря на григорианский — события, которое происходило в разных местах
с различными датами.
Это значит, что жители разных стран, которые обычно используют одну календарную систему, могут не сойтись во мнениях по поводу дат. Например,
сражение при Лоустофте состоялось 13 июня 1665 года… или 3 июня 1665 года,
в зависимости от того, на чьей стороне были вы.
Одна из странностей, заслуживающих внимания, — переход Швеции с юлианского календаря на григорианский. Швеция планировала сделать это постепенно, пропуская все високосные дни с 1700 года, пока не произойдет совмещение
с григорианским календарем. К сожалению, хотя в 1700 году все прошло по
плану, Швеция отвлеклась на Северную войну (1700–1721) и забыла о нем. 1704
и 1708 годы рассматривались как високосные; это противоречило плану, от которого после этого отказались. Чтобы вернуться к юлианскому календарю, Швеция включила в 1712 год два корректировочных дня: 29 февраля и 30 февраля.

7.2. Подготовка к работе с информацией о дате и времени

219

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

7.2. ПОДГОТОВКА К РАБОТЕ С ИНФОРМАЦИЕЙ
О ДАТЕ И ВРЕМЕНИ
Если вы дочитали предыдущий раздел и с нетерпением ожидаете кода, у меня
для вас плохие новости: в этом разделе кода тоже практически нет. Обещаю,
дойдет и до него, но структура этой главы создавалась как отражение эффективного подхода к работе с датой и временем: если вы тщательно подготовитесь
и продумаете подходы заранее, то написать код будет уже несложно. Теперь,
когда мы владеем общими понятиями и рабочей терминологией, подумаем, как
применить их в реальных продуктах.

7.2.1. Ограничение объема работ
Вы уже видели, что мир даты и времени может быть невероятно сложным.
К счастью, в обычных приложениях эта сложность, скорее всего, не понадобится. При планировании целого приложения или отдельной функциональности,
в которых используется дата и время, постарайтесь ограничить объем работ
и документируйте принятые решения.
Вероятно, начать стоит с исключения самых сложных и нишевых аспектов:
Нужно ли приложению учитывать эффект относительности?
Нужно ли учитывать корректировочные секунды?
Нужно ли работать с датами, находящимися достаточно далеко в прошлом,
чтобы пришлось учитывать исторические смены календарных систем?
Если ответ хотя бы на один из этих вопросов положительный, возможно, набор
библиотек, которые вы сможете использовать, будет ограничен, а вам определенно стоит действовать еще осторожнее, чем обычно, и серьезно исследовать
предметную область, для которой пишется код. Я не дам более конкретных
советов, так как мне еще не доводилось работать с такими приложениями, но
я предполагаю, что в этом случае выбор подходящих типов для представления
концепций продуктов будет еще важнее, чем обычно.

220

Глава 7. Эффективная работа с датой и временем

Второй уровень сложности связан с календарными системами и часовыми поясами. Потребуется ли работать с другими календарными системами, кроме
григорианского календаря? Большинство бизнес-приложений, вероятно, им
и ограничится, но, безусловно, найдутся и контрпримеры, особенно если приложение предназначено для религиозного сообщества, в котором важен конкретный календарь. В пользовательских приложениях необходимость поддержки
календарных систем, которые предпочитают клиенты, возникает чуть чаще, но
прежде чем браться за нее, стоит взвесить пользу и затраты на ее реализацию.
(Преимущества зависят от конкретного приложения, а на затраты влияет выбранная технология; уровень поддержки негригорианских календарных систем
сильно различается.)
Уровень сложности, связанной с часовыми поясами, может значительно изменяться. Вот лишь некоторые вопросы, которые стоит себе задать:
Нужна ли вообще поддержка часовых поясов в продукте? Иногда все приложение можно построить на концепциях машинного времени, что сильно
упрощает работу.
Нужно ли продукту взаимодействовать с часовыми зонами, определяемыми
другой системой? Если да, то какую базу данных часовых поясов она использует?
Разрешить ли пользователю выбирать часовые зоны или просто рассчитывать
на успешное определение часового пояса по умолчанию?
Должен ли продукт работать более чем в одном часовом поясе? Если нет,
уверены ли вы, что ситуация не изменится?
Должен ли продукт идеально соответствовать всем изменениям правил часовых поясов, активно отслеживая любые изменения, или же он может просто использовать правила часового пояса, предоставляемые по умолчанию
платформой или библиотекой?
Должен ли продукт хранить данные, которые естественным образом включают информацию о часовом поясе, или взаимодействие с часовым поясом
ограничивается выводом информации о нем на экран?
Насколько серьезное внимание следует уделять переходам часовых поясов
в отношении пропущенного и неоднозначного времени? Например, если вы
пишете систему для управления школьным расписанием, вряд ли у учеников
будут запланированы уроки на момент перехода.
Многим приложениям, которые должны сообщать пользователю дату и время,
так или иначе необходимо поддерживать часовые пояса. Тем не менее, отказавшись от встраивания гибкости, которая вам не понадобится, вы заметно упростите себе жизнь. Конечно, здесь тоже возникает компромисс: если вы написали
код с утверждением, что вам придется работать только (допустим) с часовым
поясом Парижа, то отменить последствия этого решения будет достаточно

7.2. Подготовка к работе с информацией о дате и времени

221

трудно. Тем не менее это может значительно повлиять на простоту решения.
Один из способов снизить будущие риски — проследить, чтобы все члены команды знали об этих допущениях и хорошо осознавали, когда они опираются
на них. Документирование, в каких местах системы допущения действительно
важны, упростит возможный откат изменений в будущем.
Обычно управление объемом работ возможно при наличии подробных требований
к продукту или функциональности. Продукты относительно редко изменяются
так, что в них, например, неожиданно появляется необходимость поддержки разных календарных систем. (Хотя это, конечно, возможно. Такие новые требования
скорее становятся частью выхода на новые рынки, чем частью добавления новой
функциональности.) Разработчикам, скорее всего, стоит обсудить вопросы между
собой, а затем документировать и сверить результаты с владельцами продукта.
ПРИМЕЧАНИЕ
Под термином «владельцы продукта» я понимаю людей, которые отвечают за принятие
решений о том, что должен делать продукт. В разных компаниях могут использоваться
другие названия — например, продакт-менеджеры. В зависимости от конкретной
модели разработки это могут быть люди из той же компании, что и разработчики, и/
или другой компании. Это могут быть и сами разработчики, но эту роль стоит рассматривать отдельно от принятия решений о реализации.

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

7.2.2. Уточнение требований к дате и времени
Начну этот раздел с предупреждения: если вы начнете проверять, что требования к продукту, относящиеся к дате и времени, ясны и однозначны, это вряд ли
прибавит вам популярности. Скорее всего, очень многие ответят вам: «Разве это
не очевидно?» — даже если то, что очевидно для одного человека, отличается
от того, что совершенно ясно для других. Но усилия не пропадут даром. Когда
требования становятся понятными, программирование упрощается. Без четко
сформулированных требований может оказаться, что у людей, участвующих
в разработке продукта, разные ожидания, что приводит к хаосу.
Конечно, вы сами решаете, как планировать и документировать требования.
Не существует одной обязательной методологии. Вы можете спроектировать
обширную начальную архитектуру или проектировать отдельные функции при
переходе на более гибкую разработку. Впрочем, если вы работаете по принципу
«проектируйте только то, что нужно прямо сейчас», будьте аккуратны; если
на первом спринте для представления информации вам нужна только дата, но
к четвертому окажется, что нужны дата и время (а возможно, и часовой пояс),
это значительно усложнит вам жизнь. Старайтесь по возможности предвидеть

222

Глава 7. Эффективная работа с датой и временем

будущие естественные требования, но не слишком углубляйтесь во все возможные варианты.
В широком смысле существуют два типа решений, которые необходимо фиксировать в документации: о способе интерпретирования каждого фрагмента
данных, относящихся к дате и времени, и о подходе к их обработке. Также необходимо рассмотреть представления хранения и передачи данных, но они скорее
относятся к подробностям реализации, чем к требованиям продукта. Два этих
вида решений взаимосвязаны, но мы будем рассматривать их по отдельности.
Чтобы описание было конкретным, воспользуемся сценарием с интернет-магазином. Вкратце рассматриваемые требования показаны на рис. 7.8: покупатели
могут вернуть товары в течение 3 месяцев. К концу сценария у нас появится
набор требований, которые можно реализовать и тестировать.

Покупатели
могут вернуть
товары в течение
3 месяцев

Рис. 7.8. Высокоуровневое требование, которое нуждается в детализации

Выбор подходящих концепций или типов данных
В хороших требованиях к продукту обычно указано, какие данные собираются
в конкретной ситуации (и возможно, какие данные намеренно игнорируются).
Иногда эта информация выражена неявно и частично скрыта в описании пользовательского взаимодействия, но при явном выражении она более понятна.
Информацию, относящуюся к дате и времени, обычно легко заметить, но может
быть сложнее решить, как ее обрабатывать.
Первое практическое правило: рассмотрите источник данных. Если вы сохраняете данные о том, что «что-то произошло», обычно вы начинаете с момента,
в который произошло событие. Также можно сохранить часовой пояс (или
в более общем случае — место), если он будет актуален для других операций.
Сохранение момента обычно выполняется тривиально — в большинстве баз
данных и системах ведения журналов реализованы встроенные метки времени.
ПРИМЕЧАНИЕ
Возможно, также стоит выбрать, какой источник текущего времени важнее: если
вы сохраняете текущий момент как в базе данных, так и на отдельном веб-сервере,
синхронизация двух этих часов может быть не идеальной. Насколько это важно, зависит от приложения.

7.2. Подготовка к работе с информацией о дате и времени

223

Если вы сохраняете значение даты и времени, предоставленное пользователем,
ситуация меняется. Вы используете гражданское, а не машинное время, даже
если пользователь сообщает о произошедшем событии. Вам почти наверняка
потребуется учитывать информацию часового пояса или, по крайней мере,
смещение UTC. Возможно, у вас возникнет соблазн преобразовать ее в момент
времени, но я рекомендую точно сохранить информацию, полученную от
пользователя, или хотя бы разобранное, но не обязательно преобразованное
представление. Когда мы будем рассматривать некоторые граничные случаи,
вы увидите, в каких из них подход с сохранением только времени UTC может
оказаться неподходящим, особенно при регистрации информации о будущем.
Понятно, что для требования о возврате товаров необходимо сохранить некоторые данные, но не очевидно, что это за данные, не говоря уже об используемом
представлении. Первый вопрос, который следует задать владельцу продукта:
«Покупатели могут вернуть товары в течение 3 месяцев… от чего?» Возможные
варианты ответа:
в течение 3 месяцев от нажатия пользователем кнопки Pay (Оплатить);
в течение 3 месяцев от принятия платежа;
в течение 3 месяцев от подтверждения заказа;
в течение 3 месяцев от резервирования товара;
в течение 3 месяцев от отправки заказа;
в течение 3 месяцев от получения заказа.
О том, что означает 3 месяца, мы подумаем позже, но даже в приведенном списке
представлены шесть разных точек отсчета. В пятом пункте (с отправкой) можно
даже выделить еще несколько моментов, но для простоты будем считать, что мы
успешно согласовали один из них.
Важно, что все они являются моментами времени, и имеет смысл сохранить все
эти данные для заказа. Некоторые аспекты могут действовать на уровне отдельных позиций, а не заказа в целом; например, это относится к резервированию
товаров и даже к отправке — заказ может быть отправлен в нескольких посылках.
Владелец продукта должен учесть все эти аспекты в контексте возможности
возврата товаров в течение 3 месяцев.
Предположим, владелец продукта отвечает, что для любого конкретного товара
покупатель может вернуть этот товар в течение 3 месяцев от момента отправки.
(Таким образом, окно возврата может зависеть от товара даже в одном заказе.)
Замечательно — требования стали намного точнее.
Скорее всего, вы будете регистрировать и другие моменты времени, но мы знаем,
что необходимо сохранить момент отправки каждого товара. Впрочем, это еще
не окончательное решение, и здесь можно обратиться к ранее рассмотренным

224

Глава 7. Эффективная работа с датой и временем

понятиям и задать еще несколько вопросов. Мы знаем, что 3 месяца — период,
а не промежуток, а прибавить период к моменту времени нельзя. Необходимо
вывести из момента времени другие данные, чтобы рассматривать его в гражданском времени. Это означает, что нам придется учесть календарные системы
и часовые пояса.
ПРИМЕЧАНИЕ
Все мы знаем, что требования к продукту могут меняться. Решение о том, что время
отправки определяет окно возврата, может измениться, как и решения, которые
будут приниматься позже. Хранение всех необработанных и независимых от реализации данных с самого начала позволит изменять решение на более поздней
стадии. А это означает, что следует регистрировать все моменты, перечисленные
ранее, и сохранять их как моменты, даже если затем из них будет извлекаться дополнительная информация.
Эта рекомендация связана с предыдущим советом относительно сохранения данных,
полученных от пользователя. Эти данные могут быть важны, если пользователь задал
дату и/или время. Независимой информацией в этом случае является не момент,
сохраненный машинными часами, а пользовательский ввод.

Сначала можно спросить владельца продукта, какую календарную систему использовать. Скорее всего, ответ будет простым: григорианскую календарную
систему, независимо от пользователя. (Если владелец продукта на этой стадии
даст любой другой ответ, стоит предусмотреть намного больше времени на
тестирование.)
Затем можно спросить владельца продукта, какой часовой пояс его интересует.
Именно здесь полезно привести пример, чтобы обсуждение было более конкретным. Можно рассмотреть некоторые сценарии:
веб-сервер в Бразилии;
хранение информации в базе данных в Нью-Йорке;
размещение заказа для компании, базирующейся в Калифорнии;
отправка товаров со склада в Техасе;
покупатель с адресом выставления счета в Берлине (Германия);
отправка по адресу в Сиднее (Австралия).
Момент, в который товар считается отправленным, представляет собой разное
локальное время, а возможно, даже разные даты для каждого из этих мест. Что
здесь важно? Подсказка: наверняка не веб-сервер и не база данных. Практически
любой другой ответ будет приемлемым, но поведение продуктов почти никогда
не зависит от физического местоположения задействованных компьютеров, если
только пользователи не сидят перед ними.

7.2. Подготовка к работе с информацией о дате и времени

225

Даже если владелец продукта считает, что эта ситуация притянута за уши, он
должен выбрать ответ, который кажется ему правильным, и документировать
это решение. Оно естественным образом задает отправную точку для приемочных тестов.
Допустим, владелец продукта отвечает, что актуальным часовым поясом
должен считаться тот, в который отправляется товар, — в данном случае это
Сидней (Австралия). Превосходно. Это, скорее всего, не значит, что нам придется сохранять дополнительные данные; у нас уже есть адрес, по которому
осуществляется доставка (по нему можно определить часовой пояс), момент
отправки товара как независимая точка отсчета и принятое ранее решение
о том, что мы всегда используем григорианский календарь. Момент можно
преобразовать в местное время адреса поставки, когда вы сочтете нужным.
Возможно, что его удобно сохранить непосредственно в базе данных, но это
детали реализации. Имея эту информацию, можно переходить к остальным
вопросам о функциональности.

Вопросы о поведении
Общее утверждение о том, что покупатели могут возвращать товары в течение
3 месяцев, нуждается в уточнениях. Мы выявили отправную точку для 3 месяцев,
но прежде чем переходить к реализации чего-либо, нужно собрать еще много
данных. Конечно, любой владелец продукта, ответственно выполняющий свои
обязанности, включает в требования многие подробности, но мы сейчас сосредоточимся на тех из них, которые касаются даты и времени.
Допустим, фактический путь пользователя документирован по следующей схеме:
При просмотре завершенного заказа на веб-сайте любой товар, который был
отправлен менее 3 месяцев назад, отображается с возможностью возврата.
Когда клиент щелкает по ссылке возврата, открывается форма с подробной
информацией. После ее заполнения запускается процедура возврата.
С процедурой возврата связано множество нюансов, но сейчас необходимо прояснить два аспекта даты и времени.
Во-первых, к какому моменту применять 3 месяца — когда пользователь просматривает завершенный заказ, когда он кликает по ссылке, запускающей процесс возврата, или когда он нажимает «Отправить» форму возврата? Это три
разных момента времени. Представьте, как будет раздражать покупателя, если
он просматривает заказ с возможностью вернуть товар, а при переходе по ссылке
возврата минутой позже появляется сообщение, что она уже недействительна.
С другой стороны, нельзя допускать вариант, когда пользователь оставляет
окно браузера открытым на годы и фактически имеет неограниченный период
возврата. Тот же вопрос относится и к заполнению формы.

226

Глава 7. Эффективная работа с датой и временем

Возможный набор требований с дополнительной детализацией может выглядеть так:
При просмотре завершенного заказа на веб-сайте любой товар, отправленный
менее 3 месяцев назад, отображается с возможностью возврата. Когда клиент
переходит по ссылке, сервер проверяет, был ли возврат возможен 5 минут назад, и если нет — возвращает ошибку. Это позволяет покупателям ожидать
до 5 минут между просмотром заказа и запуском процесса возврата. (Также
это означает, что если покупатель ожидал более 5 минут, но еще остается
в границах периода возврата, он все равно сможет перейти к форме возврата.)
Если проверка проходит, открывается форма с указанием, что ее необходимо
заполнить в течение 2 часов.
При отправке формы сервер проверяет, что процедура возврата была начата за последние 2 часа, и возвращает ошибку, если это не так. Если проверка проходит успешно, форма направляется на обработку и на экране
выводится подтверждение для покупателя.
В этом варианте используются два вида ограничений времени: один предоставляет период отсрочки 5 минут за пределами жесткого требования «процесс возврата
должен быть начат до времени x», а другой ограничивает продолжительность
заполнения самой формы возврата.
Мы уже на полпути к грамотному набору требований, относящихся к дате и времени. Впрочем, в утверждении «менее 3 месяцев» осталась еще одна неочевидная
проблема. Мы уже решили, что отсчет 3 месяцев должен начаться с момента
отправки заказа и 3 месяца должны ориентироваться на часовой пояс адреса
доставки. Однако в отношении точности еще не все ясно.
Как было показано в примере с голосованием, арифметические операции с календарями не подчиняются обычным математическим правилам. Таким образом,
в данном случае необходимо понять разницу между «определить время отправки
и прибавить к нему 3 месяца» и «определить текущее время и вычесть из него
3 месяца». Владелец продукта также должен определиться с детализацией: если
отправка была выполнена в 10 часов утра, должны ли три месяца завершиться
именно в 10 часов утра через три месяца? Покупателям такое решение может
показаться неочевидным. Конечно, если владелец продукта выберет этот вариант,
он превращается в требование. Однако если бы я был владельцем продукта, то
описанные мной условия выглядели бы примерно так:
Возможность возврата товара определяется датой его отправки в часовом поясе адреса доставки. Последняя дата, в которой еще может быть
выполнен возврат, вычисляется прибавлением 3 месяцев к текущей дате
адреса доставки на момент отправки товара. Если прибавление 3 месяцев
к дате отправки переходит через конец месяца, то используется начало
следующего месяца. (Пример: если товар отправляется 30 ноября, то

7.2. Подготовка к работе с информацией о дате и времени

227

последней допустимой датой возврата будет 1 марта, а не последний день
февраля.) Ссылка для возврата отображается для пользователя, пока
текущая дата в месте доставки не превысит последнюю допустимую
дату возврата.
Формулировка получается многословной, но однозначной. В ней определяется:
уровень детализации (дата возврата, а не дата и время);
природа арифметических вычислений с календарем (сложение с начальной
датой);
природа проверки (последняя дата включается);
используемый часовой пояс (адрес доставки);
способ разрешения арифметических операций с календарем (перенос на начало следующего месяца).
Последнее требование может быть не самым простым для реализации — это
зависит от используемой библиотеки, но, по крайней мере, оно четко сформулировано и хорошо тестируется.
Я бы не ожидал, что владелец продукта сформулирует подобные требования
самостоятельно, если только он уже не работал таким образом с датой и временем. Пока вы не знаете о странностях календарной арифметики, возможные
неоднозначности не всегда очевидны. Но команда разработки может проверять
требования к дате и времени, пока они не станут достаточно точными. Процесс
преобразования смутного набора требований к продукту в конкретный, однозначный тестируемый набор зависит от организации команды, но очень важно,
чтобы в итоге вы пришли к желаемому результату. Возможно, для этого придется не раз задать вопросы с нелепыми граничными случаями или же команда
разработчиков сможет предложить более конкретную версию неоднозначных
требований. На последнем шаге перед написанием кода необходимо убедиться,
что вы правильно подобрали инструментарий.

7.2.3. Использование подходящих библиотек или пакетов
И хотя написать понятный, удобочитаемый код с использованием плохих биб­
лиотек даты и времени можно, это не так просто. Если у вас есть четкий набор
требований, то у вас есть и основания для оценки технологий, используемых
для их реализации.
Впрочем, среда меняется со временем. Например, на момент написания книги
предложение Temporal с новым набором стандартных объектов для работы с датой и временем в JavaScript еще не было принято. Но когда (и если) его примут,
этот вариант стоит рассмотреть для новых проектов JavaScript.

228

Глава 7. Эффективная работа с датой и временем

Мы с удовольствием представим рекомендации для Java и .NET, так как автор
лучше всего знаком с этими платформами и обе они достаточно стабильны
в отношении доступных опций. Конечно, с момента написания текста до его
прочтения может появиться что-то новое, но в любом случае эти рекомендации
станут хорошими отправными точками.
Начнем с платформы Java. Если у вас есть возможность использовать пакет java.
time, представленный в Java 8, выберите его. Если вы почему-то ограничены
Java 6 или Java 7, проект ThreeTenBackport (https://www.threeten.org/threetenbp/)
будет хорошей альтернативой. Главное — избегайте java.util.Date и java.util.
Calendar; обе библиотеки полны ловушек, из-за которых неопытный разработчик
может написать некорректно работающий код.
Для .NET мы крайне субъективно рекомендуем использовать Noda Time (https://
nodatime.org). Конечно, ничто не мешает эффективно пользоваться встроенными
типами (DateTime, DateTimeOffset, TimeZoneInfo, TimeSpan), но они не делят логические понятия, рассмотренные ранее, по разным типам. Например, не существует типа для представления даты, а для представления понятий промежутка
и времени суток используется один и тот же тип. (С выходом .NET 6 ситуация
немного изменилась, но не будем углубляться в подробности.) А это значит, что
есть риск написать код, который выглядит правильно, но на самом деле выполняет
некорректные операции с нормальными данными (например, сложение получаса
с датой). Тот факт, что DateTime может означать «в неуказанном часовом поясе»,
«в системном местном часовом поясе» или «в UTC», тоже не упрощает ситуацию.
Но кроме этих конкретных примеров существуют более общие вопросы, по которым можно проверить любую конкретную библиотеку для вашей платформы.
Если нужно использовать другие календарные системы, кроме григорианской, поддерживаются ли они библиотекой?
Предоставляет ли библиотека достаточную степень контроля над используемыми данными часового пояса? (Например, если нужно работать с идентификаторами часовых поясов IANA, лучше не выбирать библиотеку, поддерживающую только часовые пояса Windows.)
Поддерживает ли библиотека все понятия, выявленные в требованиях,
и предоставляет ли она достаточные различия между этими понятиями,
чтобы четко выразить намерения в коде?
Предоставляет ли библиотека неизменяемые типы? Хотя у неизменяемости в
общем есть четко выраженные плюсы и минусы, как было показано в главе 4,
в контексте библиотеки даты и времени она почти всегда дает преимущество.
Всегда ли внешние зависимости (например, базы данных, другие библиотеки, сетевые API и т. д.) ведут в направлении конкретной библиотеки?
Если нужно выполнять преобразования между разными представлениями,
насколько легко это делать?

7.3. Реализация кода даты и времени

229

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

7.3. РЕАЛИЗАЦИЯ КОДА ДАТЫ И ВРЕМЕНИ
Даже пройдя всю необходимую подготовку, к написанию кода следует подходить
дисциплинированно. Потерять контроль над ситуацией в поисках кратчайшего
пути очень легко, а разобраться в возникшей путанице будет непросто.

7.3.1. Последовательное применение концепций
Последовательное применение концепций в приложении поможет предотвратить возможные ошибки. Осуществить это может быть трудно, если некоторые
фрагменты данных иначе используются в разных контекстах. Например, политика возврата в нашем сценарии базируется на фактической дате отправки,
но та, в свою очередь, базируется на моменте отгрузки со склада в сочетании
с часовым поясом места, куда отправляется товар. Действовать последовательно
можно и в этом случае, но нужно четко обозначить, что имеется в виду, каждый
раз, когда в коде приходится иметь дело с моментом отправки.
Информация о дате и времени обычно существует в трех разных формах:
в памяти при выполнении кода — обычно в форме объектов из используемой
библиотеки даты и времени;
в сетевых запросах во время передачи информации между компьютерами —
как правило, в текстовой форме, особенно для веб-приложений; разработчик
отвечает за то, что отправитель и получатель используют и ожидают данные в
одном формате. Тем не менее это может быть и двоичный протокол — обычно
непрозрачный; в этом случае вам не нужно знать (да вас это и не интересует)
смысл фактически передаваемых байтов;

230

Глава 7. Эффективная работа с датой и временем

в системах хранения данных (файлах в формате JSON, CSV или XML, базе
данных) — как и в случае с сетевыми запросами, можно не контролировать
точный формат данных. Однако часто тип данных можно выбирать с использованием полей SQL или стандартных представлений в текстовом
формате.
И снова очень важна последовательность. Например, если одна часть приложения позволяет пользователю задать дату (без времени), следует позаботиться
о том, чтобы поток информации соблюдал этот выбор во избежание дальнейшей путаницы. Это может быть запрос HTTP, содержащий текстовое значение
2020-12-20, которое затем парсится в java.time.LocalDate и сохраняется в поле
с типом DATE базы данных. Ничто не мешает написать нормально работающее
приложение, которое использует разные концепции даты и времени для этих
трех уровней, но код получится очень запутанным. Конечно, я выбрал очень
простой пример, обычно в жизни все сложнее.

Проблема рассогласования представлений
При использовании подходящей библиотеки даты и времени в основной части
приложения нередко выясняется, что база данных не имеет такого богатого
набора типов или что в коде интерфейсной части может использоваться отличающийся набор типов. Продолжим приведенный выше пример: предположим,
вы передали дату, выбранную пользователем, в текстовом виде и работали
с ней в коде в виде LocalDate, но затем ее приходится сохранять в базе данных,
поддерживающей единственный тип для работы с датой и временем — метку
времени. Что делать? Однозначного ответа нет, есть варианты.
Первый вариант — переход в концепцию, поддерживаемую базой данных. В нашем случае можно преобразовать LocalDate в момент Instant, представляющий
полночь в начале заданной даты в UTC. Преимущество такого подхода — возможность задействовать другую функциональность даты и времени внутри
базы данных. Это решение легко использовать в другом коде. Однако может
показаться, что можно создавать моменты времени, не представляющие полночь
любой даты в стандарте UTC.
Второй вариант — использование текстового поля. Например, дата может храниться в том виде, в котором она была получена от интерфейсной части, то есть
2020-12-20. Это более наглядно показывает, что в поле хранится обычная дата,
и если при этом используется формат ISO из примера (год-месяц-день), то ее
легко сортировать. С другой стороны, ее не столь эффективно хранить в базе
данных и сложнее использовать в запросах.
Третий вариант — использование числового поля с четко определенным значением. Например, дата может представляться количеством дней с 1 января 1970 года.
Такое решение может быть эффективным с точки зрения хранения и запросов

7.3. Реализация кода даты и времени

231

данных, но потребует более сложного кода во всех системах, напрямую работающих с базой данных, а также усложнит понимание данных в инструментах баз
данных (например, SQL Server Management Studio).
ПРИМЕЧАНИЕ
Постарайтесь привести входные данные к предпочтительному типу данных в памяти как можно раньше и приведите выходные данные к итоговому типу как можно
позже. Тем самым минимизируется объем кода, необходимого для работы с непоследовательным представлением. Кроме того, это одна из областей, в которых
важен принцип DRY («Не повторяйтесь»); сам код преобразования должен быть
централизован, чтобы избежать любых непоследовательностей при выполнении
преобразований.

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

Концепции, связанные с конкретными приложениями
Иногда оказывается, что одна из естественных концепций приложения не вполне
соответствует стандартным концепциям, описанным выше или представленным
в библиотеке (либо в используемой базе данных). Примером может послужить
финансовый квартал с информацией, зависящей от конкретной схемы учета,
используемой компанией. Подобные нестандартные концепции встречаются
довольно редко, но полезно знать о такой вероятности и планировать свои
действия в таком случае.
Как и прежде, важна последовательность. При обнаружении новой концепции ее
стоит инкапсулировать тем способом, который воспринимается идиоматически
для используемой вами библиотеки. Включите любые нужные преобразования,
спроектируйте подходящие текстовые представления и проработайте воплощение концепций в системах хранения данных.
Насколько рано все это нужно сделать? Здесь тоже существует компромисс.
Чем скорее начать работать над инкапсуляцией новой концепции, тем больше
гибкости будет при проектировании и тем меньше хлопот будет с преобразованием существующего кода для использования нового представления. С другой
стороны, если принять все решения на основании одного примера использования
концепции, возникает риск чрезмерной подгонки архитектуры под этот пример. В результате полученное решение не будет соответствовать требованиям
дальнейших сценариев использования. Чтобы свести к минимуму эти риски,
стоит начать поиск других примеров, столкнувшись с первым. Не обязательно
проектировать все аспекты будущей функциональности, для которых могут
потребоваться данные, но стоит хотя бы подумать, какие операции могут понадобиться и какие ограничения будут при этом действовать.

232

Глава 7. Эффективная работа с датой и временем

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

7.3.2. Отказ от значений по умолчанию в целях улучшения
тестируемости
Как уже говорилось при обсуждении политики возврата интернет-магазина,
в документах с требованиями полезно привести ряд примеров. Они идеальны
для преобразования в модульные тесты, но только если код тестируемый. В некоторых библиотеках обеспечить это не так просто, как хотелось бы, но эти
недостатки легко обойти, придерживаясь некоторых правил.
Рассмотрим конкретный пример, в котором внешне простой код содержит
ряд скрытых утверждений. (В нем используются классы из пакетов java.util
и java.text; с удовольствием отмечу, что java.time решает минимум две из
описанных проблем.)
String now = DateFormat.getDateInstance().format(new Date());

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

Существующие абстракции часов
Современные библиотеки даты и времени часто абстрагируют концепцию часов.
Впрочем, даже если они этого не делают, вы можете сделать это самостоятельно.
Пакет java.time содержит абстрактный класс Clock, который предоставляет

7.3. Реализация кода даты и времени

233

часовой пояс, а также сервис определения текущего момента времени. Noda
Time включает интерфейс IClock с единственным методом GetCurrentInstant().
В обоих случаях предоставляются возможности получения экземпляров для
тестовых целей. В любой ситуации, когда коду нужно узнать текущий момент
времени, мы рекомендуем внедрение зависимостей для получения доступа к часам — вместо любых решений, в которых всегда используются системные часы.
Если вам не очевидно, почему это необходимо для тестирования, рассмотрим искусственный простой пример. Допустим, вы хотите создать класс, который может
определить, находится ли текущий момент времени на расстоянии в пределах
одной минуты от некоторого целевого момента. В реальном коде целевой момент
лучше сделать гибким, используя параметр Duration при конструировании, но
для простоты мы его жестко зафиксируем. В следующем листинге приведен
довольно несложный код, использующий системные часы.
Листинг 7.1. Класс OneMinuteTarget, непригодный для тестирования
public final class OneMinuteTarget {
private static final Duration ONE_MINUTE = Duration.ofMinutes(1);
private final Instant minInclusive;
private final Instant maxInclusive;
public OneMinuteTarget(@Nonnull Instant target) {
minInclusive = target.minus(ONE_MINUTE);
maxInclusive = target.plus(ONE_MINUTE);
}
Эта строка затрудняет
public boolean isWithinOneMinuteOfTarget() {
тестирование кода
Instant now = Instant.now();
return now.compareTo(minInclusive) >= 0 && now.compareTo(maxInclusive) = 0 && now.compareTo(maxInclusive) Нидерланды -> Калифорния: 150 000 000 нс (150 000 мкс,
150 мс).
Конечно, наш вычислительный центр не будет пересылать данные между континентами при обработке больших данных. Тем не менее даже в локальном центре

8.4. Обработка данных: память идиск

281

передача данных по сети будет в несколько раз медленнее, чем обращение к локальным данным с диска. Для сетевых данных различия будут колоссальными.
Вычислим общее время, которое при обработке больших данных нужно выделить
на загрузку данных в зависимости от места их хранения. Обработка 100 Гбайт
данных, которые уже находятся в оперативной памяти, занимает: 250 000 нс ×
1000 (Мбайт) × 100 (Гбайт) = 25 000 000 000 нс = 25 с.
Для SSD это 1 000 000 нс × 1000 (Мбайт) × 100 (Гбайт) = 100 000 000 000 нс
= 100 с. И наконец, для HDD оно составит 20 000 000 нс × 1000 (Мбайт) ×
100 (Гбайт) = 2 000 000 000 000 нс = 2000 с = ~33 мин.
Как видите, даже если использовать SSD для всех больших данных, их загрузка будет происходить в четыре раза медленнее, чем при работе с памятью.
На практике, когда требуется хранить терабайты данных, они находятся на
стандартных HDD, потому что это выгоднее. В таком случае обработка на
базе HDD замедляется в 80 раз! На момент написания книги гигабайт памяти
HDD стоил $0,05, тогда как для SSD цена вдвое выше: $0,10. Делаем вывод, что
хранение данных на SSD обходится на 100 % дороже, чем на HDD. Результаты
приведены в табл. 8.1.
Мы видели, что обработка больших данных Hadoop базируется на доступе к
диску. Из-за этой медлительности и удешевления памяти сейчас в основном
используется новый подход на основе доступа к памяти. Далее мы рассмотрим
его подробнее.
Таблица 8.1. Время чтения с диска и из памяти
Тип ресурса

ОЗУ
Диск SSD
Диск HDD

Размер (Гбайт)

Время (секунды)

Время (минуты)

25
100
2500

0,25
~1,66
~33

100
100
100

8.4.4. Обработка данных в памяти
По мере снижения стоимости памяти архитектура новых средств обработки
больших данных начала изменяться, чтобы в полной мере использовать память.
Нередко можно увидеть кластеры вычислительных узлов с терабайтами оперативной памяти. Это позволяет инженерам создавать конвейеры данных, которые
загружают максимально возможный объем данных в память. Здесь их обработка
выполняется значительно быстрее, чем на диске. Один из самых известных и
проверенных на практике фреймворков обработки больших данных — Apache
Spark — использует память как основную точку интеграции между этапами
обработки (рис. 8.17).

282

Глава 8. Локальность данных и использование памяти
Spark processing
Итерация 1

Чтение с диска

Итерация 2
Память

Память

Запись на диск

Рис. 8.17. Обработка больших данных в памяти в Apache Spark

Точка входа для обработки больших данных требует загрузки данных из файловой системы. Локальность данных требует загрузки данных с локального диска.
При обработке в памяти данные загружаются в память компьютера. Когда текущий этап обработки завершается, результаты не записываются на диск (в отличие
от обработки больших данных на базе Hadoop). Данные остаются в памяти узла,
выполняющего обработку. На следующем этапе (преобразование, соединение)
уже не нужно заново загружать данные с диска. Таким образом, затраты на загрузку данных с диска снимаются на всех этапах, кроме первого, когда данные
загружаются. Результаты последнего этапа можно сохранить на диске.
Расчеты времени с диском и памятью показывают, что даже в лучшем случае
(с использованием SSD) обработка на базе Hadoop в четыре раза медленнее.
При обработке на базе Spark эти затраты приходится нести дважды — при загрузке данных и при сохранении результатов на диске. Та же схема обработки,
при которой нужны только два перебора данных (преобразование), требует
двух операций чтения с диска и двух операций записи на диск. В лучшем случае
с использованием SSD обработка на базе Hadoop выполняется в четыре раза
медленнее обработки на базе Spark: Hadoop — 100 с × 2 чтения + 100 с × 2 записи = 400 с, Spark — 25 c × 1 чтение + 25 с × 1 запись = 50 с. В итоге 400 ÷ 50 = 8.
На практике запись на диск часто медленнее чтения с диска. Из-за этого различия между Spark и Hadoop становятся еще заметнее. Кроме того, реальные
конвейеры больших данных обычно не ограничиваются только двумя этапами
(преобразования). Некоторые конвейеры включают десять и более этапов до
получения конечного результата. Для таких конвейеров больших данных необходимо умножить вычисления на количество этапов.
Очевидно, чем больше этапов, тем более заметны различия между обработкой
данных, хранящихся в памяти и на диске. Наконец, как уже говорилось, диски
HDD все еще используются из-за их экономичности. Вычислим общее время
для обработки данных на диске на базе Hadoop c HDD: 33 мин × 2 чтения +
33 мин × 2 записи = 132 мин = 2 часа и 12 мин. Различия между обработкой
данных в памяти и на диске колоссальны: 50 с против более 2 часов!
Надеюсь, эти числа убедят вас, что при создании современных конвейеров обработки больших данных следует стараться строить их на базе инструментов,
использующих память, таких как Apache Spark. В следующем разделе мы реализуем соединение с использованием Apache Spark.

8.5. Реализация соединений с использованием Apache Spark

283

8.5. РЕАЛИЗАЦИЯ СОЕДИНЕНИЙ
С ИСПОЛЬЗОВАНИЕМ APACHE SPARK
Прежде чем браться за реализацию логики, стоит вспомнить основы Apache
Spark. Это библиотека на базе Scala для обработки больших данных, которая
позволяет хранить промежуточные результаты в памяти. Как уже говорилось,
иногда хранить все данные в оперативной памяти невозможно. Spark позволяет
указать, что нужно делать в подобных ситуациях.
Spark также предоставляет настройку StorageLevel, которая позволяет указать,
должны ли данные храниться только в памяти или могут сбрасываться на диск
при ее заполнении. Если выбрать первый вариант, в процессе произойдет сбой,
указывающий на нехватку памяти. Можно применить разбиение данных, чтобы
они уместились в памяти компьютера. Если выбрать второй вариант, то данные
будут сохранены на диске без сбоя процесса. Обработка завершится, но потребует
гораздо больше времени. Как видите, Spark позволяет создавать обработку на
базе памяти. Но что с локальностью данных?
Чтобы понять, как добиться локальности данных при использовании Spark,
необходимо понимать архитектуру системы (рис. 8.18).
Рабочий узел (исполнитель1)

Главный узел

Управляющая
программа

Задача

Кэш

Менеджер кластера

Контекст Spark

Рабочий узел (исполнитель 2)

Задача

Кэш

Рис. 8.18. Архитектура Spark

Spark использует архитектуру «главный узел/рабочие узлы». Каждый отдельный процесс Spark работает на узлах, содержащих обрабатываемые данные.
Допустим, у вас три узла данных: один главный узел Spark и два исполнителя.
Главный узел Spark представляет собой специальный процесс, отвечающий за
обработку и отправку вычислений к данным.
Программа, которую мы напишем в этом разделе, использует Spark и отправляется на главный узел. Он сериализует программу (по аналогии с тем, как

284

Глава 8. Локальность данных и использование памяти

это делалось в первом разделе) и отправляет ее узлам-исполнителям, которые
выполняются на узлах данных, содержащих обрабатываемые данные. После
этого можно построить обработку, использующую локальность данных. В этом
случае исполнитель обрабатывает свои локальные данные в секциях, хранящихся на этом узле. Второй исполнитель обрабатывает секции, хранящиеся
на втором узле.
Если связать эту схему с нашим примером с данными пользователей и кликов,
исполнитель обработает данные некоторой части пользователей. Второй исполнитель находится на узле, на котором хранятся данные остальных пользователей.
Кэш на исполнителях Spark — оперативная память. Обрабатываемые данные
загружаются с диска или по сети и сохраняются в памяти.
Меньший набор данных о кликах clicks, участвующий в процессе соединения,
отправляется всем исполнителям. Управляющий компонент на главном узле
получает данные о кликах от узла, содержащего эти данные. Важно заметить,
что управляющий процесс должен располагать достаточным объемом памяти
для хранения данных. Затем данные кликов отправляются всем исполнителям,
где они сохраняются в кэше (ОЗУ). Таким образом, память, доступная этим
процессам, также должна быть достаточно большой для хранения данных.

8.5.1. Реализация соединения без рассылки
Начнем со сценария использования, в котором соединяются данные пользователей и кликов, но не будем делать утверждений относительно размера обоих
наборов данных. Сначала введем простую операцию соединения без оптимизации. Затем проанализируем план выполнения, который покажет, как ядро Spark
интерпретирует и выполняет операцию.
Примеры кода в этом разделе написаны на Scala, потому что этот язык позволяет
вести динамичную и удобочитаемую обработку больших данных. Кроме того,
Scala — родной язык Spark. В следующем листинге приведена простая модель
данных для нашего примера.
Листинг 8.1. Модель данных
case class UserData(userId: String, data: String)
case class Click(userId: String, url: String)

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

8.5. Реализация соединений с использованием Apache Spark

285

В нашем примере мы используем Spark Dataset API (http://mng.bz/zQKB), позволяющий применять синтаксис, сходный с SQL. Это API более высокого уровня,
инкапсулирующий RDD (http://mng.bz/0wpN).
Для тестирования данных будут задействованы имитации данных пользователей и кликов. В реальных приложениях данные будут загружаться из файловой
системы при помощи объектов чтения данных. В следующем листинге показано
чтение данных Avro (http://mng.bz/KBQj) из каталога HDFS.
Листинг 8.2. Чтение данных Avro
val usersDF = spark.read.format("avro").load("users/2020/10/10/users.avro")

Для простоты определим имитации двух наборов данных. В следующем листинге
показано, как это делается.
Листинг 8.3. Имитация наборов данных пользователей и данных о кликах
import spark.sqlContext.implicits._
val userData =
spark.sparkContext.makeRDD(List(
UserData("a", "1"),
UserData("b", "2"),
UserData("d", "200")
)).toDS()
val clicks =
spark.sparkContext.makeRDD(List(
Click("a", "www.page1"),
Click("b", "www.page2"),
Click("c", "www.page3")
)).toDS()

Здесь набор userData заполняется строками данных с идентификаторами a, b и d.
Наконец, RDD преобразуется в Dataset функцией toDS(), как показано в листинге 8.3. Мы хотим работать с набором данных, потому что он предоставляет
улучшенный API и оптимизации поверх RDD API.
Логика соединения проста, но она скрывает значительную часть информации.
В следующем листинге данные пользователей соединяются с данными о кликах.
Листинг 8.4. Соединение без утверждений
val res: Dataset[(UserData, Click)]
= userData.joinWith(clicks, userData("userId") === clicks("userId"), "inner")

Здесь данные userData соединяются с clicks. При этом выполняется внутреннее
соединение по полю userId обоих наборов данных. В результате при выполнении
запроса будут получены два результата, как показывает следующий листинг.

286

Глава 8. Локальность данных и использование памяти

Листинг 8.5. Два результата при внутреннем соединении
res.show()
assert(res.count() == 2)
+-----+-------------+
|
_1|
_2|
+-----+-------------+
|[b,2]|[b,www.page2]|
|[a,1]|[a,www.page1]|
+-----+-------------+

Результат представлен в виде таблицы. В левом столбце содержатся данные
пользователя, а в правом — данные о кликах. Данные пользователя userId d не
включены, так как для него нет соответствующих данных о кликах. То же происходит с данными о кликах для пользователя userId c.
Соединение скрывает значительную сложность. Чтобы оценить ее, можно извлечь
реальный физический план запроса. Он показывает, какая стратегия соединения
была выбрана. Для извлечения физического плана выполните метод explain(), как
показано в следующем листинге. Метод возвращает подробный физический план.
Листинг 8.6. Получение физического плана запроса
res.explain()
== Physical Plan ==
*SortMergeJoin [_1#10.userId], [_2#11.userId], Inner
:- *Sort [_1#10.userId ASC], false, 0
: +- Exchange hashpartitioning(_1#10.userId, 200)
:
+- *Project [struct(userId#2, data#3) AS _1#10]
:
+- Scan ExistingRDD[userId#2,data#3]
+- *Sort [_2#11.userId ASC], false, 0
+- Exchange hashpartitioning(_2#11.userId, 200)
+- *Project [struct(userId#7, url#8) AS _2#11]
+- Scan ExistingRDD[userId#7,url#8]

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

8.5. Реализация соединений с использованием Apache Spark

287

8.5.2. Реализация соединения с рассылкой
На следующем шаге реализуем поведение соединения, при котором один из наборов данных (clicks в нашем случае) рассылается по всем узлам данных. Для
этого необходимо изменить логику соединения, упаковав рассылаемый набор
данных в функции broadcast(). Используем для этого набор данных clicks.
Полный набор тестов приведен в следующем листинге.
Листинг 8.7. Соединение с рассылкой
test("Should inner join two DS whereas one of them is broadcast") {
import spark.sqlContext.implicits._
val userData =
spark.sparkContext.makeRDD(List(
UserData("a", "1"),
UserData("b", "2"),
UserData("d", "200")
)).toDS()
val clicks =
spark.sparkContext.makeRDD(List(
Click("a", "www.page1"),
Click("b", "www.page2"),
Click("c", "www.page3")
)).toDS()
//Если
val res: Dataset[(UserData, Click)]
= userData.joinWith(broadcast(clicks), userData("userId") ===
clicks("userId"), "inner")
//То
res.explain()
res.show()
assert(res.count() == 2)

Запрос возвращает те же данные, что и предыдущий, потому что логика не изменилась. Для нас интерес представляет физический план запроса, приведенный
в следующем листинге.
Листинг 8.8. Просмотр физического плана запроса с рассылкой
* == Physical Plan ==
* *BroadcastHashJoin [_1#234.userId], [_2#235.userId], Inner, BuildRight
* :- *Project [struct(userId#225, data#226) AS _1#234]
* : +- Scan ExistingRDD[userId#225,data#226]
* +- BroadcastExchange HashedRelationBroadcastMode(List(input[0,
struct, false].userId))
* +- *Project [struct(userId#230, url#231) AS _2#235]
* +- Scan ExistingRDD[userId#230,url#231]

288

Глава 8. Локальность данных и использование памяти

Вы заметили, что физический план существенно изменился? Во-первых, данные
уже не отсортированы. Исполнительное ядро Spark удалило этот шаг, потому
что отправлять части одного из наборов данных не требуется; следовательно,
его не нужно разбивать. Шаг Broadcast-Exchange отвечает за отправку данных
clicks всем узлам данных. Когда эти данные находятся на всех узлах, Spark
выполняет шаг Scan, использующий результат хеширования для нахождения
подходящих данных.
Получение результата еще не все. В реальном проекте следует измерить время
выполнения обоих решений. Как уже говорилось, может оказаться, что стандартное ядро запросов Spark работает более эффективно.
При рассылке данных по узлам необходима абсолютная уверенность, что данные
поместятся в памяти машины. Если рассылаемые данные начинают неконтролируемо увеличиваться в размерах, следует серьезно пересмотреть стратегию
рассылки. В следующей главе будут рассмотрены стратегии выбора сторонних
библиотек, используемых в коде.

ИТОГИ
Перемещение данных к вычислениям — более простой, но затратный метод.
Для крупных наборов данных он не подходит, так как требует пересылки
слишком больших объемов данных по сети.
Локальность данных в полной мере используется при отправке вычислений
к данным. Этот метод сложнее, но он оправдывает усилия для больших
данных. С ним не приходится перемещать столько данных, что существенно
упрощает обработку.
Обработка, использующая локальность данных, проще в распараллеливании
и масштабировании, чем обработка без нее.
В экосистеме больших данных информация распределяется по нескольким
компьютерам посредством секционирования.
Автономное и сетевое секционирование обладают разными характеристиками. Сетевое секционирование позволяет выполнять оптимизацию для
паттернов запросов, а автономное более универсально, потому что паттерны
обращений к данным часто неизвестны заранее.
Автономное секционирование, основанное на датах, часто применяется на
практике и обеспечивает бо́льшую гибкость.
Некоторые типы соединений могут в полной мере использовать локальность
данных при выполнении соединения на одной физической машине. Другие
типы соединений, для которых нужны более широкие данные, требуют перетасовки данных.

Итоги

289

Чтобы сократить перетасовку данных, следует уменьшить количество секций,
необходимых для операций соединения.
Утверждения о данных позволят использовать стратегию соединения с рассылкой.
Обработка больших данных, хранящихся на диске, более детальна, но по
скорости уступает обработке данных в памяти. Hadoop реализует первую
стратегию, в Spark используется вторая.
Для реализации соединений можно использовать Apache Spark API.
Анализ физических планов позволяет анализировать запросы. Например,
можно воспользоваться методом рассылки, а затем посмотреть, как он используется ядром выполнения запросов.
Необходимо знать данные, чтобы анализировать достоинства и недостатки
разных стратегий соединения.

9

Сторонние библиотеки:
используемые библиотеки
становятся кодом
https://t.me/it_boooks
В этой главе:
33 Ответственность за импортируемые библиотеки.
33 Анализ сторонних библиотек на удобство тестирования, стабильность и масштабируемость.
33 Принятие решений о повторной реализации логики и импортирование кода, который вам не принадлежит.

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

9.1. Импортирование библиотеки и ответственность за ее настройки

291

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

9.1. ИМПОРТИРОВАНИЕ БИБЛИОТЕКИ
И ОТВЕТСТВЕННОСТЬ ЗА ЕЕ НАСТРОЙКИ:
БЕРЕГИТЕСЬ ЗНАЧЕНИЙ ПО УМОЛЧАНИЮ
В некоторых библиотеках и фреймворках — таких, как Spring (https://spring.io/), —
приоритет отдается соглашениям, а не конфигурации. Такой подход позволяет
потенциальным клиентам использовать конкретную библиотеку немедленно,
без необходимости настраивать конфигурацию. Явные настройки заменяются
простотой UX. Пока специалисты знают об этом компромиссе и его ограничениях, реальной опасности нет.
Использование программных компонентов, не требующих значительной предварительной настройки конфигурации, существенно упрощает и ускоряет
прототипирование и эксперименты. Эти фреймворки строятся с применением
оптимальных практик и паттернов, они хороши и достаточны — если помнить
об их недостатках и проблемах.
ПРИМЕЧАНИЕ
Концепции фреймворков и библиотек часто используются как синонимы. Фреймворк
предоставляет каркас для построения приложений, но настоящая логика реализуется
в приложении. Логику необходимо каким-то образом предоставить фреймворку: посредством наследования, композиции, прослушивателей и т. д. (например, во фреймворках с внедрением зависимостей). С другой стороны, библиотека уже реализует
некоторую логику, которую можно только вызывать из кода. Например, библиотека
клиента HTTP предоставляет средства для вызова сервисов HTTP.

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

292

Глава 9. Сторонние библиотеки: используемые библиотеки становятся кодом

вместе с библиотекой. Такие значения обычно выбираются согласно логике,
подкрепленной исследованиями. Но даже если дефолтные значения выбраны
грамотно, они могут оказаться неподходящими для конкретной ситуации.
Рассмотрим простой сценарий с использованием сторонней библиотеки, ответственной за вызовы HTTP. В качестве примера возьмем библиотеку OkHttp
(https://square.github.io/okhttp/). Мы хотим запросить данные, доступные в конечной
точке сервиса /data. Для тестирования имитируем конечную точку HTTP с использованием библиотеки WireMock (http://wiremock.org/). Для конечной точки
/data будет создана заглушка, которая возвращает код статуса OK и данные тела
некоторой сущности. Этот код представлен в следующем листинге.
Листинг 9.1. Имитация сервиса HTTP
private static WireMockServer wireMockServer;
private static final int PORT = 9999;
private static String HOST;
@BeforeAll
public static void setup() {
wireMockServer = new WireMockServer(options().port(PORT));
wireMockServer.start();
HOST = String.format("http:/
/localhost:%s", PORT);
wireMockServer.stubFor(
get(urlEqualTo("/data"))
.willReturn(aResponse()
Имитирует ответ HTTP
.withStatus(200)
с кодом статуса 200
.withBody("some-data")));
и некоторыми данными
}

Запускает сервер
WireMock
на выделенном
порте PORT
Сохраняет
местоположение
в переменной HOST

Логика клиента OkHttp для отправки запроса сервису и получения ответа достаточно прямолинейна. В листинге 9.2 URL-адрес строится на базе переменной
HOST. Затем создается клиент OkHttp с использованием строителя и выполняется
вызов. Наконец, тест проверяет, что ответ содержит 200 и контент совпадает
с тем, который был предоставлен WireMock.
Листинг 9.2. Построение клиента HTTP с настройками по умолчанию
@Test
public void shouldExecuteGetRequestsWithDefaults() throws IOException {
Request request = new Request.Builder().url(HOST + "/data").build();
OkHttpClient client = new OkHttpClient.Builder().build();
Call call = client.newCall(request);
Response response = call.execute();
assertThat(response.code()).isEqualTo(200);
assertThat(response.body().string()).isEqualTo("some-data");
}

9.1. Импортирование библиотеки и ответственность за ее настройки

293

Заметьте, что клиент HTTP создается как строитель, но без явно заданных
настроек. Код выглядит просто, и можно быстро приступить к разработке.
К сожалению, его нельзя использовать в финальной версии приложения
в таком формате. Помните: импортированная сторонняя библиотека становится вашим собственным кодом. Так как этот раздел посвящен настройкам
по умолчанию, разберемся, какие из них могут создать проблемы.
При анализе настроек сторонних библиотек необходимо понимать их главные
конфигурации. В контексте каждого клиента HTTP исключительно важная
роль принадлежит тайм-аутам. Они влияют на производительность и условия
SLA вашего сервиса. Например, если SLA составляет 100 мс и вы обращаетесь
с вызовом к другим сервисам для выполнения запроса, другой вызов должен
завершиться быстрее времени SLA вашего сервиса. Выбор правильного таймаута исключительно важен, если вы хотите сохранить условия SLA.
Высокие тайм-ауты также опасны в архитектуре микросервисов. Для реализации
бизнес-функций в этой архитектуре часто приходится выдавать несколько сетевых
вызовов. Например, один микросервис может вызывать ряд других. Некоторые
из них могут связываться с микросервисами следующего уровня и т. д. Если
в таком сценарии один из сервисов зависает в ходе обработки запроса, это может
породить каскадные сбои в других сервисах, которые его вызывают. Чем выше
тайм-аут, тем больше времени займет обработка одного запроса и тем больше
вероятность каскадных сбоев. Такие сбои могут быть хуже нарушения SLA, потому что они создают риск того, что в системе произойдет критическая ошибка
и она перестанет работать.
Посмотрим, как поведет себя клиент при слишком долгом запросе к конечной
точке. Протестируем его в течение 5 с (5000 мc). Для моделирования такого
сценария в WireMock можно воспользоваться методом withFixedDelay()
(см. следующий листинг).
Листинг 9.3. Эмуляция медленной конечной точки
wireMockServer.stubFor(
get(urlEqualTo("/slow-data"))
.willReturn(aResponse()
.withStatus(200)
.withBody("some-data")
.withFixedDelay(5000)));

Для обращения к новой конечной точке можно воспользоваться URL-адресом
/slow-data. Запрос выполняется с использованием той же логики, но мы будем
измерять время, необходимое для выполнения запросов HTTP, как показано
в листинге ниже.

294

Глава 9. Сторонние библиотеки: используемые библиотеки становятся кодом

Листинг 9.4. Измерение времени запроса клиента HTTP
Request request = new Request.Builder()
➥ .url(HOST + "/slow-data").build();

Выполняет запрос
к конечной точке /
slow-data

OkHttpClient client = new OkHttpClient.Builder().build();
Call call = client.newCall(request);
long start = System.currentTimeMillis();
Response response = call.execute();
long totalTime = System.currentTimeMillis() - start;

Измеряет общее время
выполнения

assertThat(totalTime).isGreaterThanOrEqualTo(5000);
assertThat(response.code()).isEqualTo(200);
assertThat(response.body().string()).isEqualTo("some-data");

Проверяет, что запрос
занял не менее
5000 мс

Заметили, что выполнение запроса заняло не менее 5000 мс? Это произошло
из-за того, что сервер HTTP WireMock ввел указанную задержку. Если наш
код, который должен выполнять запрос за 100 мс, обратится с вызовом к этой
конечной точке, вызов будет достаточно медленным для нарушения SLA.
Вместо получения ответа в течение 100 мс (независимо от того, содержит ли
он признак успеха или неудачи) клиенты будут блокироваться в ожидании
на 5000 мс. Также это значит, что поток, выполняющий эти запросы, может
блокироваться на соответствующее время. Поток, который должен выполнить
~50 запросов (5000 ÷ 100 мс), остается заблокированным; в это время он не
может обрабатывать другие запросы, что влияет на общую производительность
сервиса. Проблема может не возникнуть, если слишком долго ожидает только
один поток. Но если все назначенные потоки (или хотя бы большинство) будут
блокироваться на длительное время, вы начнете замечать проблемы с производительностью.
Как оказалось, эта ситуация возникает из-за настроек тайм-аута по умолчанию.
Клиент сообщает о сбое обработки запроса, если ожидание превышает SLA
сервиса (100 мс). Если запрос завершается неудачей, клиент может повторно
выдать его вместо того, чтобы ждать ответа в течение 5000 мс. Взглянув на
тайм-аут чтения OkHTTP (http://mng.bz/9KP7), вы заметите, что по умолчанию он
составляет 10 с!
ПРИМЕЧАНИЕ
Проверка дефолтных настроек важна не только для сторонних библиотек, но и для
стандартных наборов средств разработки (SDK). Например, при использовании
HttpClient, поставляемого с Java JDK (http://mng.bz/jylr), по умолчанию выбирается
бесконечный тайм-аут!

Это означает, что каждый запрос HTTP может блокировать выполнение ­в ызывающей стороны на время до 10 с. Такая ситуация далека от

9.1. Импортирование библиотеки и ответственность за ее настройки

295

идеала. В реальной системе тайм-ауты следовало бы настроить в соответствии с SLA.
Предполагается, что код должен выполнить запрос к конечной точке slow-data
за время 100 мс. Также предполагается, что вызываемый сервис имеет определенный SLA в 99-м процентиле, равный 100 мс. Это означает, что 99 из 100 запросов будут выполняться в пределах 100 мс. Могут присутствовать отдельные
выбросы, занимающие большее время. Можно смоделировать такой выброс, для
выполнения которого требуется 5000 мс.
Снова выполним запрос HTTP, но на этот раз зададим тайм-аут явно, вместо
того, чтобы полагаться на значение по умолчанию. Обратите внимание на метод
readTimeout() в следующем листинге — он используется для назначения тайм-аута.
Листинг 9.5. Выполнение запроса HTTP с явным назначением тайм-аута
@Test
public void shouldFailRequestAfterTimeout() {
Request request = new Request.Builder().url(HOST + "/slow-data").build();
OkHttpClient client = new OkHttpClient
.Builder()
.readTimeout(Duration.ofMillis(100)).build();
Call call = client.newCall(request);

Назначает тайм-аут
чтения 100 мс

long start = System.currentTimeMillis();
assertThatThrownBy(call::execute).isInstanceOf(SocketTimeoutException.class);
long totalTime = System.currentTimeMillis() - start;
assertThat(totalTime).isLessThan(5000);
}

Сбой происходит быстрее,
и запрос занимает менее
5000 мс

Вызов метода execute инициирует фактическое выполнение запроса HTTP.
Запрос завершается по тайм-ауту приблизительно через 100 мс, потому что
это значение было задано вызовом readTimeout(). По прошествии указанного
времени на сторону вызова распространяется исключение. Таким образом,
ошибка не повлияет на SLA сервиса. Затем делается повторная попытка
выполнения запроса (если он идемпотентен), или же информация о сбое сохраняется на будущее. Что еще важнее, медленный ответ сервиса HTTP не
блокирует поток на долгое время. Следовательно, это никак не повлияет на
производительность сервиса.
При импорте любой сторонней библиотеки следует знать ее настройки и параметры. Неявные настройки могут подойти для построения прототипа, но явные
и тщательно подобранные для контекста настройки обязательны для реальных систем. В следующем разделе будут рассмотрены модели параллельного
выполнения и масштабируемость библиотек, которые могут использоваться
в кодовой базе.

296

Глава 9. Сторонние библиотеки: используемые библиотеки становятся кодом

9.2. МОДЕЛИ ПАРАЛЛЕЛЬНОГО ВЫПОЛНЕНИЯ
И МАСШТАБИРУЕМОСТЬ
Мы добавляем сторонние библиотеки в кодовую базу, чтобы они выполняли
некоторую работу. Значит, нам необходимо вызвать API, дождаться выполнения
и (возможно) получить результат. Эта простая схема скрывает часть сложности,
относящейся к модели обработки. Когда вы вызываете код, который вам не
принадлежит, следует обратить особое внимание на его модель параллельного
выполнения.
Первый сценарий, который мы рассмотрим, будет довольно простым. Имеется
программа, работающая по последовательной схеме с блокированием. Структура
такой программы показана на рис. 9.1.
Вызывает
method1()

Блокируется и ожидает

метод сторонней
библиотеки

Продолжает обработку

method2()

Рис. 9.1. Программа с блокирующим вызовом из кодовой базы

В нашей программе method1() выполняет метод сторонней библиотеки. Этот
метод является блокирующим, то есть поток вызывающей стороны method1()
блокируется до возвращения управления методом сторонней библиотеки. Когда метод вернет управление, выполнение вызывающей стороны продолжается
и вызывается метод method2().
Ситуация усложняется при использовании асинхронной, неблокирующей модели
выполнения. У некоторых веб-фреймворков (таких как, Node.js, Netty, Vert.x
и других) обработка базируется на модели цикла событий (рис. 9.2).
В таком контексте каждый запрос или часть работы, которая должна обрабатываться, помещается в очередь. Например, когда веб-сервер должен обработать
запрос HTTP, рабочий поток, получающий запрос, не выполняет фактическую
обработку. Он помещает данные, которые нужно обработать, в очередь. Затем
поток из пула потоков, ответственного за обработку, берет данные этой очереди
и выполняет фактическую обработку. Выполняя вызов любого метода из кода,
который не может блокироваться, нужно внимательно подходить к вызову
стороннего кода (рис. 9.3).

9.2. Модели параллельного выполнения и масштабируемость

297

Пул потоков

Новая задача

Очередь задач

Цикл событий
рабочего процесса

Завершенные задачи

Рис. 9.2. Модель обработки с циклом событий

Рабочий поток

Входящие запросы

Вызывает
Асинхронная схема
Блокируется и ожидает
выполнения

Метод сторонней
библиотеки

Сохранение данных
Очередь

Рис. 9.3. Блокирующий вызов из неблокирующего кода

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

298

Глава 9. Сторонние библиотеки: используемые библиотеки становятся кодом

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

9.2.1. Использование асинхронных и синхронных API
Допустим, вы интегрируетесь со сторонней библиотекой для сохранения и загрузки
сущностей. API является блокирующим, а это значит, что он не должен вызываться из асинхронного кода. В следующем листинге приведен код такого сценария.
Листинг 9.6. Блокирующий API
public interface EntityService {
Entity load();
void save(Entity entity);
}

Вызывающий поток блокируется независимо от того, вызывает ли он метод load
или save, но это создает проблемы и ограничивает возможности использования
этого API. Например, будет трудно подключить блокирующую обработку к уже
существующему асинхронному коду. Кроме того, потоковая модель приложения
может не допускать никакой блокировки (например, при использовании Vert.x).
Что делать, если вы хотите воспользоваться сторонней библиотекой, даже если
она блокирующая? Простейший и самый очевидный способ — создать обертку
вокруг блокирующего кода, как показано в листинге 9.7. Обертка делегирует
фактическую обработку внешней библиотеке и предоставляет методы, которые могут использоваться асинхронно. Оба метода могут вернуть сущность
CompletableFuture — обещание (promise), которое будет выполнено в будущем.
Асинхронный код, который не допускает блокирование, вызывает только неблокирующие версии этих методов.
Листинг 9.7. Асинхронная обертка для синхронного вызова
public CompletableFuture load() {
return CompletableFuture.supplyAsync(entityService::load, executor);
}
public CompletableFuture save(Entity entity) {
return CompletableFuture.runAsync(() -> entityService.save(entity), executor);
}

Обратите внимание: метод load() возвращает обещание Entity, которое может
быть выполнено в любой момент. Вызывающая сторона способна объединять

9.2. Модели параллельного выполнения и масштабируемость

299

асинхронные операции в цепочку без блокирования потока вызывающей стороны.
На первый взгляд, решение кажется простым. Тем не менее упаковать блокирующий код в асинхронную обертку не всегда просто. Асинхронные действия
должны выполняться в отдельном потоке. Для этого нужно создать специальный
пул потоков, который будет использоваться этим кодом. Пул необходимо контролировать и оптимизировать. Следует выбрать правильное количество потоков
и создать очередь входящих операций, как показано в следующем листинге.
Листинг 9.8. Создание исполнителя
public WrapIntoAsync(EntityService entityService) {
this.entityService = entityService;
executor = new ThreadPoolExecutor(1, 10, 100, TimeUnit.SECONDS, new
LinkedBlockingDeque(100));
Получает corePoolSize, maximumPoolSize,
}
keepAliveTimeout и очередь задач

Найти оптимальную конфигурацию для кода, который вам не принадлежит, может быть непросто. Необходимо знать предполагаемый трафик и провести тесты
производительности. Кроме того, если библиотека написана с использованием
блокирования, ее быстродействие может быть хуже, чем у кода, написанного
по асинхронной модели. Упаковка блокирующего кода может только отложить
проблему масштабирования, не решая ее.
Если производительность критична и не существует сторонней библиотеки,
решающей задачу по асинхронному принципу, рассмотрите возможность самостоятельной реализации ее отдельных частей. Возьмем ситуацию, в которой вы
выбираете внешнюю библиотеку, предоставляющую асинхронный API в исходном
состоянии. Следующий листинг показывает, как будет выглядеть API сервиса.
Листинг 9.9. Создание асинхронного API
public interface EntityServiceAsync {
CompletableFuture load();
CompletableFuture save(Entity entity);
}

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

300

Глава 9. Сторонние библиотеки: используемые библиотеки становятся кодом

Тот факт, что пул потоков инкапсулируется в библиотеке, не означает, что
он не создает потоки. Код вызывается из приложения. Потоки, созданные во
внутренней реализации для целей вызываемой библиотеки, все равно захватывают ресурсы в приложении. Если в приложении используется блокирующая
синхронная схема выполнения, асинхронный код вызвать проще, чем при необходимости вызывать блокирующий код в асинхронной схеме выполнения.
Единственное, что нужно сделать, — получить значение из возвращаемого
объекта CompletableFuture. Также необходимо учитывать, что вызов является
блокирующим, и этому действию рекомендуется передать разумный тайм-аут.
Но если в приложении уже применяется блокирование, это не создаст проблем.
Данный подход продемонстрирован в следующем листинге.
Листинг 9.10. Переход от неблокирующей модели к блокирующей
public class AsyncToSync {
private final EntityServiceAsync entityServiceAsync;
public AsyncToSync(EntityServiceAsync entityServiceAsync) {
this.entityServiceAsync = entityServiceAsync;
}
Преобразованный
Entity load() throws InterruptedException, ExecutionException, метод возвращает
Entity
TimeoutException {
return entityServiceAsync.load().get(100, TimeUnit.MILLISECONDS);
}
Асинхронный вызов блокируется
}
с получением значения

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

9.2. Модели параллельного выполнения и масштабируемость

301

9.2.2. Распределенная масштабируемость
Когда приложение выполняется в распределенной среде, очень важно понимать
возможности масштабирования сторонних библиотек, которые вы собираетесь
использовать. Рассмотрим библиотеку, предоставляющую средства планирования для приложения (что-то вроде заданий cron). Ее главная обязанность —
проверить, должна ли задача выполняться, и запускать ее при достижении
временного порога.
Сторонней библиотеке требуется уровень долгосрочного хранения данных для
решения ее задач. С каждой задачей связаны дата и время ее выполнения. После выполнения задачи библиотека планирования обновляет ее статус: Success,
Failed или None, если задача еще не была обработана. Библиотека планирования
схематически изображена на рис. 9.4.

Приложение
с библиотекой
планирования

job: 1, 21.02.20 20:00,
execution_status: SUCCESS
job: 2, 21.02.20 21:00
execution_status: F AILED
job: 3, 21.02.20 21:30
execution_status: NONE

Рис. 9.4. Приложение с библиотекой планирования

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

302

Глава 9. Сторонние библиотеки: используемые библиотеки становятся кодом

«договориться» о том, какой узел какую задачу будет обрабатывать. Иначе говоря,
одна задача не может обрабатываться более чем одним узлом.
Такое требование означает, что состояние заданий должно либо глобально
синхронизироваться, либо секционироваться. Если в выбранной библиотеке
планирования не реализована логика масштабирования, появляется риск серьезных проблем с производительностью или даже правильностью работы. Будем
считать, что приложение должно быть развернуто на трех узлах. Каждый из них
использует библиотеку планирования, как показано на рис. 9.5.
Если библиотека планирования не реализована с учетом возможностимасштабирования, все эти узлы будут конкурировать за запись задания в базе данных
(рис. 9.5). Правильность таких изменений может достигаться за счет применения транзакций или глобальной блокировки конкретной записи. Тем не менее
оба решения могут существенно повлиять на производительность библиотеки.
Эту проблему можно решить, если библиотека планирования поддерживает
секционирование. Например, первый узел может отвечать за задачи в указанном
диапазоне времени, другой узел — за задачи для другого периода времени и т. д.
Здесь важно то, что в используемой библиотеке должна быть предусмотрена
возможность масштабирования. Часто это нетривиальная задача, требующая
тщательного планирования и технической проработки. Не все библиотеки проектируются для работы в таком контексте. Следовательно, при выборе библиотеки,
которая заведомо будет выполняться в распределенной среде (на нескольких
узлах), следует тщательно проанализировать ее и проверить ее работоспособность
в распределенном контексте.

Узел 1

Узел 2

Узел 3

Пытается получить задание 3
Пытается получить задание 3
Пытается получить
задание 3

job: 3, 21.02.20 21:30,
execution_status: NONE

Рис. 9.5. Масштабирование библиотеки планирования по нескольким узлам

9.3. Тестируемость

303

Другое решение для библиотеки планирования, которое можно применить, —
архитектура «ведущий — ведомые». При таком подходе все запросы на планирование выполняются ведущим. Однако никакой реальной логики он не
выполняет. Если на ведущем узле происходит сбой, один из ведомых узлов
становится новым ведущим и начинает выполнять задания cron. Тем не менее
библиотека планирования должна изначально проектироваться для работы
в многоузловом контексте.
Понимание модели масштабируемости и необходимости использования глобального состояния позволит масштабировать приложение без проблем, которые
трудно исправить. Если библиотека не проектировалась для работы в распределенной среде, могут возникнуть проблемы с масштабируемостью и работоспособностью. Они часто проявляются при развертывании приложений на N узлах,
где N выше обычного количества узлов. Такие ситуации, как правило, возникают
при выбросах трафика в приложении. Что еще хуже, обычно выбросы происходят, когда для продукта открываются хорошие бизнес-возможности. Например,
на выходных. Это не самое подходящее время, чтобы узнать, что приложение
зависит от библиотеки, которая не масштабируется.
И снова используемый вами код становится вашим кодом — так будут считать
пользователи, которые заметят сбой. В следующем разделе рассматривается тестирование сторонних библиотек, призванное свести к минимуму такие проблемы.

9.3. ТЕСТИРУЕМОСТЬ
При выборе библиотеки с кодом, который вы не проектировали и не разрабатывали, не стоит доверять ей безраздельно. Не стоит делать практически никаких
предположений. Тем не менее качество и правильность проверенных и популярных библиотек часто находятся на достаточно высоком уровне. В таких случаях
тестирование с большой вероятностью проверяет предположения о библиотеке,
а не правильность ее работы. Тестирование — лучший эксперимент и проверка
пригодности сторонней библиотеки, которую вы собираетесь использовать
в коде. Однако тестирование кода, на который невозможно повлиять, несколько
отличается от тестирования собственного кода. Главная причина — этот код
нелегко изменить (если вообще возможно).
Если вы хотите протестировать компонент из своей кодовой базы и выясняется,
что код не позволяет повлиять на поведение, это относительно легко исправить.
Например, если код инициализирует внутренний компонент, не давая вызывающей стороне возможности внедрить ложное или имитированное значение,
можно провести рефакторинг кода без особых проблем. С другой стороны, при
использовании сторонней библиотеки влиять на кодовую базу становится трудно или невозможно. Даже если вы примените изменения, время от изменения

304

Глава 9. Сторонние библиотеки: используемые библиотеки становятся кодом

до развертывания может быть значительным. Из-за этого, прежде чем выбрать
стороннюю библиотеку, следует проверить ее на тестируемость.
Начнем с первой контрольной точки в списке оценки тестируемости. Она звучит
так: предоставляет ли сторонняя библиотека тестовую библиотеку, которая позволяет ее протестировать?

9.3.1. Тестовая библиотека
При импортировании библиотеки, предоставляющей сложную функциональность, должна быть возможность относительно легко протестировать ее код,
причем тестирование должно быть прямолинейным. Рассмотрим ситуацию,
в которой требуется реализовать реактивную обработку. Для этого необходимо
выбрать между парой библиотек, предоставляющих эту функциональность.
Начнем с реализации заготовки обработки, которая служит прототипом для
более сложной логики (листинг 9.11). Требуется сложить все входящие числа
в 10-секундном окне. Логика оперирует с потоком данных; это означает, что
при поступлении событий они интерпретируются в контексте окна, после чего
обработка продолжается.
Листинг 9.11. Реактивная обработка
public static Flux sumElementsWithinTimeWindow(Flux flux) {
return flux
.window(Duration.ofSeconds(10))
.flatMap(window -> window.reduce(Integer::sum));
}

Реактивная обработка компактно записывается и понятно выглядит. Это большой плюс библиотеки. Но стоит рассмотреть ее тестируемость и понять, насколько легко тестировать определенную обработку. Начнем с наивного подхода,
при котором логика тестирования определяется с нуля. Пример демонстрирует
наличие проблем и подчеркивает необходимость в специализированной тестовой
библиотеке.
Сконструируем поток из трех значений: 1, 2, 3, как показано в листинге 9.12.
Затем делается 10-секундная пауза перед проверкой логики формирования
окна. Обратите внимание: использовать метод Thread.sleep() в тестах не рекомендуется, но вскоре вы увидите, как исправить этот недочет. Наконец, мы
проверяем, что полученное значение равно 6.
Листинг 9.12. Тестирование реактивной обработки: обобщенное решение
// Дано
Flux data = Flux.fromIterable(Arrays.asList(1, 2, 3));

9.3. Тестируемость

305

Thread.sleep(10_000);
// Если
Flux result = sumElementsWithinTimeWindow(data);
// То
assertThat(result.blockFirst()).isEqualTo(6);

К сожалению, в логике есть проблемы. Сначала в ней используется приостановка
потока, которая увеличивает время на выполнение модульного теста. В реальной
системе пришлось бы провести намного более масштабное тестирование и отработать больше сценариев. Это увеличило бы время, необходимое для всех
модульных тестов, до неприемлемого уровня. Во-вторых, применение такого
подхода к тестированию усложняет проверку в более сложных сценариях. Например, как убедиться, что значение после 10 секунд не будет учтено? Нужно
выдать другое значение, подождать некоторое время, а потом проверить результаты. Анализируя этот простой пример, мы видим, что даже при использовании
хорошей библиотеки без тестовой инфраструктуры провести тестирование
крайне сложно, а иногда и вовсе невозможно.
К счастью, библиотека, используемая в этой главе, предоставляет тестовую
библиотеку. Для реактивного тестирования используется библиотека reactortest (http://mng.bz/8lPz). Это позволяет упростить тесты и делает возможным
тестирование более сложных сценариев.
Для тестов мы используем класс TestPublisher, который позволяет предоставить
данные для реактивного потока данных (листинг 9.13). Также с его помощью
можно моделировать задержки без фактического замедления общего времени выполнения запросов. Приостановка для этого не нужна, поэтому тесты
будут завершаться практически мгновенно. TestPublisher передается классу
StepVerifier. Оба класса предоставляются библиотекой реактивного тестирования, которая совместима с реактивной рабочей библиотекой.
Листинг 9.13. Тестирование реактивной обработки с использованием
тестовой библиотеки
final TestPublisher testPublisher = TestPublisher.create();
Flux result = sumElementsWithinTimeWindow(testPublisher.flux());
StepVerifier.create(result)
.then(() -> testPublisher.emit(1, 2, 3))
.thenAwait(Duration.ofSeconds(10))
.then(() -> testPublisher.emit(4))
.expectNext(6)
.verifyComplete();

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

306

Глава 9. Сторонние библиотеки: используемые библиотеки становятся кодом

сценарии снова будут сгенерированы значения 1, 2, 3. После этого имитируется 10-секундная задержка, равная размеру окна. Затем генерируется еще одно
значение. Наконец, мы проверяем, что первое произведенное значение равно 6.
Таким образом, значение, сгенерированное после длины окна, не было включено
в первое окно.
Этот подход позволяет протестировать любой сценарий, который приходится
учитывать. Кроме того, сам факт тестирования задержки не означает, что модульное тестирование будет занимать больше времени. Тесты будут выполняться
быстро, и мы сможем создать много модульных тестов для покрытия логики,
реализованной с использованием реактивной библиотеки.
ПРИМЕЧАНИЕ
Многие библиотеки предоставляют в распоряжение разработчика тестовую библиотеку. Часто ее наличие становится признаком высокого качества и упрощает разработку.

Рассмотрим второй аспект тестируемости для сторонних библиотек — способы
внедрения ложных объектов (fake) или имитаций (mock).

9.3.2. Тестирование с использованием объектов
fake (тестовых двойников) и mock
Другой важный аспект, на котором следует сосредоточиться при принятии
решения об использовании сторонней библиотеки, — возможность внедрения
объектов, предоставленных пользователем, для целей тестирования. Таким
объектом может быть имитация mock, с помощью которой можно смоделировать и проверить конкретное поведение, или объект fake (тестовый двойник),
позволяющий предоставить данные или контекст тестируемому коду. Часто
библиотеки скрывают слишком много внутренних подробностей от источника
вызова, защищаясь от потенциальных злоупотреблений со стороны пользователей. Однако это может затруднить тестирование библиотеки.
Если вам доступна кодовая база библиотеки, найдите в ней точку создания
нового экземпляра. Невозможность внедрения альтернативной реализации
для целей тестирования указывает на будущие проблемы с тестированием. При
использовании закрытой библиотеки, не раскрывающей свой исходный код,
анализ может оказаться невозможным. В таких случаях эксперименты с тестами
и проверка предположений становятся еще более важными, так как исходный
код недоступен.
Займемся тестируемостью сторонней библиотеки независимо от того, обеспечивает она возможность внедрения тестового двойника от вызывающей
стороны или нет. Допустим, вы хотите выбрать стороннюю библиотеку, которая
предоставляет приложению функциональность кэширования. Один из самых

9.3. Тестируемость

307

важных сценариев использования кэша — вытеснение старых элементов. Оно
может основываться как на размере кэша, так и на продолжительности пребывания элемента в кэше, а также на совокупности этих условий. При оценке новой
библиотеки стоит протестировать ожидаемое поведение, чтобы проверить свои
предположения относительно него.
Начнем эксперимент с построения простого кэша, который получает ключ
и преобразует его к верхнему регистру. В реальных системах используется более
сложное поведение загрузчика кэша, но для наших целей достаточно тривиального примера, представленного ниже.
Мы хотим проверить поведение библиотеки на основании наших предположений. В следующем листинге строится новый кэш со сроком жизни после записи, равным DEFAULT_EVICTION_TIME. CacheLoader получает значение для ключа,
предоставленного пользователем.
Листинг 9.14. Исходный вариант использования кэша
public class CacheComponent {
public static final Duration DEFAULT_EVICTION_TIME = Duration.ofSeconds(5);
public final LoadingCache cache;
public CacheComponent() {
cache =
CacheBuilder.newBuilder()
.expireAfterWrite(DEFAULT_EVICTION_TIME)
.recordStats()
.build(
new CacheLoader() {
@Override
public String load(@Nullable String key) throws Exception {
return key.toUpperCase();
}
});
}
public String get(String key) throws ExecutionException {
return cache.get(key);
}
}

Логика выглядит прямолинейно, но все равно следует проверить предположения относительно ее поведения. Код библиотеки написан не нами, поэтому он
может преподнести сюрпризы.
Требуется протестировать стратегию вытеснения используемого кэша. Для этого
нужно смоделировать задержку между вставкой элемента кэша и проверкой
процесса вытеснения. Поэтому мы должны ожидать столько, сколько идет вытеснение. В нашем сценарии использования это 5 секунд. В реальных системах

308

Глава 9. Сторонние библиотеки: используемые библиотеки становятся кодом

оно может длиться намного дольше (часы и даже дни). В следующем листинге
показан исходный, наивный подход к тестированию, требующий использования
Thread.sleep() c ожиданием DEFAULT_EVICTION_TIME.
Листинг 9.15. Тестирование без внедрения
// Дано
CacheComponent cacheComponent = new CacheComponent();
// Если
String value = cacheComponent.get("key");
// То
assertThat(value).isEqualTo("KEY");
// Если
Thread.sleep(CacheComponent.DEFAULT_EVICTION_TIME.toMillis());
// То
assertThat(cacheComponent.get("key")).isEqualTo("KEY");
assertThat(cacheComponent.cache.stats().evictionCount()).isEqualTo(1);

Обратите внимание: вытеснение производится при операции загрузки
(­get-методе). Чтобы инициировать его, необходимо вызвать метод доступа. Это
один из неожиданных аспектов, который не соотносится с предположениями
относительно библиотеки. Без качественного модульного теста это поведение,
скорее всего, обнаружить не удастся. Как уже говорилось, если время вытеснения
компонента слишком велико, тестирование компонента кэширования может
стать неприемлемым. Нужно обдумать исходный код сторонней библиотеки
(и возможно, просмотреть его), чтобы найти компонент, влияющий на поведение тестирования.
После беглого анализа выясняется, что LoadingCache при выполнении операции
чтения использует объект Ticker для определения того, должно ли значение
быть вытеснено. Доказательства приводятся в следующем листинге.
Листинг 9.16. Анализ тестируемости библиотеки кэширования
V get(K key, int hash, CacheLoader