Text
                    

Йонатан Шарвит Санкт-Петербург «БХВ-Петербург» 2024
УДК 004.4'236 ББК 32.973.26-018.1 Ш25 Шарвит Й. Ш25 Дата-ориентированное программирование: Пер. с англ. — СПб.: БХВ-Петербург, 2024. — 464 с.: ил. ISBN 978-5-9775-1924-3 Книга посвящена парадигме DOP (дата-ориентированному программированию), являющейся расширением философии объектно-ориентированного программирования. Предлагается новый взгляд на формирование структур данных и операции над ними в высоконагруженных приложениях. Изложенный материал дает решения сложных проблем, возникающих при управлении состоянием, разделяемыми и распределенными данными, позволяет безопасно организовать параллелизм и конкурентность, а также адаптировать ключевые принципы объектноориентированного программирования (полиморфизм, композицию, наследование) к новым задачам, связанным с обработкой больших данных. Для аналитиков данных, программистов, руководителей команд и преподавателей УДК 004.4'236 ББК 32.973.26-018.1 Группа подготовки издания: Руководитель проекта Зав. редакцией Перевод с английского Редактор Компьютерная верстка Оформление обложки Олег Сивченко Людмила Гауль Юлии Спикиной Софья Исакова Ольги Сергиенко Зои Канторович © 2023 BHV Authorized translation of the English edition © 2022 Manning Publications. This translation is published and sold by permission of Manning Publications, the owner of all rights to publish and sell the same. Авторизованный перевод с английского языка на русский издания © 2022 Manning Publications. Перевод опубликован и продается с разрешения компании-правообладателя Manning Publications. Все права защищены. "БХВ-Петербург", 191036, Санкт-Петербург, Гончарная ул., 20 ISBN 978-1-61729-857-8 (англ.) ISBN 978-5-9775-1924-3 (рус.) © Manning Publications, 2022 © Перевод на русский язык, оформление. ООО "БХВ-Петербург", ООО "БХВ", 2024
Оглавление Вступительное слово .................................................................................................... 13 Введение .......................................................................................................................... 17 Благодарности ................................................................................................................................ 18 Об этой книге ................................................................................................................................. 19 Кто должен прочитать эту книгу? ........................................................................................ 19 Как организована эта книга: дорожная карта ...................................................................... 19 О коде...................................................................................................................................... 21 Дискуссионный форум liveBook ........................................................................................... 22 Об авторе ........................................................................................................................................ 23 Об иллюстрации на обложке ........................................................................................................ 23 Персонажи пьесы ........................................................................................................................... 23 ЧАСТЬ 1. ГИБКОСТЬ ................................................................................................. 25 Глава 1. Сложность объектно-ориентированного программирования ............... 27 1.1. Дизайн ООП: классический или традиционный? ................................................................ 28 1.1.1. Этап проектирования ...................................................................................................28 1.1.2. UML 101 ....................................................................................................................... 30 1.1.3. Объяснение каждой части диаграммы классов ......................................................... 33 Класс Library ....................................................................................................................... 33 Классы Librarian, Member и User ...................................................................................... 33 Класс Catalog ...................................................................................................................... 35 Класс Book........................................................................................................................... 35 Класс BookItem.................................................................................................................... 35 1.1.4. Этап реализации ........................................................................................................... 36 1.2. Источники сложности ............................................................................................................ 36 1.2.1. Множество отношений между классами ................................................................... 38 1.2.2. Непредсказуемое поведение кода............................................................................... 40 1.2.3. Нетривиальная сериализация данных ........................................................................ 42 1.2.4. Сложные иерархии классов ........................................................................................ 44 Итоги............................................................................................................................................... 48
6 Оглавление Глава 2. Разделение кода и данных ........................................................................... 51 2.1. Две части системы ДОП......................................................................................................... 52 2.2. Объекты данных ..................................................................................................................... 54 2.3. Модули кода ............................................................................................................................ 56 2.4. Системы ДОП просты для понимания .................................................................................. 61 2.5. Системы ДОП являются гибкими ......................................................................................... 64 Итоги............................................................................................................................................... 68 Глава 3. Основные манипуляции с данными .......................................................... 69 3.1. Разработка модели данных .................................................................................................... 70 3.2. Представление записей в виде карт ...................................................................................... 74 3.3. Манипулирование данными с помощью универсальных функций .................................... 81 3.4. Вычисление результатов поиска ...........................................................................................85 3.5. Обработка записей различных типов .................................................................................... 93 Итоги............................................................................................................................................... 98 Глава 4. Управление состоянием ............................................................................. 101 4.1. Несколько версий системных данных ................................................................................ 102 4.2. Структурное совместное использование ............................................................................ 104 4.3. Реализация структурного совместного использования ..................................................... 110 4.4. Безопасность данных ............................................................................................................ 112 4.5. Фиксационный этап изменения ........................................................................................... 114 4.6. Обеспечение целостности состояния системы ................................................................... 116 4.7. Восстановление предыдущих состояний ............................................................................ 117 Итоги............................................................................................................................................. 120 Глава 5. Основы контроля конкурентности .......................................................... 123 5.1. Оптимистичный контроль конкурентности........................................................................ 124 5.2. Согласование между конкурентными изменениями.......................................................... 126 5.3. Сокращение коллекций ........................................................................................................ 129 5.4. Структурная разница ............................................................................................................ 131 5.5. Имплементация алгоритма согласования ........................................................................... 140 Итоги............................................................................................................................................. 142 Глава 6. Модульные тесты ........................................................................................ 145 6.1. Простота дата-ориентированных тестовых кейсов............................................................ 145 6.2. Модульные тесты для кода манипулирования данными ................................................... 147 6.2.1. Дерево вызовов функций .......................................................................................... 149 6.2.2. Модульные тесты для функций вниз по дереву ...................................................... 150 6.2.3. Модульные тесты для узлов в дереве ....................................................................... 154 6.3. Модульные тесты для запросов ........................................................................................... 157 6.4. Модульные мутационные тесты .......................................................................................... 162 Движение вперед ......................................................................................................................... 171 Итоги............................................................................................................................................. 172 ЧАСТЬ 2. МАСШТАБИРУЕМОСТЬ ...................................................................... 175 Глава 7. Основы валидации данных ....................................................................... 179 7.1. Валидация данных в ДОП .................................................................................................... 179 7.2. Суть JSON-схемы ................................................................................................................. 181
Оглавление 7 7.3. Гибкость и строгость схемы ................................................................................................ 188 7.4. Композиция схемы ............................................................................................................... 193 7.5. Сведения о сбоях при валидации данных ........................................................................... 197 Итоги............................................................................................................................................. 202 Глава 8. Расширенный контроль конкурентности .............................................. 203 8.1. Сложность блокировок......................................................................................................... 203 8.2. Потокобезопасный счетчик с атомами ............................................................................... 205 8.3. Потокобезопасный кеш с атомами ...................................................................................... 211 8.4. Управление состоянием с помощью атомов ...................................................................... 213 Итоги............................................................................................................................................. 215 Глава 9. Персистентные структуры данных ......................................................... 217 9.1. Потребность в персистентных структурах данных ............................................................ 217 9.2. Эффективность персистентных структур данных ............................................................. 221 9.3. Библиотеки персистентных структур данных .................................................................... 227 9.3.1. Персистентные структуры данных в Java ................................................................ 228 9.3.2. Персистентные структуры данных в JavaScript ...................................................... 229 9.4. Персистентные структуры данных в действии .................................................................. 232 9.4.1. Написание запросов с персистентными структурами данных ............................... 232 9.4.2. Операции изменения при работе с персистентными структурами данных .......... 235 9.4.3. Сериализация и десериализация ............................................................................... 236 9.4.4. Структурная разница ................................................................................................. 237 Итоги............................................................................................................................................. 240 Глава 10. Операции с базой данных ........................................................................ 243 10.1. Извлечение данных из базы данных ................................................................................. 244 10.2. Хранение данных в базе данных ....................................................................................... 251 10.3. Простая манипуляция данными......................................................................................... 254 10.4. Продвинутая обработка данных ........................................................................................ 258 Итоги............................................................................................................................................. 266 Глава 11. Веб-сервисы ................................................................................................ 269 11.1. Другой запрос функции...................................................................................................... 270 11.2. Создание внутренностей, подобных внешностям ............................................................ 270 11.3. Представление запроса клиента в виде карты .................................................................. 273 11.4. Представление ответа сервера в виде карты .................................................................... 276 11.5. Дальнейшая передача информации................................................................................... 281 11.6. Расширение результатов поиска в действии .................................................................... 284 11.7. Доставка в срок ................................................................................................................... 295 Итоги............................................................................................................................................. 296 ЧАСТЬ 3. УДОБСТВО СОПРОВОЖДЕНИЯ ....................................................... 297 Глава 12. Расширенная проверка данных ............................................................. 299 12.1. Проверка аргументов функции .......................................................................................... 299 12.2. Проверка возвращаемого значения ................................................................................... 308 12.3. Расширенная проверка данных.......................................................................................... 310 12.4. Автоматическое создание диаграмм модели данных ...................................................... 314
8 Оглавление 12.5. Автоматическая генерация модульных тестов на основе схемы .................................... 316 12.6. Новый подарок .................................................................................................................... 324 Итоги............................................................................................................................................. 326 Глава 13. Полиморфизм ............................................................................................. 327 13.1. Сущность полиморфизма ...................................................................................................327 13.2. Мультиметоды с единичной отправкой ............................................................................ 332 13.3. Мультиметоды с множественной отправкой.................................................................... 337 13.4. Мультиметоды с динамической отправкой ...................................................................... 343 13.5. Интеграция мультиметодов: производство ...................................................................... 346 Итоги............................................................................................................................................. 351 Глава 14. Расширенная обработка данных ............................................................ 353 14.1. Обновление значения на карте с помощью выразительности ........................................ 353 14.2. Манипулирование вложенными данными ........................................................................ 357 14.3. Использование наилучшего инструмента для работы ..................................................... 360 14.4. Легкое разматывание.......................................................................................................... 365 Итоги............................................................................................................................................. 370 Глава 15. Отладка ....................................................................................................... 371 15.1. Детерминизм в программировании ................................................................................... 371 15.2. Репродуцируемость с числами и строками ...................................................................... 375 15.3. Репродуцируемость с любыми данными .......................................................................... 379 15.4. Модульные тесты................................................................................................................ 383 15.5. Работа с внешними источниками данных ........................................................................ 392 Прощание ..................................................................................................................................... 394 Итоги............................................................................................................................................. 394 Приложение A. Принципы дата-ориентированного программирования........ 397 A.1. Принцип № 1: отделяйте код от данных ............................................................................ 398 A.1.1. Иллюстрация к принципу № 1 ................................................................................. 399 Нарушение принципа № 1 в ООП ................................................................................... 399 Нарушение принципа № 1 в ФП ..................................................................................... 400 Соблюдение принципа № 1 в ООП ................................................................................. 400 Соблюдение принципа № 1 в ФП ................................................................................... 401 A.1.2. Преимущества принципа № 1 .................................................................................. 401 Преимущество № 1: код может быть повторно использован в различных контекстах ......................................................................................................................... 402 Преимущество № 2: код может быть протестирован изолированно ........................... 405 Преимущество № 3: системы, как правило, менее сложны .......................................... 406 A.1.3. Издержки принципа № 1 .......................................................................................... 408 Издержка № 1: отсутствие контроля над тем, какой код к каким данным может получить доступ .................................................................................................... 408 Издержка № 2: отсутствие пакетирования ..................................................................... 408 Издержка № 3: системы состоят из большего количества объектов ........................... 409 A.1.4. Основная суть принципа № 1 ................................................................................... 409 A.2. Принцип № 2: представляйте данные с помощью обобщенных структур...................... 410 A.2.1. Иллюстрация к принципу № 2 ................................................................................. 410 A.2.2. Преимущества принципа № 2 .................................................................................. 411
Оглавление 9 Применение функций, которые не ограничены определенным случаем использования ................................................................................................................... 412 Гибкая модель данных ..................................................................................................... 412 A.2.3. Издержки принципа № 2 .......................................................................................... 413 Издержка № 1: снижение производительности ............................................................. 413 Издержка № 2: отсутствие схемы данных ...................................................................... 414 Издержка № 3: отсутствует проверка данных на валидность во время компиляции ....................................................................................................... 414 Издержка № 4: необходимость явного приведения типов ............................................ 415 A.2.4. Основная суть принципа № 2 ................................................................................... 416 A.3. Принцип № 3: данные неизменяемы .................................................................................. 417 A.3.1. Иллюстрация к принципу № 3 ................................................................................. 417 A.3.2. Преимущества принципа № 3 .................................................................................. 419 Преимущество № 1: надежный доступ к данным для всех ........................................... 419 Преимущество № 2: предсказуемое поведение кода..................................................... 419 Преимущество № 3: быстрая проверка равенства ......................................................... 420 Преимущество № 4: безопасность конкурентности без затрат .................................... 420 A.3.3. Издержки принципа № 3 .......................................................................................... 420 Издержка № 1: снижается производительность ............................................................ 421 Издержка № 2: требуется библиотека для персистентных структур данных .............. 421 A.3.4. Основная суть принципа № 3 ................................................................................... 421 A.4. Принцип № 4: отделяйте схему данных от представления данных ................................. 422 A.4.1. Иллюстрация к принципу № 4 ................................................................................. 422 A.4.2. Преимущества принципа № 4 .................................................................................. 424 Преимущество № 1: возможность свободно выбирать, какие данные следует валидировать ....................................................................................................... 424 Преимущество № 2: наличие опциональных полей ...................................................... 426 Преимущество № 3: наличие расширенных условий валидации данных.................... 428 Преимущество № 4: возможность автоматического создания визуализации модели данных .................................................................................................................. 428 A.4.3. Издержки принципа № 4 .......................................................................................... 429 Издержка № 1: слабая связь между данными и их схемой ........................................... 430 Издержка № 2: небольшое снижение производительности .......................................... 430 A.4.4. Основная суть принципа № 4 ................................................................................... 430 Заключение................................................................................................................................... 431 Приложение B. Обобщенный доступ к данным в статически типизированных языках ................................................................... 433 B.1. Динамические геттеры для строковых карт ...................................................................... 433 B.1.1. Доступ к невложенным полям карты с помощью динамических геттеров .......... 434 B.1.2. Доступ к вложенным полям карты с помощью динамических геттеров.............. 435 B.2. Геттеры значений для карт .................................................................................................. 436 B.2.1. Доступ к невложенным полям карты с помощью геттеров значений .................. 437 B.2.2. Доступ к вложенным полям карты с помощью геттеров значений ...................... 438 B.3. Типизированные геттеры для карт ..................................................................................... 440 B.3.1. Доступ к невложенным полям карты с помощью типизированных геттеров ......... 440 B.3.2. Доступ к вложенным полям карты с помощью типизированных геттеров ......... 441
10 Оглавление B.4. Обобщенный доступ к членам класса ................................................................................ 443 B.4.1. Обобщенный доступ к не вложенным членам класса ............................................ 443 B.4.2. Обобщенный доступ к членам вложенного класса ................................................ 447 B.4.3. Автоматическая сериализация объектов JSON ...................................................... 450 Итоги............................................................................................................................................. 451 Приложение C. Дата-ориентированное программирование: звено в цепи парадигм программирования ........................................................... 453 C.1. Хронология ........................................................................................................................... 453 C.1.1. 1958 год: Lisp ............................................................................................................. 453 C.1.2. 1981 год: значения и объекты .................................................................................. 453 C.1.3. 2000 год: идеальные хеш-деревья ............................................................................ 455 C.1.4. 2006 год: «Из ямы со смолой» ................................................................................. 455 C.1.5. 2007 год: Clojure ........................................................................................................ 455 C.1.6. 2009 год: неизменяемость для всех ......................................................................... 455 C.2. Принципы ДОП как наилучший подход ............................................................................ 456 C.2.1. Принцип № 1: отделяйте код от данных ................................................................. 456 C.2.2. Принцип № 2: представляйте данные с помощью обобщенных структур ........... 456 C.2.3. Принцип № 3: данные неизменяемы ....................................................................... 456 C.2.4. Принцип № 4: отделяйте схему данных от представления данных ...................... 457 C.3. ДОП и другие парадигмы, связанные с данными ............................................................. 458 C.3.1. Дата-ориентированная разработка........................................................................... 458 C.3.2. Дата-управляемое программирование..................................................................... 458 C.3.3. Дата-ориентированное программирование (ДОП)................................................. 459 Итоги............................................................................................................................................. 459 Приложение D. Ссылки на Lodash........................................................................... 461
Карин, которая ежедневно за мной присматривает

Вступительное слово Каждый принцип программирования, каждый метод проектирования, каждый архитектурный стиль и даже большинство языковых функций связаны с организацией сложности и возможностью адаптации. Две характеристики — неизменяемые данные и превращение частей программы в данные внутри самой программы — привлекли меня к Clojure в 2009 году, а совсем недавно к дата-ориентированному программированию Йехонатана Шарвита. В 2005 году я работал над одним из моих любимых проектов с некоторыми из моих любимых людей. Это был проект Java, но мы сделали две вещи, которые в то время не были распространены в мире Java. Во-первых, мы сделали наши основные значения данных неизменяемыми. Это было непросто, но сработало необыкновенно хорошо. Мы вручную накрутили методы clone и deepClone во многих классах. Отдача была огромной. В качестве примера предположим, что вам нужны шаблонные документы для создания экземпляров пользователями. Когда вы можете делать копии целых деревьев объектов, самим объектам не нужно «знать», являются ли они данными шаблона или данными экземпляра. Это решение зависит от того, какой объект содержит ссылку. Еще одно большое преимущество было получено от сравнения: когда значения неизменны, равенство идентичности указывает на равенство значений. Это может привести к очень быстрой проверке на равенство. Наш второй метод заключался в том, чтобы использовать общие данные, хотя и не в той степени, в какой Йехонатан покажет вам в этой книге. Там, где один слой имел иерархию классов, соседний слой представлял бы их как экземпляры более общего класса. То, что было бы переменной-членом в одном слое, будет описано полем в словаре в другом слое. Я уверен, что на этот стиль повлияли несколько болтунов в нашей команде. Это также сразу же окупилось, так как мы смогли компоновать и перекомпоновывать объекты в разных конфигурациях. Дата-ориентированное программирование, как вы увидите, обещает уменьшить случайную сложность и повысить уровень абстракции, с которым вы работаете. Вы начнете рассматривать повторяющееся поведение в своих программах как искусственное, результат разделения общих функций на классы, которые действуют как маленькие пространства имен, которые работают только с подмножеством значений вашей программы (их экземплярами). Мы можем «сложить вместе» почти все
14 Вступительное слово эти значения в карты и списки. Мы можем превратить имена участников (данные с трудом доступны через рефлексивные API) в ключи карты. Когда мы это делаем, код просто тает. Это первый уровень просветления. На этом этапе вы можете возразить, что компилятор использует эти имена членов во время компиляции для проверки правильности. Действительно так. Но верьте, потому что Йехонатан проведет вас к следующему уровню понимания: эти проверки во время компиляции являются небольшим подмножеством возможных проверок правильности значений. Мы также можем превратить проверки правильности в данные! Мы можем преобразовать схемы в значения внутри наших программ. Более того, мы можем применять критерии, которые все еще пытаются выяснить исследователи систем типов. Это второй уровень просветления. Дата-ориентированное программирование особенно ярко проявляется при работе с веб-API. В проводной сети нет типа системы, поэтому попытка сопоставить полезную нагрузку запроса непосредственно с классом предметной области гарантирует ненадежную и сложную реализацию. Если мы позволим данным быть данными, мы получим более простой код и гораздо меньше зависимостей от стомегабайтных фреймворковых библиотек. Итак, что случилось с такими достоинствами ООП, как инкапсуляция, наследование и полиморфизм? Оказывается, мы можем разобрать их и получить каждый из них по меню. (По моему мнению, наследование реализаций является наименее важным из них, хотя его часто преподают в первую очередь. Теперь я предпочитаю наследование интерфейсов через протоколы и сигнатуры общих функций.) Датаориентированное программирование предлагает полиморфизм «традиционного» типа: отправка одной из многих функций на основе типа первого аргумента (в объектно-ориентированном языке это маскировка первого аргумента метода. Просто бывает, что он стоит перед «.»). Однако, как и в случае проверки схемы, ДОП допускает больший динамизм. Представьте диспетчеризацию на основе типов первых двух аргументов. Или на основе того, есть ли в аргументе поле «день рождения» с сегодняшней датой! Это третий уровень просветления. А что касается инкапсуляции, мы все равно должны применить ее к организующей логике нашей программы. Мы инкапсулируем подсистемы, а не значения. Эта инкапсуляция воплощает сокрытие решений Дэвида Парнаса. Внутри подсистемы мы можем перестать отгораживать наши данные стеной от разрозненных пространств имен, навязываемых классами. По словам Алана Перлиса, «лучше иметь сто функций, работающих с одной структурой данных, чем десять функций с десятью структурами данных». В нашей бесконечной битве с энтропией мы можем использовать дата-ориентированное программирование, чтобы уменьшить объем кода, чтобы не отставать и повысить уровень абстракции, чтобы сделать логику и смысл нашей программы точными и очевидными. Наслаждайтесь путешествием и останавливайтесь на каждом новом плато, чтобы насладиться видом и сказать себе: «Это просто данные!» Майкл Т. Нигард, автор книги «Release It!: Design and Deploy Production-Ready Software»
Вступительное слово 15 Эта книга поразила меня в нужное время. Я создавал веб-приложения почти 20 лет в объектно-ориентированной среде. Я никогда не считал себя опытным программистом, но я достаточно хорошо знал свои инструменты, чтобы рассмотреть типичную бизнес-задачу, набросать модель данных и создать приложение в стиле MVC, чтобы выполнить свою работу. Проекты были захватывающими в начале. Мне нравилось ощущение соединения частей воедино и наблюдение за тем, как приложение оживает. Но как только я заставил его работать, я столкнулся с проблемами. Я не мог изменить одну деталь, не имея в виду все остальные модели. Я знал, что должен писать тесты, но мне нужно было настроить так много состояний для тестирования вещей, что оно того не стоило: я не хотел писать больше кода, который было бы трудно изменить. Даже запуск фрагментов кода в консоли был утомительным, потому что мне приходилось создавать состояние базы данных для вызова метода. Я думал, что, вероятно, делаю это неправильно, но решения, о которых я знал, такие как сложные среды тестирования, казалось, усложняли, а не облегчали задачу. Затем однажды я увидел на YouTube выступление Рича Хики, создателя Clojure. Он объяснял функциональное программирование и противопоставлял его объектноориентированному программированию, которое он насмешливо называл «пространственно-ориентированным программированием». Я не был уверен, прав ли он, но услышал скрытое сообщение, которое меня заинтриговало: «Дело не в тебе, дело в твоем языке». Я посмотрел все видео, которые смог найти, и начал думать, что Clojure может быть ответом. Прошли годы. Я продолжал смотреть видеоролики о Clojure и пытался применять функциональные принципы, когда мог. Но всякий раз, когда приходило время начинать новый проект, я возвращался к своему знакомому фреймворку. Переход на другой язык с совершенно другой экосистемой библиотек был слишком большим скачком. Затем, когда я собирался начать работу над новым продуктом, я нашел эту книгу. Слова «дата-ориентированное» в заголовке прозвучали звоночком. Я слышал, как программисты в тех видеороликах Clojure использовали эти слова раньше, но я действительно не понимал, что они означают. Кое-что о том, как проще создавать системы, которые манипулируют литералами данных (например, картами и массивами) вместо пользовательских объектов. Языки, которые я знал, хорошо поддерживали литералы данных, поэтому я подумал, что могу научиться чему-то, что поможет мне продержаться до того волшебного дня, когда я смогу переключиться на Clojure. Мое первое озарение произошло прямо во вступлении. На первых нескольких страницах Йехонатан объясняет, что, хотя он пишет на Clojure уже 10 лет, книга не привязана к конкретному языку, а примеры будут на JavaScript. «Подожди! — подумал я. — Неужели мне не нужно менять языки, чтобы значительно улучшить способ написания программ?» Я был так взволнован этой перспективой, что проглотил книгу в один присест. Мои глаза открылись и увидели то, что все это время было прямо передо мной. Конечно,
16 Вступительное слово мой код было трудно протестировать! Из-за ORM, который я использовал, вся моя функциональность была написана в объектах, которые предполагали множество состояний базы данных! Когда я увидел, как это прописано с примерами в книге, я не мог развидеть это. Мне не нужен был новый язык, мне просто нужно было по-другому подойти к программированию! Все разработчики, которых я считаю великими, указывают на одно и то же: хорошая разработка заключается в разделении вещей. Дело не только в том, чтобы заставить код работать, каким бы уродливым он ни был. Речь идет о распутывании частей друг от друга, чтобы вы могли изменить одну вещь, не нарушая все остальное. В этой книге код и данные разбираются на части с неожиданными и захватывающими результатами. Шарвит отделил способ программирования от определенного языка. Возможно, я никогда не перейду на Clojure, да и не чувствую, что мне это нужно. Дата-ориентированное программирование помогло мне увидеть новые возможности языков, которые я знаю, и множество новых фреймворков, появляющихся каждый день. Райан Сингер, автор книги «Shape Up: Stop Running in Circles and Ship Work that Matters»
Введение Я работаю инженером-программистом с 2000 года. Для меня очевидно, что есть «до» и «после» 2012 года. Почему именно 2012 год? Потому что 2012 год — это год, когда я открыл для себя Clojure. До Clojure программирование было моей работой. После Clojure программирование стало моей страстью. Несколько лет назад я задавался вопросом, какие особенности Clojure сделали этот язык программирования таким большим источником удовольствия для меня. Я поделился своими вопросами с другими членами сообщества Clojure, которые испытывают к нему ту же страсть, что и я. Вместе мы обнаружили, что особенным в Clojure были не функции, а принципы. Когда мы решили выделить основные принципы Clojure, мы поняли, что они, по сути, применимы и к другим языкам программирования. Именно тогда начала зарождаться идея этой книги. Я хотел поделиться тем, что мне так нравится в Clojure, с мировым сообществом разработчиков. Для этого мне понадобилось бы средство четкого выражения идей, которые в основном не знакомы разработчикам, не знающим Clojure. Мне всегда нравилось придумывать истории, но будут ли программисты воспринимать мои придуманные диалоги всерьез? Конечно, Платон придумывал истории в сократических диалогах, чтобы передать учение своего учителя. Точно так же раввин Иуда Халеви придумал историю о царе хазар, чтобы объяснить основы иудаизма. Но эти две работы относятся к области мысли, а не практики! Затем я вспомнил книгу по менеджменту, которую прочитал несколько лет назад, под названием «Цель» (North River Press, 2014). В этой книге Элияху Голдратт рассказывает историю директора завода, которому удается спасти свою фабрику благодаря принципам, вытекающим из теории ограничений. Платон, Иуда Халеви и Элияху Голдратт узаконили мое безумное желание написать историю, чтобы поделиться идеями.
18 Введение Благодарности Прежде всего, я хочу поблагодарить мою возлюбленную Карин. Ты верила в меня с самого начала этого проекта. Тебе всегда удается увидеть свет, даже когда он скрывается за несколькими слоями тьмы. Моим замечательным детям — Одайе, Орлу, Адваху, Нехораю и Яиру, которые были первыми слушателями историй, которые я придумал, когда был молодым папой. Вы самая прекрасная история, которую я когда-либо писал! Есть много других людей, которым я также хочу выразить свою благодарность, в том числе Джоэлу Кляйну за все увлекательные и обогащающие дискуссии об искусстве и душе; Меир Армон за то, что помогла мне уточнить, какой контент не следует включать в книгу; Ричу Хикки за изобретение Clojure, такого прекрасного языка, который охватил дата-ориентированное программирование еще до того, как у него появилось название; Кристофу Гранду, чьи ценные советы помогли мне выделить первые три принципа дата-ориентированного программирования; Марку Шампайну за тщательный анализ рукописи и множество ценных предложений; Эрику Норманду за поддержку и в частности советы по применению дата-ориентированного программирования в Java; Берту Бейтсу за то, что научил меня секретам написания хорошей книги, и Бену Баттону за ознакомление с главами, посвященными схеме JSON. Моя благодарность всем сотрудникам издательства Manning Publications, особенно Майку Стивенсу, за согласие продолжать работать со мной, несмотря на провал моей первой книги; Элеше Хайд за доступность и внимание к мельчайшим деталям; Мариусу Бутуку за восторженные положительные отзывы от прочтения первой главы; Линде Котлярски за то, что описания глав были составлены в такой увлекательной форме, и Фрэнсис Буран за улучшение ясности текста и истории. Всем рецензентам: Алексу Гауту, Аллену Дингу, Андреасу Шабусу, Эндрю Дженнингсу, Энди Киршу, Энн Эпштейн, Бертольду Франку, Кристиану Крейцер-Беку, Кристоферу Карделлу, Дейну Балии, доктору Давиду Кадамуро, Элиасу Илмари Лиинамаа, Эзре Симелоффу, Джорджу Томасу, С. Гири, Джулиано Араужо Бертоти, Грегору Рейману, Дж. М. Боровина Йоско, Джероми Мейеру, Хесусу А. Хуаресу Герреро, Джону Д. Льюису, Джону Гюнтеру, Келуму Прабату Сенанаяке, Келвину Джонсону, Кенту Р. Спиллнеру, Ким Габриэлсен, Константину Еремину, Маркусу Гезелле, Марку Элстону, Мэтью Проктору, Маурицио Томази, Майклу Айдинбасу, Милораду Имбра, Озайу Думану, Раффаэллу Вентальо, Раманану Нарараджану, Рамбабу Поза, Саурабху Сингху, Сету Макферсону, Шайло Моррис, Виктору Дюрану, Винсенту Терону, Уильяму Э. Уилеру, Йогешу Шетти и Ивану Фелизоту, ваши предложения помогли сделать эту книгу лучше. Наконец, я хотел бы упомянуть моего двоюродного брата Ниссима, которому банда варваров не позволила процветать.
Введение 19 Об этой книге Дата-ориентированное программирование было написано для того, чтобы помочь разработчикам снизить сложность создаваемых ими систем. Идеи, изложенные в этой книге, в основном применимы к системам, которые управляют информацией, — таким как интерфейсные приложения, внутренние веб-серверы или вебслужбы. Кто должен прочитать эту книгу? Дата-ориентированное программирование предназначено для разработчиков frontend, backend и full stack с парой лет опыта работы на языках программирования высокого уровня, таких как Java, C#, C++, Ruby или Python. Для разработчиков объектно-ориентированного программирования некоторые идеи, представленные в этой книге, могут вывести их из зоны комфорта и потребовать от них отучиться от некоторых парадигм программирования, с которыми они чувствуют себя легко. Разработчикам функционального программирования эту книгу будет немного легче переварить, но она также преподнесет несколько приятных сюрпризов. Как организована эта книга: дорожная карта В этой книге рассказывается история, иллюстрирующая дата-ориентированное программирование (ДОП) и то, как применять его принципы в реальных производственных системах. Мое предложение состоит в том, чтобы следить за историей и читать главы по порядку. Однако, если некоторые главы вызывают у вас большее любопытство, чем другие, имейте в виду, что материал из части 1 и главы 7 необходим для понимания частей 2 и 3. На протяжении всей книги мы используем Lodash (https://lodash.com /), чтобы проиллюстрировать, как манипулировать данными с помощью универсальных функций. В случае если вы читаете фрагмент кода, который использует незнакомую вам функцию Lodash, вы можете обратиться к приложению D, чтобы понять поведение функции. Часть 1 «Гибкость» содержит шесть глав, освещает проблемы традиционного объектно-ориентированного программирования (ООП) и ставит в центр внимания дата-ориентированное программирование, показывая, как создавать гибкие системы, используя основные принципы ДОП. Главы выстраиваются таким образом:  В главе 1 «Сложность объектно-ориентированного программирования» мы рассмотрим сложность ООП. Тогда начинается наша ДОП-сага! Послушайте разговор между Тео, старшим разработчиком, и его многообещающим коллегой Дейвом. Почувствуйте сочувствие к Тео, борющемуся со сложностью ООП, и найдите отличную причину для того, чтобы попробовать другую парадигму программирования.  В главе 2 «Разделение кода и данных» наш друг Тео ищет решение, которое уменьшит сложность и повысит гибкость систем. На кону его работа. Входит
20 Введение Джо, опытный разработчик, у которого есть для него ответ — ДОП. Узнайте, как принцип ДОП № 1 помогает снизить сложность информационных систем.  В главе 3 «Основы манипулирования данными» исследуется, как мы можем осво- бодить наши данные от инкапсуляции в жесткость класса и свободно манипулировать ими с помощью универсальных функций, применяя принцип ДОП № 2. Да здравствует революция!  Глава 4 «Управление состоянием» исследует управление состоянием с помощью мультиверсионного подхода, который позволяет нам вернуться назад во времени, восстановив систему до предыдущего состояния, потому что в ДOП состояние — это не что иное, как данные. Путешествие во времени реально — в ДОП!  Глава 5 «Основы управления параллелизмом» помогает нам получить высокую пропускную способность операций чтения и записи в параллельной системе, применяя оптимистичную стратегию управления параллелизмом. Розовые очки не требуются!  Глава 6 «Модульные тесты» предлагает выпить чашечку кофе... с Джо! Наш друг Джо доказывает, что модульное тестирование кода, ориентированного на данные, настолько просто, что вы можете заняться им в кафе. Возьмите чашечку кофе и узнайте, почему это так просто (даже для изменений!), когда вы пишете модульный тест ДОП вместе с Джо. Это по-настоящему круто! Часть 2 «Масштабируемость» иллюстрирует, как создать масштабируемую систему ДОП с акцентом на проверку данных, многопоточные среды, большие коллекции данных, а также доступ к базам данных и веб-службам. Вам нужно увеличить размер вашей системы? Нет проблем!  Глава 7 «Базовая проверка данных» учит нас, как убедиться, что данные, посту- пающие в наши системы и из них, являются достоверными, на всякий случай... потому что, как говорит Джо, вас не заставляют проверять данные в ДОП, но вы можете, когда вам нужно. Проверять или не проверять — вот в чем вопрос!  В главе 8 «Расширенное управление параллелизмом» обсуждается, что после того как наш друг Джо разберет детали реализации механизма «атом», мы научимся управлять всем состоянием системы потокобезопасным способом без использования блокировок. Вы не узнаете сложность от атома до атома!  Глава 9 «Постоянные структуры данных» переносится в более академическую среду, где наш друг Джо раскрывает внутренние детали более безопасного и масштабируемого способа сохранения неизменности данных, а также того, как его эффективно реализовать независимо от размера данных. Урок уже начался!  Глава 10 «Операции с базами данных» учит нас, как представлять данные из ба- зы данных, получать к ним доступ и управлять ими таким образом, чтобы обеспечить дополнительную гибкость и (как вы уже догадались) меньшую сложность.  Глава 11 «Веб-службы» позволяет нам открыть для себя простоту взаимодейст- вия с веб-службами. Мы узнаем, что Джо имеет в виду, когда говорит: «Мы
Введение 21 должны строить внутреннюю часть наших систем так же, как мы строим внешнюю». Часть 3 «Поддерживаемость» включает в себя такие методы ДОП, как расширенная проверка данных, полиморфизм, красноречивый код и методы отладки, которые жизненно важны, когда вы работаете в команде. Добро пожаловать в команду!  Глава 12 «Расширенная проверка данных» позволяет нам определить форму бу- дущих событий. Здесь вы узнаете, как проверять данные, когда они поступают внутри системы, что позволяет упростить разработку, определяя ожидаемую форму аргументов функции и возвращаемых значений.  Глава 13 «Полиморфизм» переносит нас вместе с Тео и Дейвом на занятия в сельской местности — подходящее место, чтобы поиграть с животными и узнать о полиморфизме без объектов с помощью мультиметодов.  Глава 14 «Расширенные возможности обработки данных» позволяет нам уви- деть, как Дейв и Тео применяют мудрый совет Джо, чтобы превратить утомительный код в красноречивый код, создавая свои собственные инструменты для обработки данных. «Поставь телегу впереди лошади» — это еще одна жемчужина от Джо!  Глава 15 «Отладка» приводит Дэйва и Тео в музей, чтобы в последний раз ска- зать «ура», поскольку они создают инновационное решение для воспроизведения и исправления ошибок. Эта книга также имеет четыре приложения.  Приложение А «Принципы дата-ориентированного программирования» обоб- щает каждый из четырех принципов ДОП, которые подробно рассматриваются в части 1, и иллюстрирует, как каждый принцип может быть применен как к языкам FP, так и к ООП. В нем также описываются преимущества каждого принципа и затраты на соблюдение каждого из них.  В приложении В «Общий доступ к данным в статически типизированных язы- ках» представлены различные способы обеспечения общего доступа к данным в статически типизированных языках программирования, таких как Java и C#.  Приложение C «Дата-ориентированное программирование: звено в цепи пара- дигм программирования» исследует идеи и тенденции, которые вдохновили ДОП. Мы рассматриваем открытия, которые делают его применимым в производственных системах в больших масштабах.  Приложение D «Ссылки на Lodash» содержит краткое описание функций Lodash, которые мы используем на протяжении всей книги, чтобы проиллюстрировать, как манипулировать данными с помощью универсальных функций, не изменяя их. О коде Большинство фрагментов кода в этой книге написаны на JavaScript. Мы выбрали JavaScript по двум причинам:
22 Введение JavaScript поддерживает как функциональное программирование, так и объектноориентированный стиль программирования. Синтаксис JavaScript легко читается в том смысле, что даже если вы не знакомы с JavaScript, вы можете прочитать фрагмент кода JavaScript на высоком уровне, как если бы это был псевдокод. Чтобы читателям с любого языка программирования было легко читать фрагменты кода, мы ограничились базовым синтаксисом JavaScript и избежали использования расширенных языковых функций, таких как функции со стрелками и асинхронная нотация. Там, где возникла концептуальная проблема в применении идеи к статически типизированному языку, мы добавили фрагменты кода на Java. Код отображается по всему тексту и в виде отдельных фрагментов кода шрифтом фиксированной ширины, подобным этому. Во многих случаях исходный код был переформатирован. Мы добавили разрывы строк и переработали отступы, чтобы вместить доступное пространство страницы в книге. Аннотации к коду также сопровождают некоторые списки, выделяя важные концепции. Вы можете получить исполняемые фрагменты кода из онлайн-версии этой книги в liveBook по адресу https://livebook.manning.com/book/data-oriented-programming, или по ссылке на Github книги здесь: https://github.com/viebel/data-orientedprogramming. Дискуссионный форум liveBook Покупка «Дата-ориентированного программирования» включает бесплатный доступ к liveBook, онлайн-платформе для чтения издательства «Мэннинг». Используя эксклюзивные функции обсуждения liveBook, вы можете прикреплять комментарии к книге глобально или к определенным разделам или абзацам. Легко делать заметки, задавать технические вопросы и отвечать на них, а также получать помощь от автора и других пользователей. Чтобы получить доступ к форуму, перейдите по ссылке https://livebook.manning.com/book/data-Oriented-programming/discussion. Вы также можете узнать больше о форумах издательства «Мэннинг» и правилах поведения на https://livebook.manning.com/discussion. Обязательство издательства «Мэннинг» перед читателями состоит в том, чтобы предоставить место, где может состояться содержательный диалог между отдельными читателями и между читателями и автором. Это не обязательство какой-либо конкретной суммы участия со стороны автора, чей вклад в форум остается добровольным (и неоплачиваемым). Мы предлагаем вам попробовать задать автору несколько сложных вопросов, чтобы его интерес не потерялся! Форум и архивы предыдущих обсуждений будут доступны на веб-сайте издателя, пока книга находится в печати.
Введение 23 Об авторе Йехонатан Шарвит имеет более чем 20-летний опыт работы в качестве инженера-программиста, программируя на C++, Java, Ruby, JavaScript, Clojure и ClojureScript, как в бэкенде, так и во внешнем интерфейсе. На момент написания этой книги он работал архитектором программного обеспечения в Cyclognito, создавая программные инфраструктуры для крупномасштабных конвейеров данных. Он делится своей страстью к программированию в своем блоге (https://blog.klipse.tech/) и на технических конференциях. Вы можете следить за ним в «Твиттере»: https://twitter.com/viebel. Об иллюстрации на обложке Рисунок на обложке «Fille de l'Isle Santorin», или «Девушка с острова Санторини», взят из сборника Жака Грассе де Сен-Совера, опубликованного в 1797 году. Каждая иллюстрация там нарисована и раскрашена вручную. В те дни было легко определить, где живут люди и каково их ремесло или положение в жизни, просто по их одежде. На своих обложках издательство Manning подчеркивает творческую жилку и инициативность, присущие всем, кто занят в сфере IT. Хочется напомнить, как разнообразны были региональные культуры еще пару веков назад и возродить память о тех временах, украшая книги картинками из подобных этнографических коллекций. Персонажи пьесы ТЕО, старший разработчик НЭНСИ, предпринимательница МОНИКА, менеджер, босс Тео ДЕЙВ, младший разработчик, коллега Тео ДЖО, независимый программист КЕЙ, психотерапевт, жена Джо ДЖЕЙН, жена Тео НЕРАЙЯ, сын Джо АУРЕЛИЯ, дочь Джо Действие этой истории происходит в Сан-Франциско.
24 Введение
Часть 1 Гибкость У тро понедельника. Теодор сидит с Нэнси на террасе La Vita è Bella, итальянской кофейни рядом с зоопарком Сан-Франциско. Нэнси — предпринимательница, которая ищет агентство по развитию для своей стартап-компании Klafim. Тео работает в агентстве по разработке программного обеспечения Albatross, которое стремится вернуть доверие стартапов. Нэнси и ее партнер по бизнесу собрали начальный капитал для Klafim, социальной сети для книг. Уникальное ценностное предложение Klafim заключается в объединении онлайн-мира с физическим миром, позволяя пользователям брать книги из местных библиотек, а затем встречаться онлайн для обсуждения книг. Большая часть продукта основана на интеграции существующих онлайн-сервисов. Единственное, что требует разработки программного обеспечения, — это то, что Нэнси называет глобальной системой управления библиотеками. Их беседу на мгновение прерывает официант, который приносит Тео его крепкий эспрессо, а Нэнси американо с молоком. Тео: По-твоему, что такое глобальная система управления библиотеками? Нэнси: Это программная система, которая выполняет основные функции ведения библиотечных дел, в основном связанные с каталогом книг и читателями. Тео: Не могла бы ты немного конкретизировать? Нэнси: Конечно. На данный момент нам нужен быстрый прототип. Если реакция рынка на Klafim будет положительной, мы будем продвигаться вперед уже с большим проектом. Тео: Какие функции тебе нужны для этапа создания прототипа? Нэнси берет салфетку из-под кофейной кружки и пишет на ней пару маркированных пунктов. ТРЕБОВАНИЯ К ПРОТОТИПУ KLAFIM  Два типа пользователей библиотеки — это читатели и библиотекари.  Пользователи входят в систему с помощью электронной почты и пароля.  Читатели могут брать книги напрокат.
26 Часть 1. Гибкость  Читатели и библиотекари могут искать книги по названию или по автору.  Библиотекари могут блокировать и разблокировать читателей (например, когда они запаздывают с возвратом книги).  Библиотекари могут перечислить книги, которые в настоящее время предоставлены читателям.  У книги может быть несколько экземпляров.  Книга принадлежит физической библиотеке. Тео: Что ж, все это довольно понятно. Нэнси: Сколько времени потребуется вашей компании, чтобы сделать прототип? Тео: Я думаю, мы могли бы сделать его в течение месяца. Скажем, в среду, 30-го. Нэнси: Это слишком долго. Нам он нужен через две недели! Тео: Это тяжело! Можешь ли ты вырезать одну или две функции? Нэнси: К сожалению, я не могу сократить какие-либо функции, но если хочешь, ты можешь сделать поиск очень простым. (Тео действительно не хочет терять этот контракт, поэтому он готов усердно работать и ложиться спать позже.) Тео: Я думаю, что это может быть выполнено к среде 16-го. Нэнси: Супер!
Сложность объектно-ориентированного программирования Капризный предприниматель В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Тенденция ООП к увеличению сложности системы.  Что делает ООП-системы трудными для понимания.  Стоимость смешивания кода и данных в объекты. В этой главе мы рассмотрим, почему системы объектно-ориентированного программирования (ООП), как правило, сложны. Эта сложность не связана с синтаксисом или семантикой конкретного языка ООП. Это то, что присуще фундаментальному пониманию ООП: программы должны состоять из объектов, которые состоят из некоторого состояния, вместе с методами для доступа к этому состоянию и управления им. На протяжении многих лет экосистемы ООП облегчали эту сложность, добавляя новые функции в язык (например, анонимные классы и анонимные функции) и разрабатывая фреймворки, которые скрывают часть этой сложности, предоставляя более простой интерфейс для разработчиков (например, Spring и Jackson в Java). Внутри фреймворки полагаются на расширенные возможности языка, такие как отражение и пользовательские аннотации. Эта глава не предназначена для прочтения как критический анализ ООП. Ее цель — повысить вашу осведомленность о тенденции к усложнению ООП как парадигмы программирования. Надеюсь, это побудит вас открыть для себя другую парадигму программирования, в которой сложность системы имеет тенденцию к снижению. Эта парадигма известна как дата-ориентированное программирование (ДОП).
28 Часть 1. Гибкость 1.1. Дизайн ООП: классический или традиционный?  ПРИМЕЧАНИЕ. Тео, Нэнси и их новый проект были представлены в начале первой части. Прочитайте вступительную часть, если вы ее пропустили. Тео возвращается в офис с салфеткой Нэнси в кармане и с большим беспокойством в душе, потому что он знает, что поставил себе жесткий дедлайн. Но у него не было выбора! На прошлой неделе Моника, его босс, совершенно ясно сказала ему, что он должен заключить сделку с Нэнси, несмотря ни на что. Albatross, где работает Тео, — консалтинговая компания по программному обеспечению, имеющая клиентов по всему миру. Изначально у него было много клиентов среди стартапов. Однако за последний год многие проекты плохо управлялись, и отдел стартапов потерял доверие своих клиентов. Вот почему руководство перевело Тео из корпоративного отдела в отдел стартапов в качестве старшего технического руководителя. Его работа заключается в том, чтобы заключать сделки и выполнять их вовремя. 1.1.1. Этап проектирования Прежде чем броситься к своему ноутбуку, чтобы закодить систему, Тео берет лист бумаги, намного больше салфетки, и начинает рисовать UML-диаграмму классов системы, которая будет реализовывать прототип Klafim. Тео объектно-ориентированный программист. Здесь для него нет никаких вопросов — каждая бизнессущность представлена объектом, а каждый объект сделан из класса. ТРЕБОВАНИЯ К ПРОТОТИПУ KLAFIM 1  Существуют два типа пользователей: читатели и библиотекари.  Пользователи входят в систему с помощью электронной почты и пароля.  Читатели могут брать книги напрокат.  Читатели и библиотекари могут искать книги по названию или по автору.  Библиотекари могут блокировать и разблокировать читателей (например, когда они запаздывают с возвратом книги).  Библиотекари могут составить список книг, которые в настоящее время предоставлены читателю.  У книги может быть несколько экземпляров.  Книга принадлежит физической библиотеке. Тео тратит некоторое время на размышления об организации системы. Он выделяет основные классы для глобальной системы управления библиотеками Klafim. 1 Английское слово Member переведено как «читатель» в контексте системы библиотеки; в листингах представлен как member.
Глава 1. Сложность объектно-ориентированного программирования 29 ОСНОВНЫЕ КЛАССЫ СИСТЕМЫ УПРАВЛЕНИЯ БИБЛИОТЕКОЙ  Library — центральная часть системного проектирования.  Book — книга.  BookItem — книга может иметь несколько копий, и каждая копия рассматривается как элемент книги.  BookLending — при предоставлении книги создается объект предоставления книги.  Member — читатель.  Librarian — библиотекарь.  User — базовый класс для библиотекаря и читателя.  Catalog — содержит список книг.  Author — автор книги. Это была самая легкая часть. Теперь начинается самое сложное: отношения между классами. Примерно через два часа Тео предлагает первый набросок проекта глобальной системы управления библиотеками. Это выглядит как схема на рис. 1.1.  ПРИМЕЧАНИЕ. Представленный здесь дизайн не претендует на звание самого умного дизайна ООП: опытные разработчики ООП, вероятно, использовали бы пару шаблонов проектирования, чтобы предложить гораздо лучший дизайн. Этот дизайн должен быть наивным и ни в коем случае не охватывает все функции системы. Это служит двум целям:  для Тео, разработчика, он достаточен, чтобы начать кодить;  для меня, автора книги, он достаточен, чтобы проиллюстрировать сложность типичной системы ООП. Тео гордится собой и только что созданной им дизайнерской схемой. Он определенно заслужил чашечку кофе! Возле кофемашины Тео знакомится с Дейвом, младшим разработчиком программного обеспечения, который присоединился к Albatross пару недель назад. Тео и Дейв ценят друг друга, поскольку любопытство Дейва заставляет его задавать сложные вопросы. Встречи у кофемашины часто превращаются в интересные дискуссии о программировании. Тео: Привет, Дейв. Как день? Дейв: Сегодня? Не очень. Я пытаюсь исправить ошибку в своем коде, но не могу понять, почему состояние моих объектов всегда меняется. Но я уверен, что разберусь с этим. А как проходит твой день? Тео: Я только что закончил дизайн системы для нового клиента. Дейв: Круто! Можно ли мне посмотреть? Я пытаюсь улучшить свои дизайнерские навыки. Тео: Конечно! Схема у меня на столе. Если хочешь, мы можем взглянуть на нее прямо сейчас.
30 Часть 1. Гибкость Рис. 1.1. Диаграмма классов для глобальной системы управления библиотеками Klafim 1.1.2. UML 101 С латте в руке Дейв следует за Тео к его столу. Тео с гордостью показывает Дейву свое произведение искусства: UML-диаграмму для системы управления библиотекой (см. рис. 1.1). Дейв, кажется, очень взволнован. Дейв: Ух ты! Такая подробная диаграмма классов. Тео: Да. Я очень доволен этим. Дейв: Но дело в том, что я никогда не могу вспомнить значение разных стрелок.
Глава 1. Сложность объектно-ориентированного программирования 31 Тео: На моей диаграмме классов есть четыре типа стрелок: композиция, ассоциация, наследование и использование. Дейв: В чем разница между композицией и ассоциацией?  ПРИМЕЧАНИЕ. Не волнуйтесь, если вы не знакомы с жаргоном ООП. Мы оставим это для следующей главы. Тео: Все дело в том, могут ли объекты жить друг без друга. В случае с композицией, когда умирает один объект, умирает и другой. Находясь в ассоциативном отношении, каждый объект имеет независимую жизнь. СОВЕТ. В отношении композиции, когда умирает один объект, умирает и другой. Находясь в ассоциативном отношении, каждый объект имеет независимый жизненный цикл. На диаграмме классов есть два типа композиций, обозначенных стрелкой с простым ромбом на одном краю и необязательной звездой на другом краю. На рис. 1.2 показано соотношение между: Library, которой принадлежит Catalog, — композиция «один к одному». ект Library умирает, то вместе с ним умирает и его объект Catalog; Если объ- Library, которая владеет многими Members, — это композиция «один ко многим». Если объект Library умирает, то все его объекты Members умирают вместе с ним. Рис. 1.2. Два вида композиции: «один к одному» и «один ко многим». В обоих случаях, когда объект умирает, составной объект умирает вместе с ним СОВЕТ. Композиционное соотношение представлено простым ромбом на одном краю и необязательной звездой на другом краю. Дейв: Есть ли ассоциативные связи на твоей диаграмме? Тео: Взгляни на стрелку между Book и Author. У нее есть пустой ромб и звезда по обоим краям, так что это ассоциативное отношение «многие ко многим». Книга может быть написана несколькими авторами, и один автор может написать несколько книг. Более того, объекты Book и Author могут жить независимо. Связь между книгами и авторами представляет собой ассоциацию «многие ко многим» (рис. 1.3).
32 Часть 1. Гибкость СОВЕТ. Ассоциативное отношение «многие ко многим» представлено пустым ромбом и звездой по обоим краям. Дейв: Я также вижу кучу пунктирных стрелок на вашей диаграмме. Тео: Пунктирные стрелки обозначают отношения использования: когда класс использует метод другого класса. Рассмотрим, например, метод Librarian:: blockMember. Он вызывает Member::block. СОВЕТ. Пунктирные стрелки указывают на отношения использования (рис. 1.4), например когда класс использует метод другого класса. Рис. 1.3. Отношения ассоциации «многие ко многим»: каждый объект живет независимо Рис. 1.4. Отношения использования: класс использует метод другого класса Дейв: Ага. И я предполагаю, что простая стрелка с пустым треугольником, как между Member и User, представляет наследование. Тео: Совершенно верно! СОВЕТ. Простые стрелки с пустыми треугольниками представляют наследование классов (рис. 1.5), где стрелка указывает на суперкласс. Рис. 1.5. Отношения наследования: класс является производным от другого класса
Глава 1. Сложность объектно-ориентированного программирования 33 1.1.3. Объяснение каждой части диаграммы классов Дейв: Спасибо за то, что освежил мои знания об UML! Теперь я думаю, что могу вспомнить, что означают разные стрелки. Тео: С удовольствием. Хочешь посмотреть, как все это сочетается? Дейв: На какой класс нам следует обратить внимание в первую очередь? Тео: Я думаю, нам следует начать с Library. Класс Library Library — это корневой класс библиотечной системы. На рис. 1.6 показана структура системы. Рис. 1.6. Класс Library С точки зрения кода (поведения) объект Library ничего не делает сам по себе. Он делегирует все объектам, которыми обладает. С точки зрения данных объект Library владеет:  несколькими объектами Member;  несколькими объектами Librarian;  одним объектом Catalog.  ПРИМЕЧАНИЕ. В этой книге мы используем термины код и поведение как взаимозаменяемые. Классы Librarian, Member и User Оба класса Librarian и Member происходят от User. На рис. 1.7 показано это соотношение.
34 Часть 1. Гибкость Рис. 1.7. Librarian и Member происходят от User Класс User представляет собой пользователя библиотеки.  Что касается данных читателя, то они представляют собой абсолютный минимум — у читателя есть идентификатор (id), адрес электронной почты (email) и пароль (password) (на данный момент без защиты и шифрования).  С точки зрения кода читатель может войти в систему, используя логин. Класс Member представляет собой читателя.  Он наследуется от User.  Что касается элементов данных, то в нем нет ничего, кроме User.  С точки зрения кода он может: • проверить книгу через checkout; • вернуть книгу через returnBook; • заблокировать себя с помощью block; • разблокировать себя с помощью unblock; • ответить, если он заблокирован через isBlocked;  Он владеет несколькими объектами BookLending.  Он использует BookItem для реализации checkout. Класс Librarian представляет собой библиотекаря.  Он происходит от User.  С точки зрения элементов данных в нем нет ничего кроме User.  С точки зрения кода он может: • блокировать и разблокировать Member; • перечислить книги, выданные читателю, через getBookLendings; • добавлять книги в библиотеку через addBookItem.  Он использует Member для реализации blockMember, unblockMember и getBookLendings.
Глава 1. Сложность объектно-ориентированного программирования 35  Он использует BookItem для реализации проверки.  Он использует BookLending для реализации getBookLendings. Класс Catalog Класс Catalog отвечает за управление книгами. На рис. 1.8 показано соотношение между классами Catalog, Librarian и Book. С точки зрения кода объект Catalog может:  искать книги через search;  добавлять книги в библиотеку через addBookItem. Рис. 1.8. Класс Catalog Объект Catalog использует Librarian для реализации addBookItem. С точки зрения данных каталогу принадлежит несколько объектов Book. Класс Book На рис. 1.9 представлен класс Book. С точки зрения данных объект Book:  должен иметь как минимум id и title;  связан с несколькими объектами Author (у книги может быть несколько авторов);  владеет несколькими объектами BookItem, по одному на каждую копию книги. Класс BookItem Класс BookItem представляет копию книги, у книги может быть много копий. С точки зрения данных объект BookItem:  должен иметь как минимум данные для читателей: id и libId (для ID физической библиотеки);  владеет несколькими объектами BookLending, по одному на каждый раз, когда книга выдается во временное пользование. С точки зрения кода объект BookItem может быть извлечен через checkout.
36 Часть 1. Гибкость Рис. 1.9. Класс Book 1.1.4. Этап реализации После этого подробного изучения диаграмм Тео Дейв осмысляет их, медленно потягивая кофе. Затем он выражает свое восхищение Тео. Дейв: Ничего себе, это потрясающе! Тео: Благодарю. Дейв: Я и не подозревал, что люди действительно тратят время на то, чтобы так подробно описать свой проект перед написанием кода. Тео: Я всегда так делаю. Это экономит мне много времени на этапе кодирования. Дейв: Когда ты начнешь кодить? Тео: Когда закончу со своим латте. Тео берет свою кружку с кофе и замечает, что его горячий латте превратился в латте со льдом. Он был так увлечен показом своей классовой диаграммы Дейву, что забыл его выпить! 1.2. Источники сложности Пока Тео наливает себе еще одну чашку кофе (на этот раз капучино), я хотел бы испытать его дизайн. На бумаге он может выглядеть красиво и понятно, но я утверждаю, что такой дизайн затрудняет понимание системы. Дело не в том, что Тео выбрал не те классы или что он неправильно понял отношения между классами. Все гораздо серьезнее.  Это о парадигме программирования, которую он выбрал для реализации системы.
Глава 1. Сложность объектно-ориентированного программирования 37  Это об объектно-ориентированной парадигме.  Это о тенденции ООП увеличивать сложность системы. СОВЕТ. ООП имеет тенденцию создавать сложные системы. На протяжении всей этой книги я имею в виду тот тип сложности, который затрудняет понимание систем, как определено в статье Бена Мозли и Питера Маркса «Из дегтярной ямы» (2006), доступной по адресу http://mng.bz/enzq. Это не имеет ничего общего с типом сложности, который связан с количеством ресурсов, потребляемых программой. Точно так же, когда я говорю о простоте, я имею в виду несложную (другими словами, легкую для понимания). Имейте в виду, что сложность и простота (например, трудное и легкое) — это не абсолютные, а относительные понятия. Мы можем сравнить сложность двух систем и определить, является ли система A более сложной (или более простой), чем система B.  ПРИМЕЧАНИЕ. Сложность в контексте этой книги означает трудность для понимания. Как упоминалось во введении к этой главе, в ООП существует множество способов уменьшить сложность. Цель этой книги — не критиковать ООП, а скорее представить парадигму программирования, называемую дата-ориентированное программирование (ДОП), которая стремится создавать менее сложные системы. На самом деле парадигма ДОП совместима с ООП. Если кто-то решит построить систему ООП, которая придерживается принципов ДОП, система будет менее сложной. Согласно ДОП основными источниками сложности в системе Тео (и многих традиционных системах ООП) являются:  смешивание кода и данных;  изменчивость объектов;  блокировка данных в объектах как элементов;  привязка кода к классам как к методам. Этот анализ аналогичен тому, что функциональное программирование (ФП) думает о традиционном ООП. Однако, как мы увидим на протяжении всей книги, подход к обработке данных, который использует ДОП для снижения сложности системы, отличается от подхода ФП. В приложении А мы проиллюстрируем, как применять принципы ДОП как в ООП, так и в стилях ФП. СОВЕТ. ДОП совместимо как с ООП, так и с ФП. В остальных разделах этой главы мы проиллюстрируем каждый из предыдущих аспектов, обобщенных в табл. 1.1. Мы рассмотрим это в контексте проекта Klafim и объясним, в каком смысле эти аспекты являются источником сложностей.
38 Часть 1. Гибкость Таблица 1.1. Аспекты ООП и их влияние на сложность системы Аспект Влияние на сложность Код и данные смешаны Классы, как правило, вовлечены во многие отношения Объекты изменчивы При чтении кода требуется повышенная сосредоточенность Объекты изменчивы В многопоточных средах требуется явная синхронизация Данные заблокированы в объектах Сериализация данных не является тривиальной задачей Код заблокирован в классах Иерархии классов сложны 1.2.1. Множество отношений между классами Один из способов оценить сложность диаграммы классов — рассматривать только объекты и их отношения, игнорируя элементы и методы, как показано на рис. 1.10. Когда мы проектируем систему, мы должны определить отношения между различными частями кода и данными. Это неизбежно. Рис. 1.10. Обзор диаграммы классов для системы управления библиотекой Klafim СОВЕТ. В ООП код и данные смешиваются в классах: данные как элементы и код как методы. С точки зрения системного анализа тот факт, что код и данные смешиваются, делает систему сложной, т. к. объекты, как правило, вовлечены во многие отношения.
Глава 1. Сложность объектно-ориентированного программирования 39 На рис. 1.11 мы более подробно рассмотрим класс Member. Member участвует в пяти отношениях: двух отношениях данных и трех отношениях кода.  Отношения данных: • Library имеет много Members; • Member имеет много BookLendinds.  Отношения кода: • Member раширяет User; • Librarian использует Member; • Member использует BookItem. Рис. 1.11. Класс Member участвует в пяти отношениях Рис. 1.12. Диаграмма классов, на которой Member разделен на объекты кода и данных Представьте на мгновение, что нам каким-то образом удалось разделить класс Member на два отдельных объекта: MemberCode для кода и MemberData для данных. Вместо класса Member с пятью отношениями у нас была бы диаграмма, показанная на рис. 1.12: объект MemberCode и три отношения; объект MemberData и два отношения. Диаграмма классов, в которой Member разделен на MemberCode и MemberData, состоит из двух независимых частей. Каждую часть легче понять, чем исходную диаграмму. Давайте разделим каждый класс нашей исходной диаграммы классов на код и объекты данных. На рис. 1.13 показана получившаяся диаграмма. Теперь система состоит из двух независимых частей:  часть, которая включает только объекты данных;  часть, которая включает только объекты кода. СОВЕТ. Система, в которой каждый класс разделен на две независимые части — код и данные, — проще, чем система, в которой код и данные смешаны. Результирующая система, состоящая из двух независимых подсистем, более понятна, чем исходная система. Тот факт, что две подсистемы независимы, означает, что каждая подсистема может быть понята отдельно и в любом порядке. Результирую-
40 Часть 1. Гибкость щая система упростилась не случайно; это логическое следствие отделения кода от данных. СОВЕТ. Система, состоящая из нескольких простых независимых частей, менее сложна, чем система, состоящая из одной сложной части. Рис. 1.13. Диаграмма классов, где каждый класс разделен на код и объекты данных 1.2.2. Непредсказуемое поведение кода Возможно, вы немного устали после анализа системного уровня, который мы сделали в предыдущем разделе. Давайте отдохнем и посмотрим на код. Взгляните на код в листинге 1.1, где мы получаем заблокированный статус читателя и отображаем его дважды. Что если я скажу вам, что, когда я вызывал displayBlockedStatusTwice, программа отображала true при первом вызове console.log? Листинг 1.1. Действительно простой код class Member { isBlocked; displayBlockedStatusTwice() { var isBlocked = this.isBlocked; console.log(isBlocked); console.log(isBlocked); } } member.displayBlockedStatusTwice(); «Конечно, он снова показал true», — скажете вы. И окажетесь правы!
Глава 1. Сложность объектно-ориентированного программирования 41 Теперь взгляните на немного другой псевдокод, показанный в листинге 1.2. Здесь мы дважды отображаем заблокированный статус читателя без назначения переменной. Тот же вопрос, что и раньше: если я скажу вам, что, когда я вызывал displayBlockedStatusTwice, программа отображала true при первом вызове console.log, можете ли вы сказать мне, что программа отображала при втором вызове console.log? Листинг 1.2. На первый взгляд простой код class Member { isBlocked; displayBlockedStatusTwice() { console.log(this.isBlocked); console.log(this.isBlocked); } } member.displayBlockedStatusTwice(); Правильный ответ: в однопоточной среде отображается true, а в многопоточной среде это непредсказуемо. Действительно, в многопоточной среде между двумя вызовами console.log может быть переключение контекста, которое изменяет состояние объекта (например, библиотекарь разблокирует читателя). На самом деле с небольшой модификацией такая же непредсказуемость кода может возникнуть даже в однопоточной среде, такой как JavaScript, когда данные изменяются с помощью асинхронного кода (см. раздел о принципе № 3 в приложении A). Разница между двумя фрагментами кода заключается в том, что:  в первом листинге (листинг 1.1) мы дважды обращаемся к логическому значению, которое является примитивным значением;  во втором листинге (листинг 1.2) мы дважды обращаемся к члену объекта. СОВЕТ. Когда данные изменяемы, код непредсказуем. Это непредсказуемое поведение второго листинга является одним из досадных последствий ООП. В отличие от примитивных типов, которые обычно неизменяемы, члены объекта изменяемы. Одним из способов решения этой проблемы в ООП является защита конфиденциального кода с помощью механизмов безопасности параллелизма, таких как мьютексы, но это приводит к таким проблемам, как снижение производительности и риск взаимоблокировок. Позже в книге мы увидим, что ДОП обрабатывает каждый фрагмент данных одинаково: как примитивные типы, так и типы коллекций являются неизменяемыми значениями. Такое ценностное отношение ко всем членам приносит спокойствие в умы разработчиков ДОП, и появляется больше мозговых клеток для обработки интересных частей создаваемых ими приложений. СОВЕТ. Неизменность данных приносит спокойствие разработчикам ДОМ.
42 Часть 1. Гибкость 1.2.3. Нетривиальная сериализация данных Тео очень устал и засыпает за своим столом. Он видит сон. Во сне Нэнси просит его сделать систему управления библиотеками Klafim доступной через REST API, используя JSON в качестве транспортного уровня. Тео должен реализовать конечную точку /search, которая получает запрос в формате JSON и возвращает результаты в формате JSON. В листинге 1.3 показан пример ввода конечной точки /search, а в листинге 1.4 показан пример вывода конечной точки /search. Листинг 1.3. Ввод JSON конечной точки /search { "searchCriteria": "author", "query": "albert" } Листинг 1.4. Вывод JSON конечной точки /search [ { "title": "The world as I see it", "authors": [ { "fullName": "Albert Einstein" } ] }, { "title": "The Stranger", "authors": [ { "fullName": "Albert Camus" } ] } ] Вероятно, Тео реализовал бы конечную точку /search, создав три класса, как показано в следующем листинге и на рис. 1.14. (Неудивительно, что все в ООП должно быть заключено в класс. Верно?)  SearchController отвечает за обработку запроса.  SearchQuery преобразует строку запроса JSON в данные.  SearchResult преобразует данные результатов поиска в строку JSON. SearchController щим потоком: (см. рис. 1.14) будет иметь единственный метод handle со следую-
Глава 1. Сложность объектно-ориентированного программирования 43 Рис. 1.14. Диаграмма классов для SearchController  создает объект SearchQuery из строки запроса JSON;  извлекает searchCriteria и queryStr из объекта SearchQuery;  вызывает метод поиска catalog:Catalog с searchCriteria и queryStr и получает books:List<Book>;  создает объект SearchResult с Books;  преобразует объект SearchResult в строку JSON. Как насчет других конечных точек, например тех, которые позволяют библиотекарям добавлять элементы книги через /add-book-item? Тео пришлось бы повторить точно такой же процесс и создать три класса:  AddBookItemController для обработки запроса;  BookItemQuery для преобразования строки запроса JSON в данные;  BookItemResult для преобразования данных результатов поиска в строку JSON. Код, связанный с десериализацией JSON, который Тео написал ранее в SearchQuery, придется переписать в BookItemQuery. То же самое для кода, который имеет дело с сериализацией JSON, который он написал ранее в SearchResult; его нужно было бы переписать в BookItemResult. Плохая новость заключается в том, что Тео пришлось бы повторять один и тот же процесс для каждой конечной точки системы. Каждый раз, когда он сталкивается с новым видом ввода или вывода JSON, ему придется создавать новый класс и писать код. Мечта Тео превращается в кошмар! Внезапно его телефон звонит рядом с тем местом, где он положил голову на стол. Когда Тео просыпается, он понимает, что Нэнси никогда не просила JSON. Все это было сном... действительно плохим сном! СОВЕТ. В ООП сериализация данных затруднена. Довольно неприятно, что обработка сериализации и десериализации JSON в ООП требует добавления такого количества классов и написания такого большого коли-
44 Часть 1. Гибкость чества кода — снова и снова! Разочарование усиливается, если учесть, что сериализация поискового запроса, запроса к товару книги или любого другого запроса очень похожа. Все сводится к:  просматриванию поля данных;  объединению имени полей данных и значения полей данных, разделенных запя- той. Почему такой простой вещи столь трудно достичь в ООП? В ООП данные должны соответствовать жесткой форме, определенной в классах, что означает, что данные заблокированы в элементах. Простого способа общего доступа к данным не существует. СОВЕТ. В ООП данные блокируются в классах как члены. Позже мы уточним, что мы подразумеваем под общим доступом к данным, и мы увидим, как ДОП предоставляет общий способ обработки сериализации и десериализации JSON. До тех пор вам придется продолжать страдать. Но, по крайней мере, вы начинаете осознавать это страдание и знаете, что его можно избежать.  ПРИМЕЧАНИЕ. Большинство языков программирования ООП немного облегчают трудности, связанные с преобразованием из и в JSON. Это либо включает в себя размышления, что, безусловно, сложная вещь, либо многословие кода. 1.2.4. Сложные иерархии классов Один из способов избежать повторного написания одного и того же кода в ООП включает наследование классов. Действительно, когда все требования системы известны заранее, вы проектируете свою иерархию классов таким образом, чтобы классы с общим поведением производились от базового класса. На рис. 1.15 показан пример этого шаблона, который фокусируется на той части нашей диаграммы классов, которая касается читателей и библиотекарей. Как Librarians, так и Members нужна возможность входа в систему, и они наследуют эту возможность от класса User. Пока все хорошо, но когда после внедрения системы вводятся новые требования, то это совершенно другая история. Перенесемся в понедельник, 29 марта, 11 утра, когда до дедлайна осталось два дня (среда в полночь). Нэнси звонит Тео со срочной просьбой. Тео не уверен, сон это или реальность. Он ущипнул себя и почувствовал щипок. Это определенно реальность! Нэнси: Как продвигается проект? Тео: Прекрасно, Нэнси. Мы идем по графику, чтобы уложиться в крайний срок. Сейчас мы проводим наш последний раунд регрессионных тестов. Нэнси: Прекрасно! Это означает, что у нас есть время для добавления крошечной функции в систему, верно? Тео: Зависит от того, что ты подразумеваешь под «крошечной».
Глава 1. Сложность объектно-ориентированного программирования 45 Нэнси: Нам нужно добавить VIP-читателей в систему. Тео: Что вы подразумеваете под VIP-читателями? Нэнси: VIP-читателям разрешается самостоятельно добавлять книги в библиотеку. Тео: Хм... Нэнси: Что? Тео: Это не такое уж и крошечное изменение! Нэнси: Почему? Рис. 1.15. Часть диаграммы классов, которая касается читателей (members) и библиотекарей (librarians) Я задам вам тот же вопрос, который Нэнси задала Тео: почему добавление VIPчитателей в нашу систему не является крошечной задачей? В конце концов, Тео уже написал код, который позволяет библиотекарям добавлять элементы книги в библиотеку (это в Librarian::addBookItem). Что мешает ему повторно использовать этот код для VIP-читателей? Причина в том, что в ООП код привязан к классам как к методам. СОВЕТ. В ООП код привязан к классам. VIP-читатели — это читатели, которым разрешено самостоятельно добавлять книги в библиотеку. Тео разбивает требования заказчика на две части:  VIP-читатели — это читатели;  VIP-читателям разрешается самостоятельно добавлять книги в библиотеку. Затем Тео решает, что ему нужен новый класс, VIPMember. Для первого требования (VIP-читатели являются читателями библиотеки) представляется разумным сделать VIPMember производным от Member. Однако выполнение второго требования (VIP-
46 Часть 1. Гибкость читателям разрешается добавлять элементы книги) является более сложным. Он не может сделать VIPMember производным от Librarian, потому что связь между VIPMember и Librarian не линейна:  с одной стороны, VIP-читатели подобны библиотекарям в том смысле, что им разрешено добавлять элементы (книги);  с другой стороны, VIP-читатели не похожи на библиотекарей в том смысле, что им не разрешается блокировать участников или перечислять книги, предоставленные участнику. Проблема в том, что код, который добавляет элементы «книги», заблокирован в классе Librarian. Класс VIPMember не может использовать этот код. На рис. 1.16 показано одно из возможных решений, которое делает код Librarian:: addBookItem доступным как для классов Librarian, так и для классов VIPMember. Вот изменения к предыдущей диаграмме классов:  базовый класс UserWithBookItemRight расширяет User;  addBookItem перемещается из библиотеки в UserWithBookItemRight;  и VIPMember, и Librarian расширяют права пользователя с помощью Bookitemright. Рис. 1.16. Диаграмма классов для системы с VIP-читателями Это было нелегко, но Тео удается справиться с изменениями вовремя благодаря тому, что он всю ночь кодит на своем ноутбуке. Он даже смог добавить новые тесты в систему и снова запустить регрессионные тесты. Однако он был так взволнован, что не обратил внимания на ромбовидную проблему, которую VIPMember ввел в свою диаграмму классов из-за множественного наследования: VIPMember расширяет как Member, так и UserWithBookItemRight, которые оба расширяют User. В среду, 31 марта, в 10 утра (за 14 часов до дедлайна) Тео звонит Нэнси, чтобы сообщить ей хорошие новости.
Глава 1. Сложность объектно-ориентированного программирования 47 Тео: Мы смогли вовремя добавить VIP-читателей в систему, Нэнси. Нэнси: Прекрасно! Я же говорила вам, что это крошечная функция. Тео: Ну да... Нэнси: Слушай, я все равно собиралась тебе позвонить. Я только что закончила встречу со своим деловым партнером, и мы поняли, что нам нужна еще одна крошечная функция перед запуском. Сможешь ли ты справиться с этим до истечения дедлайна? Тео: Опять же, это зависит от того, что ты подразумеваешь под «крошечным». Нэнси: Нам нужно добавить Супер-читателей в систему. Тео: Что вы подразумеваете под Супер-читателями? Нэнси: Супер-читателям разрешается указывать книги, предоставленные другим участникам. Тео: Э... Нэнси: Что? Тео: Это не крошечное изменение! Нэнси: Почему? Как и в случае с VIP-читателями, добавление Супер-читателей в систему требует изменений в иерархии классов Тео. На рис. 1.17 показано решение, которое имеет в виду Тео. Рис. 1.17. Диаграмма классов для системы с Супер- и VIP-читателями библиотеки Добавление Супер-читателей сделало систему действительно сложной. Тео внезапно замечает, что у него на диаграмме классов есть три бриллианта ромбовидной формы — не драгоценных камней, а трое «смертоносных бриллианта смерти», как разработчики ООП иногда называют двусмысленность, возникающую, когда класс D наследуется от двух классов B и C, где оба наследуются от класса A!
48 Часть 1. Гибкость Он пытается избежать проблем, преобразуя пользовательский класс в интерфейс и используя шаблон проектирования «композиция поверх наследования». Но из-за стресса, связанного с приближением дедлайна, он не в состоянии использовать все клетки своего мозга. На самом деле система стала настолько сложной, что он не в состоянии закончить систему к установленному сроку. Тео говорит себе, что ему следовало бы использовать композицию вместо наследования классов. Но теперь уже слишком поздно. СОВЕТ. В ООП отдавайте предпочтение композиции, а не наследованию классов. В 10 вечера, за два часа до дедлайна, Тео звонит Нэнси, чтобы объяснить ситуацию. Тео: Послушай, Нэнси, мы действительно сделали все, что могли, но мы не сможем добавить Супер-читателей в систему до истечения дедлайна. Нэнси: Не беспокойся, мы с моим деловым партнером решили пока опустить эту функцию. Мы добавим её позже. Со смешанным чувством гнева и облегчения Тео перестает расхаживать по своему кабинету. Он понимает, что проведет сегодняшнюю ночь в своей постели, а не будет корпеть над своим компьютером в офисе. Его жена наверняка будет счастлива. Тео: Я думаю, это означает, что мы готовы к запуску завтра утром. Нэнси: Да. Мы будем предлагать этот новый продукт в течение месяца или около того, и если мы получим хорошую поддержку на рынке, мы продвинемся вперед с более крупным проектом. Тео: Супер. Тогда давайте свяжемся через месяц. Удачи на старте! Итоги  Сложность в контексте этой книги означает трудность для понимания.  Мы используем термины код и поведение как взаимозаменяемые.  ДОП расшифровывается как дата-ориентированное программирование.  ООП расшифровывается как объектно-ориентированное программирование.  ФП расшифровывается как функциональное программирование.  В композиционном отношении, когда умирает один объект, умирает и другой.  Композиционное соотношение представлено простым бриллиантом (ромбом) на одном краю и необязательной звездой на другом краю.  В ассоциативном отношении каждый объект имеет независимый жизненный цикл.  Ассоциативное отношение «многие ко многим» представлено пустым ромбом и звездой по обоим краям.  Пунктирные стрелки указывают на отношение использования; например, когда класс использует метод другого класса.
Глава 1. Сложность объектно-ориентированного программирования 49  Простые стрелки с пустыми треугольниками представляют наследование класса, где стрелка указывает на суперкласс.  Дизайн, представленный в этой главе, не претендует на звание самого умного дизайна ООП. Опытные разработчики ООП, вероятно, использовали бы пару шаблонов проектирования и предложили бы гораздо лучшую схему.  Традиционные системы ООП, как правило, увеличивают сложность системы в том смысле, что системы ООП трудны для понимания.  В традиционном ООП код и данные смешиваются в классах: данные как члены и код как методы.  В традиционном ООП данные изменяемы.  Основная причина увеличения сложности связана со смешиванием кода и дан- ных вместе в объекты.  Когда код и данные смешаны, классы, как правило, вовлечены во множество отношений.  Когда объекты изменчивы, требуется дополнительное внимание, чтобы понять, как ведет себя код.  Когда объекты изменяемы, в многопоточных средах требуются явные механиз- мы синхронизации.  Когда данные заблокированы в объектах, сериализация данных не является три- виальной.  Когда код заблокирован в классах, иерархии классов, как правило, являются сложными.  Система, в которой каждый класс разделен на две независимые части, код и данные, проще, чем система, в которой код и данные смешаны.  Система, состоящая из множества простых независимых частей, менее сложна, чем система, состоящая из одной сложной части.  Когда данные изменчивы, код непредсказуем.  Стратегическое использование шаблонов проектирования может помочь в неко- торой степени снизить сложность традиционного ООП.  Неизменность данных приносит спокойствие разработчикам ДОП.  Большинство языков программирования ООП немного облегчают трудности, связанные с преобразованием из и в JSON. Это либо включает в себя размышления, что, безусловно, сложная вещь, либо многословие кода.  В традиционном ООП сериализация данных затруднена.  В традиционном ООП данные блокируются в классах как члены.  В традиционном ООП код привязан к классам.  ДОП снижает сложность за счет переосмысления данных.  ДОП совместимо как с ООП, так и с ФП.
50 Часть 1. Гибкость
Разделение кода и данных Целый новый мир В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Преимущества отделения кода от данных  Проектирование системы, в которой код и данные разделены  Внедрение системы, которая соблюдает разделение между кодом и данными Первое понимание ДОП заключается в том, что мы можем уменьшить сложность наших систем, отделив код от данных. Действительно, когда код отделен от данных, наши системы состоят из двух основных частей, о которых можно думать отдельно: объектов данных и модулей кода. Эта глава представляет собой глубокое погружение в первый принцип ДОП (кратко изложенный на рис. 2.1). Рис. 2.1. Обобщенный принцип ДОП № 1: отделите код от данных
52 Часть 1. Гибкость ПРИНЦИП № 1. Отделите код от данных таким образом, чтобы код находился в функциях, поведение которых не зависит от данных, которые каким-то образом инкапсулированы в контексте функции. В этой главе мы проиллюстрируем разделение между кодом и данными в контексте системы управления библиотеками Klafim, которую мы представили в главе 1. Мы также расскажем о преимуществах, которые это разделение приносит системе:  система проста. Это легко понять;  система является гибкой и расширяемой. Довольно часто для адаптации к ме- няющимся требованиям не требуется никаких изменений в дизайне. Эта глава посвящена разработке кода в системе, где код и данные разделены. В следующей главе мы сосредоточимся на дизайне данных. По мере продвижения по книге мы откроем для себя другие преимущества отделения кода от данных. 2.1. Две части системы ДОП Пока Тео едет домой после отправки прототипа, он спрашивает себя, был ли проект Klafim успешным или нет. Конечно, он смог удовлетворить клиента, но это была скорее удача, чем мозги. Он бы не успел вовремя, если бы Нэнси решила сохранить функцию Супер-читателей. Почему было так сложно добавить в систему крошечные функции? Почему система, которую он построил, была такой сложной? Он думал, что должен быть способ создавать более гибкие системы! На следующее утро Тео спрашивает в Hacker News и на Reddit о способах снижения сложности системы и создания гибких систем. Некоторые люди упоминают об использовании различных языков программирования, в то время как другие говорят о продвинутых шаблонах проектирования. Наконец, внимание Тео привлекает комментарий от пользователя по имени Джо. Он упоминает дата-ориентированное программирование, и утверждает, что его главная цель — снизить сложность системы. Тео никогда раньше не слышал этого термина. Из любопытства он решает связаться с Джо по электронной почте. Какое совпадение! Джо тоже живет в СанФранциско. Тео приглашает его на встречу в свой офис. Джо — 40-летний разработчик. Он был разработчиком Java почти десять лет, прежде чем перейти на Clojure около семи лет назад. Когда Тео рассказывает Джо о системе управления библиотекой, которую он разработал и построил, и о своих усилиях по адаптации к меняющимся требованиям, Джо не удивлен. Джо говорит Тео, что системы, которые он и его команда создали в Clojure за последние семь лет, менее сложные и более гибкие, чем системы, которые он использовал для создания на Java. По словам Джо, системы, которые они создают сейчас, как правило, намного проще, потому что они следуют принципам ДОП. Тео: Я никогда не слышал о дата-ориентированном программировании. Это новая концепция? Джо: И да и нет. Большинство основополагающих идей дата-ориентированного программирования, или ДОП, как мы любим его называть, хорошо известны
Глава 2. Разделение кода и данных 53 программистам как лучшие практики. Однако новизна ДОП заключается в том, что оно объединяет лучшие практики в единое целое. Тео: Для меня это немного абстрактно. Можете ли вы привести мне пример? Джо: Конечно! Возьмем, к примеру, первый принцип ДОП. Речь идет об отношениях между кодом и данными. Тео: Вы имеете в виду инкапсуляцию данных в объекты? Джо: На самом деле ДОП выступает против инкапсуляции данных. Тео: Это почему? Я думал, что инкапсуляция данных — это позитивная парадигма программирования. Джо: Инкапсуляция данных имеет как достоинства, так и недостатки. Подумайте о том, как вы разработали систему управления библиотекой. Согласно ДОП основной причиной сложности и негибкости систем является то, что код и данные смешаны вместе в объектах. СОВЕТ. ДОП выступает против инкапсуляции данных. Тео: Это похоже на то, что я слышал о функциональном программировании. Итак, если я хочу внедрить ДОП, нужно ли мне избавиться от объектно-ориентированного программирования и изучить функциональное программирование? Джо: Нет, принципы ДОП не зависят от языка. Они могут быть применены как в объектно-ориентированных, так и в функциональных языках программирования. Тео: Какое облегчение! Я боялся, что вы собираетесь рассказать мне о монадах, алгебраических типах данных и функциях более высокого порядка. Джо: Нет, ничего из этого не требуется в ДОП. СОВЕТ. Принципы ДОП не зависят от языка. Тео: Как тогда выглядит разделение между кодом и данными в ДОП? Джо: Данные представлены объектами данных, которые содержат только элементы. Код объединяется в модули, где все функции не имеют состояния. Тео: Что вы подразумеваете под функциями без состояния? Джо: Вместо того, чтобы инкапсулировать состояние в объект, объект данных передается в качестве аргумента. Тео: Я не понимаю. Джо: Давайте визуализируем это. Джо подходит к белой доске и быстро рисует диаграмму, чтобы проиллюстрировать свой комментарий (рис. 2.2). Тео: Все еще не ясно.
54 Часть 1. Гибкость Рис. 2.2. Разделение между кодом и данными Джо: Это станет понятнее, когда я покажу вам, как это выглядит в контексте вашей системы управления библиотекой. Тео: Хорошо. Начнем ли мы с кода или с данных? Джо: Что ж, это дата-ориентированное программирование, так что давайте начнем с данных. 2.2. Объекты данных В ДОП мы начинаем процесс проектирования с обнаружения объектов данных нашей системы. Вот что Джо и Тео должны сказать об объектах данных. Джо: Каковы объекты данных вашей системы? Тео: Что вы подразумеваете под объектами данных? Джо: Я имею в виду те части вашей системы, которые хранят информацию.  ПРИМЕЧАНИЕ. Объекты данных — это части вашей системы, которые содержат информацию. Тео: Это система управления библиотекой, так что у нас есть книги и читатели. Джо: Конечно, но есть и другие. Один из способов обнаружить объекты данных системы — это поиск существительных и именных фраз в требованиях системы. Тео смотрит на бумажную салфетку Нэнси. Он выделяет существительные и словосочетания, которые, по-видимому, представляют собой объекты данных. ВЫДЕЛЕНИЕ ТЕРМИНОВ В ТРЕБОВАНИЯХ, СООТВЕТСТВУЮЩИХ ОБЪЕКТАМ ДАННЫХ  Существуют два типа пользователей: читатели (members) и библиотекари (librarians).  Пользователи (users) входят в систему по электронной почте и паролю.  Читатели могут брать книги взаймы.  Читатели и библиотекари могут искать книги по названию или по автору. Библиотекари могут блокировать и разблокировать пользователей (например, когда они запаздывают с возвратом книги).  Библиотекари могут вносить в список книги, которые в настоящее время предоставлены читателю.  У книги может быть несколько экземпляров. Джо: Отлично. Можете ли вы увидеть естественный способ сгруппировать эти объекты? Тео: Не уверен, но мне кажется, что пользователи, читатели и библиотекари образуют одну группу, в то время как книги, авторы и копии книг образуют другую группу.
Глава 2. Разделение кода и данных 55 Джо: По-моему, звучит неплохо. Как бы вы назвали каждую группу? Тео: Вероятно, управление пользователями для первой группы и каталог для второй группы. ОБЪЕКТЫ ДАННЫХ СИСТЕМЫ, ОРГАНИЗОВАННЫЕ ВО ВЛОЖЕННЫЙ СПИСОК  Данные каталога: - данные о книгах; - данные об авторах; - данные о предметах книги; - данные о предоставлении книг.  Данные управления пользователями: - данные о пользователях; - данные о читателях; - данные о библиотекарях. Тео: Я не уверен насчет отношений между книгами и авторами. Должна ли это быть ассоциация или композиция? Джо: На данный момент не беспокойтесь слишком сильно о деталях. Позже мы уточним дизайн наших объектов данных. А пока давайте визуализируем две группы на ментальной карте. Тео и Джо немного совещаются. На рис. 2.3 показана составленная ими ментальная карта. Рис. 2.3. Объекты данных системы, организованные в виде ментальной карты Наиболее точный способ визуализации объектов данных системы ДОП — это нарисовать диаграмму объектов данных с различными стрелками для ассоциации и композиции. Мы вернемся к диаграммам объектов данных позже.
56 Часть 1. Гибкость СОВЕТ. Откройте для себя объекты данных вашей системы, а затем отсортируйте их по группам высокого уровня либо в виде вложенного списка, либо в виде ментальной карты. В следующей главе мы углубимся в проектирование и представление объектов данных. А пока давайте упростим ситуацию и скажем, что данные нашей библиотечной системы состоят из двух высокоуровневых групп: управление пользователями и каталог. 2.3. Модули кода Вторым шагом процесса проектирования в ДОП является определение модулей кода. Давайте еще раз послушаем Джо и Тео. Джо: Теперь, когда вы определили объекты данных вашей системы и распределили их по группам высокого уровня, пришло время подумать о кодовой части вашей системы. Тео: Что вы подразумеваете под частью кода? Джо: Один из способов подумать об этом — определить функциональность вашей системы. Тео снова смотрит на требования Нэнси. На этот раз он выделяет глагольные фразы, которые представляют функциональность. ВЫДЕЛЕНИЕ ТЕРМИНОВ В ТРЕБОВАНИЯХ, СООТВЕТСТВУЮЩИХ ФУНКЦИОНАЛЬНОСТИ  Существуют два типа пользователей: читатели и библиотекари.  Пользователи входят в систему по электронной почте и паролю.  Читатели могут брать книги взаймы.  Читатели и библиотекари могут искать книги по названию или по автору.  Библиотекари могут блокировать и разблокировать пользователей (например, когда они запаздывают с возвратом книги).  Библиотекари могут вносить в список книги, которые в настоящее время предоставлены читателю.  У книги может быть несколько экземпляров. Кроме того, для Тео очевидно, что читатели также могут вернуть книгу. Более того, должен быть способ определить, является ли пользователь библиотекарем или нет. Он добавляет их к требованиям, а затем перечисляет функциональные возможности системы. ФУНКЦИОНАЛЬНОСТЬ БИБЛИОТЕЧНОЙ СИСТЕМЫ  Поиск книги.  Добавление элемента книги.  Блокировка читателя.  Разблокировка читателя.  Вход в систему пользователя.  Внесение в список книг, которые в настоящее время предоставлены читателю.
Глава 2. Разделение кода и данных 57  Взятие книги.  Возвращение книги.  Проверка, является ли пользователь библиотекарем. Джо: Превосходно! Теперь скажите мне, какая функциональность должна быть открыта внешнему миру? Тео: Что вы подразумеваете под воздействием внешнего мира? Джо: Представьте, что система управления библиотекой предоставляет API по протоколу HTTP. Какие функциональные возможности будут доступны конечным точкам HTTP? Тео: Что ж, все функциональные возможности системы будут открыты, за исключением проверки того, является ли пользователь библиотекарем. Джо: Хорошо. Теперь дайте каждой открытой функции краткое имя и соберите их вместе в поле модуля под названием Library. Это занимает у Тео меньше минуты. На рис. 2.4 показан модуль, содержащий открытые функции библиотеки, разработанной Тео. Рис. 2.4. Модуль Library содержит открытые функции системы управления библиотекой СОВЕТ. Первым шагом в разработке кодовой части системы ДОП является объединение доступных функций в единый модуль. Джо: Превосходно! Вы только что создали свой первый модуль кода. Тео: Для меня это выглядит как класс. В чем разница между модулем и классом? Джо: Модуль — это совокупность функций. В ООП модуль представлен классом, но в других языках программирования это может быть пакет или пространство имен. Тео: Ага. Джо: Важной особенностью модулей кода ДОП является то, что они содержат только функции без состояния. Тео: Вы имеете в виду статические методы в Java? Джо: Да, и классы этих статических методов не должны иметь никаких элементов данных. Тео: Итак, как функции узнают, с какой частью информации они работают? Джо: Легко. Мы передаем это в качестве первого аргумента функции.
58 Часть 1. Гибкость Тео: Ладно. Можете ли вы привести пример? Джо, кусая ногти, просматривает список функций модуля Libary на рис. 2.4. Он видит вероятного кандидата. Джо: Давайте возьмем, к примеру, getBookLendings. В классическом ООП каковы были бы его аргументы? Тео: Идентификатор библиотекаря и идентификатор читателя. Джо: Итак, в традиционном ООП getBookLendings был бы методом класса Library, который получает два аргумента: librarianId и MemberID. Тео: Так. Джо: Теперь начинается тонкая часть. В ДОП getBookLendings является частью модуля Library, и он получает LibraryData в качестве аргумента. Тео: Не могли бы вы показать мне, что вы имеете в виду? Джо: Конечно! Джо подходит к клавиатуре Тео и начинает печатать. Он вводит пример того, как выглядит метод класса в ООП: class Library { catalog userManagement getBookLendings(userId, memberId) { // получает доступ к состоянию библиотеки через this.catalog и this.UserManagement } } Тео: И правда! Метод обращается к состоянию объекта (в нашем случае к библиотечным данным) с помощью этого. Джо: Могли бы вы сказать, что состояние объекта является аргументом методов объекта? Тео: Я бы сказал, что состояние объекта является неявным аргументом для методов объекта. СОВЕТ. В традиционном ООП состояние объекта является неявным аргументом для методов объекта. Джо: Ну, в ДОП мы передаем данные в качестве явного аргумента. Сигнатура getBookLendings будет выглядеть следующим образом. Листинг 2.1. Сигнатура getBookLendings class Library { static getBookLendings(libraryData, userId, memberId) { } }
Глава 2. Разделение кода и данных 59 Джо: Состояние библиотеки хранится в libraryData, а libraryData передается статическому методу getBookLendings в качестве явного аргумента. Тео: Это общее правило? Джо: Абсолютно! То же правило применяется к другим функциям модуля Library, а также к другим модулям. Все модули не имеют состояния — они получают библиотечные данные, которыми они манипулируют, в качестве аргумента. СОВЕТ. В ДОП функции модуля кода не имеют состояния. Они получают данные, которыми они манипулируют, в качестве явного аргумента, который обычно является первым аргументом.  ПРИМЕЧАНИЕ. Модуль — это совокупность функций. В ДОП функции модуля не имеют состояния. Тео: Это напоминает мне о Python и о том, как аргумент self появляется в сигнатурах методов. Вот, позвольте мне показать вам пример. Листинг 2.2. Объект Python как явный аргумент в сигнатурах методов class Library: catalog = {} userManagement = {} def getBookLendings(self, userId, memberId): # получает доступ к состоянию библиотеки через self.catalog и self.userManagement Джо: Действительно, но разница, о которой я говорю, гораздо глубже, чем изменение синтаксиса. Речь идет о том, что данные находятся вне модулей. Тео: Я понял это. Как вы сказали, функции модуля не имеют состояния. Джо: Именно! Хотели бы вы попробовать применить этот принцип ко всему модулю Library? Тео: Конечно. Тео уточняет дизайн модуля Library, включая подробную информацию об аргументах функций. Он представляет Джо диаграмму на рис. 2.5. Рис. 2.5. Модуль Library с аргументами функций
60 Часть 1. Гибкость Джо: Замечательно. Теперь мы готовы приступить к высокоуровневому дизайну нашей системы. Тео: Что такое высокоуровневый дизайн в ДОП? Джо: Высокоуровневый дизайн в ДОП — это определение модулей и взаимодействие между ними. Тео: Понятно. Существуют ли какие-либо рекомендации, которые помогут мне определить модули? Джо: Безусловно. Высокоуровневые модули системы соответствуют объектам данных высокого уровня. Тео: Вы имеете в виду объекты данных, которые отображаются на ментальной карте данных? Джо: Именно так! Тео снова смотрит на ментальную карту данных (рис. 2.6). Он фокусируется на библиотеке объектов данных высокого уровня, каталоге и управлении пользователями. Это означает, что в системе, помимо модуля Library, у нас есть два высокоуровневых модуля:  модуль Catalog работает с данными каталога;  модуль UserManagement имеет дело с данными управления пользователями. Рис. 2.6. Ментальная карта объектов данных высокого уровня системы управления библиотекой Затем Тео рисует высокоуровневый дизайн системы управления библиотекой с модулями Catalog и UserManagement. На рис. 2.7 показано добавление этих модулей, где:  функции Catalog получают в качестве первого аргумента catalogData;  функции UserManagement получают userManagementData в качестве первого аргу- мента. На данный момент Тео не на 100 % ясно, как объекты данных передаются между модулями. На данный момент он думает о libraryData как о классе с двумя элементами:  Сatalog содержит данные каталога;  UserManagement содержит данные управления пользователями. Тео также видит, что функции Library имеют общий шаблон. (Позже в этой главе мы увидим код некоторых функций модуля Library.)
Глава 2. Разделение кода и данных 61  В качестве аргумента они получают libraryData.  Они передают libraryData.catalog функциям Catalog.  Они передают libraryData.userManagement функциям UserManagement. СОВЕТ. Модули высокого уровня системы ДОП соответствуют объектам данных высокого уровня. Рис. 2.7. Модули системы управления библиотекой с аргументами их функций 2.4. Системы ДОП просты для понимания Тео смотрит на две диаграммы, которые представляют высокоуровневый дизайн его системы: объекты данных в ментальной карте данных (рис. 2.8) и модули кода на диаграмме модулей (рис. 2.9). Немного озадаченный, Тео спрашивает Джо: Тео: Я не уверен, что эта система лучше, чем традиционная система ООП, где объекты инкапсулируют данные. Джо: Главное преимущество системы ДОП перед традиционной системой ООП заключается в том, что ее легче понять. Тео: Что облегчает ее понимание? Джо: Дело в том, что система четко разделена на модули кода и объекты данных. Тео: Как это помогает? Джо: Когда вы пытаетесь понять объекты данных системы, вам не нужно думать о деталях кода, который манипулирует объектами данных. Тео: Значит, когда я смотрю на ментальную карту данных моей системы управления библиотекой, я могу понять это сам?
62 Часть 1. Гибкость Джо: Точно так же, когда вы пытаетесь понять модули кода системы, вам не нужно думать о деталях объектов данных, которыми манипулирует код. Существует четкое разделение проблем между кодом и данными. Тео снова смотрит на ментальную карту данных на рис. 2.8. У него появляется чтото вроде озарения: «Данные живут сами по себе!». Рис. 2.8. Ментальная карта данных системы управления библиотекой  ПРИМЕЧАНИЕ. Систему ДОП легче понять, потому что диаграмма разделена на две части: объекты данных и модули кода. Теперь Тео смотрит на диаграмму модулей на рис. 2.9. Он чувствует себя немного сбитым с толку и просит Джо разъяснить.  С одной стороны, диаграмма модулей выглядит аналогично диаграммам классов из классического ООП, поля для классов и стрелки для отношений между классами.  С другой стороны, диаграмма модулей кода выглядит намного проще, чем диа- граммы классов из классического ООП, но он не может объяснить почему. Тео: Диаграмма модулей кажется намного проще, чем диаграммы классов, к которым я привык в ООП. Я чувствую это, но не могу выразить словами. Джо: Дело в том, что диаграммы модулей имеют ограничения. Тео: Какого рода ограничения? Джо: Ограничения на функции, которые мы видели ранее. Все функции статичны (или не имеют состояния), но существуют также ограничения на отношения между модулями. СОВЕТ. Все функции в модуле ДОП не имеют состояния.
Глава 2. Разделение кода и данных 63 Рис. 2.9. Модули системы управления библиотекой с аргументами функции Тео: Как ограничены отношения между модулями? Джо: Существует единственный вид связи между модулями ДОП — отношение использования. Модуль использует код из другого модуля. Нет никакой ассоциации, никакой композиции и никакого наследования между модулями. Это то, что делает схему модуля ДОП простой для понимания. Тео: Я понимаю, почему нет никакой связи и никакой композиции между модулями ДОП. В конце концов, ассоциация и композиция — это отношения данных. Но почему нет отношения наследования? Значит ли это то, что ДОП против полиморфизма? Джо: Это отличный вопрос! Говоря кратко, что в ДОП мы достигаем полиморфизма с помощью механизма, отличного от наследования классов. Когда-нибудь мы поговорим об этом.  ПРИМЕЧАНИЕ. Обсуждение полиморфизма в ДОП см. в главе 13. Тео: Что ж, а вот это уже интересно! Я думал, что наследование — это единственный способ достичь полиморфизма. Тео снова смотрит на диаграмму модулей на рис. 2.9. Теперь он не только чувствует, что эта диаграмма проще, чем традиционные диаграммы классов ООП, он понимает, почему она проще: все функции статичны, а все отношения между модулями имеют тип usage. В табл. 2.1 обобщено восприятие Тео. СОВЕТ. Единственным видом связи между модулями ДОП является отношение использования. СОВЕТ. Каждую часть системы ДОП легко понять, потому что она предусматривает ограничения.
64 Часть 1. Гибкость Таблица 2.1. Что облегчает разборку каждой части системы ДОП Системная часть Ограничение на объекты Ограничения на отношения Объекты данных Только для членов (без кода) Ассоциация и композиция Модули кода Функции без состояния (без членов) Использование (без наследования) 2.5. Системы ДОП являются гибкими Тео: Я вижу, как резкое разделение между кодом и данными делает системы ДОП более понятными, чем классические системы ООП. Но как насчет адаптации к изменениям в требованиях? Джо: Еще одним преимуществом систем ДОП является то, что их легко расширять и адаптировать к меняющимся требованиям. Тео: Я помню, что, когда Нэнси попросила меня добавить в систему Супер-читателей и VIP-читателей, было трудно адаптировать мою систему ООП. Мне пришлось ввести несколько базовых классов, и иерархия классов стала действительно сложной. Джо: О, я знаю, что вы имеете в виду. Я столько раз испытывал подобные трудности. Опишите изменения в требованиях к Супер-читателям и VIP-читателям, и я совершенно уверен, что вы увидите, как легко было бы расширить вашу систему ДОП. ТРЕБОВАНИЯ К СУПЕР-ЧИТАТЕЛЯМ И VIP-ЧИТАТЕЛЯМ  Супер-читатели — это читатели, которым разрешено вносить книги в список, предоставленные другим читателям библиотеки.  VIP-читатели — это читатели, которым разрешено добавлять книги в библиотеку. Тео открывает свою IDE и начинает кодить функцию getBookLendings модуля Library (см. листинг 2.3), сначала не обращаясь к требованиям для Супер-читателей. Тео помнит, что Джо рассказывал ему о функциях модуля в ДОП:  функции не имеют состояния;  функции получают данные, которыми они манипулируют, в качестве своего первого аргумента. С точки зрения функциональности getBookLendings состоит из двух частей:  проверяет, что пользователь является библиотекарем;  извлекает предоставленные книги из каталога. По сути, код getBookLendings также состоит из двух частей:  вызывает функцию isLibrarian из модуля UserManagement и передает ей данные UserManagementData;  вызывает функцию getBookLendings из модуля Catalog и передает ей данные ка- талога.
Глава 2. Разделение кода и данных 65 Листинг 2.3. Получение одолженных книг от читателя библиотеки class Library { static getBookLendings(libraryData, userId, memberId) { if(UserManagement.isLibrarian(libraryData.userManagement, userId)) { return Catalog.getBookLendings(libraryData.catalog, memberId); } else { throw "Not allowed to get book lendings";❶ } } } class UserManagement { static isLibrarian(userManagementData, userId) { // будет реализовано позже ❷ } } class Catalog { static getBookLendings(catalogData, memberId) { // будет реализовано позже ❸ } } ❶ Существуют и другие способы управления ошибками. ❷ В главе 3 мы увидим, как управлять разрешениями с помощью общих коллекций данных. ❸ В главе 3 мы увидим, как запрашивать данные с помощью общих коллекций данных. Это первая часть кода ДОП от Тео, и передача всех этих объектов данных — libraryData, libraryData.UserManagement и libraryData.catalog — кажется немного неловкой. Но он сделал это! Джо смотрит на код Тео и, кажется, удовлетворен результатом. Джо: Теперь как бы вы адаптировали свой код к Супер-читателям библиотеки? Тео: Я бы добавил функцию isSuperMember в модуль UserManagement и вызвал бы ее из Library.getBookLendings. Джо: Совершенно верно! Все очень просто. Тео набирает код на своем ноутбуке, чтобы показать его Джо. Вот как Тео адаптирует свой код для Супер-читателей библиотеки. Листинг 2.4. Позволяющий Супер-читателям получать выданные книги class Library { static getBookLendings(libraryData, userId, memberId) {
66 Часть 1. Гибкость if(Usermanagement.isLibrarian(libraryData.userManagement, userId) || Usermanagement.isSuperMember(libraryData.userManagement, userId)) { return Catalog.getBookLendings(libraryData.catalog, memberId); } else { throw "Not allowed to get book lendings"; ❶ } } } class UserManagement { static isLibrarian(userManagementData, userId) { // будет реализовано позже ❷ } static isSuperMember(userManagementData, userId) { // будет реализовано позже ❷ } } class Catalog { static getBookLendings(catalogData, memberId) { // будет реализовано позже ❸ } } ❶ Есть и другие способы управления ошибками. ❷ В главе 3 мы увидим, как управлять разрешениями с помощью общих коллекций данных. ❸ В главе 3 мы увидим, как запрашивать данные с помощью общих коллекций данных. Теперь чувство неловкости, вызванное передачей всех этих объектов данных, преобладает над чувством облегчения. Адаптация к этому изменению требований занимает всего несколько строк кода и не требует изменений в дизайне системы. И снова Джо, кажется, удовлетворен получившимся результатом. СОВЕТ. Системы ДОП являются гибкими. Довольно часто они адаптируются к изменяющимся требованиям, не меняя дизайн системы. Тео начинает кодить addBookItem. Он смотрит на подпись Library.addBookItem, и значение третьего аргумента bookItemInfo ему непонятно. Он просит Джо разъяснить. Листинг 2.5. Сигнатура Library.addBookItem class Library { static addBookItem(libraryData, userId, bookItemInfo) { } }
Глава 2. Разделение кода и данных 67 Тео: Что такое bookItemInfo? Джо: Давайте назовем это информацией об экземпляре книги. Представьте, что у нас есть способ представить эту информацию в элементе данных с именем bookItemInfo. Тео: Вы имеете в виду объект? Джо: На данный момент можно думать о bookItemInfo как об объекте. Позже я покажу вам, как мы представляем данные в ДОП. Помимо этой тонкости в том, как информация об экземпляре книги представлена bookItemInfo, код для Library.addBookItem в листинге 2.6 очень похож на код, который Тео написал для Library.getBookLendings в листинге 2.4. И снова Тео поражен тем фактом, что добавление поддержки для VIP-читателей библиотеки не требует изменения дизайна. Листинг 2.6. Позволяющий VIP-читателям библиотеки добавлять книги в библиотеку class Library { static addBookItem(libraryData, userId, bookItemInfo) { if(UserManagement.isLibrarian(libraryData.userManagement, userId) || UserManagement.isVIPMember(libraryData.userManagement, userId)) { return Catalog.addBookItem(libraryData.catalog, bookItemInfo); } else { throw "Not allowed to add a book item"; ❶ } } } class UserManagement { static isLibrarian(userManagementData, userId) { // будет реализовано позже ❷ } static isVIPMember(userManagementData, userId) { // будет реализовано позже ❷ } } class Catalog { static addBookItem(catalogData, memberId) { // будет реализовано позже ❸ } } ❶ Существуют и другие способы управления ошибками. ❷ В главе 3 мы увидим, как управлять разрешениями с помощью общих коллекций данных. ❸ В главе 4 мы увидим, как управлять состоянием системы с помощью неизменяемых данных.
68 Часть 1. Гибкость Тео: Требуется большое изменение мышления, чтобы научиться отделять код от данных! Джо: Что было самым сложным для принятия? Тео: То, что данные не инкапсулируются в объекты. Джо: То же самое было и со мной, когда я переключился с ООП на ДОП. Теперь пришло время поесть! — Тео приглашает Джо на обед в Simple, симпатичный маленький ресторанчик рядом с офисом. Итоги  Принципы ДОП не зависят от языка.  Принцип № 1 ДОП заключается в отделении кода от данных.  Разделение между кодом и данными в системах ДОП делает их более простыми                 (понятными), чем традиционные системы ООП. Объекты данных — это части вашей системы, которые содержат информацию. ДОП против инкапсуляции данных. Чем более гибкой является система, тем легче ее адаптировать к меняющимся требованиям. Разделение между кодом и данными в системах ДОП делает их более гибкими, чем традиционные системы ООП. Когда код отделен от данных, у нас есть свобода работать над кодом и данными изолированно. Мы представляем данные как объекты данных. Мы обнаруживаем объекты данных нашей системы и сортируем их по группам высокого уровня либо в виде вложенного списка, либо в виде ментальной карты. Систему ДОП легче понять, чем традиционную систему ООП, потому что система разделена на две части: объекты данных и модули кода. В ДОП модуль кода представляет собой совокупность функций без состояния. Системы ДОП являются гибкими. Довольно часто они адаптируются к изменяющимся требованиям, не меняя дизайн системы. В традиционном ООП состояние объекта является неявным аргументом для методов объекта. Функции без состояния получают данные, которыми они манипулируют, в качестве явного аргумента. Высокоуровневые модули системы ДОП соответствуют объектам данных высокого уровня. Единственным видом связи между модулями кода является отношение использования. Единственными видами связи между объектами данных являются связь и композиционное отношение. Обсуждение полиморфизма в ДОП см. в главе 13.
Основные манипуляции с данными Медитация и программирование В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Представление записей с помощью строковых карт для повышения гибкости.  Манипулирование данными с помощью универсальных функций.  Доступ к каждой части информации по ее информационному пути.  Свободное получение сериализации JSON. После того как в предыдущей главе мы узнали, почему и как нужно отделять код от данных, давайте поговорим о самих данных. В отличие от традиционного ООП, где проектирование системы, как правило, предполагает жесткую иерархию классов, ДОП предписывает представлять нашу модель данных в виде гибкой комбинации карт и массивов (или списков), где мы можем получить доступ к каждой части информации по информационному пути. Эта глава представляет собой глубокое погружение во второй принцип ДОП. ПРИНЦИП № 2. Представляйте объекты данных с помощью общих структур данных. Мы повышаем гибкость системы, когда представляем записи в виде строковых отображений, а не в виде объектов, созданных из классов. Это освобождает данные от жесткости системы, основанной на классах. Данные становятся первоклассным гражданином, использующим универсальные функции для добавления, удаления или переименования полей.  ПРИМЕЧАНИЕ. Мы называем карты, содержащие строки в качестве ключей, строковыми картами. Зависимость между кодом, который манипулирует данными, и самими данными является слабой зависимостью. Коду нужно знать только ключи определенных полей в записи, которой он хочет манипулировать. Коду даже не нужно знать обо
70 Часть 1. Гибкость всех ключах в записи, только о тех, которые имеют к ней отношение. В этой главе мы будем иметь дело только с запросом данных. Управление изменениями состояния системы мы обсудим в следующей главе. 3.1. Разработка модели данных Во время обеда в Simple Тео и Джо не говорят о программировании. Вместо этого они начинают узнавать друг друга на личном уровне. Тео обнаруживает, что Джо женат на Кей, которая только что открыла свою практику творческой терапии после многих лет изучения различных областей, связанных с благополучием. Нерайя, их 14-летний сын, увлечен дронами, в то время как Аурелия, их 12-летняя дочь, играет на поперечной флейте. Джо говорит Тео, что он практикует медитацию в течение 10 лет. Медитация, по его словам, научила его, как избавиться от постоянного погружения в «штормовые мысли» (особенно негативные мысли, которые могут быть источником больших страданий), чтобы достичь более прямых отношений с реальностью. Чем больше он учится воспринимать реальность такой, какая она есть, тем спокойнее его разум. Когда он впервые начал практиковать медитацию, иногда это было трудно и даже странно, но, проявляя настойчивость, он с каждым годом улучшал свое самочувствие. Когда они возвращаются в офис, Джо говорит Тео, что его следующий шаг в их путешествии по ДОП будет связан с моделями данных. Это включает в себя представление данных. Джо: Когда мы разрабатываем информационную часть нашей системы, мы можем делать это изолированно. Тео: Что вы подразумеваете под изоляцией? Джо: Я имею в виду, что вам не нужно беспокоиться о коде, только о данных. Тео: О, точно. Я помню, вы рассказывали мне, как это делает систему ДОП проще, чем ООП. Разделение задач — это принцип проектирования, к которому я привык в ООП. Джо: Именно. Тео: И когда мы думаем о данных, единственные отношения, о которых мы должны думать, — это ассоциация и композиция. Джо: Верно. Тео: Будет ли дизайн модели данных существенно отличаться от модели данных, которую я привык разрабатывать как ООП-разработчик? Джо: Не так уж и существенно. Тео: Хорошо. Позвольте мне посмотреть, смогу ли я нарисовать диаграмму объектов данных в стиле ДОП. Тео бросает взгляд на ментальную карту данных, которую он нарисовал ранее утром. Затем он рисует диаграмму на рис. 3.1.
Глава 3. Основные манипуляции с данными 71 Он уточняет детали полей каждого объекта данных и вид отношений между объектами. На рис. 3.2 показан результат этой переопределенной диаграммы объектов данных. Рис. 3.1. Ментальная карта данных системы управления библиотекой Рис. 3.2. Модель данных системы управления библиотекой
72 Часть 1. Гибкость Джо: Следующий шаг — более четко описать отношения между объектами. Тео: Что вы имеете в виду? Джо: Например, на вашей диаграмме объектов Book и Author связаны ассоциативным отношением «многие ко многим». Как это отношение будет представлено в вашей программе? Тео: В объекте Book будет коллекция идентификаторов автора, а в объекте Author будет коллекция идентификаторов книги. Джо: Звучит хорошо. И каким будет идентификатор книги? Тео: Книжный ISBN.  ПРИМЕЧАНИЕ. Международный стандартный номер книги (ISBN) — это цифровой идентификатор коммерческой книги, который должен быть уникальным. Джо: И где вы будете хранить индекс, который позволит вам получить книгу по ее ISBN? Тео: В Catalog, потому что каталог содержит индекс bookByISBN. Джо: А как насчет идентификатора автора? Тео: Идентификатор автора — это имя автора в нижнем регистре и с тире вместо пробелов (при условии, что у нас нет двух авторов с одинаковым именем). Джо: И я предполагаю, что у вас также есть индекс автора в Catalog? Тео: Именно так. Джо: Отлично. Вы были на 100 % точны в отношении связи между Book и Author. Я попрошу вас сделать то же самое с другими отношениями системы. Для Тео это довольно легко сделать, т. к. он делал это много раз в качестве разработчика ООП. На рис. 3.3 представлена подробная диаграмма объектов системы Тео.  ПРИМЕЧАНИЕ. Под позиционной коллекцией мы подразумеваем коллекцию, в которой элементы расположены по порядку (например, список или массив). Под индексом мы подразумеваем коллекцию, элементы которой доступны через ключ (например, хеш-карту или словарь). Объект Catalog содержит два индекса:  booksByIsbn — ключи — это книжный ISBN, а значения — это объекты Book. Его тип отмечен как {Book}.  authorsById — ключи являются идентификаторами авторов, а значения являются объектами Author. Его тип отмечен как {Author}. Внутри объекта Book у нас есть authors, которая представляет собой позиционную коллекцию идентификаторов авторов типа [String]. Внутри объекта Author у нас есть books, которая представляет собой набор идентификаторов книг типа [String].
Глава 3. Основные манипуляции с данными 73 Рис. 3.3. Модель отношений управления библиотекой. Пунктирные линии (например, между Book и Author) обозначают косвенные отношения, [String] обозначает позиционный набор строк, а {Book} обозначает индекс Books  ПРИМЕЧАНИЕ. Для обозначения коллекций и типов индексов позиционная коллекция строк обозначается как [String]. Указатель книг отмечен как {Book}. В контексте модели данных ключи индекса всегда являются строками. Существует пунктирная линия между Book и Author, что означает, что связь между Book и Author является косвенной. Чтобы получить доступ к коллекции объектов Author из объекта Book, мы будем использовать индекс authorById, определенный в объекте Catalog. Джо: Мне нравится ваша диаграмма объектов данных. Тео: Благодарю. Джо: Можете ли вы сказать мне, какие три вида агрегаций данных представлены на вашей диаграмме (и фактически на любой диаграмме объектов данных)? Тео: Давайте посмотрим... у нас есть позиционные коллекции, такие как authors в Book. В каталоге у нас есть такие индексы, как booksByIsbn. Я не могу найти третий.
74 Часть 1. Гибкость Джо: Третий вид агрегации данных — это то, что мы до сих пор называли «объектом» (например, Library, Catalog, Book и т. д.). А общий термин для объекта в информатике — запись.  ПРИМЕЧАНИЕ. Запись — это структура данных, которая группирует связанные элементы данных. Это набор полей, возможно, разных типов данных. Тео: Правильно ли говорить, что диаграмма объектов данных состоит только из записей, позиционных коллекций и индексов? Джо: Это правильно. Можете ли вы сделать аналогичное заявление об отношениях между объектами? Тео: Отношения на диаграмме объектов данных представляют собой либо композицию (сплошная линия с полным ромбом), либо ассоциацию (пунктирная линия с пустым ромбом). Оба типа отношений могут быть либо «один к одному», либо «один ко многим», либо «многие ко многим». Джо: Прекрасно! СОВЕТ. Диаграмма объектов данных состоит из записей, значениями которых являются либо примитивы, позиционные коллекции, либо индексы. Связь между записями является либо композицией, либо ассоциацией. 3.2. Представление записей в виде карт До сих пор мы иллюстрировали преимущества, которые получаем от разделения кода и данных на высоком системном уровне. Существует разделение проблем между кодом и данными, и каждая часть имеет четкие ограничения:  Код состоит из статических функций, которые получают данные в качестве явного аргумента.  Объекты данных моделируются как записи, а отношения между записями представлены позиционными коллекциями и индексами. Теперь возникает вопрос о представлении данных. ДОП не может сказать ничего особенного о коллекциях и индексах. Тем не менее существует твердое мнение о представлении записей: записи должны быть представлены общими структурами данных, такими как карты. Это относится как к языкам ООП, так и к языкам ФП. В динамически типизированных языках, таких как JavaScript, Python и Ruby, представление данных кажется естественным. В то время как в статически типизированных языках, таких как Java и C#, оно немного более громоздко. Тео: Мне действительно любопытно узнать, как мы представляем позиционные коллекции, индексы и записи в ДОП. Джо: Давайте начнем с позиционных коллекций. ДОП не может сказать ничего особенного о представлении коллекций. Они могут быть связанными списками, массивами, векторами, наборами или другими коллекциями, наиболее подходящими для конкретного случая использования.
Глава 3. Основные манипуляции с данными 75 Тео: Как и в ООП. Джо: Правильно! На данный момент, чтобы упростить задачу, мы будем использовать массивы для представления позиционных коллекций. Тео: А как насчет индексов? Джо: Индексы представлены в виде гомогенных строковых карт. Тео: Что вы подразумеваете под гомогенной картой? Джо: Я имею в виду, что все значения карты имеют один и тот же вид. Например, в индексе Book все значения — Book, а в индексе Author все значения — Author и т. д. Тео: И снова как в ООП!  ПРИМЕЧАНИЕ. Гомогенная карта — это карта, на которой все значения имеют один и тот же тип. Гетерогенная карта — это карта, где значения относятся к разным типам. Джо: А вот теперь большой сюрприз: в ДОП записи представлены в виде карт, точнее, гетерогенных строковых карт. Джо подходит к белой доске и начинает рисовать. Закончив, он показывает Тео диаграмму на рис. 3.4. Рис. 3.4. Строительные блоки представления данных Тео некоторое время молчит. Он потрясен, услышав, что объекты данных системы могут быть представлены в виде общей структуры данных, где имена полей и типы значений не указаны в классе. Затем Тео спрашивает Джо: Тео: Каковы преимущества такого вздора? Джо: Гибкость и универсальность. Тео: Не могли бы вы объяснить, пожалуйста? Джо: Я объясню через мгновение, но перед этим я хотел бы показать вам, как выглядит экземпляр записи в системе ДОП. Тео: Ладно.
76 Часть 1. Гибкость Джо: Давайте возьмем в качестве примера книгу «Хранители» (англ. «Watchmen») Алана Мура и Дэйва Гиббонса, это мой любимый комикс. Этот шедевр был опубликован в 1987 году. Я собираюсь предположить, что в физической библиотеке есть две копии этой книги, идентификатор которой — nyc-central-lib, и что одна из двух копий в настоящее время отсутствует. Вот как я бы представил запись Book для «Хранителей» в ДОП. Джо подходит ближе к ноутбуку Тео. Он открывает текстовый редактор (не IDE!) и вводит запись Book для Тео. Листинг 3.1. Экземпляр записи Book, представленный в виде карты { "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authors": ["alan-moore", "dave-gibbons"], "bookItems": [ { "id": "book-item-1", "libId": "nyc-central-lib", "isLent": true }, { "id": "book-item-2", "libId": "nyc-central-lib", "isLent": false } ] } Тео смотрит на экран ноутбука. У него есть вопрос. Тео: Как я должен создать экземпляр записи Book для «Хранителей» программно? Джо: Это зависит от возможностей, которые предлагает ваш язык программирования для создания экземпляров карт. С динамическими языками, такими как JavaScript, Ruby или Python, это просто, потому что мы можем использовать литералы для карт и массивов. Позвольте мне показать вам, как это делается. Джо записывает код JavaScript, который создает экземпляр записи Book, которая представляется в виде карты в JavaScript. Он показывает код Тео. Листинг 3.2. Запись Book, представленная в виде карты в JavaScript var watchmenBook = { "isbn": "978-1779501127", "title": "Watchmen",
Глава 3. Основные манипуляции с данными 77 "publicationYear": 1987, "authors": ["alan-moore", "dave-gibbons"], "bookItems": [ { "id": "book-item-1", "libId": "nyc-central-lib", "isLent": true }, { "id": "book-item-2", "libId": "nyc-central-lib", "isLent": false } ] } Тео: А если на Java?? Джо: Это немного более утомительно, но все же выполнимо с помощью неизменяемых методов Map и List static factory.  ПРИМЕЧАНИЕ. Смотрите раздел «Создание неизменяемых списков, наборов и карт» на http://mng.bz/voGm для получения дополнительной информации об этой библиотеке Java core. Джо вводит Java-код для создания экземпляра записи Book, представленной в виде карты. Он показывает Тео Java-код. Листинг 3.3. Запись Book, представленная в виде карты на Java Map watchmen = Map.of( "isbn", "978-1779501127", "title", "Watchmen", "publicationYear", 1987, "authors", List.of("alan-moore", "dave-gibbons"), "bookItems", List.of( Map.of( "id", "book-item-1", "libId", "nyc-central-lib", "isLent", true ), Map.of ( "id", "book-item-2", "libId", "nyc-central-lib", "isLent", false ) ) );
78 Часть 1. Гибкость СОВЕТ. В ДОП мы представляем запись в виде гетерогенного отображения строк. Тео: Я бы определенно предпочел создать запись Book, используя класс Book и класс BookItem. Листинг 3.4. Запись Book как экземпляра класса Book в JavaScript class Book { isbn; title; publicationYear; authors; bookItems; constructor(isbn, title, publicationYear, authors, bookItems) { this.isbn = isbn; this.title = title; this.publicationYear = publicationYear; this.authors = authors; this.bookItems = bookItems; } } class BookItem { id; libId; isLent; constructor(id, libId, isLent) { this.id = id; this.libId = libId; this.isLent = isLent; } } var watchmenBook = new Book("978-1779501127", "Watchmen", 1987, ["alan-moore", "dave-gibbons"], [new BookItem("book-item-1", "nyc-central-lib", true), new BookItem("book-item-2", "nyc-central-lib", false)]); Джо: Тео, почему вы предпочитаете классы картам для представления записей? Тео: Это делает форму данных записи частью моей программы. В результате IDE может автоматически заполнять имена полей, и ошибки обнаруживаются во время компиляции.
Глава 3. Основные манипуляции с данными 79 Джо: Справедливо. Могу ли я показать вам некоторые недостатки этого подхода? Тео: Конечно. Джо: Представьте, что вы хотите отобразить информацию о книге в контексте результатов поиска. В этом случае вместо идентификаторов авторов вы хотите отобразить имена авторов, и вам не нужна информация об элементе книги. Как бы вы с этим справились? Тео: Я бы создал класс BookInSearchResults без члена bookItems и с членом authorNames вместо члена authorIds класса Book. Кроме того, мне нужно было бы написать конструктор копирования, который получает объект Book. Джо: В классическом ООП тот факт, что данные создаются только с помощью классов, обеспечивает безопасность. Но эта безопасность достигается за счет гибкости. СОВЕТ. В модели данных существует компромисс между гибкостью и безопасностью. Тео: Тогда это может быть иным? Джо: В подходе ДОП, где записи представлены в виде карт, нам не нужно создавать класс для каждого варианта данных. Мы можем свободно добавлять, удалять и переименовывать поля записей динамически. Наша модель данных является гибкой. Тео: Это интересно! СОВЕТ. В ДОП модель данных является гибкой. Мы можем свободно добавлять, удалять и переименовывать поля записи динамически во время выполнения. Джо: Теперь позвольте мне поговорить об универсальности. Как бы вы сериализовали содержимое объекта Book в JSON? СОВЕТ. В ДОП записи обрабатываются с помощью универсальных функций. Тео: О нет! Я помню, что во время работы над прототипом Klafim мне приснился кошмар о сериализации JSON, когда я разрабатывал первую версию системы управления библиотекой. Джо: В ДОП сериализация записи в JSON очень проста. Тео: Требуется ли использование отражения для того, чтобы просматривать поля записи, как это делает библиотека Java Gson?  ПРИМЕЧАНИЕ. Перейдите по ссылке https://github.com/google/gson для получения дополнительной информации о Gson. Джо: Вовсе нет! Помните, что в ДОП запись — это не более чем данные. Мы можем написать универсальную функцию сериализации JSON, которая работает с любой записью. Это может быть Book, Author, BookItem или что-либо еще. Тео: Потрясающе!
80 Часть 1. Гибкость СОВЕТ. В ДОП вы получаете сериализацию JSON совершенно свободно. Джо: На самом деле, как я вам сейчас покажу, многие манипуляции с данными можно выполнять с помощью универсальных функций. Тео: Являются ли универсальные функции частью языка? Джо: Это зависит от функций и от языка. Например, JavaScript предоставляет стандартную функцию сериализации JSON под названием JSON.stringify, но не для пропуска нескольких ключей или для переименования ключей. Тео: Досадно. Джо: Не совсем; существуют сторонние библиотеки, которые предоставляют средства для манипулирования данными. Популярной библиотекой для манипулирования данными в экосистеме JavaScript является Lodash.  ПРИМЕЧАНИЕ. Перейдите по ссылке https://lodash.com /, чтобы узнать больше о Lodash. Тео: А как насчет других языков? Джо: Lodash был перенесен на Java, C#, Python и Ruby. Позвольте мне дать несколько сайтов для вас. Джо добавляет эти сайты в закладки для Тео:  https://javalibs.com/artifact/com.github.javadev/underscore-lodash для Java;  https://www.nuget.org/packages/lodash/ для C#;  https://github.com/dgilland/pydash для Python;  https://rudash-website.now.sh/ для Ruby.  ПРИМЕЧАНИЕ. В книге Lodash используется для демонстрации того, как манипулировать данными с помощью универсальных функций, но в Lodash нет ничего особенного. Точно такой же подход может быть реализован с помощью других библиотек обработки данных или пользовательского кода. Тео: Здорово! Джо: На самом деле, Lodash и ее богатый набор функций обработки данных могут быть перенесены на любой язык. Вот почему так выгодно представлять записи в виде карт. СОВЕТ. ДОП идет на компромисс с безопасностью данных, чтобы получить гибкость и универсальность. У доски Джо быстро набрасывает компромиссы (см. табл. 3.1). Таблица 3.1. Компромисс между безопасностью, гибкостью и универсальностью Функции ООП ДОП Безопасность Высокая Низкая Гибкость Низкая Высокая Универсальность Низкая Высокая
Глава 3. Основные манипуляции с данными 81 3.3. Манипулирование данными с помощью универсальных функций Джо: Теперь позвольте мне показать вам, как манипулировать данными в ДОП с помощью универсальных функций. Тео: Да, мне очень любопытно посмотреть, как вы будете реализовывать функциональность поиска в системе управления библиотекой. Джо: Хорошо. Во-первых, давайте создадим экземпляр записи Catalog для данных каталога библиотеки, где у нас есть единственная книга «Хранители». Листинг 3.5. Запись Catalog var catalogData = { "booksByIsbn": { "978-1779501127": { "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authorIds": ["alan-moore", "dave-gibbons"], "bookItems": [ { "id": "book-item-1", "libId": "nyc-central-lib", "isLent": true }, { "id": "book-item-2", "libId": "nyc-central-lib", "isLent": false } ] } }, "authorsById": { "alan-moore": { "name": "Alan Moore", "bookIsbns": ["978-1779501127"] }, "dave-gibbons": { "name": "Dave Gibbons", "bookIsbns": ["978-1779501127"] } } } Тео: Я вижу два индекса, о которых мы говорили, booksByIsbn и authorsById. Как вы отличаете запись от индекса в ДОП?
82 Часть 1. Гибкость Джо: На диаграмме данных существует четкое различие между записями и индексами. Но в нашем коде и то и другое является обычными данными. Тео: Я думаю, именно поэтому этот подход называется дата-ориентированным программированием. Джо: Видите, как просто визуализировать любую часть системных данных внутри программы? Причина в том, что данные представлены как данные! СОВЕТ. В ДОП данные представляются в виде данных. Тео: Это звучит как лапалиссада1. Джо: О, неужели это так? Я бы не был так уверен! В ООП данные обычно представлены объектами, что усложняет визуализацию данных внутри программы. СОВЕТ. В ДОП мы можем визуализировать любую часть системных данных. Тео: Как бы вы извлекли название конкретной книги из данных каталога? Джо: Отличный вопрос! Фактически в системе ДОП каждая часть информации имеет информационный путь, из которого мы можем извлечь информацию. Тео: Информационный путь? Джо: Например, информационный путь к названию книги «Хранители» в каталоге — ["booksByIsbn", "978-1779501127", "title"]. Тео: А, я понимаю. Является ли информационный путь чем-то вроде пути к файлу, но имена в информационном пути соответствуют вложенным данным? Джо: Вы совершенно правы. И как только у нас будет путь к фрагменту информации, мы сможем извлечь эту информацию с помощью функции _.get от Lodash. Джо набирает несколько символов на ноутбуке Тео. Тео поражен тем, как мало кода требуется, чтобы получить название книги. Листинг 3.6. Извлечение названия книги из ее информационного пути _.get(catalogData, ["booksByIsbn", "978-1779501127", "title"]) // → "Watchmen" Тео: Как лаконично. Интересно, насколько сложно было бы реализовать такую функцию, как _.get, самому. После нескольких минут проб и ошибок Тео смог создать свою реализацию. Он показывает Джо код. Листинг 3.7. Пользовательская реализация get function get(m, path) { var res = m; 1 Лапалиссада — это очевидная истина, трюизм или тавтология, которая производит комический эффект.
Глава 3. Основные манипуляции с данными 83 for(var i = 0; i < path.length; i++) {❶ var key = path[i]; res = res[key]; } return res; } ❶ Мы могли бы использовать forEach вместо цикла for. После тестирования реализации get, предложенной Тео, Джо хвалит Тео. Он благодарен, что Тео так быстро схватывает суть. Листинг 3.8. Тестирование пользовательской реализации get get(catalogData, ["booksByIsbn", "978-1779501127", "title"]); // → "Watchmen" Джо: Молодец! Тео: Интересно, работает ли такая функция, как _.get, хорошо на статически типизированном языке, таком как Java? Джо: Это зависит от того, нужно ли вам только передавать значение по кругу или обращаться к значению конкретно. Тео: Я не улавливаю сути. Джо: Представьте, что как только вы получаете название книги, вы хотите преобразовать строку в строку верхнего регистра. Вам нужно выполнить статическое приведение к String, верно? Здесь позвольте мне показать вам пример, который преобразует значение поля в строку, затем мы можем манипулировать им как строкой. Листинг 3.9. Преобразование значения поля в строку ((String)watchmen.get("title")).toUpperCase() Тео: В этом есть смысл. Значения карты имеют разные типы, поэтому компилятор объявляет ее как Map<String,Object>. Информация о типе поля теряется. Джо: Это немного раздражает, но довольно часто наш код просто передает данные по кругу. В этом случае нам не придется иметь дело со статическим приведением. Более того, в таком языке, как C#, при использовании типа dynamic данных можно избежать приведения типов2. 2 См. http://mng.bz/4jo5 — документацию по C# о встроенной ссылке на динамические типы. См. приложение A для получения подробной информации о динамических полях и приведении типов в C#.
84 Часть 1. Гибкость СОВЕТ. В языках со статической типизацией нам иногда требуется статически приводить значения полей. Тео: А как насчет производительности? Джо: В большинстве языков программирования карты довольно эффективны. Доступ к полю на карте происходит немного медленнее, чем доступ к члену класса. Обычно это не имеет значения. СОВЕТ. Нет существенного снижения производительности при доступе к полю на карте вместо того, чтобы быть членом класса. Тео: Давайте вернемся к этой идее информационного пути. Это работает и в ООП. Я мог бы получить доступ к названию книги «Хранители» с помощью catalogData.booksByIsbn["978- 1779501127"].title. Я бы использовал члены класса для полей записи и строки для ключей индекса. Джо: Однако есть фундаментальная разница. Когда записи представлены в виде карт, информация может быть извлечена по ее информационному пути с помощью универсальной функции, такой как _.get. Но когда записи представлены в виде объектов, вам нужно написать определенный код для каждого типа информационного пути. Тео: Что вы подразумеваете под конкретным кодом? Что конкретно в catalogData.books- ByIsbn["978-1779501127"].title? Джо: В статически типизированном языке, таком как Java, вам нужно было бы импортировать определения классов для Catalog и Book. Тео: И на динамически типизированном языке, таком как JavaScript?.. Джо: Даже в JavaScript, когда вы представляете записи с объектами, созданными из классов, вы не можете легко написать функцию, которая получает путь в качестве аргумента и отображает информацию, соответствующую этому пути. Вам пришлось бы написать определенный код для каждого типа пути. Вы получили бы доступ к членам класса с помощью точечных обозначений, а к полям карты — с помощью скобочных обозначений. Тео: Вы бы сказали, что в ДОП информационный путь — это первоклассный гражданин? Джо: Абсолютно! Информационный путь может быть сохранен в переменной и передан в качестве аргумента функции. СОВЕТ. В ДОП вы можете извлекать каждую часть информации с помощью пути и универсальной функции. Джо подходит к белой доске. Он рисует диаграмму, подобную приведенной на рис. 3.5, на которой данные каталога представлены в виде дерева. Джо: Видишь ли, Тео, каждая часть информации доступна по пути, состоящему из строк и целых чисел. Например, путь к первой книге Алана Мура — ["catalog", "authorsById", "alan-moore", "bookIsbns", 0].
Глава 3. Основные манипуляции с данными 85 Рис. 3.5. Данные каталога в виде дерева 3.4. Вычисление результатов поиска Тео: Интересно. Я начинаю чувствовать силу выражения ДОП! Джо: Подождите, это только начало. Позвольте мне показать вам, как просто написать код, который извлекает информацию о книге и отображает ее в результатах поиска. Можете ли вы точно сказать мне, какая информация должна отображаться в результатах поиска? Тео: Поиск информации о книге должен возвращать isbn, title и authorNames. Джо: И как будет выглядеть запись BookInfo для «Хранителей»? Тео быстро вводит код на своем ноутбуке. Затем он показывает его Джо. Листинг 3.10. Запись BookInfo для Watchmen в контексте результата поиска { "title": "Watchmen", "isbn": "978-1779501127", "authorNames": [ "Alan Moore", "Dave Gibbons", ] }
86 Часть 1. Гибкость Джо: Теперь я покажу вам шаг за шагом, как написать функцию, которая возвращает результаты поиска, соответствующие заголовку в формате JSON. Я буду использовать общие функции манипулирования данными из Lodash. Тео: Я готов! Джо: Начнем с функции authorNames, которая вычисляет имена авторов записи Book, просматривая индекс authorById. Не могли бы вы сказать мне, какой информационный путь для имени автора, чей идентификатор — authorId? Тео: Это ["authorsById", authorId, "name"]. Джо: Теперь позвольте мне показать вам, как получить имена нескольких авторов, используя _.map. Джо вводит код для сопоставления идентификаторов авторов с именами авторов. Тео с беспечным видом заглядывает через плечо Джо. Листинг 3.11. Сопоставление идентификаторов авторов с именами авторов _.map(["alan-moore", "dave-gibbons"], function(authorId) { return _.get(catalogData, ["authorsById", authorId, "name"]); }); // → ["Alan Moore", "Dave Gibbons"] Тео: Что это за функция _.map? Это пахнет функциональным программированием! Вы сказали, что мне не придется изучать ФП, чтобы реализовать ДОП! Джо: Нет необходимости изучать функциональное программирование, чтобы использовать _.map, который представляет собой функцию, преобразующую значения коллекции. Вы можете реализовать это с помощью простого цикла for. Тео проводит пару минут перед своим компьютером, выясняя, как реализовать _.map. Теперь у него есть это! Листинг 3.12. Пользовательская реализация map function map(coll, f) { var res = []; for(var i = 0; i < coll.length; i++) {❶ res[i] = f(coll[i]); } return res; } ❶ Мы могли бы использовать forEach вместо цикла for. После тестирования реализации карты Тео Джо показывает Тео тест. Джо снова делает комплимент Тео.
Глава 3. Основные манипуляции с данными 87 Листинг 3.13. Тестирование пользовательской реализации map map(["alan-moore", "dave-gibbons"], function(authorId) { return _.get(catalogData, ["authorsById", authorId, "name"]); }); // → [ "Alan Moore", "Dave Gibbons"] Джо: Отлично сработано! Тео: Ты был прав! Это было нетрудно. Джо: Теперь давайте реализуем authorNames, используя _.map. Тео требуется несколько минут, чтобы придумать реализацию authorNames. Закончив, он поворачивает свой ноутбук к Джо. Листинг 3.14. Вычисление имен авторов книги function authorNames(catalogData, book) { var authorIds = _.get(book, "authorIds"); var names = _.map(authorIds, function(authorId) { return _.get(catalogData, ["authorsById", authorId, "name"]); }); return names; } Тео: Нам также нужна функция bookInfo, которая преобразует запись книги в запись BookInfo. Позвольте мне показать вам код для этого. Листинг 3.15. Преобразование записи книги в запись BookInfo function bookInfo(catalogData, book) { var bookInfo = { "title": _.get(book, "title"), "isbn": _.get(book, "isbn"), "authorNames": authorNames(catalogData, book) }; return bookInfo; ❶ } ❶ Нет необходимости создавать класс для bookInfo. Тео: Глядя на код, я вижу, что запись BookInfo содержит три поля: title, isbn и authorNames. Есть ли способ получить эту информацию, не заглядывая в код? Джо: Вы можете либо добавить его в диаграмму объектов данных, либо записать в документацию функции bookInfo, либо и то и другое.
88 Часть 1. Гибкость Тео: Я должен привыкнуть к идее, что в ДОП информация о поле записи не является частью программы. Джо: Действительно, это не является частью программы, но это дает нам большую гибкость. Тео: Есть ли какой-нибудь способ для меня получить все и сразу? Джо: Да, и когда-нибудь я покажу вам, как сделать информацию о поле записи частью программы ДОП (см. главы 7 и 12). Тео: Звучит интригующе! Джо: Теперь, когда у нас есть все части на месте, мы можем написать нашу функцию searchBooksByTitle, которая возвращает информацию о книгах, соответствующих запросу. Сначала мы находим записи Book, которые соответствуют запросу с помощью _.filter, а затем преобразуем каждую запись Book в запись BookInfo с помощью _.map и bookInfo. Листинг 3.16. Поиск книг, соответствующих запросу function searchBooksByTitle(catalogData, query) { var allBooks = _.values(_.get(catalogData, "booksByIsbn")); var matchingBooks = _.filter(allBooks, function(book) { return _.get(book, "title").includes(query); ❶ }); var bookInfos = _.map(matchingBooks, function(book) { return bookInfo(catalogData, book); }); return bookInfos; } ❶ Функция includes JavaScript проверяет, содержит ли строка строку в качестве подстроки. Тео: Вы снова используете функции Lodash без каких-либо объяснений! Джо: Прости мне это. Я настолько привык к базовым функциям манипулирования данными, что рассматриваю их как часть языка. Какие функции являются новыми для вас? Тео: _.values и _.filter. Джо: Что ж, _.values возвращает коллекцию, состоящую из значений карты, а _.filter возвращает коллекцию, состоящую из значений, удовлетворяющих предикату. Тео: _.values кажутся тривиальными. Позвольте мне попробовать реализовать _.filter. Реализация _.filter занимает немного больше времени. В конце концов Тео удается сделать это правильно, и теперь он может ее протестировать.
Глава 3. Основные манипуляции с данными 89 Листинг 3.17. Пользовательская реализация filter function filter(coll, f) { var res = []; for(var i = 0; i < coll.length; i++) {❶ if(f(coll[i])) { res.push(coll[i]); } } return res; } ❶ Мы могли бы использовать forEach вместо цикла for. Листинг 3.18. Тестирование пользовательской реализации filter filter(["Watchmen", "Batman"], function (title) { return title.includes("Watch"); }); // → ["Watchmen"] Тео: Для меня немного странно, что для доступа к названию записи книги мне нужно написать _.get(book, "title"). Я бы ожидал, что это будет book.title в точечной записи или book ["title"] в скобочной записи. Джо: Помните, что book — это запись, которая не представлена как объект. Это карта. Действительно, в JavaScript вы можете написать _.get(book, "title"), book.title или book["title"]. Но я предпочитаю использовать Lodash функцию _.get. На некоторых языках обозначения в виде точек и скобок могут не работать на картах. Тео: Быть независимым от языка имеет свою цену! Джо: Хорошо, но не хотели бы вы протестировать searchBooksByTitle? Тео: Абсолютно! Позвольте мне вызвать searchBooksByTitle для поиска книг, название которых содержит строку Watch. Листинг 3.19. Тестирование searchBooksByTitle searchBooksByTitle(catalogData, "Wat"); //[ // { // "authorNames": [ // "Alan Moore", // "Dave Gibbons" // ], // "isbn": "978-1779501127",
90 Часть 1. Гибкость // "title": "Watchmen" // } //] Тео: Кажется, это работает! Мы закончили с реализацией поиска? Джо: Почти. Функция searchBooksByTitle, которую мы написали, будет частью модуля Catalog, и она возвращает коллекцию записей. Мы должны написать функцию, которая является частью модуля Library и которая возвращает строку JSON. Тео: Ранее вы говорили мне, что сериализация JSON в ДОП была простой. Джо: Это правда. Код для searchBooksByTitleJSON извлекает запись Catalog, передает ее в searchBooksByTitle и преобразует результаты в JSON с помощью JSON.stringify. Это часть JavaScript. Вот, позвольте мне показать. Листинг 3.20. Реализация поиска книг в библиотеке в формате JSON function searchBooksByTitleJSON(libraryData, query) { var results = searchBooksByTitle(_.get(libraryData, "catalog"), query); var resultsJSON = JSON.stringify(results); return resultsJSON; } Джо: Чтобы протестировать наш код, нам нужно создать запись Library, содержащую нашу запись Catalog. Не могли бы вы сделать это для меня, пожалуйста? Тео: Должна ли запись Library содержать все поля библиотеки (name, address и UserManagement)? Джо: В этом нет необходимости. На данный момент нам нужно только поле catalog, затем тест для поиска книг. Листинг 3.21. Запись Library var libraryData = { "catalog": { "booksByIsbn": { "978-1779501127": { "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authorIds": ["alan-moore", "dave-gibbons"], "bookItems": [ { "id": "book-item-1", "libId": "nyc-central-lib", "isLent": true },
Глава 3. Основные манипуляции с данными 91 { "id": "book-item-2", "libId": "nyc-central-lib", "isLent": false } ] } }, "authorsById": { "alan-moore": { "name": "Alan Moore", "bookIsbns": ["978-1779501127"] }, "dave-gibbons": { "name": "Dave Gibbons", "bookIsbns": ["978-1779501127"] } } } }; Листинг 3.22. Тест для поиска книг в библиотеке в формате JSON searchBooksByTitleJSON(libraryData, "Wat"); Тео: Как мы собираемся объединить четыре функции, которые мы написали ранее? Джо: Функции authorNames, bookInfo и searchBooksByTitle входят в модуль Catalog, а searchBooksByTitleJSON — в модуль Library. Листинг 3.23. Вычисление результатов поиска для Library и Catalog class Catalog { static authorNames(catalogData, book) { var authorIds = _.get(book, "authorIds"); var names = _.map(authorIds, function(authorId) { return _.get(catalogData, ["authorsById", authorId, "name"]); }); return names; } static bookInfo(catalogData, book) { var bookInfo = { "title": _.get(book, "title"), "isbn": _.get(book, "isbn"), "authorNames": Catalog.authorNames(catalogData, book) };❶ return bookInfo; }
92 Часть 1. Гибкость static searchBooksByTitle(catalogData, query) { var allBooks = _.get(catalogData, "booksByIsbn"); var matchingBooks = _.filter(allBooks, function(book) {❷ return _.get(book, "title").includes(query); }); var bookInfos = _.map(matchingBooks, function(book) { return Catalog.bookInfo(catalogData, book); }); return bookInfos; } } class Library { static searchBooksByTitleJSON(libraryData, query) var catalogData = _.get(libraryData, "catalog"); var results = Catalog.searchBooksByTitle(catalogData, query); var resultsJSON = JSON.stringify(results); ❸ return resultsJSON; } } ❶ Нет необходимости создавать класс для bookInfo. ❷ Когда _.filter передается карте, он перебирает значения карты. ❸ Преобразует данные в JSON (часть JavaScript). После тестирования окончательного кода в листинге 3.24 Тео снова просматривает исходный код из листинга 3.23. Через несколько секунд он чувствует, что у него снова начинается Ага!-момент. Листинг 3.24. Результаты поиска в формате JSON Library.searchBooksByTitleJSON(libraryData, "Watchmen"); // → "[{\"title\":\"Watchmen\",\"isbn\":\"978-1779501127\", // → \"authorNames\":[\"Alan Moore\",\"Dave Gibbons\"]}]" Тео: Важно не то, что код лаконичен, а то, что код не содержит абстракций. Это просто манипуляция данными! Джо отвечает улыбкой, которая говорит: «Ты понял, мой друг!» Джо: Это напоминает мне о том, что мой первый учитель медитации сказал мне 10 лет назад: медитация помогает уму воспринимать реальность такой, какая она есть, без абстракций, созданных нашими мыслями. СОВЕТ. В ДОП многие части нашей кодовой базы, как правило, касаются только манипулирования данными без каких-либо абстракций.
Глава 3. Основные манипуляции с данными 93 3.5. Обработка записей различных типов Мы видели, как ДОП позволяет нам относиться к записям как к первоклассным гражданам, которыми можно гибко манипулировать с помощью универсальных функций. Но если запись — это не более чем совокупность полей, как мы узнаем, каков тип записи? У ДОП есть удивительный ответ на этот вопрос. Тео: У меня есть вопрос. Если запись — это не более чем карта, как вы узнаете тип записи? Джо: Это отличный вопрос с неожиданным ответом. Тео: Удивите меня. Джо: В большинстве случаев нет необходимости знать тип записи. Тео: Что?! Как же так? Джо: Я имею в виду, что самое важное — это значения полей. Например, взгляните на исходный код Catalog.authorNames. Он работает с записью Book, но единственное, что имеет значение, — это значение поля authorIds. Сомневаясь, Тео смотрит на исходный код Catalog.authorNames. Это то, что видит Тео. Листинг 3.25. Вычисление имен авторов книги function authorNames(catalogData, book) { var authorIds = _.get(book, "authorIds"); var names = _.map(authorIds, function(authorId) { return _.get(catalogData, ["authorsById", authorId, "name"]); }); return names; } Тео: Как насчет различия между различными типами пользователей, такими как Member и Librarian? Я имею в виду, что у них обоих есть email и encrypttedPassword. Как вы узнаете, представляет ли запись Member или Librarian? Джо: Просто. Вы проверяете, найдена ли запись в индексе librariansByEmail или MembersByEmail в индексе Catalog. Тео: Не могли бы вы быть более конкретными? Джо: Конечно! Позвольте мне написать, как могли бы выглядеть данные управления пользователями нашей крошечной библиотеки, предполагая, что у нас есть один библиотекарь и один читатель. Чтобы упростить задачу, я шифрую пароли с помощью наивной кодировки base-64 для записи UserManagement. Листинг 3.26. Запись UserManagement var userManagementData = { "librariansByEmail": {
94 Часть 1. Гибкость "franck@gmail.com" : { "email": "franck@gmail.com", "encryptedPassword": "bXlwYXNzd29yZA=="❶ } }, "membersByEmail": { "samantha@gmail.com": { "email": "samantha@gmail.com", "encryptedPassword": "c2VjcmV0",❷ "isBlocked": false, "bookLendings": [ { "bookItemId": "book-item-1", "bookIsbn": "978-1779501127", "lendingDate": "2020-04-23" } ] } } } ❶ Кодировка base-64 «mypassword». ❷ Кодировка base-64 «секрет». СОВЕТ. В большинстве случаев нет необходимости знать тип записи. Тео: Этим утром вы сказали мне, что покажете мне код для функции UserManagement.IsLibrary сегодня днем. Джо: Итак, уже полдень, и я собираюсь выполнить свое обещание. Джо реализует isLibrarian. После небольшой паузы он затем выдает тест на isLibrarian. Листинг 3.27. Проверка того, является ли пользователь библиотекарем function isLibrarian(userManagement, email) { return _.has(_.get(userManagement, "librariansByEmail"), email); } Листинг 3.28. Тестирование isLibrarian isLibrarian(userManagementData, "franck@gmail.com"); // → true Тео: Я предполагаю, что _.has — это функция, которая проверяет, существует ли ключ на карте. Верно? Джо: Так.
Глава 3. Основные манипуляции с данными 95 Тео: Хорошо. Вы просто проверяете, содержит ли карта librariansByEmail поле email. Джо: Ага. Тео: Будете ли вы использовать тот же шаблон, чтобы проверить, является ли читатель Супер-читателем или VIP-читателем? Джо: Конечно. У нас могли бы быть индексы SuperMembersByEmail и VIPMembersByEmail. Но есть способ получше. Тео: Какой? Джо: Когда участник является VIP-читателем, мы добавляем в его запись поле isVIP со значением true. Чтобы проверить, является ли читатель VIP-читателем, мы проверяем, установлено ли для поля isVIP значение true в записи читателя. Вот как я бы закодировал isVIPMember. Листинг 3.29. Проверка того, является ли читатель VIP-читателем function isVIPMember(userManagement, email) { return _.get(userManagement, ["membersByEmail", email, "isVIP"]) == true; } Тео: Я вижу, что вы обращаетесь к полю isVIP через его информационный путь ["membersByEmail", email, "isVIP"]. Джо: Да, я думаю, что это делает код кристально понятным. Тео: Я согласен. Думаю, мы можем сделать то же самое для isSuperMember и установить для isSuperfield значение true, когда читатель является Супер-читателем? Джо: Да, именно так. Листинг 3.30. Код модуля UserManagement class UserManagement { isLibrarian(userManagement, email) { return _.has(_.get(userManagement, "librariansByEmail"), email); } isVIPMember(userManagement, email) { return _.get(userManagement, ["membersByEmail", email, "isVIP"]) == true; } isSuperMember(userManagement, email) { return _.get(userManagement, ["membersByEmail", email, "isSuper"]) == true; } }
96 Часть 1. Гибкость Тео пару секунд смотрит на код модуля UserManagement. Вдруг ему в голову приходит идея. Тео: Почему бы не иметь поле типа в записи читателя, значение которого будет либо VIP, либо Super? Джо: Я предполагаю, что, согласно требованиям продукта, читатель может быть как VIP, так и Super. Тео: Хм... тогда поле types может быть коллекцией, содержащей VIP или Super или и то и другое. Джо: В некоторых ситуациях полезно иметь поле types, но я считаю, что проще иметь логическое поле для каждой функции, поддерживаемой записью. Тео: Есть ли название для таких полей, как isVIP и isSuper? Джо: Я называю их функциональными полями. СОВЕТ. Вместо сохранения информации о типе записи используйте поле функции (например, isVIP). Тео: Можем ли мы использовать функциональные поля, чтобы различать библиотекарей и читателей? Джо: Вы имеете в виду наличие полей isLibrarian и isMember? Тео: Да, и иметь общий тип записи User как для библиотекарей, так и для читателей. Джо: Можно, но я думаю, что проще иметь разные типы записей для библиотекарей и читателей: Librarian для библиотекарей и Member для читателей. Тео: Почему? Джо: Потому что существует четкое различие между библиотекарями и читателями с точки зрения данных. Например, читатели могут брать книги напрокат, а библиотекари — нет. Тео: Я согласен. Теперь нам нужно упомянуть два поля функций Member на нашей диаграмме сущностей. При этом Тео добавляет эти поля к своей диаграмме на доске. Закончив, он показывает Джо свои дополнения (рис. 3.6). Джо: Вам нравится модель данных, которую мы разработали вместе? Тео: Я нахожу ее довольно простой и понятной. Джо: Это главная цель ДОП. Тео: Кроме того, я приятно удивлен тем, насколько легко адаптироваться к меняющимся требованиям с точки зрения как кода, так и модели данных. Джо: Я полагаю, вы также рады избавиться от сложных диаграмм иерархии классов. Тео: Абсолютно! Кроме того, я думаю, что обнаружил интересную связь между ДОП и медитацией.
Глава 3. Основные манипуляции с данными 97 Рис. 3.6. Модель данных управления библиотекой с полями характеристик Member, isVIP и isSuper Джо: Правда? Тео: Когда мы ужинали в Simple, вы сказали мне, что медитация помогает вам воспринимать реальность такой, какая она есть, без фильтра ваших мыслей. Джо: Это так. Тео: Из того, чему вы научили меня сегодня, я понимаю, что в ДОП нам рекомендуется рассматривать данные как данные без фильтра наших классов. Джо: Умно! Я никогда не замечал этой связи между этими двумя дисциплинами, которые так важны для меня. Я думаю, вы хотели бы продолжить свое путешествие в царство ДОП. Тео: Определенно. Давай встретимся завтра снова. Джо: К сожалению, завтра я везу свою семью на пляж, чтобы отпраздновать двенадцатый день рождения моей старшей дочери Аурелии. Тео: Мои поздравления! Джо: Мы могли бы встретиться снова в следующий понедельник, если ты не против. Тео: С удовольствием!
98 Часть 1. Гибкость Итоги  Принцип ДОП № 2 заключается в представлении объектов данных с помощью общих структур данных.  Мы называем карты, содержащие строки в качестве ключей, строковыми кар- тами.  Представление данных как данных означает представление записей с помощью строковых карт.  Под позиционной коллекцией мы подразумеваем коллекцию, в которой элементы расположены по порядку (например, список или массив).  Позиционная коллекция строк (Strings) помечается как [String].  Под индексом мы подразумеваем коллекцию, элементы которой доступны через ключ (например, хеш-карту или словарь).  Индекс книг отмечен как {Book}.  В контексте модели данных ключи индекса всегда являются строками.  Запись — это структура данных, которая группирует связанные элементы дан- ных. Это набор полей, возможно, разных типов данных.  Однородная карта — это карта, на которой все значения имеют один и тот же тип.  Гетерогенная карта — это карта, где значения относятся к разным типам.  В ДОП мы представляем запись в виде гетерогенной строковой карты.  Диаграмма объектов данных состоит из записей, значениями которых являются либо примитивы, позиционные коллекции, либо индексы.  Связь между записями на диаграмме сущностей данных является либо компози- цией, либо ассоциацией.  Информационная часть системы ДОП является гибкой, и каждая часть инфор- мации доступна по своему информационному пути.  В модели данных существует компромисс между гибкостью и безопасностью.  ДОП идет на компромисс с безопасностью данных, чтобы получить гибкость и универсальность.  В ДОП модель данных является гибкой. Мы можем свободно добавлять, удалять и переименовывать поля записи динамически во время выполнения.  Мы манипулируем данными с помощью универсальных функций.  Универсальные функции предоставляются либо самим языком, либо сторонни- ми библиотеками, такими как Lodash.  Сериализация JSON реализована в терминах универсальной функции.  С одной стороны, мы потеряли безопасность доступа к полям записи через эле- менты, определенные во время компиляции. С другой стороны, мы освободили
Глава 3. Основные манипуляции с данными 99 данные от ограничений классов и объектов. Данные представлены в виде данных!  Слабая зависимость между кодом и данными облегчает адаптацию к изменяю- щимся требованиям.  Когда данные представлены в виде данных, легко визуализировать системные данные.  Обычно нам не нужно сохранять информацию о типе записи.  Мы можем визуализировать любую часть системных данных.  В языках со статической типизацией нам иногда требуется статически приво- дить значения полей.  Вместо сохранения информации о типе записи мы используем поле объекта.  Нет существенного снижения производительности при доступе к полю на карте вместо члена класса.  В ДОП вы можете извлекать каждую часть информации с помощью информаци- онного пути и универсальной функции.  В ДОП многие части нашей кодовой базы, как правило, касаются только мани- пулирования данными без каких-либо абстракций. Функции Lodash, представленные в этой главе Функция Описание get(map, path) Получает значение map в path has(map, path) Проверяет, есть ли на карте поле в path merge(mapA, mapB) Создает карту, полученную в результате рекурсивных слияний между mapA и mapB values(map) Создает массив значений map filter(coll, pred) Выполняет итерацию по элементам coll, возвращая массив всех элементов, для которых pred возвращает true map(coll, f) Создает массив значений, запуская каждый элемент в coll через f
100 Часть 1. Гибкость
Управление состоянием Путешествие во времени В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Мультиверсионный подход к управлению состоянием.  Вычислительный этап изменения.  Фиксационный этап изменения.  Ведение истории предыдущих версий состояния. Мы уже увидели, как ДОП обрабатывает запросы с помощью универсальных функций, которые обращаются к системным данным, представленным в виде хешкарты. В этой главе мы проиллюстрируем, как ДОП имеет дело с изменениями (запросами, которые меняют состояние системы). Вместо того чтобы обновлять существующее состояние, мы будем сохранять несколько версий системных данных. В определенный момент состояние системы относится к определенной версии системных данных. Эта глава представляет собой глубокое погружение в третий принцип ДОП. ПРИНЦИП № 3. Данные неизменяемы. Для поддержания нескольких версий системных данных требуется, чтобы данные были неизменяемыми. Это подтверждает свою эффективность как с точки зрения вычислений, так и с точки зрения памяти при использовании приема под названием структурное совместное использование. В этом приеме части данных, которые являются общими для двух версий, используются совместно вместо того, чтобы копироваться. В ДОП изменение подразделяется на два отдельных этапа.  На этапе вычисления мы находим следующую версию системных данных.  На этапе фиксации мы перемещаем состояние системы вперед, чтобы оно обращалось к версии системных данных, найденной на этапе вычисления. Это отличие этапа вычисления от этапа фиксации позволяет нам свести к минимуму ту часть нашей системы, которая сохраняет состояние. Код только на этапе фик-
102 Часть 1. Гибкость сации зависит от состояния, в то время как код на этапе вычисления изменения не имеет состояния и состоит из универсальных функций, аналогичных коду для запроса. Реализация фиксационного этапа является общей для всех изменений. Как следствие, на этапе фиксации у нас есть возможность гарантировать, что состояние всегда обращается к валидной версии системных данных. Еще одним преимуществом такого подхода к управлению состоянием является то, что мы можем отслеживать историю предыдущих версий системных данных. Восстановить систему в предыдущее состояние (если это необходимо) будет очень просто. В табл. 4.1 показаны эти два этапа. Таблица 4.1. Два этапа изменения Этап Действие Состояние Реализация Вычисление Вычисляется следующая версия системных данных Без состояния Частная Фиксация Состояние системы перемещается вперед С сохранением состояния Общая В этой главе мы предполагаем, что в нашей системе изменения не происходят конкурентно. Управление конкурентностью мы рассмотрим в следующей главе. 4.1. Несколько версий системных данных Когда Джо приходит в офис в понедельник, он говорит Тео, что ему нужно размяться, прежде чем начинать работу с умом. Тео и Джо идут гулять по кварталу, и тема разговора переходит к системам управления версиями. Они обсуждают, что Git отслеживает всю историю фиксаций и что восстановить код в предыдущее состояние можно легко и быстро. Тео делится, что способность Git «путешествовать во времени» напоминает ему один из его любимых фильмов, «Назад в будущее», а Джо вспоминает, что месяц назад он смотрел трилогию «Назад в будущее» со своим 14-летним сыном Нерайей. Завершив прогулку, они возвращаются в офис Тео. Тео и Джо угощаются из кофемашины на кухне, прежде чем приступить к сегодняшнему уроку. Джо: Итак, мы уже видели, как в ДОП можно управлять запросами, которые извлекают информацию из системы. Теперь я покажу вам, как можно управлять изменениями. Под изменением я имею в виду операцию, которая меняет состояние системы.  ПРИМЕЧАНИЕ. Изменение — это операция, которая меняет состояние системы. Тео: Есть ли в ДОП фундаментальная разница между запросами и изменениями? В конце концов, все состояние системы представлено в виде хеш-карты. Я смог бы легко написать код, который изменяет часть хеш-карты, и он будет похож на код, который извлекает информацию из хеш-карты.
Глава 4. Управление состоянием 103 Джо: Вы могли бы изменить данные на месте, но тогда было бы сложно гарантировать, что код изменения не приведет систему к невалидной дате. Вы также потеряете возможность отслеживать предыдущие версии состояния системы. Тео: Понятно. Итак, как мы поступаем с изменениями в ДОП? Джо: Мы используем подход с несколькими версиями состояния, аналогичный тому, что делает система управления версиями, такая как Git; мы управляем различными версиями системных данных. В определенный момент состояние системы относится к версии системных данных. После внесения изменения мы перемещаем ссылку вперед. Тео: Я что-то не пойму: является ли состояние системы изменяемым или неизменяемым? Джо: Данные неизменяемы, но ссылка на состояние изменяема. СОВЕТ. Данные неизменяемы, но ссылка на состояние изменяема. Заметив замешательство на лице Тео, Джо быстро рисует на доске схему. Затем он показывает Тео рис. 4.1, надеясь, что ему все станет ясно. Рис. 4.1. После внесения изменения B состояние системы относится к версии данных V12. После внесения изменения C состояние системы ссылается на данные V13 Тео: Означает ли это, что перед запуском кода изменения мы создаем копию системных данных? Джо: Нет, это было бы неэффективно, т. к. нам пришлось бы сделать глубокую копию данных. Тео: Как же тогда это работает?
104 Часть 1. Гибкость Джо: Это работает с помощью приема под названием «структурное совместное использование», при котором большая часть данных между последующими версиями состояния используется совместно, а не копируется. В результате использования этого приема эффективно создаются новые версии системных данных как с точки зрения памяти, так и с точки зрения вычислений. Тео: Я заинтригован. СОВЕТ. При помощи структурного совместного использования можно эффективно (с точки зрения памяти и вычислений) создавать новые версии данных. Джо: Сейчас я подробно объясню, как работает структурное использование. Тео еще раз смотрит на диаграмму с рис. 4.1, которая иллюстрирует, как состояние системы относится к версии системных данных. Внезапно у него возникает вопрос. Тео: А предыдущие версии системных данных сохраняются? Джо: В простом приложении предыдущие версии автоматически удаляются программой сборки мусора. Но в некоторых случаях мы сохраняем исторические ссылки на предыдущие версии данных. Тео: В каких именно случаях? Джо: Например, если мы хотим, чтобы в нашей системе, как в Git, поддерживались путешествия во времени, мы можем легко вернуть систему к предыдущей версии состояния. Тео: Теперь я понял, что вы имеете в виду, говоря, что данные неизменяемы, но ссылка на состояние изменяема! 4.2. Структурное совместное использование Как упоминалось в предыдущем разделе, структурное совместное использование позволяет эффективно создавать новые версии неизменяемых данных. В ДОП мы используем структурное совместное использование на вычислительном этапе изменения для того, чтобы найти следующее состояние системы на основе текущего состояния. Внутри вычислительного этапа нам не нужно иметь дело с управлением состоянием; это откладывается до этапа фиксации. Как следствие, код, участвующий в вычислительном этапе изменения, не имеет состояния и так же прост, как код запроса. Тео: Я действительно заинтригован этим более эффективным способом создания новых версий данных. Как он работает? Джо: Давайте возьмем простой пример из нашей библиотечной системы. Представьте, что вы хотите изменить значение поля в книге из каталога; например, год издания комикса «Хранители». Можете ли вы сообщить мне информационный путь для года издания «Хранителей»? Тео бросает быстрый взгляд на данные каталога на рис. 4.2. Затем он отвечает на вопрос Джо.
Глава 4. Управление состоянием 105 Рис. 4.2. Визуализация данных из каталога. Узлы в информационном пути к году публикации «Хранителей» отмечены пунктирной рамкой Тео: Информационный путь для года публикации «Хранителей»: ["catalog", "booksByIsbn", "978-1779501127", "publicationYear"]. Джо: Теперь позвольте мне показать, как можно использовать неизменяемую функцию _.set, которую также предоставляет библиотека Lodash. Тео: Подождите! Почему вы говорите, что эта функция неизменяемая? В документации Lodash для _.set на их веб-сайте написано, что она изменяет объект. Джо: Вы правы, просто по умолчанию функции Lodash не являются неизменяемыми. Чтобы применить неизменяемую версию функций, нужно использовать модуль Lodash FP, как описано в руководстве по Lodash FP.  ПРИМЕЧАНИЕ. По ссылке https://lodash.com/docs/4.17.15#set можно просмотреть документацию Lodash для функции _.set, а на https://github.com/lodash/lodash/wiki/FP-Guide вы найдете руководство по Lodash FP. Тео: Имеют ли неизменяемые функции ту же сигнатуру, что и изменяемые функции? Джо: По умолчанию порядок аргументов в неизменяемых функциях перемешивается. Руководство Lodash FP объясняет, как решить эту проблему. При использовании следующего фрагмента кода сигнатура неизменяемых функций будет точно такой же, как и у изменяемых.
106 Часть 1. Гибкость Листинг 4.1. Настройка Lodash таким образом, чтобы неизменяемые и изменяемые функции имели одинаковую сигнатуру _ = fp.convert({ "cap": false, "curry": false, "fixed": false, "immutable": true, "rearg": false }); СОВЕТ. Чтобы использовать неизменяемые функции Lodash, примените модуль Lodash FP и настройте его таким образом, чтобы сигнатура неизменяемых функций была такой же, как в документации на сайте Lodash. Тео: В общем и целом все еще можно полагаться на документацию Lodash при использовании неизменяемых версий функций. Джо: За исключением отрывка в документации, в котором говорится, что функция изменяет объект. Тео: Конечно! Джо: Теперь я покажу вам, как написать код, который создает версию библиотечных данных с неизменяемой функцией _.set. Пальцы Джо порхают по клавиатуре Тео. Затем Тео просматривает получившийся код, создающий версию библиотечных данных, в которой год издания «Хранителей» установлен на 1986 год. Листинг 4.2. Использование _.set в качестве неизменяемой функции var nextLibraryData = _.set(libraryData, ["catalog", "booksByIsbn", "978-1779501127", "publicationYear"], 1986);  ПРИМЕЧАНИЕ. Функция считается неизменяемой, когда вместо изменения данных она создает новую версию данных без изменения тех данных, которые она получает. Тео: Вы уже говорили, что структурное совместное использование позволяет неизменяемым функциям быть эффективными с точки зрения памяти и вычислений. Скажите, а что именно делает их эффективными? Джо: С удовольствием, но перед этим вы должны ответить на ряд вопросов. Готовы? Тео: Ну да... Джо: На какую часть библиотечных данных влияет обновление года издания «Хранителей»: на UserManagement или на Catalog?
Глава 4. Управление состоянием 107 Тео: Только на Catalog. Джо: На какую часть Catalog? Тео: Только на индекс booksByIsbn. Джо: На какую часть индекса booksByIsbn? Тео: Только на запись Book, содержащую информацию о «Хранителях». Джо: На какую часть записи Book? Тео: Только на поле publicationYear. Джо: Прекрасно! Теперь предположим, что текущая версия библиотечных данных выглядит следующим образом. Джо подходит к доске и рисует диаграмму. Результат показан на рис. 4.3. Рис. 4.3. Высокоуровневая визуализация текущей версии Library Тео: Пока все в порядке... Джо: Далее, позвольте мне показать вам, что делает неизменяемая функция, когда вы используете ее для создания новой версии Library, в которой год издания «Хранителей» установлен на 1986 вместо 1987. Джо обновляет свою диаграмму (рис. 4.4). Тео: А не могли бы вы объяснить?
108 Часть 1. Гибкость Рис. 4.4. Совместное структурное использование обеспечивает эффективный способ создания новой версии данных. Next Library рекурсивно состоит из узлов, которые используют части Library, которые являются общими для двух версий Джо: Неизменяемая функция создает новую хеш-карту Library, которая рекурсивно использует части текущей версии Library, которые являются общими для двух версий, вместо их глубокого копирования. Тео: Для меня это немного абстрактно. Джо: Следующая версия Library использует ту же хеш-карту UserManagament, что и старая версия. Catalog внутри следующей Library использует тот же индекс authorsById, что и текущий Catalog. Запись Book для «Хранителей» в следующей версии Catalog использует все поля текущей записи Book, за исключением поля publicationYear. Тео: Итак, на самом деле большая часть данных является общей для двух версий. Верно? Джо: Абсолютно! Вот почему этот прием называется структурным совместным использованием. СОВЕТ. Структурное совместное использование обеспечивает эффективный способ (как с точки зрения памяти, так и с точки зрения вычислений) создания новой версии данных путем рекурсивного совместного использования частей, которым не нужно изменяться. Тео: Очень круто! Джо: И в самом деле. Теперь давайте посмотрим, как написать изменение для добавления читателя библиотеки, используя неизменяемые функции.
Глава 4. Управление состоянием 109 Джо снова подходит к доске. На рис. 4.5 показана диаграмма, которую Джо рисует, чтобы проиллюстрировать, как выглядит структурное совместное использование при добавлении нового читателя библиотеки. Рис. 4.5. Добавление читателя библиотеки при помощи структурного совместного использования. Большая часть данных является общей для двух версий Тео: Потрясающе! Хеш-карты Catalog и librarians копировать не обязательно! Джо: Теперь с точки зрения кода мы должны написать функцию Library.addMember, которая делегирует задачу UserManagement.addMember. Тео: Предполагаю, это будет похоже на код, который мы писали ранее для реализации поискового запроса на книги, где Library.searchBooksByTitleJSON делегирует задачу Catalog.searchBooksByTitle. Джо: Будет похоже в том смысле, что все функции статичны и получают данные, которыми они манипулируют, в качестве аргумента. Но есть два отличия. Во-первых, изменение может завершиться неудачей, например, если добавляемый элемент уже существует. Во-вторых, код для Library.addMember немного более сложный, чем код для Library.searchBooksByTitleJSON, потому что мы должны создать новую версию Library, которая ссылается на новую версию UserManagement. Давайте я покажу вам пример. Листинг 4.3. Код для изменения, добавляющего читателя библиотеки UserManagement.addMember = function(userManagement, member) { var email = _.get(member, "email"); var infoPath = ["membersByEmail", email]; if(_.has(userManagement, infoPath)) { ❶ throw "Member already exists."; } var nextUserManagement = _.set( userManagement, ❷
110 Часть 1. Гибкость infoPath, member); return nextUserManagement; }; Library.addMember = function(library, member) { var currentUserManagement = _.get(library, "userManagement"); var nextUserManagement = UserManagement.addMember( currentUserManagement, member); var nextLibrary = _.set(library, "userManagement", nextUserManagement); ❸ return nextLibrary; }; ❶ Проверяет, имеется ли уже существующий читатель с таким адресом электронной почты. ❷ Создает новую версию UserManagement, которая включает в себя нового читателя. ❸ Создает новую версию библиотеки, содержащую новую версию UserManagement. Тео: Для меня немного странно, что неизменяемые функции возвращают обновленную версию данных вместо того, чтобы изменять их на месте. Джо: Для меня это тоже было странно, когда я впервые столкнулся с неизменяемыми данными в Clojure семь лет назад. Тео: Сколько времени вам потребовалось, чтобы привыкнуть к этому? Джо: Пара недель. 4.3. Реализация структурного совместного использования Когда Джо уходит, возле кофемашины Тео встречает Дейва. Дейв выглядит озадаченным. Дейв: Что за парень только что вышел из офиса? Тео: Это Джо. Мой наставник по ДОП. Дейв: А что такое ДОП? Тео: ДОП — это программирование, ориентированное на данные. Дейв: Никогда раньше не слышал такого термина. Тео: Это довольно мощная парадигма программирования, но программисты о ней пока что мало знают. Насколько я понял, ДОП значительно упрощает программирование. Дейв: А ты можешь привести мне пример? Тео: Я только что узнал о структурном совместном использовании и о том, как оно позволяет эффективно создавать новые версии данных без копирования. Дейв: Как это работает?
Глава 4. Управление состоянием 111 Тео ведет Дейва в свой офис и показывает ему схему, оставленную Джо на доске (рис. 4.6). В течение нескольких минут Тео объясняет Дейву, что именно происходит на схеме, и в конце концов Дейв вник в суть. Дейв: Как реализуется структурное совместное использование? Тео: Я не знаю. Я использовал функцию _.set из Lodash. Дейв: Звучит как интересный вызов. Тео: Принимай его, если хочешь. А я слишком устал, чтобы заниматься этими рекурсивными алгоритмическими штучками. Рис. 4.6. Структурное совместное использование в действии На следующий день Тео задерживается у офисной кабинки Дейва, прежде чем отправиться к себе в офис. Дейв с гордостью показывает Тео свою реализацию структурного совместного использования. Тео поражает то, что это всего лишь 11 строк кода JavaScript! Листинг 4.4. Реализация структурного совместного использования function setImmutable(map, path, v) { var modifiedNode = v; var k = path[0]; var restOfPath = path.slice(1); if (restOfPath.length > 0) { modifiedNode = setImmutable(map[k], restOfPath, v); }
112 Часть 1. Гибкость var res = Object.assign({}, map); ❶ res[k] = modifiedNode; return res; } ❶ Поверхностно копирует карту в JavaScript. Тео: Дейв, да это же гениально! Дейв (улыбаясь): Да ладно тебе. Тео: Так, мне нужно идти. Я опаздываю на встречу с Джо! Он, наверное, уже сидит в моем кабинете и ногти грызет от нетерпения. 4.4. Безопасность данных Джо готов начать дневной урок. Но сперва Тео задает ему вопрос по вчерашнему материалу. Тео: Мне кое-что непонятно относительно этого самого структурного совместного использования. Что произойдет, если мы напишем код, который изменяет часть данных, которая является общей для двух версий данных? Повлияет ли это изменение на обе версии? Джо: Не могли бы вы, пожалуйста, написать фрагмент кода, который иллюстрирует ваш вопрос? Тео начинает печатать на своем ноутбуке. Он придумывает такой код, который проиллюстрирует изменение части данных, совместно используемых двумя версиями. Листинг 4.5. Изменение данных, совместно используемых двумя версиями var books = { "978-1779501127": { "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authorIds": ["alan-moore", "dave-gibbons"] } }; var nextBooks = _.set(books, ["978-1779501127", "publicationYear"], 1986) console.log("Before:", nextBooks["978-1779501127"]["authorIds"][1]); books["978-1779501127"]["authorIds"][1] = "dave-chester-gibbons"; console.log("After:", nextBooks["978-1779501127"]["authorIds"][1]); // → Before: dave-gibbons // → After: dave-chester-gibbons
Глава 4. Управление состоянием 113 Тео: Мой вопрос: каково значение isBlocked в updatedMember? Джо: Ответ заключается в том, что изменение данных с помощью нативного установщика хеш-карты запрещено. Все манипуляции с данными должны выполняться с помощью неизменяемых функций.  ПРИМЕЧАНИЕ. Все манипуляции с данными должны выполняться с помощью неизменяемых функций. Запрещено использовать нативный установщик хеш-карты. Тео: Когда вы говорите «запрещено», вы имеете в виду, что разработчик должен сам убедиться, что этого не произойдет. Верно? Джо: Именно. Тео: Есть ли способ защитить систему от ошибки разработчика? Джо: Да, есть способ обеспечить неизменяемость данных на уровне структуры данных. Это называется персистентными структурами данных. Тео: Являются ли персистентные структуры данных также эффективными с точки зрения памяти и вычислений? Джо: На самом деле то, как данные организованы внутри персистентных структур данных, делает их даже более эффективными, чем неизменяемые функции. СОВЕТ. Персистентные структуры данных неизменяемы на уровне данных. Их невозможно изменить даже по ошибке. Тео: Существуют ли библиотеки, предоставляющие персистентные структуры данных? Джо: Определенно. У меня на компьютере как раз имеется список этих библиотек. Джо, человек вполне организованный (для программиста), быстро находит этот список. Он показывает его Тео:  Immutable.js в JavaScript на https://immutable-js.com/;  Paguro в Java на https://github.com/GlenKPeterson/Paguro;  Immutable Collections в C# на http://mng.bz/y4Ke;  Pyrsistent в Python на https://github.com/tobgu/pyrsistent;  Hamster in Ruby на https://github.com/hamstergem/hamster. Тео: Почему бы не использовать персистентные структуры данных вместо неизменяемых функций? Джо: Недостаток персистентных структур данных заключается в том, что они не являются нативными. Это означает, что для работы с ними требуется преобразование из нативного в персистентное и из персистентного в нативное. Тео: Какой подход вы бы порекомендовали? Джо: Если хотите побаловаться, начинайте с неизменяемых функций. Но для рабочего приложения я бы рекомендовал использовать персистентные структуры данных.
114 Часть 1. Гибкость Тео: Жаль, что нативные структуры данных не являются персистентными! Джо: Это одна из причин, по которой я люблю Clojure: нативные структуры данных в этом языке неизменяемы! 4.5. Фиксационный этап изменения Итак, мы рассмотрели реализацию вычислительного этапа изменения. Этап вычисления не имеет состояния в том смысле, что в ходе него не вносится никаких изменений в систему. Теперь давайте посмотрим, как обновить состояние системы внутри этапа фиксации. Тео еще раз смотрит на код для Library.addMember. Его кое-что беспокоит: эта функция возвращает новое состояние библиотеки, содержащее дополнительного читателя, но это не влияет на текущее состояние библиотеки. Листинг 4.6. Этап фиксации перемещает состояние системы вперед Library.addMember = function(library, member) { var currentUserManagement = _.get(library, "userManagement"); var nextUserManagement = UserManagement.addMember( currentUserManagement, member); var nextLibrary = _.set(library, "userManagement", nextUserManagement); return nextLibrary; }; Тео: Как я вижу, Library.addMember не изменяет состояние библиотеки. Как же обновляется состояние библиотеки? Джо: Это очень хороший вопрос. Library.addMember имеет дело только с вычислением данных и не имеет состояния. Состояние обновляется на этапе фиксации путем перемещения вперед версии состояния, на которую ссылается состояние системы. Тео: Что вы имеете в виду? Джо: Вот что происходит, когда мы добавляем в систему читателя. На этапе вычисления создается версия состояния, состоящая из двух читателей. Перед этапом фиксации состояние системы ссылается на версию состояния с одним элементом. На этапе фиксации происходит перемещение состояния системы вперед так, чтобы оно ссылалось на версию состояния с двумя элементами. СОВЕТ. На этапе фиксации происходит перемещение состояния системы вперед к версии состояния, возвращенной этапом вычисления. Джо рисует на доске еще одну иллюстрацию (рис. 4.7). Он надеется, что это поможет прояснить любые недоразумения, которые могут возникнуть у Тео.
Глава 4. Управление состоянием 115 Рис. 4.7. Этап фиксации перемещает состояние системы вперед Тео: А как это реализуется? Джо: Код состоит из двух классов: System, одноэлементного класса с сохранением состояния, который реализует изменения, и SystemState, одноэлементного класса с сохранением состояния, который управляет состоянием системы. Тео: Для меня это звучит как классическое ООП. Джо: Верно, и то, что эта часть системы сохраняет состояние, тоже напоминает ООП. Тео: Рад видеть, что вы все еще видите какую-никакую пользу в ООП. Джо: Медитации научили меня тому, что каждая часть нашей вселенной играет свою роль. Тео: Неплохо! Не могли бы вы показать мне какой-нибудь код? Джо: Конечно. Джо на мгновение задумывается, прежде чем начать печатать. Он хочет показать класс System и его реализацию изменения addMember. Листинг 4.7. Класс System class System { addMember(member) { var previous = SystemState.get(); var next = Library.addMember(previous, member); SystemState.commit(previous, next); ❶ } } ❶ Класс SystemState разобран в листинге 4.8. Тео: Как выглядит SystemState? Джо: У меня было предчувствие, что вы об этом спросите. Вот код для класса SystemState, который является классом с сохранением состояния!
116 Часть 1. Гибкость Листинг 4.8. Класс SystemState class SystemState { systemState; get() { return this.systemState; } commit(previous, next) { this.systemState = next; } } Тео: Я не понимаю, в чем смысл SystemState. Это простой класс с геттером и функцией фиксации, верно? Джо: Через мгновение мы обогатим код метода SystemState.commit так, чтобы он обеспечивал валидацию данных и отслеживание истории. А прямо сейчас важно отметить, что код этапа вычисления не имеет состояния и отделен от кода этапа фиксации, который сохраняет состояние. СОВЕТ. Этап вычисления не имеет состояния. Этап фиксации сохраняет состояние. 4.6. Обеспечение целостности состояния системы Тео: Меня все еще беспокоит то, что функции манипулируют неизменяемыми данными на этапе вычисления. Как нам сохранить целостность данных? Джо: Что вы имеете в виду? Тео: В ООП данными манипулируют только методы, принадлежащие к тому же классу, что и сами данные. Это предотвращает повреждение внутреннего состояния класса другими классами. Джо: Не могли бы вы привести мне пример невалидного состояния библиотеки? Тео: Представьте, что код изменения добавляет запись в список взятых читателем книг, не помечая эту книгу как взятую для чтения в каталоге. Тогда системные данные будут повреждены. Джо: В ДОП мы спокойно можем обеспечивать целостность данных на уровне всей системы вместо того, чтобы распределять валидацию между многими классами. Тео: Как это работает? Джо: Тот факт, что код для этапа фиксации является общим для всех изменений, позволяет нам валидировать системные данные в одном централизованном месте. В начале этапа фиксации выполняется шаг, который проверяет, является ли версия состояния системы, подлежащая фиксации, валидной. Если данные невалидны, фиксация отклоняется. Давайте я вам покажу.
Глава 4. Управление состоянием 117 Листинг 4.9. Валидация данных на этапе фиксации SystemState.commit = function(previous, next) { if(!SystemValidity.validate(previous, next)) { // not implemented for now throw "The system data to be committed is not valid!"; }; this.systemData = next; }; Тео: Это похоже на хук фиксации в Git. Джо: Мне нравится ваша аналогия! Тео: Почему вы передаете предыдущее состояние из previous и следующее состояние из next в SystemValidity.validate? Джо: Потому что это позволяет SystemValidity.validate оптимизировать валидацию с точки зрения вычислений. Например, мы можем валидировать только те данные, которые изменились. СОВЕТ. В ДОП мы валидируем системные данные в целом. Валидация данных отделена от манипулирования данными. Тео: Как выглядит код SystemValidity.validate? Джо: Когда-нибудь я покажу вам, как определить схему данных и проверить, соответствует ли фрагмент данных схеме.  ПРИМЕЧАНИЕ. Смотрите главы 7 и 12, чтобы увидеть, как именно Джо определяет схему данных. 4.7. Восстановление предыдущих состояний Еще одно преимущество мультиверсионного подхода с неизменяемыми данными, которыми манипулируют с помощью структурного совместного использования, заключается в том, что мы можем отслеживать историю всех версий данных, не перегружая память нашей программы. Это позволяет нам, например, легко восстановить систему до более раннего состояния. Тео: Вы упоминали, что можно легко восстановить систему до предыдущего состояния. Не могли бы вы показать мне, как это делается? Джо: С радостью, но перед этим я хочу убедиться, что вы понимаете, почему отслеживание всех версий данных эффективно с точки зрения памяти. Тео: Я думаю, это связано с тем фактом, что неизменяемые функции используют структурное совместное использование и большая часть данных между последующими версиями состояния является общей. СОВЕТ. Структурное совместное использование позволяет сохранять множество версий состояния системы без чрезмерного использования памяти.
118 Часть 1. Гибкость Джо: Превосходно! Теперь я покажу вам, как просто можно отменить изменение. Чтобы реализовать механизм отмены, наш класс SystemState должен иметь две ссылки на системные данные: systemData ссылается на текущее состояние системы, а previousSystemData ссылается на предыдущее состояние системы. Тео: В этом есть смысл. Джо: На этапе фиксации мы обновляем как previousSystemData, так и systemData. Тео: Что именно нужно, чтобы реализовать механизм отмены? Джо: Отмена достигается за счет того, что systemData ссылается на ту же версию системных данных, что и previousSystemData. Тео: Не могли бы вы показать это на примере? Джо: Чтобы упростить задачу, я присвою каждой версии состояния системы свой номер. Нумерация будет начинаться с V0, и каждый раз, когда фиксируется изменение, версия приращивается: V1, V2, V3 и т. д. Тео: ОК. Джо: Предположим, что в настоящее время наше системное состояние — номер V12 (рис. 4.8). В объекте SystemState systemData ссылается на V12, а previousSystemData ссылается на V11. Рис. 4.8. Когда состояние системы пронумеровано как V12, systemData ссылается на V12, а previousSystemData ссылается на V11 Тео: Пока все ясно... Джо: А теперь, когда фиксируется изменение (например, добавляется элемент), обе ссылки перемещаются вперед: systemData ссылается на V13, а previousSystemData ссылается на V12. Джо стирает с доски, чтобы освободить место для другой диаграммы (рис. 4.9). Когда он заканчивает новый рисунок, он показывает его Тео. Рис. 4.9. Когда фиксируется изменение, systemData ссылается на V13, а previousSystemData ссылается на V12
Глава 4. Управление состоянием 119 Тео: Предполагаю, что, когда мы отменяем изменение, обе ссылки перемещаются назад. Джо: Теоретически да, но на практике необходимо поддерживать стек всех ссылок на состояние. На данный момент, чтобы упростить ситуацию, мы сохраним только ссылку на предыдущую версию. Как следствие, когда мы отменяем изменение, обе ссылки указывают на V12. Сейчас я нарисую еще одну диаграмму, которая показывает это состояние (рис. 4.10). Рис. 4.10. Когда изменение отменяется, как systemData, так и previousSystemData ссылаются на версию 12 Тео: Не могли бы вы показать, как реализовать этот механизм отмены? Джо: На самом деле для этого требуется всего пара изменений в классе SystemState. Обратите внимание на изменения в функции commit. Внутри systemDataBeforeUpdate мы сохраняем ссылку на текущее состояние системы. Если валидация и разрешение конфликта завершаются успешно, мы обновляем как previousSystemData, так и systemData. Листинг 4.10. Класс SystemState с возможностью отмены class SystemState { systemData; previousSystemData; get() { return this.systemData; } commit(previous, next) { var systemDataBeforeUpdate = this.systemData; if(!Consistency.validate(previous, next)) { throw "The system data to be committed is not valid!"; } this.systemData = next; this.previousSystemData = systemDataBeforeUpdate; } undoLastMutation() { this.systemData = this.previousSystemData; } }
120 Часть 1. Гибкость Тео: Как я понял, реализация System.undoLastMutation — это всего лишь вопрос того, чтобы systemData ссылался на то же значение, что и previousSystemData. Джо: Как я уже упоминал, если нам нужно провести несколько отмен, код будет немного сложнее, но идею вы поняли. Тео: Кажется, понял. Фильм «Назад в будущее» относится к области научной фантастики, а вот в ДОП путешествия во времени реальны. Итоги  Принцип ДОП № 3 гласит, что данные неизменяемы.  Изменение — это операция, которая изменяет состояние системы.  При мультиверсионном подходе к управлению состоянием мутации подразде- ляются на этап вычисления и этап фиксации.  Все манипуляции с данными должны выполняться с помощью неизменяемых функций. Запрещено использовать нативный установщик хеш-карты.  Структурное совместное использование позволяет эффективно создавать новые версии данных (с точки зрения памяти и вычислений), где данные, которые являются общими для двух версий, используются совместно, а не копируются.  При применении структурного совместного использования создается новая вер- сия данных путем рекурсивного совместного использования частей, которым не нужно изменяться.  Изменение происходит в два этапа: вычисление и фиксация.  Функция считается неизменяемой, когда вместо изменения данных она создает новую версию данных без изменения тех, которые она получает.  На этапе вычисления данные обрабатываются с помощью неизменяемых функ- ций, которые используют структурное совместное использование.  Этап вычисления не имеет состояния.  На этапе фиксации обновляется состояние системы.  На этапе фиксации происходит перемещение состояния системы вперед к вер- сии состояния, возвращаемой фазой вычисления.  Данные неизменяемы, но ссылка на состояние изменяется.  Этап фиксации сохраняет состояние.  Системные данные валидируются в целом. Валидация данных отделена от ма- нипуляции данными.  Тот факт, что код для этапа фиксации является общим для всех изменений, по- зволяет валидировать состояние системы в одном централизованном месте, прежде чем обновится состояние системы.
Глава 4. Управление состоянием 121  Хранение истории версий системных данных экономит память благодаря структурному совместному использованию.  Восстановить систему до одного из ее предыдущих состояний легко благодаря четкому разделению между этапом вычисления и этапом фиксации.  Для того чтобы использовать неизменяемые функции библиотеки Lodash, необ- ходимо использовать модуль Lodash FP (https://github.com/lodash/lodash/wiki/ FP-Guide). Функции Lodash, представленные в этой главе Функция Описание set(map, path, value) Создает карту с теми же полями, что и map, с добавлением поля <path, value>
122 Часть 1. Гибкость
Основы контроля конкурентности Семейные конфликты В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Управление конкурентными изменениями с помощью оптимистичной стратегии управления конкурентностью без блокировок.  Поддержка высокой пропускной способности операций чтения и записи.  Согласование конкурентных изменений. Изменения, необходимые для управления конкурентностью в системе, происходят только на этапе фиксации. Они включают в себя алгоритм согласования, который является универсальным в том смысле, что его можно использовать в любой системе, где данные представлены в виде неизменяемой хеш-карты. Реализация алгоритма согласования эффективна, поскольку последующие версии состояния системы создаются с помощью структурного совместного использования. В предыдущей главе мы проиллюстрировали мультиверсионный подход к управлению состоянием, при котором изменение состояния подразделяется на два отдельных этапа. Первый — этап вычисления, который касается только вычислений. Второй — этап фиксации, на котором ссылка на состояние продвигается вперед. Обычно в системе во время эксплуатации такие изменения происходят конкурентно. Просто взять и продвинуть состояние вперед, как мы это делали в предыдущей главе, неуместно. В настоящей главе мы узнаем, как обрабатывать конкурентные изменения. В ДОП, поскольку только код этапа фиксации отслеживает состояние, мы можем использовать оптимистичную стратегию контроля конкурентности, которая не включает в себя механизмы блокировки. Как следствие, пропускная способность операций чтения и записи высока. Изменения в коде довольно значительны, поскольку мы должны реализовать алгоритм, который согласовывает конкурентные изменения. Но эти изменения влияют только на этап фиксации. Код для этапа вычисления остается тем же, что и в предыдущей главе.
124 Часть 1. Гибкость  ПРИМЕЧАНИЕ. Чтобы понять эту главу, потребуется больше усилий. Блок-схема алгоритма согласования непроста, а реализация включает в себя сложную рекурсию. 5.1. Оптимистичный контроль конкурентности Этим утром, прежде чем приступить к работе, Тео приглашает Джо в фитнес-зал в офисе, и во время бега на степ-тренажере двое мужчин снова говорят о своей личной жизни. Джо рассказывает о ссоре, которая произошла у него вчера вечером с Кей, которая думает, что он уделяет больше внимания своей работе, чем своей семье. Тео рассказывает о болезненном конфликте, который у него был с Джейн, его женой, по поводу управления домашним бюджетом. Они были на приеме у семейного психотерапевта, специалиста по имаготерапии. Имаготерапия позволила им превратить свой конфликт в возможность расти и исцеляться. Джо навострил уши, когда услышал слово «конфликт», потому что сегодняшний урок будет посвящен разрешению конфликтов и конкурентных изменений. Однако это конфликты другого рода... После душа и полезного завтрака Тео и Джо приступают к работе. Джо: Вчера я показал вам, как управлять состоянием с помощью неизменяемых данных, предполагая, что изменения не происходят конкурентно. Сегодня я покажу вам, как в ДОП контролировать конкурентность. Тео: Мне любопытно узнать, какие механизмы блокировки вы используете в ДОП для синхронизации конкурентных изменений. Джо: На самом деле мы не используем механизмы блокировки! Тео: А почему? Джо: Блокировки вредят производительности, и если вы не будете осторожны, в системе произойдет взаимоблокировка. Тео: Итак, как вы справляетесь с возможными конфликтами между конкурентными изменениями в ДОП? Джо: В ДОП мы используем стратегию без блокировок, называемую оптимистичным контролем конкурентности. Это стратегия, которая позволяет таким базам данных, как Elasticsearch, быть высокомасштабируемыми.  ПРИМЕЧАНИЕ. Посетите https://www.elastic.co/elasticsearch/, чтобы узнать больше об Elasticsearch. Тео: Вы сейчас похожи на моего семейного психотерапевта, когда она говорит о своей оптимистичной стратегии разрешения конфликтов без гнева. Джо: Оптимистичный контроль конкурентности и ДОП хорошо работают вместе. Как вы сейчас увидите, оптимистичный контроль конкурентности очень эффективен, когда системные данные неизменяемы. СОВЕТ. Оптимистичный контроль конкурентности с неизменяемыми данными очень эффективен.
Глава 5. Основы контроля конкурентности 125 Тео: Как это работает? Джо: Оптимистичный контроль конкурентности происходит, когда мы позволяем при изменениях «просить прощения вместо разрешения». СОВЕТ. Оптимистичный контроль конкурентности происходит, когда мы позволяем при изменениях просить прощения вместо разрешения. Тео: Что вы имеете в виду? Джо: Этап вычисления выполняет вычисления так, как если бы это было единственное запущенное изменение. Этап фиксации отвечает за согласование конкурентных изменений, когда они не конфликтуют, или за прерывание изменения. СОВЕТ. Этап вычисления выполняет вычисления так, как если бы кроме них ничего не менялось. Этап фиксации отвечает за согласование конкурентных изменений, когда они не конфликтуют, или за прерывание изменения. Тео: Похоже, что это будет сложно реализовать. Джо: Работа с государством никогда не бывает простой. Но хорошая новость заключается в том, что код для логики согласования на этапе фиксации является универсальным. Тео: Означает ли это, что один и тот же код для этапа фиксации может быть использован в любой системе ДОП? Джо: Определенно. Код, реализующий этап фиксации, ничего не предполагает о деталях системы, за исключением того, что системные данные представлены в виде неизменяемой карты. СОВЕТ. Реализация этапа фиксации в рамках оптимистичного контроля конкурентности является универсальной. Ее можно использовать в любой системе, где данные представлены неизменяемой хеш-картой. Тео: Это потрясающе! Джо: Еще одна интересная вещь заключается в том, что обработка конкурентности не требует каких-либо изменений в коде на этапе вычисления. С точки зрения этапа вычисления следующая версия системных данных вычисляется изолированно, как если бы никакие другие изменения не выполнялись конкурентно. Джо встает, чтобы проиллюстрировать свою мысль на доске. Пока Тео рассматривает диаграмму на рис. 5.1, Джо обобщает информацию, приведенную в табл. 5.1. Таблица 5.1. Две фазы изменения при оптимистичном управлении параллелизмом Этап Действие Состояние Реализация Вычисление Вычисляется следующее состояние в изоляции Без состояния Частная Фиксация Согласовывается и обновляется состояние системы С сохранением состояния Общая
126 Часть 1. Гибкость Рис. 5.1. Блок-схема оптимистичного контроля конкурентности 5.2. Согласование между конкурентными изменениями Тео: Не могли бы вы привести примеры конфликтующих конкурентных изменений? Джо: Конечно. Одним из примеров является попытка двух читателей библиотеки позаимствовать один и тот же экземпляр книги. Другим примером может послужить ситуация, когда два библиотекаря обновляют год издания одной и той же книги. Тео: Вы упомянули, что код для логики согласования на этапе фиксации является универсальным. Что именно вы подразумеваете под логикой согласования? Джо: Это очень похоже на то, что происходит в Git, когда мы производим слияние ветки обратно с основной веткой. Тео: Мне нравится, когда основная ветка остается прежней. Джо: Да, приятно, когда слияние не имеет конфликтов и может выполняться автоматически. Вы помните, как Git обрабатывает слияние в этом случае? Тео: Git выполняет быструю перемотку вперед; система обновляет основную ветку, чтобы она была такой же, как ветка слияния. Джо: Верно! И что происходит, когда вы обнаруживаете, что тем временем другой разработчик зафиксировал свой код в основной ветке? Тео: Тогда Git выполняет трехстороннее слияние, пытаясь объединить все изменения из двух веток слияния с основной веткой.
Глава 5. Основы контроля конкурентности 127 Джо: Всегда ли все проходит гладко? Тео: Обычно — да, но два разработчика могли изменить одну и ту же строку в одном файле. Тогда придется разрешать конфликт вручную. Терпеть этого не могу! СОВЕТ. Во время эксплуатации в системе несколько изменений выполняются конкурентно. Прежде чем обновлять состояние, нужно согласовать конфликты между возможными конкурентными изменениями. Джо: В ДОП алгоритм согласования на этапе фиксации очень похож на слияние в Git, за исключением того, что вместо ручного разрешения конфликтов мы прерываем изменение. Существуют три возможности согласования возможных конкурентных изменений: быстрая перемотка вперед, трехстороннее слияние и прерывание. Джо снова подходит к доске. Он рисует две диаграммы, показанные на рис. 5.2 и 5.3. Рис. 5.2. Блок-схема процесса согласования Рис. 5.3. Когда начинается этап фиксации, существуют три версии состояния системы Тео: Не могли бы вы объяснить более подробно? Джо: Когда начинается этап фиксации изменения, у нас есть три версии состояния системы: previous, которая является версией, на которой этап вычисления основывал свои вычисления; current, которая является текущей версией во время этапа фиксации; и next, которая является версией, возвращенной этапом вычисления.
128 Часть 1. Гибкость Тео: Почему current отличается от previous? Джо: Это случается, когда другие изменения происходят одновременно с нашим изменением. Тео: Понятно. Джо: Если мы находимся в ситуации, где текущее состояние совпадает с предыдущим состоянием, это означает, что никакие изменения не выполняются конкурентно. Поэтому, как и в Git, мы можем безопасно выполнить быструю перемотку вперед и обновить состояние системы при помощи следующей версии. Тео: А что если состояние не осталось прежним? Джо: Тогда это означает, что изменения выполнялись конкурентно. Мы должны проверять наличие конфликтов способом, аналогичным трехстороннему слиянию, используемому Git. Разница в том, что вместо сравнения строк мы сравниваем поля системной хеш-карты. Тео: Не могли бы вы это объяснить? Джо: Мы вычисляем разницу между previous и next, а также между previous и current. Если у двух разниц нет общих полей, то нет и конфликта между изменениями, которые выполнялись одновременно. Мы можем безопасно применить изменения от previous к next версии current. Джо делает свое объяснение наглядным с помощью другой диаграммы на доске. Затем он показывает рис. 5.4 Тео. Рис. 5.4. При трехстороннем слиянии мы вычисляем разницу между версиями previous и next и применяем ее к версии current Тео: А что если возникнет конфликт? Джо: Тогда мы прервем мутацию. Тео: Прерывать запрос пользователя вроде как не полагается. Джо: На самом деле в системе, ориентированной на пользователя, конфликтующие конкурентные изменения довольно редки. Вот почему можно прервать действие и позволить пользователю снова запустить изменение. Здесь позвольте мне набросать таблицу, чтобы показать вам различия между Git и ДОП (табл. 5.2). Тео: Здорово! Это очень полезно, но в случаях, когда два изменения обновляют одно и то же поле одного и того же объекта, я думаю, что следует сообщить пользователю, что запрос не может быть обработан. СОВЕТ. В системе, ориентированной на пользователя, конфликтующие конкурентные изменения встречаются довольно редко.
Глава 5. Основы контроля конкурентности 129 Таблица 5.2. Аналогия между Git и дата-ориентированным программированием Дата-ориентированное программирование Git Конкурентные изменения Разные ветки Версия системных данных Операция фиксации Состояние Ссылка Этап вычисления Ветвление Валидация Хук предварительной фиксации Согласование Слияние Быстрая перемотка вперед Быстрая перемотка вперед Трехстороннее слияние Трехстороннее слияние Прерывание Разрешение конфликта вручную Хеш-карта Дерево (папка) Листовой узел Блоб (файл) Поле данных Строка кода 5.3. Сокращение коллекций Джо: Готовы ли вы бросить вызов своему разуму с помощью реализации алгоритма нахождения разницы? Тео: Давайте сделаем небольшой перерыв на кофе, если вы не возражаете. Тогда я буду готов взяться за что угодно. Насладившись большой кружкой горячего кофе и парой сдобных печений, Тео и Джо возвращаются к работе. Обсуждение алгоритма нахождения разницы продолжается. Джо: В реализации алгоритма нахождения разницы мы будем сокращать коллекции. Тео: Я слышал о сокращении коллекций в разговоре о FP, но я не помню подробностей. Не могли бы вы напомнить мне, как это работает? Джо: Представьте, что вы хотите вычислить сумму элементов в коллекции чисел. При использовании метода _.reduce из Lodash это будет выглядеть так. Листинг 5.1. Суммирование чисел с помощью _.reduce _.reduce([1, 2, 3], function(res, elem) { return res + elem; }, 0); // → 6
130 Часть 1. Гибкость Тео: Я не понимаю. Джо подходит к доске и набрасывает описание метода _.reduce. Тео терпеливо ждет, пока Джо отложит маркер, прежде чем взглянуть на получившееся. МЕТОД _.REDUCE Метод _.reduce принимает три аргумента:  coll — коллекция элементов;  f — функция, которая принимает два аргумента;  initVal — некоторое значение. Логическая схема: 1. Инициализировать currentRes с initVal. 2. Для каждого элемента х коллекции coll обновить currentRes при помощи f (currentRes, х). 3. Вернуть currentRes. Тео: Вы не будете возражать, если я вручную расширю логическую схему вашего кода для _.reduce? Джо: Я думаю, это отличная идея! Тео: В нашем случае значение initVal равно 0. Это означает, что первым вызовом f будет f(0, 1). Затем у нас будет f(f(0, 1), 2) и, наконец, f(f(f(0, 1), 2), 3). Джо: Мне нравится это ручное расширение, Тео! Давайте его визуализируем. Теперь к доске подходит Тео и рисует диаграмму. На рис. 5.5 показано, как она выглядит. Рис. 5.5. Визуализация метода _.reduce Тео: Теперь все намного яснее. Я думаю, что после реализации моей собственной пользовательской версии _.reduce ясность достигнет ста процентов. Работа над реализацией reduce() занимает у Тео гораздо меньше времени, чем он ожидал. В мгновение ока он показывает Джо код.
Глава 5. Основы контроля конкурентности 131 Листинг 5.2. Пользовательская реализация _.reduce function reduce(coll, f, initVal) { var currentRes = initVal; for (var i = 0; i < coll.length; i++) { ❶ currentRes = f(currentRes, coll[i]) } return currentRes; } ❶ Можно использовать forEach вместо цикла for. Убедившись, что код Тео работает так, как положено (см. листинг 5.3), Джо начинает гордиться Тео. Похоже, ученик схватывает быстрее, чем он ожидал. Листинг 5.3. Тестирование пользовательской реализации reduce() reduce([1, 2, 3], function(res, elem) { return res + elem; }, 0); // → 6 Джо: Молодец! 5.4. Структурная разница  ПРИМЕЧАНИЕ. В этом разделе рассматривается реализация алгоритма структурной разницы. Пропустите этот раздел, если вы не хотите прямо сейчас бросать вызов своему разуму, изучая сложный пример использования рекурсии. Это не помешает вам насладиться остальной частью книги. Вы можете вернуться к этому разделу позже. Тео: Как вычислить разницу между множеством версий состояния системы? Джо: Это самая сложная часть алгоритма согласования. Нам нужно имплементировать алгоритм структурной разницы для хеш-карт. Тео: В каком смысле разница является структурной? Джо: Алгоритм структурной разницы рассматривает структуру хеш-карт и игнорирует порядок полей. Тео: Не могли бы вы привести мне пример? Джо: Давайте начнем с карт без вложенных полей. В сущности, есть три вида разниц: замена поля, добавление поля и удаление поля. Чтобы не усложнять, пока разберемся только с заменой и добавлением. Джо снова подходит к доске и рисует табл. 5.3, охватывающую три вида разниц. На доске, — подумал Тео, — скоро совсем не останется места.
132 Часть 1. Гибкость Таблица 5.3. Виды структурных разниц для карт без вложенных полей Вид Первая карта Вторая карта Разница Замена {"a": 1} {"a": 2} {"a": 2} Добавление {"a": 1} {"a": 1, "b": 2} {"b": 2} Удаление {"a": 1, "b": 2} {"a": 1} Не поддерживается Тео: Насколько я вижу, порядок расположения карт имеет большое значение. А как насчет вложенных полей? Джо: Идея та же, но вложения делают ее более трудной для понимания. Джо изменяет несколько столбцов в табл. 5.3. Закончив, он показывает Тео вложенные поля в табл. 5.4. Таблица 5.4. Виды структурных разниц для карт с вложенными полями Вид Первая карта Вторая карта Разница Замена { { { "a": { "x": 1 } } Добавление "a": { "x": 2 } } { } { { "a": { "x": 1 } "a": { "x": 1, "y": 2, } } "a": { "x": 2 } "a": { "y": 2 } } } Удаление Не поддерживается { { "a": { "x": 1, "y": 2, } "a": { "y": 2 } } }  ПРИМЕЧАНИЕ. Версия алгоритма структурной разницы, проиллюстрированная в этой главе, не касается удалений. Работа с удалениями, безусловно, возможна, но для этого требуется более сложный алгоритм. Тео: Действительно, это труднее понять. А как насчет массивов? Джо: Мы сравниваем элементы массивов по порядку: если они равны, разница равна null; если они различаются, разница равна значению второго массива. Джо подытоживает виды разниц еще в одной таблице на доске. Тео смотрит на результат в табл. 5.5.
Глава 5. Основы контроля конкурентности 133 Таблица 5.5. Виды структурных разниц для массивов без вложенных элементов Вид Первый массив Второй массив Разница Замена [1] [2] [2] Добавление [1] [1, 2] [null, 2] Удаление [1, 2] [1] Не поддерживается Тео: Такое использование null выглядит странно, но в целом сойдет. Сложно ли имплементировать алгоритм структурной разницы? Джо: Определенно! Потребовалось хорошенько позаниматься умственной гимнастикой, чтобы придумать эти 30 строк кода. Джо загружает код из одного из своих личных репозиториев. Тео, слегка склонившись набок и подперев голову большим и указательным пальцами, изучает код. Листинг 5.4. Имплементация структурной разницы function diffObjects(data1, data2) { var emptyObject = _.isArray(data1) ? [] : {}; ❶ if(data1 == data2) { return emptyObject; } var keys = _.union(_.keys(data1), _.keys(data2)); ❷ return _.reduce(keys, function (acc, k) { var res = diff( _.get(data1, k), _.get(data2, k)); if((_.isObject(res) && _.isEmpty(res)) || ❸ ❹ (res == "no-diff")) { ❺ return acc; } return _.set(acc, [k], res); }, emptyObject); } function diff(data1, data2) { if(_.isObject(data1) && _.isObject(data2)) { return diffObjects(data1, data2); } if(data1 !== data2) { return data2; }
134 Часть 1. Гибкость return "no-diff"; ❺ } ❶ .isArray проверяет, является ли его аргумент массивом. ❷ _.union создает массив уникальных значений из двух массивов (похоже на объединение двух наборов в математике). ❸ _.isObject проверяет, является ли его аргумент коллекцией (либо картой, либо массивом). ❹ _.isEmpty проверяет, является ли его аргумент пустой коллекцией. ❺ “no-diff” отмечает, что два значения совпадают. Тео: Ого! Здесь есть рекурсия внутри сокращения! Я уверен, что Дейву такое понравится, но лично я слишком устал, чтобы разобраться в этом коде прямо сейчас. Давайте сосредоточимся на том, что он делает, а не на том, как он это делает. Чтобы ознакомиться с алгоритмом структурной разницы, Тео запускает алгоритм с примерами из таблицы, которую Джо рисовал на доске. В то время как пальцы Тео печатают все более сложные примеры, его мысли сфокусировались на сфере производительности. Листинг 5.5. Пример использования структурной разницы var data1 = { "a": { "x": 1, "y": [2, 3], "z": 4 } }; var data2 = { "a": { "x": 2, "y": [2, 4], "z": 4 } } diff(data1, data2); //{ // "a": { // "x": 2, // "y": [ // undefined, // 4 // ] // } //}
Глава 5. Основы контроля конкурентности 135 Тео: А что насчет производительности алгоритма структурной разницы? Похоже, что алгоритм перебирает листья обоих фрагментов данных? Джо: Обычно это так и есть. Но при наличии системных данных, которыми манипулируют с помощью структурного совместного использования, код будет намного эффективнее. Тео: Что вы имеете в виду? Джо: Когда применяется структурное совместное использование, большинство вложенных объектов совместно используются двумя версиями состояния системы. Поэтому в большинстве случаев, когда код вводит diffObjects, ответ немедленно возвращается, потому что data1 и data2 совпадают. СОВЕТ. Вычисление разницы между двумя версиями состояния эффективно, потому что у двух хеш-карт, созданных с помощью структурного совместного использования из одной и той же хеш-карты, большинство общих узлов — общие. Тео: Это еще одно преимущество неизменяемых данных... Я хочу посмотреть, как алгоритм разницы ведет себя с конкурентными изменениями. Пожалуй, начну с крошечной библиотеки без пользователей, а также каталога с единственной книгой — «Хранители». Листинг 5.6. Данные для крошечной библиотеки var library = { "catalog": { "booksByIsbn": { "978-1779501127": { "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authorIds": ["alan-moore", "dave-gibbons"] } }, "authorsById": { "alan-moore": { "name": "Alan Moore", "bookIsbns": ["978-1779501127"] }, "dave-gibbons": { "name": "Dave Gibbons", "bookIsbns": ["978-1779501127"] } } } };
136 Часть 1. Гибкость Джо: Я предлагаю начать с неконфликтующих изменений. А вы что предлагаете? Тео: Предлагаю изменение, которое обновляет год издания «Хранителей», и изменение, которое обновляет и название, и имя автора «Хранителей». На своем ноутбуке Тео создает три версии библиотеки. Он показывает Джо свой код, в котором одно изменение обновляет год издания «Хранителей», а другое — название и имя автора комикса. Листинг 5.7. Два неконфликтующих изменения var previous = library; var next = _.set( library, ["catalog", "booksByIsbn", "978-1779501127", "publicationYear"], 1986); var libraryWithUpdatedTitle = _.set( library, ["catalog", "booksByIsbn", "978-1779501127", "title"], "The Watchmen"); var current = _.set( libraryWithUpdatedTitle, ["catalog", "authorsById", "dave-gibbons", "name"], "David Chester Gibbons"); Тео: Интересно узнать, как выглядит разница между previous и current. Джо: Запустите код, и вы увидите. Тео запускает фрагменты кода, чтобы узнать структурную разницу между previous и next, а также структурную разницу между previous и current. Удовлетворив свое любопытство, Тео обнаруживает, что все начинает вставать на свои места. Листинг 5.8. Структурная разница между картами с одним отличием diff(previous, next); //{ // "catalog": { // "booksByIsbn": { // "978-1779501127": { // "publicationYear": 1986 // } // } // } //}
Глава 5. Основы контроля конкурентности 137 Листинг 5.9. Структурная разница между картами с двумя отличиями diff(previous, current); //{ // "authorsById": { // "dave-gibbons": { // "name": "David Chester Gibbons", // } // }, // "catalog": { // "booksByIsbn": { // "978-1779501127": { // "title": "The Watchmen" // } // } // } //} // Джо: Подскажите, пожалуйста, информационный путь для одного поля в структурной разнице между previous и next? Тео: Вот он: ["catalog", "booksByIsbn", "978-1779501127", "publicationYear"]. Джо: Точно. А каковы информационные пути полей в структурных разницах между previous и next? Тео: Вот они: ["catalog", "booksByIsbn", "978-1779501127", "title"] для названия книги и ["authorsById", "dave-gibbons", "name"] для имени автора. Джо: Прекрасно! Итак, догадаетесь ли вы, как обнаружить конфликтующие изменения, проверив информационные пути структурных разниц? Тео: Нам нужно проверить, есть ли у них общий информационный путь. Джо: Верно! Если это так, значит, изменения конфликтуют. Тео: Но я понятия не имею, как написать код, который извлекает информационные пути вложенной карты. Джо: Повторюсь, это непростой код, который включает в себя рекурсию внутри reduce. Сейчас я загружу еще один фрагмент кода из своего репозитория и покажу его вам. Листинг 5.10. Вычисление информационных путей (вложенной) карты function informationPaths (obj, path = []) { return _.reduce(obj, function(acc, v, k) { if (_.isObject(v)) { return _.concat(acc,
138 Часть 1. Гибкость informationPaths(v, _.concat(path, k))); } return _.concat(acc, [_.concat(path, k)]); }, []); } Тео: Давайте посмотрим, работает ли ваш код со структурно разными изменениями так, как ожидалось. Тео тестирует код Джо с помощью двух фрагментов. Первый показывает информационные пути структурной разницы между previous и next, а второй показывает информационные пути структурной разницы между previous и current. Листинг 5.11. Поля, которые отличаются в previous и next informationPaths(diff(previous, next)); // → ["catalog.booksByIsbn.978-1779501127.publicationYear"] Листинг 5.12. Поля, которые отличаются в previous и current informationPaths(diff(previous, current)); // [ // [ // "catalog", // "booksByIsbn", // "978-1779501127", // "title" // ], // [ // "authorsById", // "dave-gibbons", // "name" // ] //] Тео: Славно! Я предполагаю, что в Lodash есть функция, которая проверяет, есть ли у двух массивов общий элемент. Джо: Почти так. Есть метод _.intersection, который возвращает массив уникальных значений, которые находятся в двух заданных массивах. Однако для нашей цели нам нужно проверить, является ли пересечение пустым. Посмотрите на этот пример (листинг 5.13).
Глава 5. Основы контроля конкурентности 139 Листинг 5.13. Проверка того, имеют ли две карты разниц общий информационный путь function havePathInCommon(diff1, diff2) { return !_.isEmpty(_.intersection(informationPaths(diff1), informationPaths(diff2))); } Тео: Вы уже говорили, что в случае неконфликтующих изменений можно безопасно пропатчить изменения, вызванные переходом от previous к next и в current. Как это реализовать? Джо: Нужно выполнить рекурсивное слияние между current и разницей между previous и next. Тео: А в Lodash есть неизменяемая версия рекурсивного слияния? Джо: Есть, вот еще один пример. Взгляните на этот код. Листинг 5.14. Применение патча _.merge(current, (diff(previous, next))); //{ // "authorsById": { // "dave-gibbons": { // "name": "David Chester Gibbons" // } // }, // "catalog": { // "authorsById": { // "alan-moore": { // "bookIsbns": ["978-1779501127"] // "name": "Alan Moore" // }, // "dave-gibbons": { // "bookIsbns": ["978-1779501127"], // "name": "Dave Gibbons" // }, // }, // "booksByIsbn": { // "978-1779501127": { // "authorIds": ["alan-moore", "dave-gibbons"], // "isbn": "978-1779501127", // "publicationYear": 1986, // "title": "The Watchmen" // } // } // } //}
140 Часть 1. Гибкость Тео: Неужели все так просто? Джо: Даже очень. 5.5. Имплементация алгоритма согласования Джо: Теперь у нас на руках все, что нужно для имплементации алгоритма согласования. Тео: Какие нужны будут изменения? Джо: Потребуются только изменения в коде SystemState.commit. Вот, посмотрите на этот пример на моем ноутбуке. Листинг 5.15. Класс SystemState class SystemState { systemData; get() { return this.systemData; } set(_systemData) { this.systemData = _systemData; } commit(previous, next) { var nextSystemData = SystemConsistency.reconcile( this.systemData, ❶ previous, next); if(!SystemValidity.validate(previous, nextSystemData) throw "The system data to be committed is not valid!"; }; this.systemData = nextSystemData; } } ❶ Класс SystemConsistency имплементирован в листинге 5.16. Тео: Как SystemConsistency выполняет согласование? Джо: Класс SystemConsistency запускает процесс согласования путем сравнения previous и current. Если они совпадают, то мы производим быструю перемотку вперед и возвращаемся к next. Посмотрите на этот код для SystemConsistency. Листинг 5.16. Процесс согласования в действии class SystemConsistency { static threeWayMerge(current, previous, next) { var previousToCurrent = diff(previous, current); ❶ var previousToNext = diff(previous, next);
Глава 5. Основы контроля конкурентности 141 if(havePathInCommon(previousToCurrent, previousToNext)) { return _.merge(current, previousToNext); } throw "Conflicting concurrent mutations."; } static reconcile(current, previous, next) { if(current == previous) { return next; ❶ } return SystemConsistency.threeWayMerge(current, previous, next); } } ❶ Когда состояние системы совпадает с состоянием, используемым на этапе вычисления, выполняется быстрая перемотка вперед. Тео: Подождите-ка! Почему вы сравниваете previous и current по ссылке? Разве вы не должны сравнивать их по значению? И будет дороговато сравнивать все листья двух вложенных хеш-карт! Джо: Это еще одно преимущество неизменяемых данных. Когда данные не изменяются, можно безопасно сравнивать ссылки. Если они совпадают, мы точно знаем, что данные одни и те же. СОВЕТ. Когда данные неизменяемы, безопасно сравнивать по ссылке, и это очень быстро. Когда ссылки совпадают, это означает, что данные одни и те же. Тео: А что скажете об имплементации алгоритма трехстороннего слияния? Джо: Когда previous отличается от current, это означает, что были запущены конкурентные изменения. Чтобы определить, существует ли конфликт, мы вычисляем две разницы: разницу между previous и current и разницу между previous и next. Если пересечение между двумя разницами пустое, это означает, что конфликта нет. Мы можем безопасно пропатчить изменения между previous и next в current. Тео более подробно рассматривает код для класса SystemConsistency в листинге 5.16. Он пытается понять, является ли код потокобезопасным. Тео: По-моему, код для класса SystemConsistency не является потокобезопасным! Если существует переключение контекста между проверкой того, изменилась ли система в классе SystemConsistency, и обновлением состояния в классе systemData, изменение может перекрыть изменения, внесённые на предыдущем шаге. Джо: Вы совершенно правы! Этот код прекрасно работает в однопоточной среде, такой как JavaScript, где конкурентность обрабатывается с помощью цикла событий. Однако в многопоточной среде код необходимо доработать, чтобы он был потокобезопасным. Когда-нибудь вам это покажу.
142 Часть 1. Гибкость  ПРИМЕЧАНИЕ. Класс SystemConsistency не является потокобезопасным. Мы сделаем его таковым в главе 8. Тео: Кажется, я понял, почему вы назвали это оптимистичным контролем конкурентности. Потому что мы предполагаем, что конфликты происходят не слишком часто. Верно? Джо: Абсолютно! Интересно, что сказала бы ваш психотерапевт о конфликтах, которые не могут быть разрешены. Есть ли такие случаи, когда пара не может прийти к согласию? Тео: Не помню, чтобы она когда-либо допускала такую ситуацию. Джо: Она, должно быть, очень оптимистичный человек. Итоги  Оптимистичный контроль конкурентности позволяет при изменениях просить прощения вместо разрешения.  Оптимистичный контроль конкурентности свободен от блокировок.  Управление конкурентными изменениями состояния системы с помощью опти- мистичного контроля конкурентности позволяет системе поддерживать высокую пропускную способность операций чтения и записи.  Оптимистичный контроль конкурентности с неизменяемыми данными очень эффективен.  Перед обновлением состояния необходимо согласовать конфликты между воз- можными конкурентными изменениями.  Мы согласовываем конкурентные изменения примерно так, как Git обрабатыва- ет слияние двух веток: производится либо ускоренная перемотка вперед, либо трехстороннее слияние.  Изменения, необходимые для того, чтобы система могла управлять конкурент- ностью, происходят только на этапе фиксации.  Этап вычисления выполняет их так, как если бы было запущено только одно- единственное изменение.  Этап фиксации отвечает за попытку согласовать конкурентные изменения.  Алгоритм согласования универсален в том смысле, что его можно использовать в любой системе, где системные данные представлены в виде неизменяемой хеш-карты.  Имплементация алгоритма согласования эффективна, поскольку он использует тот факт, что последующие версии состояния системы создаются с помощью структурного совместного использования.  В системе, ориентированной на пользователя, конфликтующие конкурентные изменения встречаются довольно редко.
Глава 5. Основы контроля конкурентности 143  Когда невозможно безопасно согласовать конкурентные изменения, мы преры- ваем изменение и просим пользователя повторить попытку.  Вычисление структурной разницы между двумя версиями состояния эффектив- но, потому что у двух хеш-карт, созданных с помощью структурного совместного использования из одной и той же хеш-карты, большинство общих узлов общие.  Когда данные неизменяемы, безопасно сравнивать по ссылке, и это очень быст- ро. Когда ссылки совпадают, это означает, что данные одни и те же.  Существуют три вида структурных разниц между двумя вложенными хеш-кар- тами: замена, добавление и удаление.  Наш алгоритм структурной разницы поддерживает замены и добавления, но не удаления. Функции Lodash, представленные в этой главе Функция Описание concat(arrA, arrB) Создает новый массив, конкатенируя arrA и arrB intersection(arrA, arrB) Создает массив уникальных значений как в arrA, так и в arrB union(arrA, arrB) Создает массив уникальных значений из arrA и arrB find(coll, pred) Перебирает элементы коллекции coll, возвращая первый элемент, для которого pred возвращает значение true isEmpty(coll) Проверяет, является ли coll пустой reduce(coll, f, initVal) Сокращает coll до значения, которое является накопленным результатом выполнения каждого элемента в coll через f, где при каждом последующем инициировании предоставляется возвращаемое значение предыдущего isArray(coll) Проверяет, является ли coll массивом isObject(coll) Проверяет, является ли coll коллекцией
144 Часть 1. Гибкость
Модульные тесты Программирование в кофейне В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Генерация минимального ввода данных для тестового кейса.  Сравнение выходных данных функции с ожидаемыми выходными данными.  Рекомендации по качеству и количеству тестовых кейсов. В дата-ориентированной системе наш код имеет дело в основном с манипулированием данными: большинство наших функций получают данные и возвращают их. Как следствие, довольно легко написать модульные тесты, чтобы проверить, ведет ли наш код себя так, как ожидалось. Модульный тест состоит из тестовых кейсов, которые генерируют входные данные и сравнивают выходные данные функции с ожидаемыми выходными данными. В этой главе мы пишем модульные тесты для запросов и изменений, которые мы писали в предыдущих главах. 6.1. Простота дата-ориентированных тестовых кейсов Тео и Джо сидят за большим деревянным столом в углу «La vie est belle», милой маленькой французской кофейни, расположенной недалеко от моста Золотые Ворота. Тео заказывает кофе с молоком и круассан, а Джо — крепкий эспрессо с шоколадной крошкой. Вместо обычных общих дискуссий о программировании и жизни, когда они находятся вне офиса, Джо подводит дискуссию к очень конкретной теме — модульным тестам. Тео просит у Джо объяснений. Тео: Являются ли модульные тесты такой простой темой, что мы можем заняться ею здесь, в кафе? Джо: Модульные тесты в целом — нет. Но модульные тесты для дата-ориентированного кода — да!
146 Часть 1. Гибкость Тео: Почему это имеет значение? Джо: Подавляющее большинство кодовой базы дата-ориентированной системы связано с манипулированием данными. Тео: Да. Я заметил, что почти все функции, которые мы написали до сих пор, получают данные и возвращают их. СОВЕТ. Большая часть кода в дата-ориентированной системе связана с манипулированием данными. Джо: Написание тестового кейса для функций, которые имеют дело с данными, заключается только в генерации входных данных и ожидаемых выходных данных и сравнении выходных данных функции с ожидаемыми выходными данными. ШАГИ ТЕСТОВОГО КЕЙСА 1. Сгенерируйте входные данные: dataIn. 2. Сгенерируйте ожидаемый результат: dataOut. 3. Сравните выходные данные функции с ожидаемыми выходными данными: f(dataIn) и dataOut. Тео: Это все? Джо: Да. Как вы скоро увидите, в ДОП обычно нет необходимости в фиктивных функциях. Тео: Я понимаю, как сравнивать примитивные значения, такие как строки или числа, но я не уверен, как бы я сравнивал коллекции данных, такие как карты. Джо: Сравнивайте поле за полем. Тео: Рекурсивно? Джо: Да! Тео: О нет! Я не могу написать какой-либо рекурсивный код в кафе. Мне нужно спокойствие моего офиса для такого рода вещей. Джо: Не волнуйся. В ДОП данные представлены в общем виде. В Lodash есть универсальная функция под названием _.isEqual для рекурсивного сравнения наборов данных. Он работает как с картами, так и с массивами. Джо открывает свой ноутбук. Он может убедить Тео, выполнив несколько фрагментов кода с _.isEqual, чтобы сравнить равный набор данных с неравным. Листинг 6.1. Рекурсивное сравнение одинакового набора данных _.isEqual({ "name": "Alan Moore", "bookIsbns": ["978-1779501127"] }, { "name": "Alan Moore",
Глава 6. Модульные тесты 147 "bookIsbns": ["978-1779501127"] }); // → true Листинг 6.2. Рекурсивное сравнение неравнозначного набора данных _.isEqual({ "name": "Alan Moore", "bookIsbns": ["978-1779501127"] }, { "name": "Alan Moore", "bookIsbns": ["bad-isbn"] }); // → false Тео: Славно! Джо: Большинство тестовых кейсов в ДОП следуют этому шаблону. Тео решает, что хочет попробовать. Он включает свой ноутбук и набирает несколько строк псевдокода. Листинг 6.3. Общий шаблон дата-ориентированного тестового кейса var dataIn = { // input }; var dataOut = { // expected output }; _.isEqual(f(dataIn), dataOut); СОВЕТ. Писать модульные тесты для кода, который занимается манипулированием данными, несложно. Тео: Действительно, это похоже на то, чем мы можем заняться в кафе! 6.2. Модульные тесты для кода манипулирования данными Официант в элегантном галстуке-бабочке приносит Тео круассан, а Джо шоколадную пасту. Двое друзей на мгновение прерывают свою дискуссию, чтобы насладиться французской выпечкой. Когда они заканчивают, они просят официанта принести им напитки. Тем временем они возобновляют дискуссию.
148 Часть 1. Гибкость Джо: Помните ли вы поток кода реализации поискового запроса? Тео: Позвольте мне еще раз взглянуть на код, который реализует поисковый запрос. Тео запускает реализацию поискового запроса на своем ноутбуке. Заметив, что Джо снова грызет ногти, он быстро проверяет код. Листинг 6.4. Код, участвующий в реализации поискового запроса class Catalog { static authorNames(catalogData, authorIds) { return _.map(authorIds, function(authorId) { return _.get(catalogData, ["authorsById", authorId, "name"]); }); } static bookInfo(catalogData, book) { var bookInfo = { "title": _.get(book, "title"), "isbn": _.get(book, "isbn"), "authorNames": Catalog.authorNames(catalogData, _.get(book, "authorIds")) }; return bookInfo; } static searchBooksByTitle(catalogData, query) { var allBooks = _.get(catalogData, "booksByIsbn"); var matchingBooks = _.filter(allBooks, function(book) { return _.get(book, "title").includes(query); }); var bookInfos = _.map(matchingBooks, function(book) { return Catalog.bookInfo(catalogData, book); }); return bookInfos; } } class Library { static searchBooksByTitleJSON(libraryData, query) { var catalogData = _.get(libraryData, "catalog"); var results = Catalog.searchBooksByTitle(catalogData, query); var resultsJSON = JSON.stringify(results); return resultsJSON; } }
Глава 6. Модульные тесты 149 6.2.1. Дерево вызовов функций Официант приносит Тео кофе с молоком, а Джо крепкий эспрессо. Они продолжают свою дискуссию, наслаждаясь кофе. Джо: Прежде чем писать модульный тест для потока кода, я считаю полезным визуализировать дерево вызовов функций потока кода. Тео: Что вы подразумеваете под деревом вызовов функций? Джо: Я нарисую дерево вызовов функций для потока кода Library. searchBooksByTitleJSON. Джо ставит свой эспрессо и берет салфетку из автомата. Он осторожно кладет ее на стол и начинает рисовать. Закончив, он показывает иллюстрацию Тео (рис. 6.1). Рис. 6.1. Дерево вызовов функций для потока кода поискового запроса Тео: Славно! Можете ли вы научить меня, как нарисовать такое же дерево вызовов функций? Джо: Конечно. Корень дерева — это имя функции, для которой вы рисуете дерево, в нашем случае Library.searchBooksByTitleJSON. Дочерние элементы узла в дереве — это имена функций, вызываемых функцией. Например, если вы снова посмотрите на код для Library.searchBooksByTitleJSON (листинг 6.4), вы увидите, что он вызывает Catalog.searchBooksByTitle, _.get и JSON.stringify. Тео: Как долго я могу продолжать рекурсивно расширять дерево? Джо: Вы продолжаете до тех пор, пока не дойдете до функции, которая не принадлежит кодовой базе вашего приложения. Эти узлы являются листьями нашего дерева; например, функции из Lodash: _.get, _.map и т. д. Тео: Что делать, если код функции не вызывает никаких других функций? Джо: Функция, которая не вызывает никакой другой функции, была бы листом в дереве. Тео: Как насчет функций, которые вызываются внутри анонимных функций, таких как Catalog.bookInfo?
150 Часть 1. Гибкость Джо: Catalog.bookInfo отображается в коде Catalog.searchBooksByTitle. Поэтому он считается дочерним узлом Catalog.searchBooksByTitle. Тот факт, что он вложен в анонимную функцию, не имеет значения в контексте дерева вызовов функций.  ПРИМЕЧАНИЕ. Дерево вызовов функций для функции f — это дерево, корень которого равен f, а дочерние элементы узла g в дереве — это функции, вызываемые g. Листья дерева — это функции, которые не являются частью кодовой базы приложения. Это функции, которые не вызывают никаких других функций. Тео: Очень здорово визуализировать мой код в виде дерева, но я не понимаю, как это связано с модульными тестами. Джо: Дерево вызовов функций подсказывает нам, какое качество и количество тест-кейсов мы должны написать. Тео: Как? Джо: Увидите через мгновение. 6.2.2. Модульные тесты для функций вниз по дереву Джо: Давайте начнем с функции, которая появляется в самом глубоком узле нашего дерева: Catalog.authorNames. Взгляните на код для Catalog.authorNames и скажите мне, каковы входные и выходные данные Catalog.authorNames. Джо поворачивает свой ноутбук, чтобы Тео мог поближе рассмотреть код. Тео делает глоток кофе с молоком, просматривая то, что есть на ноутбуке Джо. Листинг 6.5. Код Catalog.authorNames Catalog.authorNames = function (catalogData, authorIds) { return _.map(authorIds, function(authorId) { return _.get(catalogData, ["authorsById", authorId, "name"]); }); }; Тео: Ввод Catalog.authorNames — это catalogData и authorIds. Выходные данные — это authorNames. Джо: Не могли бы визуализировать это? Тео: Конечно. Теперь очередь Тео брать салфетку. Он рисует небольшой прямоугольник с двумя стрелками внутрь и одной стрелкой наружу, как показано на рис. 6.2. Джо: Превосходно! Теперь, сколько комбинаций входных данных вы бы включили в модульный тест для Catalog.authorNames? Тео: Дайте-ка подумать. Тео тянется за другой салфеткой. На этот раз он создает таблицу, чтобы собраться с мыслями (табл. 6.1).
Глава 6. Модульные тесты 151 Рис. 6.2. Визуализация ввода и вывода Catalog.authorNames Таблица 6.1. Таблица тест-кейсов для Catalog.authorNames catalogData authorIds authorNames Каталог с двумя авторами Пустой массив Пустой массив Каталог с двумя авторами Массив с одним идентификатором автора Массив с одним именем автора Каталог с двумя авторами Массив с двумя идентификаторами автора Массив с двумя именами авторов Тео: Для начала у меня был бы catalogData с двумя идентификаторами автора и вызов Catalog.authorNames с тремя аргументами: пустой массив, массив с одним идентификатором автора и массив с двумя идентификаторами автора. Джо: Как бы вы сгенерировали catalogData? Тео: Точно так же, как мы создавали его раньше. Повернувшись к своему ноутбуку, Тео пишет код для catalogData. Он показывает его Джо. Листинг 6.6. Полная карта catalogData var catalogData = { "booksByIsbn": { "978-1779501127": { "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authorIds": ["alan-moore", "dave-gibbons"], "bookItems": [ { "id": "book-item-1", "libId": "nyc-central-lib", "isLent": true }, { "id": "book-item-2", "libId": "nyc-central-lib",
152 Часть 1. Гибкость "isLent": false } ] } }, "authorsById": { "alan-moore": { "name": "Alan Moore", "bookIsbns": ["978-1779501127"] }, "dave-gibbons": { "name": "Dave Gibbons", "bookIsbns": ["978-1779501127"] } } }; Джо: Вы можете использовать свою большую карту catalogData для модульного теста, но вы также можете использовать карту меньшего размера в контексте Catalog.authorNames. Вы можете избавиться от поля booksByIsbn каталога catalogData и полей bookIsbns авторов. Джо удаляет несколько строк из catalogData и получает карту гораздо меньшего размера. Он показывает доработку Тео. Листинг 6.7. Минимальная версия catalogData var catalogData = { "authorsById": { "alan-moore": { "name": "Alan Moore" }, "dave-gibbons": { "name": "Dave Gibbons" } } }; Тео: Подождите минутку! Этот catalogData недействителен. Джо: В ДОП достоверность данных зависит от контекста. В контексте Library. searchBooksByTitleJSON и Catalog.searchBooksByTitle минимальная версия catalogData действительно недействительна. Однако в контексте Catalog.bookInfo и Catalog.authorNames это вполне допустимо. Причина в том, что эти две функции имеют доступ только к полю authorById в catalogData. СОВЕТ. Достоверность данных зависит от контекста.
Глава 6. Модульные тесты 153 Тео: Почему в тест-кейсе лучше использовать минимальную версию данных? Джо: По очень простой причине — чем меньше данных, тем легче ими манипулировать. СОВЕТ. Чем меньше данных, тем легче ими манипулировать. Тео: Я буду признателен за это, когда напишу модульные тесты! Джо: Определенно! И последнее, прежде чем мы начнем программировать: как бы вы проверили, что вывод Catalog.authorNames соответствует ожиданиям? Тео: Я бы проверил, что значение, возвращаемое Catalog.authorNames, представляет собой массив с ожидаемыми именами авторов. Джо: Как бы вы справились со сравнением массивов? Тео: Дайте мне подумать. Я хочу сравнивать по значению, а не по ссылке. Я предполагаю, что мне придется проверить, что массив имеет ожидаемый размер, а затем рекурсивно проверять элемент за элементом. Джо: Это слишком большая умственная нагрузка, когда вы находитесь в кафе. Как я показал вам ранее (см. листинг 6.1), мы можем рекурсивно сравнить две коллекции данных по значению с помощью _.isEqual из Lodash. СОВЕТ. Мы можем сравнить вывод и ожидаемый вывод наших функций с помощью _.isEqual. Тео: Звучит отлично! Позвольте мне написать тестовые кейсы. Тео начинает печатать на своем ноутбуке. Через несколько минут у него есть несколько тестовых кейсов для Catalog.authorNames, каждый из которых сделан из вызова функции Catalog.authorNames, обернутого в _.isEqual. Листинг 6.8. Модульный тест для Catalog.authorNames var catalogData = { "authorsById": { "alan-moore": { "name": "Alan Moore" }, "dave-gibbons": { "name": "Dave Gibbons" } } }; _.isEqual(Catalog.authorNames(catalogData, []), []); _. isEqual(Catalog.authorNames( catalogData, ["alan-moore"]), ["Alan Moore"]);
154 Часть 1. Гибкость _.isEqual(Catalog.authorNames(catalogData, ["alan-moore", "dave-gibbons"]), ["Alan Moore", "Dave Gibbons"]); Джо: Отличная работа! Можете ли вы придумать больше тестовых кейсов? Тео: Да. Существуют тестовые кейсы, в которых идентификатор автора не отображается в данных каталога, и тестовые наборы с пустыми данными каталога. С минимальными данными каталога и _.isEqual очень легко написать множество тестовых кейсов! Тео действительно нравится этот вызов. Он создает еще несколько тестовых кейсов, чтобы показать их Джо. Листинг 6.9. Дополнительные тест-кейсы для Catalog.authorNames _.isEqual(Catalog.authorNames({}, []), []); _.isEqual(Catalog.authorNames({}, ["alan-moore"]), [undefined]); _.isEqual(Catalog.authorNames(catalogData, ["alan-moore", "albert-einstein"]), ["Alan Moore", undefined]); _.isEqual(Catalog.authorNames(catalogData, []), []); _.isEqual(Catalog.authorNames(catalogData, ["albert-einstein"]), [undefined]); Тео: Как запустить эти модульные тесты? Джо: Используйте предпочтительную тестовую среду.  ПРИМЕЧАНИЕ. Мы не имеем здесь дел с тест-раннерами и тестовыми фреймворками. Мы занимаемся только логикой текст-кейсов. 6.2.3. Модульные тесты для узлов в дереве Тео: Мне любопытно посмотреть, как выглядят модульные тесты для верхнего узла в дереве вызовов функций. Джо: Конечно. Давайте напишем модульный тест для Catalog.bookInfo. Сколько тест-кейсов у вас будет для Catalog.bookInfo? Листинг 6.10. Код Catalog.bookInfo Catalog.bookInfo = function (catalogData, book) { return { "title": _.get(book, "title"), "isbn": _.get(book, "isbn"), "authorNames": Catalog.authorNames(catalogData, _.get(book, "authorIds")) }; };
Глава 6. Модульные тесты 155 Рис. 6.3. Визуализация ввода и вывода Catalog.bookInfo Тео еще раз смотрит на код Catalog.bookInfo на своем ноутбуке. Затем, потянувшись за другой салфеткой, чертит схему ее входа и выхода (рис. 6.3). Тео: У меня было бы такое же количество тест-кейсов для Catalog.authorNames: книга с одним автором, с двумя авторами, с существующими авторами, с несуществующими авторами, с... Джо: Ну ничего себе! Это не обязательно. Учитывая, что мы уже написали модульные тесты для Catalog.authorNames, нам не нужно снова проверять все кейсы. Нам просто нужно написать минимальный тест-кейс, чтобы убедиться, что код работает. СОВЕТ. Когда мы пишем модульный тест для функции, мы предполагаем, что функции, вызываемые этой функцией, охватываются модульными тестами и работают должным образом. Это значительно сокращает количество тест-кейсов в наших модульных тестах. Тео: Это имеет смысл. Джо: Как бы вы написали минимальный тест-кейс для Catalog.bookInfo? Тео еще раз смотрит на код Catalog.bookInfo (листинг 6.10). Теперь он может ответить на вопрос Джо. Тео: Я бы использовал те же данные каталога, что и для Catalog.authorNames и записи книги. Я бы проверил, что функция ведет себя так, как ожидалось, сравнив возвращаемое значение с записью информации о книге, используя _.isEqual. Позвольте-ка мне показать вам. Тео требуется немного больше времени, чтобы написать модульный тест. Причина в том, что и входные, и выходные данные Catalog.authorNames являются записями. Работать с записью сложнее, чем с массивом строк (как в случае с Catalog. authorNames). Тео ценит тот факт, что _.isEqual избавляет его от написания кода, сравнивающего свойство карт по свойствам. Закончив, он показывает результат Джо и берет салфетку, чтобы вытереть лоб. Листинг 6.11. Модульный тест для Catalog.bookInfo var catalogData = { "authorsById": { "alan-moore": { "name": "Alan Moore" },
156 Часть 1. Гибкость "dave-gibbons": { "name": "Dave Gibbons" } } }; var book = { "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authorIds": ["alan-moore", "dave-gibbons"] }; var expectedResult = { "authorNames": ["Alan Moore", "Dave Gibbons"], "isbn": "978-1779501127", "title": "Watchmen", }; var result = Catalog.bookInfo(catalogData, book); _.isEqual(result, expectedResult); Джо: Идеально! Теперь, как бы вы сравнили тип модульных тестов для Catalog .bookInfo с модульными тестами для Catalog.authorNames? Тео: С одной стороны, в модульном тесте Catalog.book-Info есть только один тесткейс. С другой стороны, данные, задействованные в тест-кейсе, более сложны, чем данные, задействованные в тест-кейсах для Catalog.authorNames. Джо: Именно так! Функции, которые появляются в глубоком узле дерева вызовов функций, как правило, требуют большего количества тест-кейсов, но данные, используемые в этих тест-кейсах, менее сложны. СОВЕТ. Функции, которые отображаются на более низком уровне в дереве вызовов функций, как правило, содержат менее сложные данные, чем функции, которые отображаются на более высоком уровне в дереве (табл. 6.2). Таблица 6.2. Корреляция между глубиной функции в дереве вызовов функций и качеством и количеством тест-кейсов Глубина дерева Сложность данных Количество тест-кейсов Ниже Выше Ниже Выше Ниже Выше
Глава 6. Модульные тесты 157 6.3. Модульные тесты для запросов В предыдущем разделе мы увидели, как писать модульные тесты для служебных функций, таких как Catalog.bookInfo и Catalog.authorNames. Теперь мы увидим, как писать модульные тесты для узлов дерева запросов вызовов функций, близких к корню дерева. Джо: Тео, как бы вы написали модульный тест для кода точки входа поискового запроса? Чтобы вспомнить подробности, Тео проверяет код на Library.searchBooksByTitleJSON. Хотя Джо был прав в том, что сегодняшняя тема достаточно проста, чтобы насладиться атмосферой кофейни, этим утром он довольно много программировал. Листинг 6.12. Код Library.searchBooksByTitleJSON Library.searchBooksByTitleJSON = function (libraryData, query) { var catalogData = _.get(libraryData, "catalog"); var results = Catalog.searchBooksByTitle(catalogData, query); var resultsJSON = JSON.stringify(results); return resultsJSON; }; Затем он размышляет о том, как написать модульный тест для этого кода. После еще одного Ага!-момента теперь он получил это. Тео: Входными данными для Library.searchBooksByTitleJSON являются данные библиотеки и строка запроса, а выходными данными является строка JSON (рис. 6.4). Итак, я бы создал запись данных библиотеки с одной книгой и написал бы тесты со строками запроса, которые соответствуют названию книги и не совпадают. Рис. 6.4. Ввод и вывод Library.searchBooksByTitleJSON Джо: Как насчет ожидаемых результатов тестов? Тео: В тех случаях, когда строка запроса совпадает, ожидаемым результатом является строка JSON с информацией о книге. В тех случаях, когда строка запроса не совпадает, ожидаемым результатом будет строка JSON с пустым массивом. Джо: Хм... Тео: Что?
158 Часть 1. Гибкость Джо: Мне не нравится твой ответ. Тео: Почему? Джо: Потому что твой тест-кейс основан на сравнении строк, а не на сравнении данных. Тео: Какое это имеет значение? В конце концов, строки, которые я сравниваю, получены в результате сериализации данных. Джо: Сравнивать строки JSON по своей сути гораздо сложнее, чем сравнивать данные. Например, две разные строки могут быть сериализацией одного и того же фрагмента данных. Тео: О, правда? Как? Джо: Взгляните на эти две строки. Они представляют собой сериализацию одних и тех же данных. Это разные строки, потому что поля отображаются в разном порядке, но на самом деле они сериализуют одни и те же данные! Джо поворачивает свой ноутбук к Тео. Когда Тео смотрит на код, он понимает, что Джо снова прав. Листинг 6.13. Две разные строки, которые сериализуют одни и те же данные var stringA = "{\"title\":\"Watchmen\",\"publicationYear\":1987}"; var stringB = "{\"publicationYear\":1987,\"title\":\"Watchmen\"}"; СОВЕТ. Избегайте использования сравнения строк в модульных тестах для функций, которые работают с данными. Тео: Ага... Хорошо, что я могу сделать вместо этого? Джо: Вместо сравнения вывода Library.searchBooksByTitleJSON со строкой вы можете десериализовать вывод и сравнить его с ожидаемыми данными. Тео: Что вы подразумеваете под десериализацией строки? Джо: Например, десериализация строки s означает создание фрагмента данных, сериализация которого равна s. Тео: Есть ли функция Lodash для десериализации строк? Джо: На самом деле есть встроенная функция JavaScript для десериализации строк; это называется JSON.parse. Джо достает свой ноутбук и показывает Тео пример десериализации строк. Код иллюстрирует обычное использование JSON.parse. Листинг 6.14. Пример десериализации строки var myString = "{\"publicationYear\":1987,\"title\":\"Watchmen\"}"; var myData = JSON.parse(myString); _.get(myData, "title"); // → "Watchmen"
Глава 6. Модульные тесты 159 Тео: Круто! Позвольте мне попробовать написать модульный тест для Library. searchBooksByTitleJSON, используя JSON.parse. Тео не нужно много времени, чтобы придумать код. Используя свой ноутбук, он вводит модульный тест. Листинг 6.15. Модульный тест для Library.searchBooksByTitleJSON var libraryData = { "catalog": { "booksByIsbn": { "978-1779501127": { "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authorIds": ["alan-moore", "dave-gibbons"] } }, "authorsById": { "alan-moore": { "name": "Alan Moore", "bookIsbns": ["978-1779501127"] }, "dave-gibbons": { "name": "Dave Gibbons", "bookIsbns": ["978-1779501127"] } } } }; var bookInfo = { "isbn": "978-1779501127", "title": "Watchmen", "authorNames": ["Alan Moore", "Dave Gibbons"] }; _.isEqual(JSON.parse(Library.searchBooksByTitleJSON(libraryData, "Watchmen")), [bookInfo]); _.isEqual(JSON.parse(Library.searchBooksByTitleJSON(libraryData, "Batman")), []); Джо: Отличная работа! Думаю, вы готовы перейти к последней части головоломки и написать модульный тест для Catalog.searchBooksByTitle.
160 Часть 1. Гибкость Поскольку Тео и Джо уже довольно давно обсуждают модульные тесты, они хотят еще эспрессо. Они зовут официанта и делают заказ, затем Тео снова смотрит на код Catalog.searchBooksByTitle. Листинг 6.16. Код Catalog.searchBooksByTitle Catalog.searchBooksByTitle = function(catalogData, query) { var allBooks = _.get(catalogData, "booksByIsbn"); var matchingBooks = _.filter(allBooks, function(book) { return _.get(book, "title").includes(query); }); var bookInfos = _.map(matchingBooks, function(book) { return Catalog.bookInfo(catalogData, book); }); return bookInfos; }; Написание модульного теста для Catalog.searchBooksByTitle — более приятное занятие для Тео, чем написание модульного теста для Library.searchBooksByTitleJSON. Он ценит это по двум причинам:  нет необходимости десериализовать вывод, потому что функция возвращает данные;  нет необходимости заключать данные каталога в карту данных библиотеки. Листинг 6.17. Модульный тест для Catalog.searchBooksByTitle var catalogData = { "booksByIsbn": { "978-1779501127": { "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authorIds": ["alan-moore", "dave-gibbons"] } }, "authorsById": { "alan-moore": { "name": "Alan Moore", "bookIsbns": ["978-1779501127"] }, "dave-gibbons": { "name": "Dave Gibbons", "bookIsbns": ["978-1779501127"] } } };
Глава 6. Модульные тесты 161 var bookInfo = { "isbn": "978-1779501127", "title": "Watchmen", "authorNames": ["Alan Moore", "Dave Gibbons"] }; _.isEqual(Catalog.searchBooksByTitle(catalogData, "Watchmen"), [bookInfo]); _.isEqual(Catalog.searchBooksByTitle(catalogData, "Batman"), []); Джо: Это хорошее начало! Тео: Я думал, что с меня хватит. Что я пропустил? Джо: Вы забыли протестировать кейсы, когда строка запроса полностью строчная. Тео: Вы правы! Позвольте мне быстро добавить еще один тест-кейс. Менее чем за минуту Тео создает дополнительный тест-кейс и показывает его Джо. Каково же было разочарование, когда Тео обнаружил, что тест-кейс с watchmen в нижнем регистре провалился! Листинг 6.18. Дополнительный тест-кейс для Catalog.searchBooksByTitle _.isEqual(Catalog.searchBooksByTitle(catalogData, "watchmen"), [bookInfo]); Джо: Не расстраивайтесь слишком сильно, мой друг. В конце концов, цель модульных тестов — найти ошибки в коде, чтобы вы могли их исправить. Можете ли вы исправить код Catalog-Data.searchBooksByTitle? Тео: Конечно. Все, что мне нужно сделать, это перевести строку запроса и название книги в нижний регистр перед их сравнением. Я бы, наверное, сделал что-то подобное. Листинг 6.19. Исправленный код Catalog.searchBooksByTitle Catalog.searchBooksByTitle = function(catalogData, query) { var allBooks = _.get(catalogData, "booksByIsbn"); var queryLowerCased = query.toLowerCase();❶ var matchingBooks = _.filter(allBooks, function(book) { return _.get(book, "title") .toLowerCase()❷ .includes(queryLowerCased); }); var bookInfos = _.map(matchingBooks, function(book) { return Catalog.bookInfo(catalogData, book); }); return bookInfos; }; ❶ Преобразует запрос в нижний регистр. ❷ Преобразует название книги в нижний регистр.
162 Часть 1. Гибкость После исправления кода Catalog.searchBooksByTitle Тео снова запускает все тесткейсы. На этот раз все они проходят — какое облегчение! Листинг 6.20. Дополнительный тест-кейс для Catalog.searchBooksByTitle _.isEqual(Catalog.searchBooksByTitle(catalogData, "watchmen"), [bookInfo]); Джо: Это такое приятное чувство, когда все тест-кейсы пройдены. Тео: Безусловно. Джо: Я думаю, что мы написали модульные тесты для всего кода поискового запроса, так что теперь мы готовы написать модульные мутационные тесты. Слава богу, официант только что принес наш заказ. 6.4. Модульные мутационные тесты Джо: Прежде чем писать модульные мутационные тесты для проверки добавления члена, давайте нарисуем дерево вызовов функций для System.addMember. Тео: Это я могу сделать. Тео просматривает код функций, участвующих в добавлении членов. Он замечает, что код распределен по трем классам: System, Library и UserManagement. Листинг 6.21. Функции, участвующие в операции добавления члена System.addMember = function(systemState, member) { var previous = systemState.get(); var next = Library.addMember(previous, member); systemState.commit(previous, next); }; Library.addMember = function(library, member) { var currentUserManagement = _.get(library, "userManagement"); var nextUserManagement = UserManagement.addMember( currentUserManagement, member); var nextLibrary = _.set(library, "userManagement", nextUserManagement); return nextLibrary; }; UserManagement.addMember = function(userManagement, member) { var email = _.get(member, "email"); var infoPath = ["membersByEmail", email]; if(_.has(userManagement, infoPath)) { throw "Member already exists."; }
Глава 6. Модульные тесты 163 var nextUserManagement = _.set(userManagement, infoPath, member); return nextUserManagement; }; Тео хватает еще одну салфетку. Нарисовать дерево вызовов функций для System.addMember теперь совсем несложно (рис. 6.5). Рис. 6.5. Дерево вызовов функций для System.addMember Джо: Превосходно! Итак, какие функции дерева должны быть протестированы на изменения, касающиеся добавления членов? Тео: Я думаю, что нам нужно протестировать следующие функции: System. addMember, SystemState.get, SystemState.commit, Library.addMember и UserManagement. addMember. Это так? Джо: Вы совершенно правы. Давайте отложим написание модульных тестов для функций, принадлежащих SystemState, на потом. Это общие функции, которые следует тестировать вне контекста конкретного изменения. Давайте пока предположим, что мы уже написали модульные тесты для класса SystemState. У нас осталось три функции: System.addMember, Library.addMember и UserManagement. addMember. Тео: В каком порядке мы должны писать модульные тесты, снизу вверх или сверху вниз? Джо: Начнем с самого главного — с UserManagement.addMember. Две другие функции являются просто обертками. Тео: Ладно. Джо: Написание модульного теста для основной функции изменения требует больше усилий, чем написание теста для запроса. Причина в том, что запрос возвращает ответ на основе системных данных, тогда как изменение вычисляет новое состояние системы на основе текущего состояния системы и некоторых аргументов (рис. 6.6).
164 Часть 1. Гибкость Рис. 6.6. Результат изменения сложнее, чем результат запроса СОВЕТ. Написание модульного мутационного теста для основной функции требует больше усилий, чем для запроса. Тео: Это означает, что в тест-кейсах UserManagement.addMember как входные данные, так и ожидаемые выходные данные являются картами, описывающими состояние системы. Джо: Именно. Начнем с простейшего случая, когда начальное состояние системы пусто. Тео: Вы имеете в виду, что userManagementData, переданный в UserManagement. addMember, является пустой картой? Джо: Ага. Тео снова кладет руки на клавиатуру ноутбука, на мгновение задумывается и начинает печатать. Он напоминает себе, что код должен добавить элемент в пустую карту управления пользователями и проверить, соответствует ли результирующая карта ожиданиям. Когда он закончил, он показывает свой код Джо. Листинг 6.22. Тест-кейс для Catalog.addMember без членов var member = { "email": "jessie@gmail.com", "password": "my-secret" }; var userManagementStateBefore = {}; var expectedUserManagementStateAfter = { "membersByEmail": { "jessie@gmail.com": { "email": "jessie@gmail.com", "password": "my-secret" } } }; var result = UserManagement.addMember(userManagementStateBefore, member); _.isEqual(result, expectedUserManagementStateAfter);
Глава 6. Модульные тесты 165 Джо: Очень хорошо! Продолжайте и напишите тест-кейс, когда начальное состояние не пусто. Тео знает, что для этого потребуется еще несколько строк кода, но ничего сложного. Когда он заканчивает, он снова показывает код Джо. Листинг 6.23. Тест-кейс для Catalog.addMember с существующими элементами var jessie = { "email": "jessie@gmail.com", "password": "my-secret" }; var franck = { "email": "franck@gmail.com", "password": "my-top-secret" }; var userManagementStateBefore = { "membersByEmail": { "franck@gmail.com": { "email": "franck@gmail.com", "password": "my-top-secret" } } }; var expectedUserManagementStateAfter = { "membersByEmail": { "jessie@gmail.com": { "email": "jessie@gmail.com", "password": "my-secret" }, "franck@gmail.com": { "email": "franck@gmail.com", "password": "my-top-secret" } } }; var result = UserManagement.addMember(userManagementStateBefore, jessie); _.isEqual(result, expectedUserManagementStateAfter); Джо: Потрясающий! Можете ли вы придумать другие тест-кейсы для UserManagement. addMember? Тео: Нет. Джо: Как насчет случаев, когда изменение не работает?
166 Часть 1. Гибкость Тео: Верно! Я всегда забываю об отрицательных тестах. Предполагаю, что это связано с тем, что я оптимистичный человек. СОВЕТ. Не забудьте включить отрицательные тест-кейсы в ваши модульные тесты. Джо: Я тоже. Чем больше я медитирую, тем больше я могу сосредоточиться на положительной стороне жизни. В любом случае как бы вы написали тест-кейс, в котором изменение не удалось? Тео: Я бы передал в UserManagement.addMember член, который уже существует в userManagementStateBefore. Джо: И как бы вы проверили, что код ведет себя должным образом в случае сбоя? Тео: Дайте-ка подумать. Когда член уже существует, UserManagement.addMember создает исключение. Поэтому в моем тест-кейсе мне нужно поместить код в блок try/catch. Джо: Звучит неплохо. Опять от Тео не требуется слишком много усилий, чтобы создать новый тест-кейс. Закончив, он с нетерпением поворачивает свой ноутбук к Джо. Листинг 6.24. Тест-кейс для UserManagement.addMember, если ожидается, что он не сработает var jessie = { "email": "jessie@gmail.com", "password": "my-secret" }; var userManagementStateBefore = { "membersByEmail": { "jessie@gmail.com": { "email": "jessie@gmail.com", "password": "my-secret" } } }; var expectedException = "Member already exists."; var exceptionInMutation; try { UserManagement.addMember(userManagementStateBefore, jessie); } catch (e) { exceptionInMutation = e; } _.isEqual(exceptionInMutation, expectedException);
Глава 6. Модульные тесты 167 Тео: Теперь я думаю, что готов двигаться дальше и писать модульные тесты для Library.addMember и System.addMember. Джо: Я с вами согласен. Пожалуйста, начните с Library.addMember. Тео: Library.addMember очень похож на UserManagement.addMember. Поэтому я думаю, что напишу аналогичные тест-кейсы. Джо: На самом деле этого не потребуется. Как я уже говорил вам, когда мы писали модульные тесты для запроса, когда вы пишете модульный тест для функции, вы можете предположить, что функции ниже по дереву работают так, как ожидалось. Тео: Верно. Поэтому я просто напишу тест-кейс для существующих участников. Джо: Действуйте! Тео начинает с копирования и вставки кода из тестового примера UserManagement. addMember с существующими элементами в листинге 6.23. После нескольких изменений модульный тест для Library.addMember готов. Листинг 6.25. Модульный тест для Library.addMember var jessie = { "email": "jessie@gmail.com", "password": "my-secret" }; var franck = { "email": "franck@gmail.com", "password": "my-top-secret" }; var libraryStateBefore = { "userManagement": { "membersByEmail": { "franck@gmail.com": { "email": "franck@gmail.com", "password": "my-top-secret" } } } }; var expectedLibraryStateAfter = { "userManagement": { "membersByEmail": { "jessie@gmail.com": { "email": "jessie@gmail.com", "password": "my-secret" },
168 Часть 1. Гибкость "franck@gmail.com": { "email": "franck@gmail.com", "password": "my-top-secret" } } } }; var result = Library.addMember(libraryStateBefore, jessie); _.isEqual(result, expectedLibraryStateAfter); Джо: Красиво! Теперь мы готовы к последней части. Напишите модульный тест для System .addMember. Прежде чем начать, не могли бы вы описать ввод и вывод System.addMember? Тео еще раз смотрит на код System.addMember и колеблется; он немного сбит с толку. Функция ничего не возвращает! Листинг 6.26. Код System.addMember System.addMember = function(systemState, member) { var previous = systemState.get(); var next = Library.addMember(previous, member); systemState.commit(previous, next); }; Тео: Ввод System.addMember — это экземпляр состояния системы и член. Но я не уверен, что выводит System.addMember. Джо: На самом деле System.addMember не имеет никакого вывода. Он относится к той части нашего кода с сохранением состояния, которая не связана с манипулированием данными. Хотя ДОП позволяет нам уменьшить размер части нашего кода с сохранением состояния, он все еще существует. Вот как я это себе представляю. Джо подзывает официанта, чтобы узнать, можно ли принести еще салфеток. Решив эту проблему, он рисует диаграмму на рис. 6.7. Рис. 6.7. System.addMember не возвращает данные — он изменяет состояние системы!
Глава 6. Модульные тесты 169 Тео: Тогда как мы можем проверить, что код работает должным образом? Джо: Мы получим состояние системы после выполнения кода и сравним его с ожидаемым значением состояния. Тео: Хорошо. Я попробую написать модульный тест. Джо: Написание модульных тестов для кода с отслеживанием состояния сложнее, чем для кода манипулирования данными. Это требует спокойствия в офисе. Тео: Тогда давайте вернемся в офис. Официант! Счет, пожалуйста. Тео оплачивает счет, и они с Джо возвращаются на канатной дороге в Albatross. Когда они возвращаются в офис, Тео начинает писать модульный тест для Library.addMember. Тео: Можем ли мы использовать _.isEqual с состоянием системы? Джо: Определенно. Состояние системы — это карта, как и любая другая карта. СОВЕТ. Состояние системы — это карта. Следовательно, в контексте тест-кейса мы можем сравнить состояние системы после внесения изменения с ожидаемым состоянием системы, используя _.isEqual. Тео копирует и вставляет код для Library.addMember (листинг 6.21), который инициализирует данные для теста. Затем он передает объект SystemState, инициализированный с помощью libraryStateBefore, в System.addMember. Наконец, чтобы завершить тест, он сравнивает состояние системы после внесения изменения с ожидаемым значением состояния. class SystemState { systemState; get() { return this.systemState; } commit(previous, next) { this.systemState = next; } } window.SystemState = SystemState; Листинг 6.27. Модульный тест для System.addMember var jessie = { "email": "jessie@gmail.com", "password": "my-secret" }; var franck = { "email": "franck@gmail.com", "password": "my-top-secret" };
170 Часть 1. Гибкость var libraryStateBefore = { "userManagement": { "membersByEmail": { "franck@gmail.com": { "email": "franck@gmail.com", "password": "my-top-secret" } } } }; var expectedLibraryStateAfter = { userManagement": { "membersByEmail": { "jessie@gmail.com": { "email": "jessie@gmail.com", "password": "my-secret" }, "franck@gmail.com": { "email": "franck@gmail.com", "password": "my-top-secret" } } } }; var systemState = new SystemState();❶ systemState.commit(null, libraryStateBefore); ❷ System.addMember(systemState, jessie); ❸ _.isEqual(systemState.get(), expectedLibraryStateAfter); ❹ ❶ Создает пустой объект SystemState (см. главу 4). ❷ Инициализирует состояние системы данными библиотеки перед изменением. ❸ Выполняет изменение объекта SystemState. ❹ Проверяет состояние после выполнения изменения. Джо: Ничего себе, я впечатлен; вы сделали это! Поздравляю! Тео: Спасибо. Я так рад, что в ДОП большая часть нашего кода связана с манипулированием данными. Определенно, приятнее писать модульные тесты для кода без сохранения состояния, который имеет дело только с манипулированием данными. Джо: Теперь, когда вы знаете основы ДОП, не хотели бы вы провести рефакторинг кода своего прототипа Klafim в соответствии с принципами ДОП?
Глава 6. Модульные тесты 171 Тео: Определенно. Нэнси сказала мне вчера, что Klafim набирает обороты на рынке. Я должен встретиться с ней через неделю или около того, чтобы обсудить следующие шаги. Надеюсь, она будет готова работать с Albatross в долгосрочной перспективе. Джо: Интересно. Знаете ли вы, что может повлиять на решение Нэнси? Тео: Наша смета расходов, конечно, но я знаю, что она контактирует с другими компаниями-разработчиками программного обеспечения. Если мы выступим с конкурентоспособным предложением, я думаю, мы заключим сделку. Джо: Я совершенно уверен, что после рефакторинга в ДОП реализация фич займет гораздо меньше времени. Это означает, что вы должны иметь возможность предложить Нэнси более низкую общую стоимость, чем у конкурентов, верно? Тео: Я буду надеяться на лучшее. Движение вперед Встреча с Нэнси прошла хорошо. Albatross заключил сделку, Моника (босс Тео) довольна, и это будет долгосрочный проект с хорошим бюджетом. Им нужно будет нанять команду разработчиков, чтобы уложиться в жесткие сроки. По дороге обратно в офис Тео отвечает на телефонный звонок от Джо. Джо: Как прошла встреча с Нэнси? Тео: Мы заключили сделку! Джо: Отлично! Я говорил вам, что с ДОП оценка затрат будет ниже. Тео: На самом деле мы не собираемся использовать ДОП для этого проекта. Джо: Почему? Тео: После рефакторинга прототипа системы управления библиотеками в ДОП я провел глубокий анализ со своими инженерами. Мы пришли к выводу, что ДОП может хорошо подойти для этапа прототипа, но он не будет хорошо работать в масштабе. Джо: Не могли бы вы поделиться деталями вашего анализа? Тео: Прямо сейчас не могу. Я за рулем. Джо: Не могли бы мы встретиться в вашем офисе сегодня попозже? Тео: Я очень занят новым проектом и жесткими сроками. Джо: Давайте встретимся хотя бы для того, чтобы как следует попрощаться. Тео: Хорошо. Тогда давай встретимся в четыре часа дня.  ПРИМЕЧАНИЕ. История продолжается в начале части 2.
172 Часть 1. Гибкость Итоги  Большая часть кода в дата-ориентированной системе связана с манипулировани- ем данными.  Написать модульные тесты для кода, связанного с манипулированием данными, несложно.  Тест-кейсы следуют одной и той же простой общей схеме: • генерировать входные данные; • генерировать ожидаемые выходные данные; • сравнивать выходные данные функции с ожидаемыми выходными данными.  Чтобы сравнить вывод функции с ожидаемым выводом данных, нам нужно рекурсивно сравнить два фрагмента данных.  Рекурсивное сравнение двух фрагментов данных реализовано с помощью уни- версальной функции.  Когда функция возвращает строку JSON, мы анализируем строку обратно в дан- ные, чтобы иметь дело со сравнением данных, а не со сравнением строк.  Дерево вызовов функций для функции f — это дерево, в котором корнем являет- ся f, а дочерними элементами узла g в дереве являются функции, вызываемые g.  Листья дерева — это функции, которые не являются частью кодовой базы приложения и являются функциями, которые не вызывают никаких других функций.  Визуализация дерева вызовов функций помогает нам определить качество и ко- личество тест-кейсов в модульном тесте.  Функции, расположенные на более низком уровне дерева вызовов функций, как правило, используют менее сложные данные, чем функции, расположенные на более высоком уровне дерева.  Функции, расположенные на более низком уровне в дереве вызовов функций, обычно должны быть покрыты большим количеством тестовых примеров, чем функции, расположенные на более высоком уровне в дереве.  Модульные мутационные тесты сосредоточены на фазе расчета изменения.  Достоверность данных зависит от контекста.  Чем меньше данных, тем легче ими манипулировать.  Мы сравниваем выходные данные и ожидаемые выходные данные наших функ- ций с универсальной функцией, которая рекурсивно сравнивает две части данных (например, _.isEqual).  Когда мы пишем модульный тест для функции, мы предполагаем, что функции, вызываемые этой функцией, покрываются модульными тестами и работают должным образом. Это значительно уменьшает количество тест-кейсов в наших модульных тестах.
Глава 6. Модульные тесты 173  Мы избегаем использования сравнения строк в модульных тестах для функций, которые работают с данными.  Написание модульного теста для основной функции изменения требует больше усилий, чем для запроса.  Не забудьте включить отрицательные тест-кейсы в ваши модульные тесты.  Состояние системы — это карта. Следовательно, в контексте тест-кейса мы можем сравнить состояние системы после внесения изменения с ожидаемым состоянием системы, используя универсальную функцию, такую как _.isEqual.
174 Часть 1. Гибкость
Часть 2 Масштабируемость Т ео чувствует себя довольно неловко перед встречей с Джо. Он с таким энтузиазмом рассказывал о ДОП и так хорошо его обучал. Каждая встреча с ним была возможностью узнать что-то новое. Тео страшно благодарен за время, которое провел с ним Джо. Тео не хотел бы вредить ему никоим образом. Как ни странно, Джо входит в офис с таким же непринужденным видом, как обычно, и даже улыбается. Джо: Я очень рад, что ты заключил сделку с Нэнси. Тео: Ага. Мы в нашем офисе очень этому рады, но и немного переживаем. Джо: А почему переживаете? Тео: Ну... Нам нужно нанять команду разработчиков, а сроки довольно сжатые. Джо: Но вы говорили, что не будете использовать ДОП. Я думал, сроки будут обычные. Тео: Нет, просто моя начальница Моника очень сильно хотела заключить сделку. Она считает, что успех этого проекта стратегически важен для Albatross, поэтому стоит пойти на некоторый риск, обозначив, по ее выражению, «оптимистичные» сроки. Я сказал ей, что такие сроки абсолютно нереалистичные, но Моника настаивает на том, что если мы будем принимать разумные решения и привлечем больше разработчиков, у нас все получится. Джо: Понятно. Теперь я вижу, почему по телефону вы мне сказали, что очень заняты. Но все же, пожалуйста, поделитесь причинами, которые заставили вас думать, что ДОП не подойдет в масштабе? Тео: Прежде всего позвольте мне поблагодарить вас за все, чему вы меня научили. Повторная реализация прототипа Klafim с помощью ДОП была увлекательным и продуктивным занятием благодаря гибкости, которую предлагает эта парадигма. Джо: Я рад, что вам это пригодилось. Тео: Но, как я уже сказал вам по телефону, сейчас мы масштабируемся до долгосрочного проекта с несколькими разработчиками, работающими над большой кодовой базой. Мы пришли к выводу, что ДОП не будет работать в большом масштабе.
176 Часть 2. Масштабируемость Джо: Поделитесь-ка причинами такого умозаключения. Тео: Причин много. Прежде всего, поскольку ДОП работает только с общими структурами данных, трудно понять, какие данные у нас есть на руках, в то время как в ООП мы знаем тип каждого фрагмента данных. Для прототипа это сгодилось. Но по мере роста кодовой базы и вовлечения в проект все большего числа разработчиков нам тяжело придется. Джо: Я вас понял. Что еще, друг мой? Тео: Наша система будет работать в многопоточной среде. Я рассмотрел представленную вами стратегию управления конкурентностью, и она не является потокобезопасной. Джо: Понял. А еще что? Тео: Я провел небольшое исследование по внедрению неизменяемых структур данных со структурным разделением. Я обнаружил, что при увеличении размера структур данных происходит значительное снижение производительности. Джо: Все понятно. Что-то еще? Тео: По мере роста нашей системы мы будем использовать базу данных для хранения данных приложений и внешних сервисов для обогащения информации о книгах, а то, что вы уже мне показывали, хранит данные в памяти. Джо: Понял, понял. А еще-то что, дружище? Тео: А вам не кажется, что у меня достаточно причин отказаться от ДОП? Джо: Я абсолютно уверен, что ваши опасения по поводу ДОП при масштабировании имеют смысл. Однако это не означает, что вы должны отказаться от ДОП. Тео: Что вы имеете в виду? Джо: С помощью медитации я научился не привязываться к возражениям, которые возникают у меня в голове во время практики. Иногда, чтобы успокоить наш разум, нужно просто продолжать дышать, но иногда требуется более глубокая медитация. Тео: Я не понимаю, как дыхание убедит меня дать ДОП второй шанс. Джо: В этом случае дыхания может быть и недостаточно, но более глубокое знание ДОП может прийти на помощь. До сих пор я делился с вами только тем материалом, который был необходим для рефакторинга вашего прототипа. Для того чтобы использовать ДОП в большом проекте, необходимо еще несколько уроков. Тео: Но у меня нет времени на дополнительные уроки. Мне нужно работать. Джо: Вы слышали историю о молодом дровосеке и старике? Тео: Нет. Джо: Тогда я расскажу.
Часть 2. Масштабируемость 177 МОЛОДОЙ ДРОВОСЕК И СТАРИК Молодой дровосек с большим трудом пытался спилить дерево. Старик, стоявший неподалеку и наблюдавший за происходящим, спросил: «Что ты делаешь?» «Ты что, слепой? — ответил дровосек. — Я рублю это дерево». Старик ответил: «Ты же весь измучался! Сделай перерыв. Наточи свою пилу». Молодой дровосек объяснил старику, что он пилил уже несколько часов и у него не было времени сделать перерыв. Старик настаивал: «Если ты наточишь пилу, то срубишь дерево гораздо быстрее». Дровосек сказал: «У меня нет времени затачивать пилу. Разве ты не видишь, я слишком занят!» Тео на мгновение задумался над этой историей. Он задался вопросом, стоит ли ему потратить время на то, чтобы наточить свою пилу и перейти на более глубокий уровень практики. Тео: Вы в самом деле думаете, что с ДОП реализация проекта займет гораздо меньше времени? Джо: Я в этом уверен! Тео: Но если мы пропустим сроки, меня, вероятно, уволят. Вы ничем не рискуете, а я как раз наоборот. Джо: Давай заключим сделку. Если вы не уложитесь в срок и вас уволят, я найму вас в свою компанию с зарплатой в два раза больше той, которую вы получаете в Albatross. Тео: А что, если мы уложимся в срок? Джо: Если вы уложитесь в срок, вас, вероятно, повысят в должности. В таком случае я попрошу вас купить подарок для моего сына Нерайи и моей дочери Аурелии. Тео: Годится! Когда я получу свой первый урок о том, как глубже погрузиться в ДОП? Джо: Почему бы не начать прямо сейчас? Тео: Так, сейчас перенесу свои встречи.
178 Часть 2. Масштабируемость
Основы валидации данных Торжественный подарок В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Важность валидации данных на границах системы.  Валидация данных с использованием языка JSON-схемы.  Интеграция валидации данных в существующую кодовую базу.  Получение подробной информации о сбоях валидации данных. На первый взгляд может показаться, что применение ДОП означает доступ к данным без их валидации, а также погружение в сказочную реальность, где данные всегда валидны. На самом деле валидация данных не только возможна, но и рекомендована, когда мы следуем принципам дата-ориентированного программирования. В этой главе показано, как валидировать данные, когда они представлены с помощью общих структур данных. Основное внимание в ней уделяется валидации данных, происходящей на границах системы, в то время как в третьей части книги мы будем заниматься валидацией данных по мере их прохождения через систему. Эта глава представляет собой глубокое погружение в четвертый принцип ДОП. ПРИНЦИП № 4. Отделяйте схему данных от представления данных. 7.1. Валидация данных в ДОП Тео перенес свои встречи. Сроки намечены грозные, поэтому он все еще не уверен, верно ли было дать ДОП второй шанс.  ПРИМЕЧАНИЕ. Причина, по которой Тео перенес свои встречи, объясняется в начале второй части. Найдите минутку, чтобы прочитать вступительную часть, если вы ее пропустили.
180 Часть 2. Масштабируемость Джо: Как вы думаете, какого аспекта ООП вам будет не хватать больше всего в вашем большом проекте? Тео: Валидации данных. Джо: Расскажите-ка поподробнее. Тео: В ООП есть надежная гарантия того, что при создании экземпляра класса его поля-элементы будут иметь правильные имена и правильные типы. Но с ДОП слишком легко допустить ошибки в именах и типах полей. Джо: Что ж, у меня для вас прекрасные новости! Существует способ валидации данных и в ДОП. Тео: А как это будет работать? Я думал, что ДОП и проверка данных — это две взаимоисключающие вещи! Джо: Вовсе нет. ДОП и в самом деле не вынуждает вас валидировать данные, но это не мешает вам делать это самостоятельно. В ДОП схема данных отделена от представления данных. Тео: Я не понимаю, устранит ли это проблемы с согласованностью данных. Джо: Согласно принципам ДОП наиболее важные для валидации данные — это данные, которые пересекают границы системы. Тео: Какие границы вы имеете в виду? Джо: В случае веб-сервера это будут области, в которых веб-сервер взаимодействует со своими клиентами и со своими источниками данных. Тео: А можно посмотреть на диаграмму? Джо подходит к доске и берет ручку. Затем он рисует диаграмму, подобную приведенной на рис. 7.1. Рис. 7.1. Высокоуровневая архитектура современного веб-сервера Джо: Эта архитектурная диаграмма определяет то, что мы называем границами системы с точки зрения обмена данными. Сможете ли вы назвать три границы системы?  ПРИМЕЧАНИЕ. Границы системы определяются как области, в которых система обменивается данными.
Глава 7. Основы валидации данных 181 Тео: Сейчас подумаю. Первая — это граница с клиентом, затем идет граница с базой данных и, наконец, граница с веб-сервисом. Джо: Именно! Важно определить границы системы, потому что в ДОП мы разграничиваем два вида валидации данных: валидация, которая происходит на границах системы, и валидация, которая происходит внутри системы. Сегодня мы сосредоточимся на валидации, которая происходит на границах системы. Тео: Означает ли это, что валидация данных на границах системы более важна? Джо: Конечно! Как только вы убедитесь, что данные, поступающие в систему и из нее, являются валидными, вероятность появления неподходящих данных внутри системы довольно мала. СОВЕТ. После валидации данных на границах системы повторная валидация данных внутри системы не является обязательной. Тео: Тогда зачем нам нужна проверка данных внутри системы? Джо: Это связано с упрощением кодирования системы по мере роста кодовой базы. Тео: И какова же основная цель валидации данных на границах? Джо: Чтобы предотвратить ввод невалидных данных и их вывод из системы, а также для отображения полезной информации об ошибках при столкновении с невалидными данными. Я нарисую на доске таблицу, и вы сможете увидеть отличия (табл. 7.1). Таблица 7.1. Два вида валидации данных Вид валидации данных Цель Среда На границах Защита Использование Внутри Облегчение разработки Разработка Тео: А когда вы расскажете мне о валидации данных внутри системы? Джо: Позже, когда кодовая база станет больше. 7.2. Суть JSON-схемы Тео: На данный момент Система управления библиотекой — это приложение, которое выполняется в памяти, без базы данных и подключенных к нему HTTPклиентов. Но Нэнси, вероятно, захочет, чтобы я превратил систему в настоящий веб-сервер с клиентами, базой данных и внешними сервисами. Джо: Хорошо. Давайте представим, как будет выглядеть запрос клиента на поиск книг. Тео: По сути, поисковый запрос состоит из строки и полей, которые вы хотели бы получить для книг, название которых содержит эту строку. Таким образом, запрос состоит из двух полей: title, который является строкой, и fields, который является массивом строк.
182 Часть 2. Масштабируемость Тео быстро записывает что-то на доске. Закончив, он отходит в сторону, чтобы Джо смог взглянуть на получившийся код для поискового запроса. Листинг 7.1. Пример поискового запроса { "title": "habit", "fields": ["title", "weight", "number_of_pages"] } Джо: Понятно. Позвольте, я покажу вам, как выразить схему поискового запроса отдельно от представления данных поискового запроса. Тео: Что именно вы подразумеваете под «отдельно»? Джо: Представление данных выступает само по себе, а схема данных — сама по себе. Вы можете проводить проверку соответствия фрагмента данных схеме данных, как захотите и когда захотите. СОВЕТ. В ДОП схема данных отделена от представления данных. Тео: Как по мне, так это довольно абстрактно. Джо: Это точно. Но через мгновение все прояснится. А пока я покажу вам, как создать схему данных для поискового запроса на языке схем, называемом JSONсхемой. Тео: Я обожаю JSON!  ПРИМЕЧАНИЕ. Информацию о языке JSON-схема можно найти по адресу https://jsonschema.org. Для схем в этой книге используется JSON-схема версии 2020-12. Джо: Во-первых, мы должны указать тип данных запроса. Каким будет тип данных в случае запроса на поиск книг? Тео: Карта. Джо: В схеме JSON тип данных для карт называется object. Я покажу вам базовую канву карты. Это карта с двумя полями: type и properties. Джо подходит к доске. Он набрасывает код для карты с двумя полями. Листинг 7.2. Схема канвы карты { "type": "object", "properties": {...} } Джо: Значение поля type — «object», а значение поля properties — карта со схемой для полей карты.
Глава 7. Основы валидации данных 183 Тео: Насколько я понял, внутри поля свойств мы будем выражать схему полей карты в виде JSON-схемы. Джо: Верно. Тео: У меня уже голова кружится от рекурсии... Джо: В JSON-схеме любая из схем обычно представляет собой объект JSON с полем, называемым type, которое определяет тип данных. Например, тип поля title — string, а вот... Тео: ...тип поля fields — array. Джо: Да! Теперь к доске подходит Тео. Он заполняет пробелы в схеме поискового запроса при помощи информации о полях. Листинг 7.3. Канва схемы для поискового запроса { "type": "object", "properties": { "title": {"type": "string"}, "fields": {"type": "array"} } } Тео собрался было вернуться к своему столу, но Джо сделал ему знак «Пожалуйста, оставайтесь пока у доски». Тео разворачивается и возвращается к доске. Джо: Можно уточнить свойство поля fields, предоставив информацию о типе элементов в массиве. В JSON-схеме схема массива имеет свойство под названием items, значением которых является схема для элементов массива. Тео сразу же добавляет эту информацию на доску. Отойдя в сторону, он показывает Джо результат. Листинг 7.4. Схема поискового запроса с информацией об элементах массива { "type": "object", "properties": { "title": {"type": "string"}, "fields": { "type": "array", "items": {"type": "string"} } } }
184 Часть 2. Масштабируемость Прежде чем вернуться к своему столу, Тео спрашивает Джо: Тео: Теперь-то мы закончили? Джо: Еще нет. Можно также быть поточнее в отношении поля fields в поисковом запросе. Я предполагаю, что поля в запросе должны быть частью закрытого списка полей. Поэтому вместо того, чтобы разрешать любую строку, мы могли бы составить список разрешенных значений. Тео: Что-то вроде значения перечисления? Джо: Точно! На самом деле JSON-схема поддерживает значения перечислений с помощью ключевого слова enum. Вместо {"type": "string"} вам нужно ввести {"enum": [...]} и заменить точки на поддерживаемые поля. Тео снова поворачивается к доске. Он заменяет точки информацией, о которой поведал Джо. Листинг 7.5. Схема для поискового запроса со значениями перечисления { "type": "object", "properties": { "title": {"type": "string"}, "fields": { "type": "array", "items": { "enum": [ "publishers", "number_of_pages", "weight", "physical_format", "subjects", "publish_date", "physical_dimensions" ] } } } } Тео: Ну теперь-то все? Джо: Почти. Нам нужно решить, являются ли поля нашего поискового запроса необязательными или обязательными. В нашем случае обязательны как поле title, так и поле fields. Тео: И как же нам выразить эту информацию в JSON-схеме? Джо: Существует поле с именем required, значение которого представляет собой массив, состоящий из имен обязательных полей на карте.
Глава 7. Основы валидации данных 185 Тео добавляет на доску поле required и смотрит на Джо. На этот раз Джо делает знак: «Теперь можете вернуться к своему столу». Листинг 7.6. Схема поискового запроса var searchBooksRequestSchema = { "type": "object", "properties": { "title": {"type": "string"}, "fields": { "type": "array", "items": { "enum": [ "publishers", "number_of_pages", "weight", "physical_format", "subjects", "publish_date", "physical_dimensions" ] } } }, "required": ["title", "fields"] }; Джо: Теперь я покажу вам, как произвести валидацию фрагмента данных в соответствии со схемой. Тео: Что значит произвести валидацию? Джо: Валидировать данные в соответствии со схемой означает проверить, соответствуют ли данные схеме. В нашем случае это означает проверку того, является ли фрагмент данных валидным поисковым запросом на книги. СОВЕТ. Валидация данных в ДОП — это проверка соответствия фрагмента данных схеме. Тео: Понял. Джо: Есть несколько библиотек, которые обеспечивают валидацию для JSONсхемы. В них есть функция validate, которая получает схему и фрагмент данных и возвращает значение true, если данные валидны, и false, когда данные невалидны. У меня на ноутбуке совершенно случайно оказался файл, содержащий список библиотек с функцией валидации схемы (табл. 7.2). Если хотите, мы его распечатаем. Тео включает принтер, пока Джо ищет на ноутбуке нужную таблицу. Отыскав файл, он сверяется с Тео и нажимает кнопку Печать.
186 Часть 2. Масштабируемость Таблица 7.2. Библиотеки для валидации JSON-схемы Язык Библиотека URL JavaScript Ajv https://github.com/ajv-validator/ajv Java Snow https://github.com/ssilverman/snowy-json C# JSON.net Schema https://www.newtonsoft.com/jsonschema Python jschon https://github.com/marksparkza/jschon Ruby JSONSchemer https://github.com/davishmcclurg/json_schemer Тео: Итак, если я вызову validate с этим поисковым запросом и этой схемой, будет возвращено значение true? Тео имеет в виду пример поискового запроса из листинга 7.7 и схему из листинга 7.6. Листинг 7.7. Пример поискового запроса { "title": "habit", "fields": ["title", "weight", "number_of_pages"] } Джо: А вы попробуйте сами! И в самом деле! Когда Тео выполняет код для проверки поискового запроса, возвращается значение true. Листинг 7.8. Валидация поискового запроса var searchBooksRequestSchema = { "type": "object", "properties": { "title": {"type": "string"}, "fields": { "type": "array", "items": {"type": "string"} } }, "required": ["title", "fields"] }; var searchBooksRequest = { "title": "habit", "fields": ["title", "weight", "number_of_pages"] };
Глава 7. Основы валидации данных 187 validate(searchBooksRequestSchema, searchBooksRequest); // → true Джо: А теперь попробуйте выполнить невалидный запрос. Тео: Сейчас подумаю, какой же вид невалидности попробовать. Точно, сделаю-ка я опечатку в поле title и назову его tilte с буквой l перед буквой t. Как и ожидалось, код с типом возвращает значение false. Тео не удивлен, а Джо улыбается от уха до уха. Листинг 7.9. Валидация недействительного поискового запроса var invalidSearchBooksRequest = { "tilte": "habit", "fields": ["title", "weight", "number_of_pages"] }; validate(searchBooksRequestSchema, invalidSearchBooksRequest); // → false Тео: Синтаксис JSON-схемы гораздо более многословный, чем синтаксис объявления элементов в классе. Почему это? Джо: По двум причинам. Во-первых, поскольку JSON-схема не зависит от языка, ее можно использовать в любом языке программирования. Как я уже говорил, в большинстве языков программирования доступны средства валидации JSONсхемы. Тео: Ясно. Джо: Во-вторых, JSON-схема позволяет вам выражать условия валидации, которые гораздо сложнее, а то и невозможно выразить, когда данные представлены с помощью классов. СОВЕТ. Выразительные способности JSON-схемы очень мощные! Тео: Вы меня заинтриговали! А сможете привести еще несколько примеров? Джо: Сейчас поговорим о композиции схемы. А однажды я покажу вам несколько примеров расширенной валидации.  ПРИМЕЧАНИЕ. Расширенная валидация описана в главе 12. Тео: А что такое расширенная валидация? Джо: Под расширенной валидацией я подразумеваю, например, проверку того, что число попадает в заданный диапазон, или того, что строка соответствует регулярному выражению. Тео: Есть ли способ получить подробную информацию о том, почему запрос невалиден?
188 Часть 2. Масштабируемость Джо: Конечно! Я покажу вам попозже. А пока позвольте мне показать вам, как проверить, что ответ, который сервер отправляет обратно клиенту, является валидным. Тео: Это выглядит намного сложнее, чем поисковый запрос книги! Джо: Почему? Тео: Потому что ответ на поисковый запрос состоит из нескольких результатов поиска книг, и в каждом результате поиска некоторые из полей являются необязательными! 7.3. Гибкость и строгость схемы Джо: А не могли бы вы привести мне пример того, как будет выглядеть ответ на запрос о поиске книги? Тео: Взгляните-ка на такой пример. Это поисковый запрос с информацией о двух книгах: «Семь навыков высокоэффективных людей» и «Сила привычки». Листинг 7.10. Пример ответа на поисковый запрос [ { "title": "7 Habits of Highly Effective People", "available": true, "isbn": "978-0812981605", "subtitle": "Powerful Lessons in Personal Change", "number_of_pages": 432 }, { "title": "The Power of Habit", "available": false, "isbn_13": "978-1982137274", "subtitle": "Why We Do What We Do in Life and Business", "subjects": [ "Social aspects", "Habit", "Change (Psychology)" ] } ] Джо: Какое совпадение, что вы упомянули о «Силе привычки». Я как раз сейчас читаю эту книгу, чтобы избавиться от своей привычки грызть ногти. Итак, какие поля обязательны, а какие необязательны в ответе на поиск книги? Тео: В информации о книге поля title и available обязательны для заполнения. Остальные поля являются необязательными.
Глава 7. Основы валидации данных 189 Джо: Когда мы создавали схему для запроса на поиск книг, я уже упоминал, что поля на карте по умолчанию являются необязательными. Чтобы сделать поле обязательным, мы должны включить его в массив required. Я бы попробовал реализовать это с помощью чего-то такого. Листинг 7.11. Схема ответа на поисковый запрос var searchBooksResponseSchema = { "type": "array", "items": { "type": "object", "required": ["title", "available"], "properties": { "title": {"type": "string"}, "available": {"type": "boolean"}, "subtitle": {"type": "string"}, "number_of_pages": {"type": "integer"}, "subjects": { "type": "array", "items": {"type": "string"} }, "isbn": {"type": "string"}, "isbn_13": {"type": "string"} } } }; СОВЕТ. В JSON-схеме поля карты являются необязательными по умолчанию. Тео: Однако же указывать список обязательных полей намного проще, чем указывать, что элемент в классе допускает значение null! Джо: Согласен! Тео: С другой стороны, мне кажется, что при вложении схемы информации о книге в схему ответов на поисковый запрос код становится труден для чтения. Джо: Ничего не мешает вам отделить схему информации о книге от схемы ответов на поисковый запрос. Тео: Как это? Джо: Это нормально для JSON, друг мой. Вы можете свободно манипулировать схемой, как и любой другой картой в вашей программе. Например, можно вложить схему информации о книге в переменную с именем bookInfoSchema и использовать это значение в схеме ответа на поиск книг. Сейчас я реорганизую схему, чтобы показать вам, что я имею в виду.
190 Часть 2. Масштабируемость Листинг 7.12. Реорганизованная схема ответа на поисковый запрос var bookInfoSchema = { "type": "object", "required": ["title", "available"], "properties": { "title": {"type": "string"}, "available": {"type": "boolean"}, "subtitle": {"type": "string"}, "number_of_pages": {"type": "integer"}, "subjects": { "type": "array", "items": {"type": "string"} }, "isbn": {"type": "string"}, "isbn_13": {"type": "string"} } }; var searchBooksResponseSchema = { "type": "array", "items": bookInfoSchema }; Тео: В самом деле, должен признать, что JSON-схемы более компонуемы, чем определения классов. СОВЕТ. JSON-cхемы — это просто карты. Мы вольны компоновать их и манипулировать ими, как и любой другой картой. Джо: Давайте перейдем к валидации данных, полученных из внешних источников. Тео: А разве есть отличия? Джо: На самом деле нет, но я воспользуюсь этим как возможностью показать вам и другие возможности JSON-схемы. Тео: С удовольствием узнал бы, как используется валидация данных, когда мы обращаемся к данным из базы данных. Джо: Каждый раз, когда мы обращаемся к данным извне, рекомендуется валидировать их. Покажите мне, пожалуйста, пример того, как будет выглядеть ответ базы данных на поисковый запрос. СОВЕТ. Рекомендуется валидировать данные, поступающие из внешнего источника. Тео: Когда мы запрашиваем книги из базы данных, мы ожидаем получить массив книг с тремя полями: title, isbn и available. Первые два значения должны быть строками, а третье должно быть булевым значением (Boolean).
Глава 7. Основы валидации данных 191 Джо: А эти поля необязательные или обязательные? Тео: Что вы имеете в виду? Джо: Могут ли существовать книги, для которых некоторые поля не определены? Тео: Нет. Джо: В этом случае схема довольно проста. Не хотите ли попробовать написать схему для ответа базы данных? Тео: Дайте подумать. Это массив объектов, где каждый объект имеет три свойства, так что получится что-то вроде этого? Листинг 7.13. Схема ответа базы данных { "type": "array", "items": { "type": "object", "required": ["title", "isbn", "available"], "properties": { "title": {"type": "string"}, "available": {"type": "boolean"}, "isbn": {"type": "string"} } } } Джо: Отлично, друг мой! Теперь я хочу рассказать вам о поле additionalProperties в JSON-схеме. Тео: А что это? Джо: Взгляните на этот массив. Листинг 7.14. Массив книг с дополнительным свойством [ { "title": "7 Habits of Highly Effective People", "available": true, "isbn": "978-0812981605", "dummy_property": 42 }, { "title": "The Power of Habit", "available": false, "isbn": "978-1982137274", "dummy_property": 45 } ]
192 Часть 2. Масштабируемость Джо: Это валидный ответ базы данных? Тео: Нет. В ответе базы данных не должно быть поля dummy_property. В нем должны быть только три обязательных поля, указанных в схеме. Джо: Это может вас удивить, но по умолчанию поля, не указанные в схеме объекта, разрешены в JSON-схеме. Чтобы запретить их, нужно установить для поля additionalProperties значение false, как здесь. Листинг 7.15. Запрещение свойства, не упомянутого в схеме var booksFromDBSchema = { "type": "array", "items": { "type": "object", "required": ["title", "isbn", "available"], "additionalProperties": false, "properties": { "title": {"type": "string"}, "available": {"type": "boolean"}, "isbn": {"type": "string"} } } }; СОВЕТ. В JSON-схеме по умолчанию разрешены поля, не указанные в схеме карты. Тео: А почему так? Джо: Причина в том, что обычно наличие дополнительных полей на карте не вызывает проблем. Если ваш код не интересуется каким-либо полем, он просто игнорирует его. Но иногда нам нужно быть максимально точными, тогда мы установим false в качестве значения для additionalProperties. Тео: А как насчет схемы поискового запроса и ответа из предыдущего обсуждения? Должны ли мы установить для additionalProperties значение false? Джо: Прекрасный вопрос. Я бы сказал, что это дело вкуса. Лично мне нравится разрешать дополнительные поля в запросах и запрещать их в ответах. Тео: В чем здесь преимущество? Джо: Ну, веб-сервер занимается ответами, которые он отправляет своим клиентам. Тогда нам лучше проявить максимальную точность. Однако запросы создаются клиентами, и я предпочитаю делать все возможное, чтобы обслуживать своих клиентов, даже когда они не так точны, как следовало бы. Тео: Ах, конечно. «Клиент всегда прав». Джо: На самом деле мне больше нравится принцип надежности, сформулированный Джоном Постелом: «Будьте консервативны в том, что вы отправляете, будьте либеральны в том, что вы принимаете».
Глава 7. Основы валидации данных 193 СОВЕТ. Рекомендуется досконально точно относиться к данным, которые вы отправляете, и проявлять гибкость в отношении данных, которые вы получаете. 7.4. Композиция схемы Тео: А что насчет валидации данных, поступающих с внешнего веб-сервиса? Джо: Приведите-ка пример! Тео: В ближайшем будущем нам придется интегрироваться с сервисом под названием Open Library Books API, который предоставляет подробную информацию о книгах.  ПРИМЕЧАНИЕ. Для получения информации об API Open Library Books см. https://openlibrary.org/dev/docs/api/books. Джо: Покажите, пожалуйста, ответ сервиса для, допустим, комикса «Хранители». Тео: Хорошо. Вот. Тео нажимает несколько клавиш на своей клавиатуре и выводит ответ. Джо долго смотрит на получившуюся JSON-схему. Листинг 7.16. Пример ответа API Open Library Books { "publishers": [ "DC Comics" ], "number_of_pages": 334, "weight": "1.4 pounds", "physical_format": "Paperback", "subjects": [ "Graphic Novels", "Comics & Graphic Novels", "Fiction", "Fantastic fiction" ], "isbn_13": [ "9780930289232" ], "title": "Watchmen", "isbn_10": [ "0930289234" ], "publish_date": "April 1, 1995", "physical_dimensions": "10.1 x 6.6 x 0.8 inches" }
194 Часть 2. Масштабируемость Тео недоумевает: «Что может быть особенного в этой схеме?» Пока Джо размышляет над этим фрагментом JSON, Тео пишет JSON-схему для ответа Books API. Это кажется не сложнее написания любой из предыдущих схем. Когда Тео заканчивает, он просит Джо взглянуть на схему. Листинг 7.17. Схема ответа API Open Library Books { "type": "object", "required": ["title"], "properties": { "title": {"type": "string"}, "publishers": { "type": "array", "items": {"type": "string"} }, "number_of_pages": {"type": "integer"}, "weight": {"type": "string"}, "physical_format": {"type": "string"}, "subjects": { "type": "array", "items": {"type": "string"} }, "isbn_13": { "type": "array", "items": {"type": "string"} }, "isbn_10": { "type": "array", "items": {"type": "string"} }, "publish_date": {"type": "string"}, "physical_dimensions": {"type": "string"} } } Джо: Вышло отлично! Тео: Было не так уж и сложно. Я только не могу понять, почему вы так долго смотрели на ту схему ответа. Джо: А это все из-за полей isbn_10 и isbn_13. Я предполагаю, что они оба не являются обязательными. Тео: Верно! Поэтому я и не включил их в поле required своей схемы. Джо: Но одно из них всегда должно там присутствовать, не так ли? Тео: Иногда одно, а иногда и оба, как для «Хранителей». Это зависит от года издания книги. Книги, опубликованные до 2007 года, имеют isbn_10, а книги, опубликованные после 2007 года, имеют isbn_13.
Глава 7. Основы валидации данных 195 Джо: А, понятно. И у «Хранителей» есть и то и другое, потому что первоначально этот комикс был опубликован в 1986 году, а затем снова начал публиковаться после 2007 года. Тео: Точно. Джо: Значит, вам нужно, чтобы ваша схема указывала, что одно из полей isbn является обязательным. Это дает мне шанс рассказать вам о композиции JSONсхемы. Тео: Что же это такое? Джо: Это способ комбинирования схем аналогично тому, как мы комбинируем логические условия с AND, OR и NOT. Тео: Давайте на это посмотрим. Джо: Сейчас. Как бы вы выразили схему для ответа Books API в виде композиции из трех схем: basicBookInfoSchema, написанной вами схемы, где обязательно только поле title; mandatoryIsbn13, схемы, где обязательно только поле isbn_13, и mandatoryIsb10, схемы, где обязательно только isbn_10? Тео: Я думаю, это должно быть так: basicBookInfoSchema AND (mandatoryIsbn13 OR mandatoryIsbn10). Джо: Абсолютно верно! Правда, в JSON-схеме мы используем ключевые слова allOf вместо AND и anyOf вместо OR. Джо показывает Тео результат в листинге 7.18 и пример его использования в листинге 7.19. Листинг 7.18. Схема ответа внешнего API var basicBookInfoSchema = { "type": "object", "required": ["title"], "properties": { "title": {"type": "string"}, "publishers": { "type": "array", "items": {"type": "string"} }, "number_of_pages": {"type": "integer"}, "weight": {"type": "string"}, "physical_format": {"type": "string"}, "subjects": { "type": "array", "items": {"type": "string"} }, "isbn_13": { "type": "array", "items": {"type": "string"} },
196 Часть 2. Масштабируемость "isbn_10": { "type": "array", "items": {"type": "string"} }, "publish_date": {"type": "string"}, "physical_dimensions": {"type": "string"} } }, var mandatoryIsbn13 = { "type": "object", "required": ["isbn_13"] }; var mandatoryIsbn10 = { "type": "object", "required": ["isbn_10"] }; var bookInfoSchema = { "allOf": [ basicBookInfoSchema, { "anyOf": [mandatoryIsbn13, mandatoryIsbn10] } ] }; Листинг 7.19. Проверка ответа внешнего API var bookInfo = { "publishers": [ "DC Comics" ], "number_of_pages": 334, "weight": "1.4 pounds", "physical_format": "Paperback", "subjects": [ "Graphic Novels", "Comics & Graphic Novels", "Fiction", "Fantastic fiction" ], "isbn_13": [ "9780930289232" ],
Глава 7. Основы валидации данных 197 "title": "Watchmen", "isbn_10": [ "0930289234" ], "publish_date": "April 1, 1995", "physical_dimensions": "10.1 x 6.6 x 0.8 inches" }; validate(bookInfoSchema, bookInfo); // → true Тео: Понятно, почему они называются allOf и anyOf. Первое ключевое слово означает, что данные должны соответствовать всем схемам, а второе означает, что данные должны соответствовать любой из схем. Джо: Ага.  ПРИМЕЧАНИЕ. JSON-схема также поддерживает ключевое слово oneOf для случаев, когда данные должны быть действительны только для одной схемы. Тео: Неплохо. Кажется, что при использовании композиции JSON-схема обладает большей выразительностью, чем та, к которой я привык для представления данных с помощью классов. Джо: Это только начало. В следующий раз я покажу вам больше условий валидации данных, которые не могут быть выражены, когда данные представлены с помощью классов.  ПРИМЕЧАНИЕ. О расширенной валидации данных рассказывается в главе 12. Тео: Однако меня все еще беспокоит один момент. Когда данные невалидны, невозможно узнать, что именно пошло не так. 7.5. Сведения о сбоях при валидации данных Джо: До сих пор мы рассматривали валидацию в JSON-схеме так, как если бы она была двоичной: либо фрагмент данных валиден, либо нет. Тео: Ну да... Джо: На самом деле когда фрагмент данных невалиден, можно получить подробную информацию о причине этой невалидности. Тео: Можем ли мы, например, когда отсутствует обязательное поле, получить название этого поля? Джо: Да. И даже когда фрагмент данных не соответствует ожидаемому типу, мы можем получить информацию и об этом. Тео: Полезная штука!
198 Часть 2. Масштабируемость Джо: Несомненно. Позвольте мне показать вам, как это работает. До сих пор мы использовали общую функцию validate, но когда мы сталкиваемся с ошибками валидации, нам нужно что-то более конкретное. Тео: Почему? Джо: Потому что каждая библиотека валидации данных имеет свой собственный способ выведения информации о сбоях валидации. Например, в валидаторе Ajv из JavaScript ошибки с последней валидации данных хранятся в виде массива внутри экземпляра валидатора. Тео: А почему в массиве? Джо: Потому что сбоев может быть несколько. Но давайте начнем с единичного сбоя. Представьте, что мы сталкиваемся с запросом на поиск книги, где поле заголовка называется myTitle вместо title. Взгляните на этот пример. Как вы можете увидеть, сначала мы создаем экземпляр валидатора. Листинг 7.20. Доступ к ошибкам валидации в Ajv var searchBooksRequestSchema = { "type": "object", "properties": { "title": {"type": "string"}, "fields": { "type": "array", "items": {"type": "string"} } }, "required": ["title", "fields"] }; var invalidSearchBooksRequest = { "myTitle": "habit", "fields": ["title", "weight", "number_of_pages"] }; var ajv = new Ajv(); ❶ ajv.validate(searchBooksRequestSchema, invalidSearchBooksRequest); ajv.errors ❷ ❶ Создает экземпляр валидатора. ❷ Отображает ошибки валидации. Тео: А как выглядит информация внутри массива errors? Джо: Выполните фрагмент кода — и увидите.
Глава 7. Основы валидации данных 199 Когда Тео выполняет фрагменты кода из листинга 7.20, он едва верит своим глазам. Он изучает информацию, с трудом ее переваривая. Листинг 7.21. Сведения об одном сбое валидации данных в формате массива [ { "instancePath": "", "schemaPath": "#/required", "keyword": "required", "params": { "missingProperty":"title" }, "message": "must have required property 'title'" } ] Тео: Мне довольно сложно понять содержимое массива errors. Джо: Мне тоже. К счастью, Ajv предоставляет служебную функцию errorsText для преобразования массива errors в удобочитаемый формат. Посмотрите, например, что возвращается при вызове errorsText. Листинг 7.22. Отображение ошибок в удобочитаемом формате ajv.errorsText(ajv.errors); // → "data must have required property 'title'" Тео: Давайте посмотрим, что происходит, когда в данных более одного сбоя валидации. Джо: По умолчанию Ajv улавливает только одну ошибку валидации. СОВЕТ. По умолчанию Ajv фиксирует только первый сбой валидации. Тео: Насколько я понимаю, это сделано из соображений производительности. Как только валидатор обнаруживает ошибку, он прекращает парсинг данных. Джо: Наверное, так оно и есть. В любом случае, чтобы перехватить более одного сбоя валидации, вам нужно передать параметры allErrors конструктору Ajv. Посмотрите на такой код. Листинг 7.23. Обнаружение множественных сбоев валидации var searchBooksRequestSchema = { "type": "object", "properties": { "title": {"type": "string"},
200 Часть 2. Масштабируемость "fields": { "type": "array", "items": {"type": "string"} } }, "required": ["title", "fields"] }; var invalidSearchBooksRequest = { ❶ "myTitle": "habit", "fields": [1, 2] }; var ajv = new Ajv({allErrors: true}); ❷ ajv.validate(searchBooksRequestSchema, invalidSearchBooksRequest); ajv.errorsText(ajv.errors); ❸ // → "data must have required property 'title', // → data/fields/0 must be string, // → data/fields/1 must be string" ❶ Запрос с тремя сбоями. ❷ Создает экземпляр конструктора Ajb с allErrors: значение true задается для того, чтобы обнаружить более чем один сбой. ❸ Конвертирует ошибки в удобочитаемый формат. Джо: Мы валидируем поисковый запрос с полем myTitle вместо title и цифрами вместо строк в массиве fields. Как вы можете увидеть в выходных данных фрагмента кода, возвращаются три ошибки. Тео: Великолепно! Похоже, что теперь у меня есть все, что нужно, чтобы добавить валидацию данных к границам моей системы, на случай если Нэнси попросит меня превратить Систему управления библиотекой в веб-сервер. Джо: Вы позволите мне сделать вам небольшой подарок в знак нашей дружбы? Тео: Почту за честь. Джо достает из сумки небольшой сверток, перевязанный светло-зеленой лентой. Торжественным жестом он вручает его Тео. Когда Тео развязывает ленту, он обнаруживает элегантный листок бумаги, украшенный симпатичными миниатюрными узорами. В центре листа Тео читает надпись «Шпаргалка по JSON-схеме». Он улыбается, просматривая шпаргалку. Очень полезный подарок. Листинг 7.24. Шпаргалка по JSON-схеме { "type": "array", ❶ "items": {
Глава 7. Основы валидации данных 201 "type": "object", ❷ "properties": { ❸ ❹ "myNumber": {"type": "number"}, ❺ "myString": {"type": "string"}, "myEnum": {"enum": ["myVal", "yourVal"]}, ❻ ❼ "myBool": {"type": "boolean"} }, "required": ["myNumber", "myString"], ❽ "additionalProperties": false ❾ } } ❶ На корневом уровне данные — это массив. ❷ Каждый элемент массива — это карта. ❸ Свойства каждого поля в карте. ❹ myNumber — это число. ❺ myString — это строка. ❻ myEnum — это значение перечисления с двумя вероятными сценариями: «myVal» и «yourVal». ❼ myBool — это булево значение. ❽ Обязательные поля в карте — это myNumber и myString; прочие поля необязательны. ❾ Мы не разрешаем поля, которые не упомянуты в схеме явно. Затем Тео переворачивает шпаргалку и обнаруживает, что обратная сторона тоже заполнена рисунками. В центре листа он читает надпись: «Пример валидных данных». Листинг 7.25. Пример валидных данных [ { ❶ "myNumber": 42, "myString": "Hello", "myEnum": "myVal", "myBool": true }, {❷ "myNumber": 54, "myString": "Happy" } ] ❶ Эта карта валидна, потому что все ее поля валидны. ❷ Эта карта валидна, потому что она содержит все обязательные поля.
202 Часть 2. Масштабируемость Итоги  Принцип ДОП № 4 заключается в разделении схемы данных и представлении данных.  Границы системы определяются как области, в которых система обменивается данными.  Примерами валидации данных на границах системы являются валидация кли- ентских запросов и ответов, а также валидация данных, поступающих из внешних источников.  Валидация данных в ДОП означает проверку соответствия фрагмента данных схеме.  Когда фрагмент данных невалиден, мы получаем информацию о сбоях валида- ции и отправляем эту информацию клиенту в удобочитаемом формате.  Когда данные на границах системы валидны, повторная валидация данных внут- ри системы не является необходимой.  JSON-схема — это язык, который позволяет нам отделять валидацию данных от представления данных.  Синтаксис JSON-схемы может показаться многословным.  Выразительная способность JSON-схемы высока.  JSON-схемы — это просто карты, и поэтому мы можем свободно манипулиро- вать ими, как и любыми другими картами в наших программах.  Мы можем сохранить определение схемы в переменной и использовать эту переменную в другой схеме.  В JSON-схеме поля карты по умолчанию являются необязательными.  Рекомендуется проверять данные, поступающие из внешнего источника.  Рекомендуется точно относиться к данным, которые вы отправляете, и прояв- лять гибкость в отношении данных, которые вы получаете.  Ajv — это библиотека схем JSON на JavaScript.  По умолчанию Ajv фиксирует только первый сбой валидации.  О расширенной валидации говорится в главе 12.
Расширенный контроль конкурентности И никаких взаимоблокировок! В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Атомы как альтернатива блокировкам.  Управление потокобезопасным счетчиком и потокобезопасным кешем в памяти (англ. in-memory cash) с помощью атомов.  Управление состоянием всей системы потокобезопасным способом с помощью атомов. Традиционный способ управления конкурентностью в многопоточной среде включает механизмы блокировки, такие как мьютексы. Механизмы блокировки, как правило, увеличивают сложность системы, потому что не так-то просто убедиться, что система свободна от взаимоблокировок. В ДОП мы используем тот факт, что данные неизменяемы, а также используем свободный от блокировок механизм, называемый атом, для управления конкурентностью. Атомами управлять проще, чем блокировками, потому что они не блокируются. Как следствие, обычная сложность блокировок, которые требуются, чтобы избежать взаимоблокировок, не относится к атомам.  ПРИМЕЧАНИЕ. Эта глава в основном относится к многопоточным средам, таким как Java, C#, Python и Ruby. Это менее актуально для однопоточных сред, таких как JavaScript. Фрагменты кода JavaScript в этой главе написаны так, как если бы JavaScript был многопоточным. 8.1. Сложность блокировок Этим воскресным днем, проезжая на велосипеде по мосту Золотые Ворота, Тео с беспокойством думает о проекте Klafim. Он еще не совсем уверен, что стоило делать ставку на ДОП. Внезапно Тео понимает, что он еще не запланировал следующую сессию с Джо. Он слезает с велосипеда, чтобы позвонить Джо. К сожалению, линия занята.
204 Часть 2. Масштабируемость Когда Тео возвращается домой, он снова пытается позвонить Джо, но телефон снова занят. После ужина Тео пытается дозвониться Джо еще раз, с тем же результатом: короткие гудки. «Наверное, Джо сегодня очень занят», говорит себе Тео. Измученный 50-мильной поездкой на велосипеде со средней скоростью 17 миль в час, он засыпает на диване. Когда Тео просыпается, он с восторгом видит сообщение от Джо: «Встретимся в понедельник утром в 11 утра?». Тео отвечает поднятым большим пальцем и готовится к следующей рабочей неделе. Когда Джо приходит в офис, Тео спрашивает его, почему накануне у него постоянно был занят телефон. Джо отвечает, что он собирался задать Тео тот же вопрос. Они озадаченно смотрят друг на друга, а затем одновременно разражаются смехом, когда осознают, что произошло: по удивительному совпадению они пытались дозвониться друг другу в одно и то же время. Они одновременно рассмеялись: «Взаимоблокировка!» Далее наши программисты проследовали в офис Тео. Когда они добрались до стола Тео, Джо объявил, что сегодняшняя сессия будет посвящена контролю конкурентности в многопоточных средах. Джо: Итак, как же управлять конкурентностью в многопоточной среде? Тео: Нужно защитить доступ к критическим разделам с помощью механизма блокировки, например мьютекса. Джо: Когда вы говорите «доступ», вы имеете в виду доступ на запись или доступ на чтение тоже? Тео: И то и другое! Джо: А почему нужно защищать доступ на чтение с помощью блокировки? Тео: Потому что без защиты от блокировки в середине чтения запись может произойти в другом потоке. Тогда чтение становится логически непоследовательным. Джо: Другим вариантом было бы клонировать данные перед их обработкой при чтении. Тео: Иногда я клонирую данные; но во многих случаях, когда объем большой, клонирование данных слишком затратно. СОВЕТ. Клонирование данных во избежание блокировок при чтении не масштабируется. Джо: В ДОП нам не нужно клонировать данные или защищать доступ на чтение. Тео: Потому что данные неизменяемы? Джо: Верно. Когда данные неизменяемы, даже если запись происходит в другом потоке во время чтения, это не сделает чтение непоследовательным, потому что запись никогда не изменяет считываемые данные. Тео: Похоже, что чтение всегда работает со снимком (англ. snapshot) данных. Джо: Точно!
Глава 8. Расширенный контроль конкурентности 205 СОВЕТ. Когда данные неизменяемы, операция чтения всегда безопасна. Тео: А что насчет доступа на запись? Разве не нужно защищать это блокировками? Джо: Нет. Тео: А почему нет? Джо: Есть более простой механизм под названием «атом». Тео: Рад слышать, что есть что-то более простое, чем блокировки. Я каждый раз с трудом интегрирую блокировки в многопоточную систему. Джо: Я тоже! Я до сих пор помню ошибку, с которой мы столкнулись во время эксплуатации 10 лет назад. Мы забыли снять блокировку, когда в критической секции возникло исключение. Получилась ужасная взаимоблокировка. Тео: Взаимоблокировок в самом деле трудно избежать. В прошлом году у нас возникла проблема с взаимоблокировкой, когда две блокировки не были разомкнуты как положено. Джо: У меня для вас отличные новости. При использовании атомов взаимоблокировок вообще не бывает! СОВЕТ. При использовании атомов взаимоблокировок не бывает. Тео: Звучит здорово. Рассказывайте дальше! СОВЕТ. Атомы предоставляют способ управления конкурентностью без блокировок. 8.2. Потокобезопасный счетчик с атомами Джо: Давайте начнем с простого случая: счетчик, используемый потоками совместно. Тео: Что вы подразумеваете под счетчиком? Джо: Представьте, что мы хотим подсчитать количество обращений к базе данных и записать общее количество обращений к журналу за каждую минуту. Тео: Представил. Джо: Не напишите ли для нашего многопоточного счетчика JavaScript-код с использованием блокировок? Тео: Но JavaScript однопоточный! Джо: Я знаю, но это просто для иллюстрации. Представьте, что JavaScript многопоточный и что он предоставляет объект мьютекс, который можно блокировать и разблокировать. Тео: Это как-то нелепо. Я думаю, выглядеть будет примерно так. Тео подходит к доске. Он пишет нечто, что, по его мнению, является кодом JavaScript для многопоточного счетчика с блокировками.
206 Часть 2. Масштабируемость Листинг 8.1. Потокобезопасный счетчик, защищенный мьютексом var mutex = new Mutex(); var counter = 0; function dbAccess() { mutex.lock(); counter = counter + 1; mutex.unlock(); // access the database } function logCounter() { mutex.lock(); console.log('Number of database accesses: ' + counter); mutex.unlock(); } Джо: Прекрасно. Теперь я покажу вам, как написать тот же самый код с помощью атомов. Атом предоставляет три метода:  get возвращает текущее значение атома;  set перезаписывает текущее значение атома;  swap получает функцию и обновляет значение атома при помощи результата функции, вызванной для текущего значения атома. Джо расстегивает молнию на чехле своего ноутбука и достает листок бумаги. Он протягивает его Тео. Тео приятно удивлен, поскольку на листе бумаги кратко описаны вышеупомянутые методы (табл. 8.1). Таблица 8.1. Три метода атома Метод Описание get Возвращает текущее значение set Перезаписывает текущее значение swap Обновляет текущее значение при помощи функции Тео: Что будет, если мы реализуем потокобезопасный счетчик с помощью атома? Джо: На самом деле все довольно просто. Джо достает свой ноутбук, включает его и начинает печатать. Закончив, он поворачивает ноутбук так, чтобы Тео смог увидеть код для реализации потокобезопасного счетчика в атоме. Листинг 8.2. Потокобезопасный счетчик, хранящийся в атоме var counter = new Atom(); counter.set(0);
Глава 8. Расширенный контроль конкурентности 207 function dbAccess() { counter.swap(function(x) { ❶ return x + 1; }); // access the database } function logCounter() { console.log('Number of database accesses: ' + counter.get()); } ❶ Аргумент x — это текущее значение атома, такое же, как в counter.get(). Тео: Расскажите-ка мне, что здесь происходит? Джо: Без проблем! Сначала мы создаем пустой атом. Затем инициализируем значение атома с помощью counter.set(0). В логгере потоков (англ. logger thread) мы считываем текущее значение атома с помощью counter.get(). Тео: А как нам прирастить счетчик в потоках, которые обращаются к базе данных? Джо: Нужно вызывать swap с функцией, которая получает x и возвращает x + 1. Тео: Я не понимаю, как swap может быть потокобезопасным вообще без использования блокировок. Джо быстро подходит к доске. Он набрасывает схему с рис. 8.1. Рис. 8.1. Высокоуровневый алгоритм swap Джо: Видите ли, swap вычисляет следующее значение атома, и перед изменением текущего значения атома он проверяет, изменилось ли значение атома во время вычисления. Если это так, swap повторяет попытку до тех пор, пока во время вычисления не произойдет никаких изменений. Тео: Насколько легко реализуется swap? Джо: Я покажу вам реализацию класса Atom, и вы сами увидите.
208 Часть 2. Масштабируемость Листинг 8.3. Реализация класса Atom class Atom { state; constructor() {} get() { return this.state; } set(state) { this.state = state; } swap(f) { while(true) { var stateSnapshot = this.state; var nextState = f(stateSnapshot); if (!atomicCompareAndSet(this.state, stateSnapshot, nextState)) { ❶ continue; } return nextState; } } } ❶ Использует специальную потокобезопасную операцию сравнения, поскольку this.state могло измениться в другом потоке во время выполнения функции f. Тео подходит ближе к доске. Он вносит правку в диаграмму Джо так, чтобы алгоритм swap-операции выглядел более подробным. Получившаяся диаграмма приведена на рис. 8.2. Однако у Тео все еще осталось несколько вопросов. Тео: Что такое atomicCompareAndSet? Джо: Это основная работа атома. atomicCompareAndSet атомарно устанавливает для состояния новое значение тогда и только тогда, когда состояние равно предоставленному старому значению. Она возвращает значение true при успехе и false при неудаче. Тео: Как эта функция может быть атомарной без использования блокировок? Джо: Хороший вопрос! На самом деле atomicCompareAndSet — это операция сравнения с обменом (англ. compare-and-swap), предоставляемая языком, который полагается на функциональность самого процессора. Например, в Java в пакете java.util.concurrent.atomic есть универсальный класс AtomicReference, который предоставляет метод compareAndSet().
Глава 8. Расширенный контроль конкурентности 209 Рис. 8.2. Подробный высокоуровневый алгоритм swap  ПРИМЕЧАНИЕ. См. http://tutorials.jenkov.com/java-concurrency/compare-and-swap.html для получения общей информации об операциях сравнения с обменом. Реализации для многопоточных языков приведены в табл. 8.2. Таблица 8.2. Реализация атомарного сравнения с обменом на различных языках Язык Ссылка Java http://mng.bz/mx0W JavaScript Неактуально (однопоточный язык) Ruby http://mng.bz/5KG8 Python https://github.com/maxcountryman/atomos C# http://mng.bz/6Zzp Тео: Кстати о Java: как будет выглядеть реализация атома? Джо: Да примерно также, за исключением того факта, что класс Atom должен использовать дженерики, а внутреннее состояние должно храниться в AtomicReference. Джо запускает Java-реализацию класса Atom на своем ноутбуке. Тео просматривает код. Листинг 8.4. Реализация класса Atom в Java class Atom<ValueType> { private AtomicReference<ValueType> state;
210 Часть 2. Масштабируемость public Atom() {} ValueType get() { return this.state.get(); } void set(ValueType state) { this.state.set(state); ❶ } ValueType swap(UnaryOPerator<ValueType> f) { while(true) { ValueType stateSnapshot = this.state.get(); ValueType nextState = f(stateSnapshot); if (!this.state.compareAndSet(stateSnapshot, nextState)) { continue; } } return nextState; } } ❶ this.state могло измениться в другом потоке во время выполнения f. Тео: А что насчет использования атома в Java? Джо: Смотрите. Это довольно просто. Листинг 8.5. Использование класса Atom в Java Atom<Integer> counter = new Atom<Integer>(); counter.set(0); counter.swap(x -> x + 1); counter.get(); В течение пары минут Тео размышляет обо всех этих атомах-шматомах и переваривает новую информацию. Затем он спрашивает Джо: Тео: А что если swap никогда не увенчается успехом? Я имею в виду, может ли цикл while внутри кода для swap оказаться бесконечным циклом? Джо: Нет! По определению, когда atomicCompareAndSet терпит неудачу с потоком, это означает, что тот же самый атом был изменен в другом потоке во время выполнения swap. В этом соревновании потоков всегда есть победитель. Тео: Неужели не может случиться так, что один поток никогда не добивается успеха, потому что он всегда проигрывает в соревновании с другими потоками?
Глава 8. Расширенный контроль конкурентности 211 Джо: Теоретически да, может. Но я никогда не сталкивался с такой ситуацией. Если у вас в наличии тысячи потоков, которые не занимаются ничем, кроме обмена атомов, это, наверное, может произойти. Но на практике, как только атом заменяется, потоки выполняют реальную работу, например выполняют доступ к базе данных или операцию ввод-вывод. Это дает возможность другим потокам успешно обменивать атомы.  ПРИМЕЧАНИЕ. В теории атомы могут вызвать голодание в системе с тысячами потоков, которые не занимаются ничем, кроме обмена атомов. На практике, как только атом заменяется, потоки выполняют реальную работу (например, получают доступ к базе данных), что создает возможность для других потоков успешно обменивать атомы. Тео: Интересно... Действительно, атомами, похоже, гораздо проще управлять, чем блокировками. Джо: Теперь позвольте мне показать вам, как использовать атомы с составными данными. Тео: А что, есть отличия? Джо: Обычно работать со составными данными сложнее, чем с примитивными типами. Тео: Когда вы уговаривали меня стать адептом ДОП, вы упомянули, что там можно управлять данными с такой же простотой, с которой мы управляем числами. СОВЕТ. В ДОП управление данными осуществляется с той же простотой, что и управление числами. Джо: Это именно то, что я собираюсь вам показать. 8.3. Потокобезопасный кеш с атомами Джо: Вы знакомы с понятием кеша в памяти? Тео: Вы имеете в виду мемоизацию (англ. memoization, memory + optimization)? Джо: Вроде того. Представьте, что запросы к базе данных не слишком сильно различаются в рамках приложения. В этом случае имеет смысл хранить результаты предыдущих запросов в памяти, чтобы улучшить (сократить) время отклика. Тео: Да, точно! Джо: Какую структуру данных вы бы использовали для хранения кеша в памяти? Тео: Наверное, строковую карту, где ключи — это запросы, а значения — это результаты из базы данных. СОВЕТ. Довольно часто кеш в памяти представляется в виде строковой карты. Джо: Превосходно! А теперь можете ли вы написать код для кеширования запросов к базе данных потокобезопасным способом, используя блокировку?
212 Часть 2. Масштабируемость Тео: Дайте подумать: я буду использовать неизменяемую строковую карту. Следовательно, мне не нужно защищать доступ на чтение блокировкой. Необходимо защитить только обновление кеша. Джо: Да вы уже прекрасно разбираетесь! Тео: Код получится примерно такой. Листинг 8.6. Потокобезопасный кеш с блокировками var mutex = new Mutex(); var cache = {}; function dbAccessCached(query) { var resultFromCache = _.get(cache, query); if (resultFromCache != nil) { return resultFromCache; } var result = dbAccess(query); mutex.lock(); cache = _.set(cache, query, result); mutex.unlock(); return result; } Джо: Славно! Теперь позвольте мне показать вам, как написать тот же код, используя атом вместо блокировки. Взгляните на него и дайте мне знать, если чтонибудь непонятно. Листинг 8.7. Потокобезопасный кеш с атомами var cache = new Atom(); cache.set({}); function dbAccessCached(query) { var resultFromCache = _.get(cache.get(), query); if (resultFromCache != nil) { return resultFromCache; } var result = dbAccess(query); cache.swap(function(oldCache) { return _.set(oldCache, query, result); }); return result; } Тео: Я не понимаю функцию, которую вы передаете методу swap.
Глава 8. Расширенный контроль конкурентности 213 Джо: Функция, переданная на swap, получает текущее значение кеша, которое является строковой картой, и возвращает новую версию строковой карты с дополнительной парой «ключ-значение». Тео: Теперь ясно. Но беспокоит вопрос производительности метода swap в случае строковой карты. Как работает сравнение? Я имею в виду, что сравнение двух строковых карт может занять много времени. Джо: Нет, если вы сравниваете их по ссылке. Как мы уже обсуждали, когда данные неизменяемы, будет безопасно сравнивать с эталоном, а еще это очень быстро. СОВЕТ. Когда данные неизменяемы, безопасно (и быстро) сравнивать их с эталоном. Тео: Круто. Получается, что атомы хорошо подходят для работы с неизменяемыми данными. Джо: Абсолютно верно! 8.4. Управление состоянием с помощью атомов Джо: Помните, как пару недель назад я показывал вам, как можно разрешать потенциальные конфликты между изменяемостями? Вы тогда говорили, что код не получился потокобезопасным. Тео: Давайте еще раз посмотрим на тот код. Тео просматривает код для класса systemData, который он написал некоторое время назад (повторяется в листинге 8.8). Без логики валидации код будет проще для понимания. Листинг 8.8. Класс SystemData из части 1 class SystemState { systemData; get() { return this.systemData; } set(_systemData) { this.systemData = _systemData; } commit(previous, next) { this.systemData = SystemConsistency.reconcile(this.systemData, previous, next); } }
214 Часть 2. Масштабируемость Через несколько минут он вспоминает, как работает метод commit. Внезапно у него появляется желание воскликнуть «Эврика!» Тео: А ведь этот код не является потокобезопасным, потому что код SystemConsistency. reconcile внутри метода commit не защищен. Ничего не мешает двум потокам выполнять этот код одновременно. Джо: Так точно! Теперь скажите мне, как сделать этот код потокобезопасным? Тео: С блокировками? Джо: Да что вы... Тео: Шучу, шучу. Мы сделаем код потокобезопасным не с помощью блокировки, а с помощью атома. Джо: Вы меня разыграли! Тео: Итак, мне бы следовало хранить системные данные внутри атома. Методы get и set класса systemData возьмут и вызовут методы get и set атома. Нормально? Листинг 8.9. Класс systemData с атомом (без метода commit) class SystemState { systemData; constructor() { this.systemData = new Atom(); } get() { return this.systemData.get(); } commit(prev, next) { this.systemData.set(next); } } Джо: Превосходно. Теперь самое интересное: реализуйте метод commit, вызвав метод swap атома. Тео: Вместо того чтобы вызывать функцию SystemConsistency.concircate() напрямую, мне нужно обернуть ее в вызов swap. Вот так нормально? Листинг 8.10. Реализация systemData.commit с помощью атома SystemData.commit = function(previous, next) { this.systemData.swap(function(current) { return SystemConsistency.reconcile(current, previous, next); }); };
Глава 8. Расширенный контроль конкурентности 215 Джо: Мне нечего добавить! Тео: Из-за всей этой истории с атомом я снова вспоминаю о том, что случилось вчера, когда мы пытались дозвониться друг другу в одно и то же время. Джо: Что вы имеете в виду? Тео: Не знаю, но у меня сложилось впечатление, что мьютексы похожи на телефонные звонки, а атомы — на текстовые сообщения. Джо улыбается Тео, но не раскрывает значения своей улыбки. После вчерашней телефонной взаимоблокировки Тео почти уверен, что они с Джо на одной волне. Итоги  Управлять конкурентностью при помощи атомов намного проще, чем при по- мощи блокировок, потому что отсутствуют риски возникновения взаимоблокировок.  Клонирование данных во избежание блокировок чтения не масштабируется.  Когда данные неизменяемы, чтение всегда безопасно.  Атомы предоставляют способ управления конкурентностью без блокировок.  С атомами взаимоблокировок не бывает никогда.  Использование атомов для потокобезопасного счетчика тривиально, поскольку состояние счетчика представлено примитивным типом (целым числом).  Мы можем управлять композитными данными потокобезопасным способом с помощью атомов.  Высокомасштабируемый подход к управлению состоянием из части 1 можно сделать потокобезопасным, сохраняя все состояние системы внутри атома.  Довольно часто кеш в памяти представляется в виде строковой карты.  Когда данные неизменяемы, безопасно (и быстро) сравнивать с эталоном.  В теории атомы могут вызвать голодание в системе с тысячами потоков, кото- рые не занимаются ничем, кроме обмена атомов.  На практике, как только атом заменяется, потоки выполняют реальную работу (например, получают доступ к базе данных), что создает возможность для других потоков успешно обменивать атомы.
216 Часть 2. Масштабируемость
Персистентные структуры данных Стоя на плечах у гигантов В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Внутренние подробности о персистентных структурах данных.  Эффективность персистентных структур данных с точки зрения времени и памяти.  Использование персистентных структур данных в приложении. В части 1 мы обсуждали, как управлять состоянием системы без изменения данных; неизменяемость в этом случае поддерживается за счет манипулирования состоянием только с помощью неизменяемых функций структурного совместного использования. В этой главе мы представляем более безопасный и масштабируемый способ сохранения неизменяемости данных: представление данных с помощью так называемых персистентных структур данных. Эффективные имплементации персистентных структур данных с помощью сторонних библиотек существуют для большинства языков программирования. 9.1. Потребность в персистентных структурах данных На этот раз Тео встречается с Джо в университете. Тео спрашивает, является ли сегодняшняя тема академической по своей природе, и Джо отвечает, что использование персистентных структур данных стало возможным в языках программирования только после их открытия в 2001 году исследователем-информатиком по имени Фил Бэгвелл1. 1 Бэгвелл Ф. Идеальные хеш-деревья (№ REP_WORK), 2001. Статья доступна по ссылке: https://lampwww.epfl.ch/papers/idealhashtrees.pdf.
218 Часть 2. Масштабируемость В 2007 году Рич Хикки, создатель Clojure, использовал это открытие в качестве основы для персистентных структур данных в этом языке программирования. Джо решил раскрыть Тео секреты этих структур данных в университетской аудитории, чтобы почтить память Фила Бэгвелла, который, к сожалению, скончался в 2012 году. Когда они добрались до нужной аудитории, Джо начал разговор с вопроса. Джо: Ну как, вы привыкаете к запрету ДОП на изменение данных на месте с созданием вместо этого новых версий? Тео: Я думаю, что да, но две вещи беспокоят меня в концепции структурного совместного использования, которую вы мне показали. Джо: Что вас беспокоит, друг мой? Тео: Безопасность и производительность. Джо: Что вы подразумеваете под безопасностью? Тео: Я говорю о том, что использование неизменяемых функций для манипулирования данными не предотвращает случайные изменения в них. Джо: Это правда! Какой способ справиться с неизменяемостью вам показать: простой до безобразия или реальный? Тео: Каковы плюсы и минусы каждого из способов? Джо: Простой до безобразия способ легок в применении, но неэффективен, а реальный способ эффективен, но нелегок. Тео: Тогда давайте начнем с простого до безобразия. Джо: Каждый язык программирования предоставляет свой собственный способ защиты данных от изменения. Тео: Как это сделать, например, на Java? Джо: Java предоставляет неизменяемые коллекции и способ преобразовать список или карту в неизменяемый список или неизменяемую карту.  ПРИМЕЧАНИЕ. Неизменяемые коллекции — это не то же самое, что персистентные структуры данных. Джо открывает свой ноутбук и включает его. Он показывает два примера кода: один для неизменяемых списков и один для неизменяемых карт. Листинг 9.1. Преобразование изменяемого списка в неизменяемый список в Java var myList = new ArrayList<Integer>(); myList.add(1); myList.add(2); myList.add(3); var myImmutableList = List.of(myList.toArray());
Глава 9. Персистентные структуры данных 219 Листинг 9.2. Преобразование изменяемой карты в неизменяемую карту в Java var myMap = new HashMap<String, Object>(); myMap.put("name", "Isaac"); myMap.put("age", 42); var myImmutableMap = Collections.unmodifiableMap(myMap); Тео: Что происходит, когда мы пытаемся изменить неизменяемую коллекцию? Джо: Java выдает исключение UnsupportedOperationException. Тео: А в JavaScript? Джо: JavaScript предоставляет функцию Object.freeze(), которая предотвращает изменение данных. Она работает как с массивами JavaScript, так и с объектами. В течение минуты Джо ищет что-то на своем ноутбуке. Когда он находит то, что искал, он показывает Тео код. Листинг 9.3. Создание неизменяемого объекта в JavaScript var a = [1, 2, 3]; Object.freeze(a); var b = {foo: 1}; Object.freeze(b); Тео: Что происходит, когда мы пытаемся изменить «замороженный» объект? Джо: Это зависит от обстоятельств. В строгом режиме JavaScript выдается исключение TypeError, а в нестрогом режиме изменение замороженного объекта завершается тихим сбоем.  ПРИМЕЧАНИЕ. Строгий режим JavaScript — это способ выбрать ограниченный вариант JavaScript, который превращает некоторые тихие ошибки в ошибки, которые выдаются. Тео: А если говорить о вложенных коллекциях, будут ли такие коллекции также замороженными? Джо: Нет, но в JavaScript можно написать функцию DeepFreeze(), которая рекурсивно замораживает объект. Вот еще один пример. Листинг 9.4. Рекурсивное замораживание объекта в JavaScript function deepFreeze(object) { // Retrieve the property names defined on object const propNames = Object.getOwnPropertyNames(object); // Freeze properties before freezing self for (const name of propNames) { const value = object[name];
220 Часть 2. Масштабируемость if (value && typeof value === "object") { deepFreeze(value); } } return Object.freeze(object); } Тео: Насколько я вижу, можно гарантировать, что данные никогда не будут изменены, а значит, что мои опасения по поводу безопасности напрасны. Теперь позвольте мне поделиться своими опасениями по поводу производительности. СОВЕТ. Можно и вручную убедиться, что данные не изменяются, но этот способ очень громоздкий. Джо: Слушаю. Тео: Если я правильно понял, основная идея структурного совместного использования заключается в том, что наибольшая часть данных используется совместно двумя версиями. Джо: Верно. Тео: Держа это в уме, можно создавать новые версии коллекций с использованием поверхностной копии вместо глубокой. А еще вы утверждали, что это эффективно. Джо: Точно! Тео: Итак, вот что меня беспокоит. Если у нас есть коллекция с большим количеством записей, поверхностная копия может оказаться дорогостоящей. Джо: Не могли бы вы привести пример коллекции с большим количеством записей? Тео: Допустим, каталог со 100 000 книг. Джо: На моем компьютере создание неглубокой копии коллекции из 100 000 записей занимает не более 50 миллисекунд. Тео: Иногда даже 50 миллисекунд на обновление — это неприемлемо. Джо: Я с вами полностью согласен. Когда неизменяемость данных требуется в масштабе, до безобразия простое структурное совместное использование не подходит. Тео: А еще неглубокое копирование массива из 100 000 элементов при каждом обновлении увеличит объем памяти программы на 100 КБ. Джо: Действительно, в масштабе у нас есть проблема как с памятью, так и с вычислениями. СОВЕТ. До безобразия простое структурное совместное использование в масштабе приводит к снижению производительности, как с точки зрения памяти, так и вычислений. Тео: Нет ли решения получше?
Глава 9. Персистентные структуры данных 221 Джо: Есть! Для этого вам нужно изучить реальный способ обращения с неизменяемостью. Он называется персистентными структурами данных. 9.2. Эффективность персистентных структур данных Тео: В каком смысле эти структуры данных являются персистентными? Джо: Персистентные структуры данных названы так потому, что они всегда сохраняют свои предыдущие версии. СОВЕТ. Персистентные структуры данных всегда сохраняют предыдущую версию самих себя при изменении. Джо: Персистентные структуры данных устраняют две основные проблемы простого до безобразия структурного совместного использования: проблемы безопасности и производительности. Тео: Давайте начнем с безопасности. Как персистентные структуры данных предотвращают случайное изменение данных? Джо: В таком языке, как Java они реализуют методы изменения интерфейсов коллекции, вызывая исключение времени выполнения UnsupportedOperationException. Тео: А в таком языке, как JavaScript? Джо: В JavaScript персистентные структуры данных предоставляют свои собственные методы для доступа к данным, и ни один из этих методов не изменяет данные. Тео: Означает ли это, что мы не можем использовать точечную нотацию для доступа к полям? Джо: Да, означает. Доступ к полям персистентных структур данных осуществляется через определенный API. Тео: А как насчет эффективности? Как персистентные структуры данных позволяют эффективно создать новую версию огромной коллекции? Джо: Персистентные структуры данных организуют данные таким образом, что становится возможным использование структурного совместного использования на уровне структуры данных. Тео: Не могли бы вы это объяснить? Джо: Конечно. Давайте начнем с самой простой структуры данных: связанного списка. Представьте, что у вас есть связанный список из 100 000 элементов. Тео: Хорошо. Джо: Что нужно сделать, чтобы добавить элемент в начало списка? Тео: Вы имеете в виду создать новую версию списка с дополнительным элементом? Джо: Ага!
222 Часть 2. Масштабируемость Тео: Ну, можно скопировать список, а затем добавить элемент в начало списка, но этот метод довольно дорогостоящий. Джо: А что если я скажу вам, что исходный связанный список — гарантированно неизменяемый? Тео: В этом случае можно создать новый список с новым заголовком, который указывает на заголовок исходного списка. Тео подходит к классной доске. Он берет кусок мела и рисует схему, показанную на рис. 9.1. Рис. 9.1. Структурное совместное использование со связанными списками Джо: Будет ли эффективность этой операции зависеть от размера списка? Тео: Нет, операция будет эффективна независимо от размера списка. Джо: Это то, что я подразумеваю под структурным совместным использованием на уровне самой структуры данных. Этот способ основан на простом, но могущественном знании: когда данные неизменяемы, ими можно безопасно пользоваться совместно. СОВЕТ. Когда данные неизменяемы, ими можно безопасно пользоваться совместно. Тео: Я понял, как работает структурное совместное использование на уровне структуры данных для связанных списков и операций добавления в начало списка, но как оно будет работать с такими операциями, как добавление элемента или изменение элемента в списке? Джо: Для этой цели нам нужно действовать хитрее и представить наш список в виде дерева. Тео: А как это поможет? Джо: Это поможет, потому что когда список представлен в виде дерева, большинство узлов в дереве могут быть общими для двух версий списка. Тео: Я в полном замешательстве. Джо: Представьте, что вы взяли список из 100 000 элементов и разделили его на два списка по 50 000 элементов в каждом: элементы от 0 до 49 999 — в списке 1 и элементы от 50 000 до 99 999 — в списке 2. Сколько операций вам потребуется, чтобы создать новую версию списка, в которой изменен один элемент, допустим, элемент с индексом 75 100? Тео затрудняется мысленно представить себе нечто подобное. Он возвращается к доске и рисует диаграмму (рис. 9.2). Как только Тео взглянул на получившуюся у него диаграмму, он смог с легкостью ответить на вопрос Джо.
Глава 9. Персистентные структуры данных 223 Рис. 9.2. Структурное совместное использование при разделении списка из 100 000 элементов Тео: Список 1 может быть совместно использован с помощью одной операции. Мне нужно будет создать новую версию списка 2, где изменен элемент 75 100. Это займет 50 000 операций, так что всего получается одна операция доступа к совместному использованию и одна операция копирования 50 000 элементов. В целом у нас будет 50 001 операция. Джо: Верно. Как видите, разделив наш исходный список на два списка, мы можем создать новую версию списка при помощи ряда операций в количестве, равном размеру списка, разделенному на 2. Тео: Я согласен, но 50 000 — это все еще большая цифра. Джо: Действительно, но никто не мешает нам снова применить тот же трюк, разделив список 1 и список 2 еще на два списка каждый. Тео: Как именно? Джо: Мы можем составить список 1.1 с элементами от 0 до 24 999, затем список 1.2 с элементами от 25 000 до 49 999, далее список 2.1 с элементами от 50 000 до 74 999 и список 2.2 с элементами от 75 000 до 99 999. Тео: Вы можете нарисовать это на доске? Джо: Конечно. Теперь к доске подходит Джо. Он рисует диаграмму, как на рис. 9.3. Рис. 9.3. Структурное совместное использование при двукратном разделении списка из 100 000 элементов
224 Часть 2. Масштабируемость Тео: Дайте-ка я подсчитаю количество операций для обновления одного элемента. Требуется две операции доступа к совместному использованию и одна операция копирования 25 000 элементов. В целом для создания новой версии списка требуется 25 002 операции. Джо: Все верно! Тео: Тогда давайте разделим список еще раз! Джо: Обязательно. На самом деле мы можем разбивать список снова и снова, пока размер списков не станет не более 2. Можете ли вы догадаться, какова тогда будет сложность создания новой версии? Тео: Я бы сказал, около log2 N операций. Джо: Я вижу, вы хорошо помните школьный курс математики. Сможете ли вы представить себе, чему будет равен log2 N, когда N равно 100 000? Тео: Дайте-ка подумать... 2 в степени 10 — это около 1000, а 2 в степени 7 — это 128. Таким образом, ответ должен быть немного меньше 17. Джо: Ответ будет 16,6, если быть точным. Это означает, что, для того чтобы обновить элемент в персистентном списке из 100 000 элементов, нам потребуется около 17 операций. То же самое касается доступа к элементам. Тео: Неплохо, но 17 — это такое значение, которым все же нельзя пренебречь. Джо: Согласен. Можно легко повысить производительность для доступа к элементам, используя более высокий коэффициент ветвления в нашем дереве. Тео: Что вы имеете в виду? Джо: Вместо того, чтобы делить на 2 на каждом уровне, мы можем делить на 32. Тео: Но время выполнения нашего алгоритма все равно будет расти с логарифмом числа N. Джо: Вы правы. С теоретической точки зрения это одно и то же. Однако с практической точки зрения разница будет велика. Тео: Почему? Джо: Потому что логарифм числа N по основанию 32 в 5 раз меньше, чем логарифм N по основанию 2. Тео: Это верно: 2 в степени 5 равно 32. Джо: Возвращаясь к нашему списку из 100 000 элементов, не могли бы вы сказать мне, сколько операций требуется для доступа к элементу, если коэффициент ветвления равен 32? Тео: С коэффициентом ветвления 2 ответ был равен 16,6. Если я разделю 16,6 на 5, то получу 3,3. Джо: Правильно! СОВЕТ. Используя коэффициент ветвления 32, мы повышаем эффективность доступа к элементам в персистентных списках.
Глава 9. Персистентные структуры данных 225 Тео: А улучшает ли этот трюк также и производительность обновления элемента в списке? Джо: Да, еще как улучшает. Тео: А как именно? Нам придется копировать 32 элемента на каждом уровне вместо 2 элементов. Это 16-кратный прирост производительности, который не компенсируется тем фактом, что глубина дерева уменьшена в 5 раз! Джо: Я вижу, вы неплохо считаете! Есть еще одна вещь, которую следует принять во внимание при практическом анализе производительности: архитектура современного процессора. Тео: Интересно. Чем больше вы рассказываете мне о персистентных структурах данных, тем больше я понимаю, почему вы захотели провести эту встречу в университете: мы залезли вглубь академических вопросов. Джо: Да. Итак, продолжим. Современные процессоры считывают и записывают данные из основной памяти и в нее единицами строк кеша часто длиной в 32 или 64 байта. Тео: И как это влияет на производительность? Джо: Приятным следствием этого шаблона доступа к данным является то, что копирование одного массива размером в 32 байта выполняется намного быстрее, чем копирование 16 массивов размером в 2 байта, принадлежащих разным уровням дерева. Тео: А почему так? Джо: Причина в том, что копирование массива размером в 32 байта может быть выполнено за одну пару обращений к кешу: одно для чтения и одно для записи. А вот для массивов, принадлежащих разным уровням дерева, каждому массиву требуется своя собственная пара обращений к кешу, даже если в массиве всего 2 элемента. Тео: Другими словами, производительность обновления персистентного списка в большой степени зависит от глубины дерева. СОВЕТ. В архитектурах современных процессоров производительность обновления персистентного списка в гораздо большей степени зависит от глубины дерева, чем от количества узлов на каждом уровне дерева. Джо: Это верно до некоторой степени. Использование коэффициента ветвления 64 при современных процессорах на самом деле снизит производительность операций обновления. Тео: Понятно. Джо: Теперь я сделаю еще одно интересное утверждение, которое не является точным с теоретической точки зрения, но будет точным на практике. Тео: Что за утверждение? Джо: Количество операций, необходимых для получения или обновления элемента в персистентном списке с коэффициентом ветвления 32, является постоянным.
226 Часть 2. Масштабируемость Тео: Как такое может быть? Вы ведь только что сказали, что количество операций равно логарифму числа N по основанию 32. Джо: Имейте терпение, друг мой. Каково наибольшее количество элементов, которое может включать в себя список на практике? Тео: Я не знаю. Никогда об этом не думал. Джо: Давайте предположим, что для хранения одного элемента в списке требуется 4 байта. Тео: ОК. Джо: А теперь скажите, пожалуйста, сколько памяти потребуется для хранения списка из 10 миллиардов элементов? Тео: Вы имеете в виду число в виде единицы с 10 нулями? Джо: Да. Тео: Каждый элемент занимает 4 байта, так что получится около 40 ГБ! Джо: Верно. Согласны ли вы с тем, что не имеет смысла хранить список, который занимает 40 ГБ памяти? Тео: Согласен. Джо: Итак, давайте возьмем 10 миллиардов в качестве верхней границы количества элементов в списке. Чему будет равен логарифм 10 миллиардов по основанию 2? Тео снова использует классную доску, чтобы прояснить свои мысли. Так он быстро находит ответ. Тео: Один миллиард — это примерно 230. Следовательно, 10 миллиардов — это примерно 233. Это означает, что логарифм 10 миллиардов по основанию 2 равен 33, значит, логарифм 10 миллиардов по основанию 32 должен быть примерно 33/5, что немного меньше 7. Джо: Я снова впечатлен вашими математическими способностями. Если быть точным, логарифм 10 миллиардов по основанию 32 равен 6,64. Тео (улыбаясь): Так точно я считать не стал. Джо: Убедил ли я вас в том, что на практике доступ к элементу в персистентном списке или его обновление по существу являются постоянными? Тео: Да, и это просто потрясающе! СОВЕТ. Персистентными списками можно манипулировать за время, приближенное к постоянному. Джо: Я тоже так думаю. Тео: А что насчет персистентных карт? Джо: Будет очень похоже, но я думаю, что у нас сейчас нет времени это обсуждать. Внезапно Тео посмотрел на свои часы. Сегодняшняя утренняя встреча пролетела очень быстро. Он заметил, что пора возвращаться в офис и обедать.
Глава 9. Персистентные структуры данных 227 9.3. Библиотеки персистентных структур данных По пути в офис Тео и Джо почти совсем не разговаривают. Мысли Тео возвращают его к тому, чему он научился в университетской аудитории. Он проникся большим уважением к Филу Бэгвеллу, который обнаружил, как можно эффективно манипулировать персистентными структурами данных, и к Ричу Хикки, который создал язык программирования, включивший это открытие в качестве основной функции и сделавший его доступным для всех в мире. Сразу после обеда Тео просит Джо показать ему, как выглядит реальное манипулирование персистентными структурами данных в каком-нибудь языке программирования. Тео: Доступны ли персистентные структуры данных на всех языках программирования? Джо: Несколько языков программирования, таких как Clojure, Scala и C#, предоставляют их как часть языка. Однако в большинстве языков вам понадобится сторонняя библиотека. Тео: Не могли бы вы дать рекомендации? Джо: Конечно. Используя ноутбук Тео, Джо добавляет в закладки несколько сайтов. Он точно знает, по каким ссылкам нужно переходить. Затем, пока Тео просматривает эти сайты, Джо подходит к доске и записывает информацию о библиотеках в табл. 9.1.  Immutable.js для JavaScript по ссылке https://immutable-js.com/.  Paguro для Java по ссылке https://github.com/GlenKPeterson/Paguro.  Immutable Collections для C# по ссылке http://mng.bz/QW51.  Pyrsistent для Python по ссылке https://github.com/tobgu/pyrsistent.  Hamster для Ruby по ссылке https://github.com/hamstergem/hamster. Таблица 9.1. Библиотеки персистентных структур данных Язык Библиотека JavaScript Immutable.js Java Paguro C# Предоставляется языком Python Pyrsistent Ruby Hamster Тео: Что требуется для интеграции персистентных структур данных, предоставляемых сторонней библиотекой, в наш код?
228 Часть 2. Масштабируемость 9.3.1. Персистентные структуры данных в Java Джо: В объектно-ориентированном языке, таком как Java, довольно просто интегрировать в программу персистентные структуры данных, потому что такие структуры данных реализуют интерфейсы коллекций, помимо частей интерфейса, которые изменяются на месте. Тео: Что вы имеете в виду? Джо: Возьмем, к примеру, библиотеку Paguro для Java. Персистентные карты Paguro реализуют доступные только для чтения методы java.util.Map, такие как get() и containsKey(), но не такие методы, как put() и remove(). С другой стороны, векторы Paguro реализуют доступные только для чтения методы java.util.List, такие как get() и size(), но не set(). Тео: Что произойдет, когда мы вызовем put() или remove() на карте Paguro? Джо: Выдается исключение UnSupportedOperationException. Тео: А что насчет итерации элементов коллекции Paguro с помощью forEach()? Джо: Это работает так же, как и в любой коллекции Java. Позвольте-ка мне показать вам пример. Листинг 9.5. Итерация по вектору Paguro var myVec = PersistentVector.ofIter( List.of(10, 2, 3)); ❶ for (Integer i : myVec) { System.out.println(i); } ❶ Создает вектор Paguro из списка Java. Тео: А как насчет потоков Java? Джо: Коллекции Paguro — это коллекции Java, поэтому они поддерживают интерфейс потоков Java. Взгляните на этот код. Листинг 9.6. Потоковая передача вектора Paguro var myVec = PersistentVector.ofIter(List.of(10, 2, 3)); vec1.stream().sorted().map(x -> x + 1); СОВЕТ. Коллекции Paguro реализуют доступные только для чтения части интерфейсов коллекций Java. Следовательно, они могут быть переданы любым методам, которые ожидают получения коллекции Java без ее изменения. Тео: Итак, вы рассказали мне, как использовать коллекции Paguro в качестве коллекций Java, доступных только для чтения. А как вносить изменения в персистентные структуры данных Paguro?
Глава 9. Персистентные структуры данных 229 Джо: Способом, аналогичным функции _.set() из Lodash FP, о которой мы говорили ранее. Вместо того чтобы мутировать на месте, создается новая версия. Тео: Какие методы предоставляет Paguro для создания новых версий структуры данных? Джо: Для векторов используется replace(), а для карт используется assoc(). Листинг 9.7. Создание модифицированной версии вектора Paguro var myVec = PersistentVector.ofIter(List.of(10, 2, 3)); var myNextVec = myVec.replace(0, 42); Листинг 9.8. Создание модифицированной версии карты Paguro var myMap = PersistentHashMap.of(Map.of("aa", 1, "bb", 2) .entrySet()); ❶ var myNextMap = myMap.assoc("aa", 42); ❶ Создает карту Paguro из набора записей карты Java. Тео: Вот! Теперь я понял, как использовать персистентные структуры данных в Java, но как насчет JavaScript? 9.3.2. Персистентные структуры данных в JavaScript Джо: В таком языке, как JavaScript, интегрировать персистентные структуры данных немного сложнее. Тео: Как же так? Джо: Дело в том, что объекты и массивы JavaScript не предоставляют интерфейса. Тео: Облом. Джо: Это не так ужасно, как кажется, потому что коллекции Immutable.js предоставляют свой собственный набор функций для манипулирования структурами данных. Тео: Что вы имеете в виду? Джо: Сейчас скажу. Но сначала позвольте мне показать вам, как инициировать персистентные структуры данных в Immutable.js. Тео: Ладно! Джо: Immutable.js предоставляет удобную функцию, которая рекурсивно конвертирует нативный объект данных в неизменяемый. Она называется Immutable. fromJS().
230 Часть 2. Масштабируемость Тео: Что вы подразумеваете под рекурсивным конвертированием? Джо: Представьте себе карту, в которой хранятся библиотечные данные из нашей Системы управления библиотекой: у такой карты есть значения, которые сами по себе являются картами. Функция Immutable.fromJS() конвертирует вложенные карты в неизменяемые карты. Тео: Покажите, пожалуйста, код. Джо: Безусловно. Взгляните на этот код JavaScript для библиотечных данных. Листинг 9.9. Конвертирование в неизменяемые данные var libraryData = Immutable.fromJS({ "catalog": { "booksByIsbn": { "978-1779501127": { "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authorIds": ["alan-moore", "dave-gibbons"] } }, "authorsById": { "alan-moore": { "name": "Alan Moore",} "bookIsbns": ["978-1779501127"] }, "dave-gibbons": { "name": "Dave Gibbons", "bookIsbns": ["978-1779501127"] } } } }); Тео: Вы имеете в виду, что значение catalog в карте libraryData само по себе является неизменяемой картой? Джо: Да, и то же самое является правдой для booksByIsbn, authorIds и т. д. Тео: Круто! Итак, как же получить доступ к полю внутри неизменяемой карты? Джо: Как я уже говорил, Immutable.js предоставляет свой собственный API для доступа к данным. Например, чтобы получить доступ к полю внутри неизменяемой карты, нужно использовать Immutable.get() или Immutable.getIn(), как показано ниже (листинг 9.10).
Глава 9. Персистентные структуры данных 231 Листинг 9.10. Доступ к полю и вложенному полю в неизменяемой карте Immutable.get(libraryData, "catalog"); Immutable.getIn(libraryData, ["catalog", "booksByIsbn", "978-1779501127", "title"]); // → "Watchmen" Тео: А как внести изменения в карту? Джо: Аналогично тому, что мы делали с Lodash FP: нужно использовать карту Immutable.set() или Immutable.setIn() для создания новой версии карты, в которой изменено поле. И вот как это делается. Листинг 9.11. Создание новой версии карты, в которой изменено поле Immutable.setIn(libraryData, ["catalog", "booksByIsbn", "978-1779501127", "publicationYear"], 1988); Тео: Что произойдет, если попытаться получить доступ к полю в карте, используя точки или скобки — условные обозначения из JavaScript? Джо: Вы получите доступ к внутреннему представлению карты вместо доступа к полю карты. Тео: Означает ли это, что невозможно передать данные из Immutable.js в Lodash для манипулирования ими? Джо: Да, но довольно легко преобразовать любую неизменяемую коллекцию в нативный объект JavaScript и обратно. Тео: Как? Джо: Immutable.js предоставляет метод toJS() для преобразования произвольной глубоко вложенной неизменяемой коллекции в объект JavaScript. Тео: Но если у меня огромная коллекция, на ее преобразование может уйти много времени, верно? Джо: Точно. Нам нужно решение получше. Надеюсь, Immutable.js предоставляет свой собственный набор функций для манипулирования данными, таких как map(), filter() и reduce(). Тео: А что если мне понадобится еще больше возможностей манипулирования данными, вроде, например, _.groupBy() из Lodash? Джо: Тогда можно написать свои собственные функции для манипулирования данными, которые работают с коллекциями Immutable.js, или использовать библиотеку вроде mudash, которая предоставляет возможность переноса из Lodash в Immutable.js.  ПРИМЕЧАНИЕ. Вы можете получить доступ к библиотеке mudash по адресу https://github.com/brianneisler/mudash.
232 Часть 2. Масштабируемость Тео: А что бы вы посоветовали? Джо: Выпить чашку кофе, а после я покажу вам, как перенести функции из Lodash в Immutable.js и как адаптировать код из вашей Системы управления библиотекой. Вы сможете выбрать тот подход, который лучше всего подходит для вашего текущего проекта. 9.4. Персистентные структуры данных в действии Джо: Давайте начнем с поискового запроса. Не могли бы вы взглянуть на текущий код и рассказать мне о функциях Lodash, которые мы использовали для реализации поискового запроса? Тео: Включая код для модульных тестов? Джо: Конечно!  ПРИМЕЧАНИЕ. Модульный тест поискового запроса приведен в главе 6. 9.4.1. Написание запросов с персистентными структурами данных Тео: Функциями Lodash, которые мы использовали, были get, map, filter и isEqual. Джо: Вот пример переноса этих четырех функций из Lodash в Immutable.js. Листинг 9.12. Перенос некоторых функций из Lodash в Immutable.js Immutable.map = function(coll, f) { return coll.map(f); }; Immutable.filter = function(coll, f) { if(Immutable.isMap(coll)) { return coll.valueSeq().filter(f); } return coll.filter(f); }; Immutable.isEqual = Immutable.is; Тео: Код выглядит довольно простым. Но все же не могли бы вы объяснить его мне, функция за функцией? Джо: Конечно. Давайте начнем с get. Для доступа к полю на карте Immutable.js предоставляет две функции: get для прямых полей и getIn для вложенных полей. В этом заключается отличие от Lodash, где _.get работает как с прямыми, так и с вложенными полями.
Глава 9. Персистентные структуры данных 233 Тео: А что насчет map? Джо: Immutable.js предоставляет свою собственную функцию map. Единственное отличие заключается в том, что это метод коллекции, но его можно легко адаптировать. Тео: А что насчет filter? Как сделать так, чтобы эта функция работала как для массивов, так и для карт, таких как filter из Lodash? Джо: Immutable.js предоставляет метод valueSeq, который возвращает значения карты. Тео: Круто. А что насчет isEqual для сравнения двух коллекций? Джо: Это очень просто. Immutable.js предоставляет функцию под названием is, которая работает точно так же, как isEqual. Тео: Пока что все понятно. Что нужно сделать прямо сейчас, чтобы заставить код поискового запроса работать с Immutable.js? Джо: Нужно просто заменить каждый из символов подчеркивания «_» на слово «Immutable»; _.map становится Immutable.map, _.filter становится Immutable.filter, и _.isEqual становится Immutable.isEqual. Тео: Поверить не могу, что это так просто! Джо: А вы сами попробуйте, и все увидите. Иногда код может получиться громоздким, потому что нужно преобразовывать объекты JavaScript в объекты Immutable.js, используя Immutable.fromJS. Тео копирует и вставляет фрагменты кода и модульные тесты для поискового запроса. Затем он использует свою IDE, чтобы заменить символ «_» на слово «Immutable». Когда Тео выполняет тесты, и они проходят успешно, он удивлен, но доволен. Джо улыбается. Листинг 9.13. Реализация поиска книг с использованием персистентных структур данных class Catalog { static authorNames(catalogData, authorIds) { return Immutable.map(authorIds, function(authorId) { return Immutable.getIn( catalogData, ["authorsById", authorId, "name"]); }); } static bookInfo(catalogData, book) { var bookInfo = Immutable.Map({ "title": Immutable.get(book, "title"), "isbn": Immutable.get(book, "isbn"), "authorNames": Catalog.authorNames( catalogData, Immutable.get(book, "authorIds")) });
234 Часть 2. Масштабируемость return bookInfo; } static searchBooksByTitle(catalogData, query) { var allBooks = Immutable.get(catalogData, "booksByIsbn"); var queryLowerCased = query.toLowerCase(); var matchingBooks = Immutable.filter(allBooks, function(book) { return Immutable.get(book, "title"). toLowerCase(). Includes(queryLowerCased); }); var bookInfos = Immutable.map(matchingBooks, function(book) { return Catalog.bookInfo(catalogData, book); }); return bookInfos; } } Листинг 9.14. Тестирование поиска книг с использованием персистентных структур данных var catalogData = Immutable.fromJS({ "booksByIsbn": { "978-1779501127": { "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authorIds": ["alan-moore", "dave-gibbons"] } }, "authorsById": { "alan-moore": { "name": "Alan Moore", "bookIsbns": ["978-1779501127"] }, "dave-gibbons": { "name": "Dave Gibbons", "bookIsbns": ["978-1779501127"] } } }); var bookInfo = Immutable.fromJS({ "isbn": "978-1779501127", "title": "Watchmen",
Глава 9. Персистентные структуры данных 235 "authorNames": ["Alan Moore", "Dave Gibbons"] }); Immutable.isEqual( Catalog.searchBooksByTitle(catalogData, "Watchmen"), Immutable.fromJS([bookInfo])); // → true Immutable.isEqual( Catalog.searchBooksByTitle(catalogData, "Batman"), Immutable.fromJS([])); // → true 9.4.2. Операции изменения при работе с персистентными структурами данных Тео: Давайте пойдем дальше и перенесем изменение для добавления читателя библиотеки. Джо: Конечно. Чтобы перенести изменение для добавления читателя библиотеки из Lodash в Immutable.js, требуется всего лишь снова заменить символ подчеркивания «_» на слово «Immutable». Давайте взглянем на фрагмент кода. Листинг 9.15. Реализация добавления читателей библиотеки с помощью персистентных структур данных UserManagement.addMember = function(userManagement, member) { var email = Immutable.get(member, "email"); var infoPath = ["membersByEmail", email]; if(Immutable.hasIn(userManagement, infoPath)) { throw "Member already exists."; } var nextUserManagement = Immutable.setIn(userManagement, infoPath, member); return nextUserManagement; }; Тео: Итак, для тестов нужно будет преобразовать объекты JavaScript в объекты Immutable.js, используя Immutable.fromJS. Нормально получается? Листинг 9.16. Тестирование добавления нового читателя библиотеки с использованием персистентных структур данных var jessie = Immutable.fromJS({ "email": "jessie@gmail.com", "password": "my-secret" });
236 Часть 2. Масштабируемость var franck = Immutable.fromJS({ "email": "franck@gmail.com", "password": "my-top-secret" }); var userManagementStateBefore = Immutable.fromJS({ "membersByEmail": { "franck@gmail.com": { "email": "franck@gmail.com", "password": "my-top-secret" } } }); var expectedUserManagementStateAfter = Immutable.fromJS({ "membersByEmail": { "jessie@gmail.com": { "email": "jessie@gmail.com", "password": "my-secret" }, "franck@gmail.com": { "email": "franck@gmail.com", "password": "my-top-secret" } } }); var result = UserManagement.addMember(userManagementStateBefore, jessie); Immutable.isEqual(result, expectedUserManagementStateAfter); // → true Джо: Великолепно! 9.4.3. Сериализация и десериализация Тео: Поддерживает ли Immutable.js также сериализацию и десериализацию из JSON? Джо: Эта библиотека поддерживает сериализацию «из коробки». Что касается десериализации, придется написать нашу собственную функцию. Тео: Предоставляет ли Immutable.js функцию Immutable.stringify()? Джо: В этом нет необходимости, потому что нативная функция JSON.stringify() работает с объектами Immutable.js. Вот еще один пример. Листинг 9.17. Сериализация в формате JSON для коллекции Immutable.js var bookInfo = Immutable.fromJS({ "isbn": "978-1779501127", "title": "Watchmen",
Глава 9. Персистентные структуры данных 237 "authorNames": ["Alan Moore", "Dave Gibbons"] }); JSON.stringify(bookInfo); // → {\"isbn\":\"978-1779501127\",\"title\":\"Watchmen\", // → \"authorNames\":[\"Alan Moore\",\"Dave Gibbons\"]} Тео: Откуда функция JSON.stringify() знает, как обрабатывать коллекцию Immutable.js? Джо: Как разработчика в сфере объектно-ориентированного программирования, вас не должно это удивлять. Тео: Хм... Дайте-ка мне немного подумать. Так, вот мое предположение. Это происходит потому, что JSON.stringify() вызывает какой-то метод на свой аргумент? Джо: Точно! Если объект, переданный функции JSON.stringify(), имеет метод .toJSON(), он вызывается при помощи JSON.stringify(). Тео: Неплохо. Как насчет десериализации JSON? Джо: Это делается в два этапа. Сначала вы преобразуете строку JSON в объект JavaScript, а затем в неизменяемую коллекцию. Тео: Что-то вроде этого фрагмента кода? Листинг 9.18. Преобразование строки JSON в неизменяемую коллекцию Immutable.parseJSON = function(jsonString) { return Immutable.fromJS(JSON.parse(jsonString)); }; Джо: Точно. 9.4.4. Структурная разница Тео: Итак, мы уже переносили фрагменты кода, которые содержали простые манипуляции с данными. Интересно посмотреть, как происходит перенос в сложных случаях манипулирования данными, когда код вычисляет структурную разницу между двумя картами.  ПРИМЕЧАНИЕ. В главе 5 вводится понятие структурной разницы. Джо: Здесь все тоже сработает гладко, но придется перенести еще восемь функций. Листинг 9.19. Перенос функций Lodash, участвующих в вычислении структурной разницы Immutable.reduce = function(coll, reducer, initialReduction) { return coll.reduce(reducer, initialReduction); };
238 Часть 2. Масштабируемость Immutable.isEmpty = function(coll) { return coll.isEmpty(); }; Immutable.keys = function(coll) { return coll.keySeq(); }; Immutable.isObject = function(coll) { return Immutable.Map.isMap(coll); }; Immutable.isArray = Immutable.isIndexed; Immutable.union = function() { return Immutable.Set.union(arguments); }; Тео: Все выглядит безумно просто, за одним исключением: использование аргументов в Immutable.union. Джо: В JavaScript arguments — это неявный объект, подобный массиву, который содержит значения аргументов функции. Тео: Понятно. И снова пример магии JavaScript! Джо: Ага. Нам нужно использовать объект arguments, потому что в Lodash и Immutable.js немного отличаются сигнатуры функции union. Функция Immutable. Set.union получает массив списков, тогда как в Lodash функция_.union получает несколько массивов. Тео: Логично. Позвольте мне попробовать. Подув на пальцы, как опытный взломщик сейфов (сначала на одну руку, а потом на другую), Тео начинает печатать. И снова он с удивлением обнаруживает, что после замены символа подчеркивания «_» на слово «Immutable» в листинге 9.20 тесты в листинге 9.21 проходят успешно. Листинг 9.20. Реализация структурной разницы с персистентными структурами данных function diffObjects(data1, data2) { var emptyObject = Immutable.isArray(data1) ? Immutable.fromJS([]) : Immutable.fromJS({}); if(data1 == data2) { return emptyObject; }
Глава 9. Персистентные структуры данных var keys = Immutable.union(Immutable.keys(data1), return Immutable.reduce(keys, function (acc, k) { var res = diff(Immutable.get(data1, k), Immutable.get(data2, k)); if((Immutable.isObject(res) && Immutable.isEmpty(res)) || (res == "data-diff:no-diff")) { return acc; } return Immutable.set(acc, k, res); }, emptyObject); } function diff(data1, data2) { if(Immutable.isObject(data1) && Immutable.isObject(data2)) { return diffObjects(data1, data2); } if(data1 !== data2) { return data2; } return "data-diff:no-diff"; } Листинг 9.21. Тестирование структурной разницы с персистентными структурами данных var data1 = Immutable.fromJS({ g: { c: 3 }, x: 2, y: { z: 1 }, w: [5] }); var data2 = Immutable.fromJS({ g: { c:3 }, x: 2, y: { z: 2 }, w: [4] }); 239
240 Часть 2. Масштабируемость Immutable.isEqual(diff(data1, data2), Immutable.fromJS({ "w": [ 4 ], "y": { "z": 2 } })); Джо: И что вы обо всем этом думаете, друг мой? Тео: Я думаю, что использовать персистентные коллекции данных с библиотекой, подобной Immutable.js, намного проще, чем понять внутренние свойства персистентных структур данных. Но я все же доволен, что знаю, как там все работает «под капотом». Проводив Джо до двери офиса, Тео встретил Дейва. Дейв заглядывал в окошко кабинета Тео, рассматривая доску и стремясь мельком увидеть сегодняшнюю тему урока о ДОП. Дейв: Чему тебя сегодня научил Джо? Тео: Он отвез меня в университет и научил основам персистентных структур данных для работы с неизменяемостью в масштабе. Дейв: А что не так со структурным совместным использованием, которое я реализовал пару месяцев назад? Тео: Когда количество элементов в коллекции достаточно велико, простое до безобразия структурное совместное использование приводит к проблемам с производительностью. Дейв: Понятно. Расскажи-ка подробнее. Тео: Я бы с удовольствием, но после такого интересного, но утомительного дня мой мозг отказывается нормально работать. Очень скоро я тебе все расскажу, обещаю. Дейв: Годится. Приятного тебе вечера, Тео. Тео: И тебе, Дейв. Итоги  Можно вручную убедиться, что данные не изменяются, но это громоздко.  В масштабе простое до безобразия структурное совместное использование при- водит к снижению производительности с точки зрения и памяти, и вычислений.  Простое до безобразия структурное совместное использование не предотвращает случайные изменения в структурах данных.  Неизменяемые коллекции — это не то же самое, что персистентные структуры данных.
Глава 9. Персистентные структуры данных 241  Неизменяемые коллекции не предоставляют эффективного способа создания новых версий коллекций.  Персистентные структуры данных защищают данные от изменений.  Персистентные структуры данных обеспечивают эффективный способ создания новых версий коллекций.  Персистентные структуры данных всегда сохраняют предыдущую версию самих себя при их изменении.  Персистентные структуры данных представляют данные изнутри таким образом, что структурное совместное использование хорошо масштабируется с точки зрения памяти и вычислений.  Когда данные неизменяемы, ими можно безопасно пользоваться совместно.  Изнутри персистентность использует коэффициент ветвления, равный 32.  На практике манипулирование персистентными структурами данных эффектив- но даже для коллекций с 10 миллиардами записей.  Из-за особенностей современной архитектуры процессоров производительность обновления персистентного списка в гораздо большей степени зависит от глубины дерева, чем от количества узлов на каждом уровне дерева.  Персистентными списками можно управлять практически за постоянное время.  В большинстве языков программирования реализацию персистентных структур данных предоставляют сторонние библиотеки.  Коллекции Paguro реализуют доступные только для чтения части интерфейсов коллекций Java.  Коллекции Paguro могут быть переданы любым методам, которые ожидают по- лучения коллекции Java, без их изменения.
242 Часть 2. Масштабируемость
Операции с базой данных Облако есть облако В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Извлечение данных из базы данных.  Хранение данных в базе данных.  Манипулирование данными, извлеченными из базы данных. Традиционно в ООП мы используем шаблоны проектирования и сложные слои объектов для структурирования доступа к базе данных. В ДОП мы предпочитаем представлять данные, извлеченные из базы данных, с помощью общих наборов данных, а именно списков карт, где поля в картах соответствуют значениям столбцов базы данных. Как мы увидим на протяжении всей главы, тот факт, что поля внутри карты доступны динамически через их имена, позволяет нам использовать один и тот же общий код для разных объектов данных. СОВЕТ. Лучший способ манипулировать данными — это представлять их как данные. В этой главе мы проиллюстрируем применение дата-ориентированных принципов при доступе к данным из реляционной базы данных. Предполагается, что читатель в общем виде разбирается в реляционных базах данных и синтаксисе запросов SQL (например, SELECT, AS, WHERE и INNER JOIN). Этот подход можно легко адаптировать к базам данных NoSQL. Приложения, работающие на сервере, обычно хранят данные в базе данных. В ДОП мы представляем данные, извлеченные из базы данных так же, как мы представляем любые другие данные в нашем приложении — с помощью универсальных коллекций данных. Это ведет к снижению сложности системы и повышению универсальности.
244 Часть 2. Масштабируемость 10.1. Извлечение данных из базы данных Тео и Джо отправляются на прогулку в парк рядом с офисом. Они сидят на скамейке рядом с красивым озером и смотрят на облака. После пары минут медитативного молчания Джо спрашивает Тео: «Что вы видите?». Тео говорит ему, что это облако кажется ему лошадью, а то похоже на автомобиль. На обратном пути в офис Тео просит Джо объяснить, что такое облака. Джо отвечает с загадочной улыбкой: «Облако есть облако». Тео: До сих пор вы показывали мне, как ДОП представляет данные, которые хранятся в памяти приложения. А как насчет данных, поступающих извне? Джо: В каком смысле извне? Тео: Данные, поступающие из базы данных. Джо: Я еще вернусь к этому вопросу. Как, по-вашему, мы должны представлять данные, поступающие из базы данных, в ДОП? Тео: Как общие коллекции данных, полагаю. Джо: Именно! В ДОП мы всегда представляем данные с помощью общих наборов данных. Тео: Означает ли это, что мы можем манипулировать данными из базы данных с такой же гибкостью, с какой мы манипулируем данными в памяти? Джо: Определенно. СОВЕТ. В ДОП мы представляем данные из базы данных с помощью общих наборов данных и манипулируем ими с помощью общих функций. Тео: Не могли бы вы показать мне, как получить результаты поиска книг, когда данные каталога хранятся в базе данных SQL? Джо: Я покажу. Во-первых, расскажите мне, как бы вы спроектировали таблицы, в которых хранятся данные каталога. Тео: Вы имеете в виду точные схемы таблиц с информацией о первичных ключах и возможности обнуления каждого столбца? Джо: Нет, мне нужен только приблизительный обзор таблиц, их столбцов и взаимосвязей между таблицами. Тео подходит к доске. На рис. 10.1 показана диаграмма, которую он рисует, объясняя Джо свои мысли. Тео: У меня есть таблица books с тремя столбцами: title, ISBN и publication_year. У меня также есть таблица authors с двумя столбцами: для id и name. Позвольте мне нарисовать эти таблицы на доске, чтобы дать вам наглядность (табл. 10.1 и 10.2). Джо: Как насчет связи между книгами и авторами? Тео: Давайте посмотрим, книга может быть написана несколькими авторами, и один автор может написать несколько книг. Поэтому мне нужна таблица
Глава 10. Операции с базой данных 245 book_authors «многие ко многим», столбцами, book_isbn и author_id. которая связывает авторов и книги с двумя Таблица 10.1. Таблица books, заполненная двумя книгами title ISBN publication_year The Power of Habit 978-0812981605 2012 7 Habits of Highly Effective People 978-1982137274 1989 Таблица 10.2. Таблица authors, заполненная тремя авторами id name sean-covey Sean Covey stephen-covey Stephen Covey charles-duhigg Charles Duhigg Рис. 10.1. Модель базы данных для книг и авторов Тео снова поворачивается к доске. Он рисует табл. 10.3 book_authors, чтобы показать Джо. Таблица 10.3. Таблица book_authors book_isbn author_id 978-1982137274 sean-covey 978-1982137274 stephen-covey 978-0812981605 charles-duhigg Джо: Отлично! Давайте начнем с самого простого случая. Мы собираемся написать код, который ищет книги, соответствующие названию, и возвращает основную информацию о книгах. Под основной информацией я подразумеваю название, ISBN и год публикации.
246 Часть 2. Масштабируемость Тео: А как насчет авторов книг? Джо: Мы разберемся с этим позже, т. к. это немного сложнее. Можете ли вы написать SQL-запрос для извлечения книг, содержащих слово habit в названии? Тео: Конечно. Это задание далось Тео довольно легко. Сначала он записывает SQL-запрос (листинг 10.1), затем отображает результаты (табл. 10.4). Листинг 10.1. SQL-запрос для извлечения книг, название которых содержит habit SELECT title, isbn, publication_year FROM books WHERE title LIKE '%habit%'; Таблица 10.4. Результаты SQL-запроса для книг, название которых содержит слово habit title ISBN publication_year The Power of Habit 978-0812981605 2012 7 Habits of Highly Effective People 978-1982137274 1989 Джо: Как бы вы описали эти результаты как сбор данных? Тео: Я бы сказал, что это список карт. СОВЕТ. В ДОП доступ к данным из базы данных NoSQL аналогичен тому, как мы получаем доступ к данным из реляционной базы данных. Джо: Правильно! Теперь можете ли вы записать результаты поиска в виде списка карт? Тео: Это звучит не слишком сложно. Как насчет этого? Листинг 10.2. Результаты поиска в виде списка карт [ { "title": "7 Habits of Highly Effective People", "isbn": "978-1982137274", "publication_year": 1989 }, { "title": "The Power of Habit",
Глава 10. Операции с базой данных 247 "isbn": "978-0812981605", "publication_year": 2012 } ] Джо: Как насчет схемы JSON для результатов поиска? Тео: Это не должно быть слишком сложно, если вы позволите мне взглянуть на шпаргалку схемы JSON, которую вы любезно предложили мне на днях. Джо: Конечно. Цель подарка — быть использованным тем, кто его получает. Тео просматривает шпаргалку схемы JSON, чтобы освежить в памяти синтаксис схемы JSON. Через несколько минут Тео придумывает схему для результатов поиска. Он, безусловно, использует дар Джо с пользой. Листинг 10.3. Шпаргалка JSON-схемы { "type": "array", "items": { "type": "object", "properties": { "myNumber": {"type": "number"}, "myString": {"type": "string"}, "myEnum": {"enum": ["myVal", "yourVal"]}, "myBool": {"type": "boolean"} }, "required": ["myNumber", "myString"], "additionalProperties": false } } Листинг 10.4. Схема JSON для результатов поиска в базе данных var dbSearchResultSchema = { "type": "array", "items": { "type": "object", "required": ["title", "isbn", "publication_year"], "properties": { "title": {"type": "string"}, "isbn": {"type": "string"}, "publication_year": {"type": "integer"} } } }; Джо: Превосходно. Теперь я собираюсь показать вам, как реализовать searchBooks таким образом, чтобы он извлекал данные из базы данных и возвращал строку
248 Часть 2. Масштабируемость JSON с результатами. Мы собираемся использовать только общие коллекции данных от уровня базы данных до сериализации JSON, и это круто. Тео: Будет ли это похоже на реализацию searchBooks, которую мы написали, когда вы учили меня основам ДОП? Джо: Абсолютно. С той лишь разницей, что тогда состояние системы хранилось локально, и мы опрашивали его функцией типа _.filter. Теперь мы используем SQL-запросы для получения состояния из базы данных. С точки зрения представления данных и манипулирования ими все точно так же. Джо подходит к доске и набрасывает поток данных на рис. 10.2. Тео изучает схему. Рис. 10.2. Поток данных для обслуживания запроса, который извлекает данные из базы данных Джо: Этап манипулирования данными на диаграмме реализуется с помощью универсальных функций, которые управляют наборами данных. По мере того как наши примеры станут более сложными, я думаю, вы увидите преимущества возможности манипулировать наборами данных с помощью универсальных функций. Тео: Звучит интересно... Джо: Для связи с базой данных мы используем драйвер, который возвращает список карт. В JavaScript вы могли бы использовать драйвер SQL, такой как nodepostgres.  ПРИМЕЧАНИЕ. См. https://node-postgres.com для получения дополнительной информации об этой коллекции модулей node.js для взаимодействия с базами данных PostgreSQL. Тео: А в Java? Джо: В Java вы можете использовать JDBC (Java database connectivity, соединение с базой данных Java) в дополнение к небольшой служебной функции, которая преобразует набор результатов JDBC в список карт. Если я смогу воспользоваться вашим ноутбуком, я покажу вам, что имею в виду. Джо извлекает фрагмент кода из одного из своих личных репозиториев GitHub. Затем он показывает код преобразования JDBC Тео, который кажется немного удивленным.
Глава 10. Операции с базой данных 249 Листинг 10.5. Преобразование набора результатов JDBC в список хеш-карт List<Map<String, Object>> convertJDBCResultSetToListOfMaps(ResultSet rs) { List<Map<String, Object>> listOfMaps = new ArrayList<Map<String, Object>>(); ResultSetMetaData meta = rs.getMetaData(); while (rs.next()) { Map map = new HashMap(); for (int i = 1; i <= meta.getColumnCount(); i++) { String key = meta.getColumnLabel(i); Object value = rs.getObject(i); map.put(key, value); } listOfMaps.add(map); } return listOfMaps; } СОВЕТ. Преобразование набора результатов JDBC в список хеш-карт выполняется довольно просто. Тео: Я ожидал, что преобразовать набор результатов JDBC в список хеш-карт будет намного сложнее. Джо: Это просто, потому что в некотором смысле JDBC ориентирован на данные. Тео: А как насчет типов полей? Джо: Когда мы преобразуем набор результатов JDBC в список карт, каждое значение рассматривается как Object. Тео: Это раздражает, потому что это означает, что для доступа к значению нам нужно привести его к его типу. Джо: И да и нет. Посмотрите на наш пример использования поиска книг. Мы передаем все значения, не глядя на их тип. Конкретный тип значения имеет значение только тогда, когда мы сериализуем результат в JSON, и это обрабатывается библиотекой сериализации JSON. Это называется поздним связыванием.  ПРИМЕЧАНИЕ. При позднем связывании мы откладываем работу с типами данных как можно дольше. Тео: Означает ли это, что в моем приложении мне разрешено манипулировать данными, не имея дела с конкретными типами? СОВЕТ. В ДОП повышается гибкость, поскольку многие части системы могут свободно манипулировать данными, не имея дело с конкретными типами. Джо: Именно так. Через мгновение вы увидите позднее связывание в действии. Это одно из самых больших преимуществ ДОП. Тео: Как интересно, я не могу дождаться, чтобы увидеть это!
250 Часть 2. Масштабируемость Джо: И последнее, прежде чем я покажу вам код для извлечения результатов поиска из базы данных. Чтобы облегчить чтение, я собираюсь написать код JavaScript так, как если бы JavaScript имел дело с вводом-выводом синхронным образом. Тео: Что вы имеете в виду? Джо: В JavaScript операция ввода-вывода, например отправка запроса в базу данных, выполняется асинхронно. В реальной жизни это означает использование либо функций обратного вызова, либо ключевых слов async и await. Тео: Ах да, это потому, что JavaScript однопоточный.  ПРИМЕЧАНИЕ. Для простоты фрагменты JavaScript в этой главе написаны так, как если бы JavaScript обрабатывал ввод-вывод синхронным образом. В реальном JavaScript нам нужно использовать async и await вызовов ввода-вывода. Джо: Действительно, поэтому я буду писать код, который взаимодействует с базой данных так, как если бы JavaScript выполнял операции ввода-вывода синхронно. Вот пример. Листинг 10.6. Поиск книг в базе данных, возврат результатов в формате JSON var dbClient; ❶ var ajv = new Ajv({allErrors: true});❷ var title = "habit"; var matchingBooksQuery = `SELECT title, isbn FROM books WHERE title LIKE '%$1%'`;❸ var books = dbClient.query(matchingBooksQuery, [title]); ❹ if(!ajv.validate(dbSearchResultSchema, books)) { var errors = ajv.errorsText(ajv.errors); throw "Internal error: Unexpected result from the database: " + errors; } JSON.stringify(books); ❶ dbClient удерживает соединение с базой данных. ❷ Инициализирует Ajv (библиотеку проверки схемы JSON) с помощью allErrors: true, чтобы перехватить все ошибки проверки данных. ❸ Использует параметризованный SQL-запрос в качестве наилучшей практики обеспечения безопасности. ❹ Передает параметры в SQL-запрос в виде списка значений (в нашем случае списка с одним значением). Тео: Я понимаю, что в языке с динамической типизацией, таком как JavaScript, типы значений в списке карт, возвращаемых dbClient.query, определяются во вре-
Глава 10. Операции с базой данных 251 мя выполнения. Как это работает на языке со статической типизацией, таком как Java, и какие бывают типы полей данных в книгах? Джо: Функция convertJDBCResultSetToListOfMaps, которую мы создали ранее (см. листинг 10.5), возвращает список Map<String, Object>. Но библиотеки сериализации JSON, такие как Gson, умеют определять во время выполнения конкретный тип значений на карте и сериализовать значения в соответствии с их типом.  ПРИМЕЧАНИЕ. См. https://github.com/google/gson для получения информации о библиотеке Gson для сериализации/десериализации Java. Тео: Что вы подразумеваете под сериализацией значения в соответствии с его типом? Джо: Например, значением поля publish_year является число; поэтому оно не заключено в кавычки. Однако значением поля title является строка; поэтому оно заключено в кавычки. Тео: Замечательно! Теперь я понимаю, что вы имеете в виду под поздним связыванием. Джо: Супер! Теперь позвольте мне показать вам, как мы храним данные в базе данных. 10.2. Хранение данных в базе данных В предыдущем разделе мы увидели, как извлекать данные из базы данных в виде списка карт. Далее мы увидим, как хранить данные в базе данных, когда данные представлены картой. Тео: Полагаю, что хранение данных в базе данных очень похоже на выборку данных из базы данных. Джо: Это похоже в том смысле, что мы имеем дело только с общими коллекциями данных. Можете ли вы написать параметризованный SQL-запрос, который вставляет строку с информацией о пользователе, используя только email и encrypted_password, пожалуйста? Тео: Да, сейчас. Тео размышляет над кодом и пишет несколько строк SQL, как просил Джо. Он показывает это Джо. Листинг 10.7. SQL-инструкция для добавления пользователя библиотеки INSERT INTO members (email, encrypted_password) VALUES ($1, $2) Джо: Замечательно! А вот как интегрировать ваш SQL-запрос в код нашего приложения.
252 Часть 2. Масштабируемость Листинг 10.8. Добавление пользователя библиотекой из приложения var addMemberQuery = "INSERT INTO members (email, password) VALUES ($1, $2)"; dbClient.query(addMemberQuery, [_.get(member, "email"),❶ _.get(member, "encryptedPassword")]); ❶ Передает два параметра SQL-запросу в виде массива. Тео: Ваш код очень понятен, но что-то меня все же беспокоит. Джо: Что же? Тео: Я нахожу громоздким использование _.get(user, "email") вместо user.email, как если бы данные были представлены классом. Джо: В JavaScript вам разрешено использовать запись через точку user.email вместо _.get(user, "email"). Тео: Тогда почему бы вам не использовать запись через точку? Джо: Потому что я хотел показать вам, как вы можете применять принципы ДОП даже в таких языках, как Java, где запись через точку недоступна для хеш-карт.  ПРИМЕЧАНИЕ. В этой книге мы избегаем использования точечной записи JavaScript для доступа к полю в хеш-карте, чтобы проиллюстрировать, как применять ДОП в языках, которые не поддерживают точечную запись в хеш-картах. Тео: Это точно то, что я имею в виду. Я считаю громоздким в таком языке, как Java, использовать _.get(user, "email") вместо user.email, как если бы данные были представлены классом. Джо: С одной стороны, это громоздко. С другой стороны, представление данных с помощью хеш-карты вместо статического класса позволяет гибко обращаться к полям. Тео: Я знаю, вы столько раз говорили мне это! Но не могу к этому привыкнуть. Джо: Позвольте мне привести вам еще один пример преимуществ гибкого доступа к полям данных в контексте добавления члена в базу данных. Вы сказали, что писать [_.get(member, "email"), _.get(member, "encryptedPassword")] менее удобно, чем писать [member.email, member.encryptedPassword]. Верно? Тео: Все так! Джо: Позвольте мне показать вам, как написать тот же код более лаконично, используя функцию Lodash под названием _.at. Тео: Что делает _.at функция? Джо: Она получает карту m, список keyList и возвращает список, состоящий из значений m, связанных с ключами в keyList. Тео: Как насчет примера?
Глава 10. Операции с базой данных 253 Джо: Конечно. Мы создаем список из полей email и encryptedPassword пользователя библиотекой. Джо печатает. Затем он показывает этот код Тео. Листинг 10.9. Создание списка из некоторых значений на карте с помощью _.at var member = { "email": "samantha@gmail.com", "encryptedPassword": "c2VjcmV0", "isBlocked": false }; _.at(member, ["email", "encryptedPassword"]); // ? ["samantha@gmail.com", "c2VjcmV0"] Тео: Значения в результатах появляются в том же порядке, что и ключи в keyList? Джо: Да. Тео: Классно! СОВЕТ. Доступ к полю в хеш-карте более гибкий, чем доступ к члену в объекте, созданном из класса. Джо: А вот код для добавления пользователя библиотекой с помощью _.at. Листинг 10.10. Использование _.at для возврата нескольких значений из карты class CatalogDB { static addMember(member) { var addMemberQuery = `INSERT INTO members (email, encrypted_password) VALUES ($1, $2)`; dbClient.query(addMemberQuery, _.at(member, ["email", "encryptedPassword"])); } } Тео: Я вижу, как функция _.at становится действительно полезной, когда нам нужно передать большее количество значений полей. Джо: Я покажу вам больше примеров, использующих гибкий доступ к данным, который у нас есть в ДОП.
254 Часть 2. Масштабируемость 10.3. Простая манипуляция данными Довольно часто в производственном приложении нам необходимо изменить данные, извлеченные из базы данных. Самый простой случай — это когда нам нужно переименовать столбцы из базы данных в те, которые больше подходят для нашего приложения. Джо: Вы заметили, что имена столбцов в нашей базе данных соответствуют стилю написания snake? Тео: Я так привык к стилю, что нет. Я даже не думал об этом. Джо: Ну, например, столбец для года публикации книги называется publication_ year. Тео: Погодите-ка... Джо: Внутри JSON мне нравится использовать стиль Pascal, например publicationYear. Тео: И я бы предпочел иметь bookTitle вместо title. Джо: Итак, мы оба недовольны строкой JSON, которую возвращает searchBooks, если мы передаем данные, извлеченные из базы данных, как есть. Тео: Именно! Джо: Как бы мы починили ее? Тео: Я бы изменил SQL-запрос так, чтобы он переименовывал столбцы в результатах. Позвольте мне показать. Листинг 10.11. Переименование столбцов внутри SQL-запроса SELECT title AS bookTitle, isbn, publication_year AS publicationYear FROM books WHERE title LIKE '%habit%'; Джо: Это сработало бы, но кажется немного странным изменять SQL-запрос так, чтобы он соответствовал соглашению об именовании приложения. Тео: Да, я согласен. Я полагаю, что такая база данных, как MongoDB, не позволяет легко переименовывать поля внутри запроса. Джо: Ага. Иногда имеет смысл иметь дело с именами полей в коде приложения. Как бы вы с этим справились? Тео: Что ж, в таком случае для каждой карты, возвращаемой запросом к базе данных, я бы использовал функцию для изменения имен полей. Джо: Не могли бы вы показать мне, как будет выглядеть код? Тео: Конечно. Как насчет такого?
Глава 10. Операции с базой данных 255 Листинг 10.12. Переименование определенных ключей в списке карт function renameBookInfoKeys(bookInfo) { return { "bookTitle": _.get(bookInfo, "title"), "isbn": _.get(bookInfo, "isbn"), "publicationYear": _.get(bookInfo, "publication_year") }; } var bookResults = [ { "title": "7 Habits of Highly Effective People", "isbn": "978-1982137274", "publication_year": 1989 }, { "title": "The Power of Habit", "isbn": "978-0812981605", "publication_year": 2012 } ]; _.map(bookResults, renameBookInfoKeys); Джо: Не могли бы вы написать аналогичный фрагмент кода для каждого запроса, который вы извлекли из базы данных? Тео: Что вы имеете в виду? Джо: Предположим, вы хотите переименовать поля, возвращаемые запросом, который извлекает книги, взятые пользователем. Тео: А-а. Я бы написал аналогичный фрагмент кода для каждого случая. Джо: В ДОП мы можем использовать тот факт, что имя поля — это просто строка, и написать универсальную функцию под названием renameResultKeys, которая работает с каждым списком карт. Тео: Ого! Но как renameResultKeys узнает, какие поля переименовывать? Джо: Вы передаете сопоставление между старым и новым именами в виде карты. СОВЕТ. В ДОП имена полей — это просто строки. Это позволяет нам писать общие функции для управления списком карт, которые представляют данные, извлеченные из базы данных. Тео: Не могли бы вы показать мне пример? Джо: Конечно. Подобную карту можно передать в renameResultKeys, чтобы переименовать поля в результатах поиска книг. Так, например, вы можете написать renameResultKeys следующим образом.
256 Часть 2. Масштабируемость Листинг 10.13. Переименование полей в результатах SQL renameResultKeys(bookResults, { "title": "bookTitle", "publication_year": "publicationYear" }); Тео: Что случилось с полем, в котором хранится isbn? Джо: Когда поле не упоминается, renameResultKeys оставляет его как есть. Тео: Классно! Можете ли вы показать мне реализацию renameResultKeys? Джо: Конечно, речь идет только о map и reduce, поэтому я бы сделал что-то вроде этого. Листинг 10.14. Переименование ключей в результатах SQL function renameKeys(map, keyMap) { return _.reduce(keyMap, function(res, newKey, oldKey) { var value = _.get(map, oldKey); var resWithNewKey = _.set(res, newKey, value); var resWithoutOldKey = _.omit(resWithNewKey, oldKey); return resWithoutOldKey; }, map); } function renameResultKeys(results, keyMap) { return _.map(results, function(result) { return renameKeys(result, keyMap); }); } Тео: Этот код не так уж и прост для понимания! Джо: Не волнуйтесь. Чем больше вы пишете функции обработки данных с map, filter и reduce, тем больше вы к этому привыкаете. Тео: Я надеюсь! Джо: Что действительно важно на данный момент, так это то, что вы понимаете, что делает возможным в ДОП написание такой функции, как renameResultKeys. Тео: Я бы сказал, это потому, что поля доступны динамически со строками. Джо: Точно. Можно сказать, что поля — это первоклассные граждане. СОВЕТ. В ДОП поля — это первоклассные граждане. Тео: Как бы вы написали модульные тесты для функции манипулирования данными, такой как renameResultKeys?
Глава 10. Операции с базой данных 257 Джо: Это похоже на модульные тесты, которые мы писали ранее. Вы генерируете входные данные и ожидаемые результаты и следите за тем, чтобы фактические результаты соответствовали ожидаемым результатам. Я попрошу вас подождать, т. к. это может занять некоторое время. Пока Джо занят кодированием, Тео пользуется случаем, чтобы сбегать на кухню и приготовить два эспрессо. Какая удача! На столе стоит коробка швейцарского шоколада. Он хватает пару штук и возвращается в свой офис как раз в тот момент, когда Джо заканчивает модульный тест. Листинг 10.15. Модульный тест для renameResultKeys var listOfMaps = [ { "title": "7 Habits of Highly Effective People", "isbn": "978-1982137274", "publication_year": 1989 }, { "title": "The Power of Habit", "isbn": "978-0812981605", "publication_year": 2012 } ]; var expectedResults = [ { "bookTitle": "7 Habits of Highly Effective People", "isbn": "978-1982137274", "publicationYear": 1989 }, { "bookTitle": "The Power of Habit", "isbn": "978-0812981605", "publicationYear": 2012 } ]; var results = renameResultKeys(listOfMaps, {"title": "bookTitle", "publication_year": "publicationYear"}); _.isEqual(expectedResults, results); Тео: Супер! Джо: Вы понимаете, почему можете свободно использовать renameResultKeys с результатами любого SQL-запроса?
258 Часть 2. Масштабируемость Тео: Потому что код renameResultKeys отделен от внутренних деталей представления данных, с которыми он работает. Джо: Совершенно верно! Теперь предположим, что SQL-запрос возвращает информацию о пользователе в таблице. Как бы вы использовали renameResultKeys для переименования email в userEmail? Предположим, что таблица выглядит следующим образом (табл. 10.5). И снова в игру вступает доска. Когда Джо заканчивает, он показывает Тео таблицу. Таблица 10.5. Результаты SQL-запроса, который возвращает email и encrypted_password некоторых пользователей email encrypted_password jennie@gmail.com secret-pass franck@hotmail.com my-secret Тео: Это просто! На своем ноутбуке Тео пишет код для переименования email. Удовлетворенный, он поворачивает ноутбук к Джо. Листинг 10.16. Переименование email в userEmail var listOfMaps = [ { "email": "jennie@gmail.com", "encryptedPassword": "secret-pass" }, { "email": "franck@hotmail.com", "encryptedPassword": "my-secret" } ]; renameResultKeys(listOfMaps, {"email": "userEmail"}); Джо: Превосходно! Я думаю, вы готовы перейти к продвинутой обработке данных. 10.4. Продвинутая обработка данных В некоторых случаях нам необходимо изменить структуру строк, возвращаемых SQL-запросом (например, объединить поля из разных строк в единую карту). Это можно было бы сделать на уровне SQL-запроса, используя расширенные функции, такие как объединение JSON в PostgreSQL. Однако иногда имеет смысл изменить
Глава 10. Операции с базой данных 259 данные внутри приложения, поскольку это упрощает выполнение SQL-запросов. Как и в случае простого сценария манипулирования данными из предыдущего раздела, как только мы напишем код, который реализует некоторые манипуляции с данными, мы можем свободно использовать один и тот же код для аналогичных вариантов использования, даже если они связаны с объектами данных разных типов. Тео: Какой вид продвинутой обработки данных вы имели в виду? Джо: Вы узнаете об этом через минуту, но сначала SQL-задание для вас. Напишите SQL-запрос, который возвращает книги, включая имена авторов, содержащие слово habit в названии. Тео: Я попробую это сделать. После некоторых проб и ошибок Тео удается это сделать. Он записывает SQLзапрос, который объединяет три таблицы: books, book_authors и authors. Листинг 10.17. SQL-запрос для получения книг, содержащих слово habit SELECT title, isbn, authors.name AS author_name FROM books INNER JOIN book_authors ON books.isbn = book_authors.book_isbn INNER JOIN authors ON book_authors.author_id = authors.id WHERE books.title LIKE '%habit%'; Джо: Сколько строк в результатах? Тео подходит к доске. Он быстро набрасывает таблицу с результатами, затем отвечает на вопрос Джо. Поскольку у «7 Habits of Highly Effective People» два автора, Тео дважды перечисляет книгу в табл. 10.6. Тео: Три ряда. Таблица 10.6. Результаты SQL-запроса, который извлекает книги, название которых содержит слово habit, включая имена авторов title isbn author_name 7 Habits of Highly Effective People 978-1982137274 Sean Covey 7 Habits of Highly Effective People 978-1982137274 Stephen Covey The Power of Habit 978-0812981605 Charles Duhigg
260 Часть 2. Масштабируемость Джо: И сколько книг? Тео: Три. Джо: Можете ли вы показать мне результаты SQL-запроса в виде списка карт? Тео: Конечно. Листинг 10.18. Список карт с результатами для листинга 10.17 [ { "title": "7 Habits of Highly Effective People", "isbn": "978-1982137274", "publication_year": "Sean Covey" }, { "title": "7 Habits of Highly Effective People", "isbn": "978-1982137274", "author_name": "Stephen Covey" }, { "title": "The Power of Habit", "isbn": "978-0812981605", "author_name": "Charles Duhigg" } ] Джо: И как выглядит список карт, которые нам нужно вернуть? Тео: Это список с двумя картами, где имена авторов объединены в список. Позвольте-ка мне написать код для этого. Листинг 10.19. Объединение имен авторов в список [ { "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People", "authorNames": [ "Sean Covey", "Stephen Covey" ] }, { "isbn": "978-0812981605", "title": "The Power of Habit", "authorNames": ["Charles Duhigg"] } ]
Глава 10. Операции с базой данных 261 Джо: Идеально! Теперь давайте рассмотрим пример продвинутой задачи манипулирования данными, в которой мы преобразуем список карт, возвращенных из базы данных, в список карт, в которых объединены имена авторов. Тео: Хм... Звучит не так уж и просто. Джо: Позвольте мне разбить задачу на два этапа. Сначала мы группируем строки, принадлежащие одной и той же книге (с одинаковым ISBN). Затем в каждой группе мы объединяем имена авторов в список. Подождите, я нарисую это в виде конвейера обработки данных. Джо подходит к доске. Он рисует диаграмму (рис. 10.3). Джо: Имеет ли сейчас это смысл? Рис. 10.3. Конвейер данных для объединения имен авторов
262 Часть 2. Масштабируемость Тео: Да, конвейер данных имеет смысл, но я понятия не имею, как написать код, который его реализует! Джо: Позвольте мне показать шаг за шагом. Давайте начнем с группировки книг с одинаковым ISBN с помощью _.groupBy. Листинг 10.20. Группировка строк по ISBN var sqlRows = [ { "title": "7 Habits of Highly Effective People", "isbn": "978-1982137274", "author_name": "Sean Covey" }, { "title": "7 Habits of Highly Effective People", "isbn": "978-1982137274", "author_name": "Stephen Covey" }, { "title": "The Power of Habit", "isbn": "978-0812981605", "author_name": "Charles Duhigg" } ]; _.groupBy(sqlRows, "isbn"); Листинг 10.21. Строки, сгруппированные по ISBN { "978-0812981605": [ { "author_name": "Charles Duhigg", "isbn": "978-0812981605", "title": "The Power of Habit" } ], "978-1982137274": [ { "author_name": "Sean Covey", "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People" }, { "author_name": "Stephen Covey", "isbn": "978-1982137274",
Глава 10. Операции с базой данных 263 "title": "7 Habits of Highly Effective People" } ] } Тео: Каков следующий шаг? Джо: Теперь нам нужно взять каждый список строк в rowsByIsbn и объединить имена авторов. Тео: И как мы это сделаем? Джо: Давайте сделаем это в списке из двух строк для «7 Habits of Highly Effective People». Код выглядит следующим образом. Листинг 10.22. Объединение имен авторов var rows7Habits = [ { "author_name": "Sean Covey", "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People" }, { "author_name": "Stephen Covey", "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People" } ]; var authorNames = _.map(rows7Habits, "author_name");❶ var firstRow = _.nth(rows7Habits, 0); var bookInfoWithAuthorNames = _.set(firstRow, "authorNames", authorNames); _.omit(bookInfoWithAuthorNames, "author_name");❷ ❶ Извлекает имена авторов из всех строк. ❷ Удаляет поле author_name. Джо: Сначала мы берем имена авторов из всех строк. Затем берем первую строку за основу для информации о книге, добавляем поле authorNames и удаляем поле author_name. Тео: Можем ли мы сделать из этого функцию? Джо: Именно это я и хотел предложить! Тео: Я вызову функцию aggregateField. Она получит три аргумента: строки, имя поля для агрегирования и имя поля, которое содержит объединение. Тео поворачивается к своему ноутбуку. Через пару минут на его экране отображается реализация для aggregateField.
264 Часть 2. Масштабируемость Листинг 10.23. Объединение произвольного поля function var var var aggregateField(rows, fieldName, aggregateFieldName) { aggregatedValues = _.map(rows, fieldName); firstRow = _.nth(rows, 0); firstRowWithAggregatedValues = _.set(firstRow, aggregateFieldName, aggregatedValues); return _.omit(firstRowWithAggregatedValues, fieldName); } Джо: Вы не могли бы написать тест-кейс, чтобы убедиться, что ваша функция работает должным образом? Тео: С удовольствием! Смотрите! Листинг 10.24. Тест-кейс для aggregateField var expectedResults = { "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People", "authorNames": [ "Sean Covey", "Stephen Covey" ] }; _.isEqual(expectedResults, aggregateField(rows7Habits, "author_name", "authorNames")); Джо: Превосходно! Теперь, когда у нас есть функция, которая объединяет поле из списка строк, нам нужно только сопоставить функцию со значениями нашего rowsByIsbn. Позвольте мне закодить это. Листинг 10.25. Объединение имен авторов в rowsByIsbn var rowsByIsbn = _.groupBy(sqlRows, "isbn"); var groupedRows = _.values(rowsByIsbn); _.map(rowsByIsbn, function(groupedRows) { return aggregateField(groupedRows, "author_name", "authorNames"); })
Глава 10. Операции с базой данных 265 Тео: Почему вы взяли значения rowsByIsbn? Джо: Потому что на самом деле нас не волнуют ключи в rowsByIsbn. Мы заботимся только о группировке строк в значениях хеш-карты. Тео: Позвольте мне попытаться объединить все, что мы сделали, и написать функцию, которая получает список строк и возвращает список информации о книге с именами авторов, объединенными в список. Джо: Удачи, мой друг! Для Тео это не так сложно, как кажется. После пары проб и ошибок он приходит к коду и тест-кейсу. Листинг 10.26. Объединение поля в списке строк function aggregateFields(rows, idFieldName, fieldName, aggregateFieldName) { var groupedRows = _.values(_.groupBy(rows, idFieldName)); return _.map(groupedRows, function(groupedRows) { return aggregateField(groupedRows, fieldName, aggregateFieldName); }); } var sqlRows = [ { "title": "7 Habits of Highly Effective People", "isbn": "978-1982137274", "author_name": "Sean Covey" }, { "title": "7 Habits of Highly Effective People", "isbn": "978-1982137274", "author_name": "Stephen Covey" }, { "title": "The Power of Habit", "isbn": "978-0812981605", "author_name": "Charles Duhigg" } ]; var expectedResults = [ { "authorNames": [ "Sean Covey", "Stephen Covey" ],
266 Часть 2. Масштабируемость "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People" }, { "authorNames": ["Charles Duhigg"], "isbn": "978-0812981605", "title": "The Power of Habit", } ]; _.isEqual(aggregateFields(sqlRows, "isbn", "author_name", "authorNames"), expectedResults); Тео: Я думаю, что у меня получилось. Джо: Поздравляю! Я горжусь вами, Тео. Теперь Тео понимает, что имел в виду Джо, когда сказал ему «облако есть облако», когда они возвращались из парка в офис. Вместо того чтобы ограничивать данные пределами наших объектов, ДОП направляет нас к представлению данных как данных. Итоги  Внутри наших приложений мы представляем данные, извлеченные из базы дан- ных, независимо от того, являются ли они реляционными или нереляционными, в виде общей структуры данных.  В случае реляционной базы данных данные представляются в виде списка карт.  Представление данных из базы данных в виде данных снижает сложность сис- темы, поскольку для этого нам не нужны шаблоны проектирования или сложные иерархии классов.  Мы можем свободно манипулировать данными из базы данных с помощью об- щих функций, таких как возврат списка, составленного из значений некоторых полей данных, создание версии карты без поля данных или группировка карт в списке в соответствии со значением поля данных.  Мы используем универсальные функции для манипулирования данными с большой гибкостью, используя тот факт, что внутри хеш-карты мы получаем доступ к значению поля через его имя, представленное в виде строки.  Когда мы упаковываем нашу логику манипулирования данными в пользователь- ские функции, которые получают имена полей в качестве аргументов, мы можем использовать эти функции для различных объектов данных.
Глава 10. Операции с базой данных 267  Лучший способ манипулировать данными — это представлять данные как дан- ные.  Мы представляем данные из базы данных с помощью общих наборов данных и манипулируем ими с помощью общих функций.  Доступ к данным из базы данных NoSQL осуществляется аналогично подходу, представленному в этой главе для доступа к данным из реляционной базы данных.  При позднем связывании мы заботимся о типах данных как можно позже.  Гибкость повышается, поскольку многие части системы могут свободно мани- пулировать данными, не имея дела с конкретными типами.  Доступ к полю в хеш-карте является более гибким, чем доступ к члену в объек- те, созданном из класса.  В ДОП имена полей — это просто строки. Это позволяет нам писать универ- сальные функции для управления списком карт, представляющих данные, извлеченные из базы данных.  В ДОП поля — первоклассные граждане.  Мы можем реализовать переименование ключей в списке карт или агрегирова- ние строк, возвращаемых запросом к базе данных, с помощью универсальных функций.  JDBC расшифровывается как подключение к базе данных Java.  Преобразование результирующего набора JDBC в список карт довольно просто. Функции Lodash, представленные в этой главе Функция Описание at(map, [paths]) Создает массив значений, соответствующих path из map omit(map, [paths]) Создает карту, состоящую из полей map, не входящих в paths nth(arr, n) Получает элемент с индексом n в arr groupBy(coll, f) Создает карту, состоящую из ключей, сгенерированных по результатам выполнения каждого элемента coll через f. Соответствующее значение для каждого ключа представляет собой массив элементов, отвечающих за генерацию ключа
268 Часть 2. Масштабируемость
Веб-сервисы Верный посланник В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Представление клиентского запроса в виде карты.  Представление ответа сервера в виде карты.  Дальнейшая передача данных.  Объединение данных из разных источников. Архитектура современных информационных систем состоит из программных компонентов, написанных на различных языках программирования, таких как JSON, которые взаимодействуют по проводам, отправляя и получая данные, представленные в независимом от языка формате обмена данными. ДОП применяет тот же принцип связи между внутренними частями программы. СОВЕТ. Когда веб-браузер отправляет запрос веб-сервису, довольно часто сам веб-сервис отправляет запросы другим веб-сервисам, чтобы выполнить запрос веб-браузера. Одним из популярных форматов обмена данными является JSON. Внутри программы компоненты взаимодействуют, отправляя и получая данные, представленные в независимом от компонентов формате, а именно неизменяемые наборы данных. В контексте веб-сервисы, которые выполняют запрос клиента путем извлечения данных из базы данных и других веб-сервисов или представление данных, как и в случае неизменяемых наборов данных, приводит к следующим преимуществам:  использованию общих функций манипулирования данными для манипулирова- ния данными из нескольких источников;  передача данных осуществляется свободно, без каких-либо дополнительных сложностей.
270 Часть 2. Масштабируемость 11.1. Другой запрос функции После своевременного предоставления базы данных milestone Тео звонит Нэнси, чтобы поделиться хорошими новостями. Вместо того чтобы праздновать успех Тео, Нэнси спрашивает его об ETA для следующего этапа — расширенной информации о книгах с помощью Open Library Books API. Тео говорит ей, что перезвонит ей насчет ETA к концу дня. Когда Джо приходит в офис, Тео рассказывает ему о разговоре с Нэнси. Тео: Мне только что позвонила Нэнси, и она переживает из-за следующего этапа. Джо: Что ждет нас на следующем этапе? Тео: Вы помните Open Library Books API, о котором я рассказывал вам несколько недель назад?  ПРИМЕЧАНИЕ. Вы можете найти Open Library Books API по адресу https://openlibrary.org/dev/docs/api/books. Джо: Нет. Тео: Это веб-сервис, который предоставляет подробную информацию о книгах. Джо: Круто! Тео: Нэнси хочет расширить результаты поиска книг. Вместо извлечения информации о книге из базы данных нам нужно получить расширенную информацию о книге из Open Library Books API. Джо: Какого рода информация? Тео: Все! Количество страниц, вес, физический формат, темы и т. д. Джо: А как насчет информации из базы данных? Тео: Кроме информации о наличии книг, она нам больше не нужна. Джо: Вы уже ознакомились с Open Library Books API? Тео: Это кошмар! Для некоторых книг информация содержит дюжину полей, а для других книг она содержит только два или три поля. Джо: Тогда в чем проблема? Тео: Я понятия не имею, как представлять данные, которые настолько разрежены и непредсказуемы. Джо: Когда мы представляем данные как данные, это не проблема. Давай выпьем кофе, и я тебе покажу. 11.2. Создание внутренностей, подобных внешностям Пока Тео пьет свой макиато, Джо рисует схему на доске. (На рис. 11.1 показана схема Джо.)
Глава 11. Веб-сервисы 271 Рис. 11.1. Высокоуровневая архитектура современной информационной системы Джо: Прежде чем мы углубимся в детали реализации функции расширения результатов поиска книг, позвольте мне дать вам краткое введение. Тео: Конечно. Джо делает глоток своего эспрессо. Затем он указывает на диаграмму на доске (рис. 11.1). Джо: Вам это кажется знакомым? Тео: Конечно! Джо: Можете ли вы показать мне примерные этапы в потоке данных веб-сервиса? Тео: Ага. Тео придвигается ближе к доске. Он пишет список шагов (см. врезку) рядом со схемой архитектуры. ЭТАПЫ ПОТОКА ДАННЫХ ВНУТРИ ВЕБ-СЕРВИСА 1. Получить запрос от клиента. 2. Применить бизнес-логику к запросу. 3. Извлечь данные из внешних источников (например, базы данных и других веб-служб). 4. Применить бизнес-логику к ответам из внешних источников. 5. Отправить ответ клиенту. Джо: Прекрасно! Теперь приходит важное понимание о ДОП. Тео: Я весь внимание. Джо: Мы должны строить внутреннюю часть наших систем так же, как мы строим внешнюю. Тео: Что вы имеете в виду? Джо: Как компоненты системы взаимодействуют по проводам? Тео: Путем отправки данных. Джо: Зависит ли формат данных от языка программирования компонентов? Тео: Нет, довольно часто это JSON, для которого у нас есть парсеры на всех языках программирования.
272 Часть 2. Масштабируемость Джо: Идиома гласит, что внутри нашей программы внутренние компоненты программы должны взаимодействовать таким образом, чтобы это не зависело от компонентов. Тео: Я этого не понимаю. Джо: Позвольте мне объяснить, почему традиционное ООП нарушает эту идиому. Возможно, тогда все станет яснее. Когда данные представлены с помощью классов, внутренним компонентам программы необходимо знать внутренние определения классов, чтобы взаимодействовать. Тео: Что вы имеете в виду? Джо: Чтобы иметь возможность получить доступ к члену класса, компоненту необходимо импортировать определение класса. Тео: Каким образом это могло быть иначе? Джо: В ДОП, как мы уже видели, внутренние компоненты программы взаимодействуют с помощью общих наборов данных. Это похоже на то, как компоненты системы взаимодействуют по проводам. СОВЕТ. Мы должны строить внутреннюю часть наших систем так же, как мы строим внешнюю. Тео: Почему это так важно? Джо: С точки зрения дизайна это важно, потому что это означает, что внутренние компоненты программы слабо связаны. Тео: Что вы подразумеваете под слабо связанными? Джо: Я имею в виду, что компонентам не нужны знания о внутренностях других компонентов. Единственное, что требуется знать, — это названия полей. СОВЕТ. В ДОП внутренние компоненты программы слабо связаны. Тео: А с точки зрения реализации? Джо: Как вы сейчас увидите, реализовать этапы потока данных, которые вы только что записали на доске, несложно. Все сводится к выражению бизнес-логики в терминах общих функций манипулирования данными. Позвольте мне показать вам диаграмму. Джо подходит к доске и набрасывает рисунок (рис. 11.2). Когда Джо заканчивает, звонит его мобильный телефон. Он извиняется и выходит на улицу, чтобы ответить на звонок. Тео несколько минут стоит в одиночестве перед доской, размышляя, «как мы строим внутренние части наших систем так же, как мы строим внешние». Сам того не замечая, он берет маркер и начинает рисовать новую диаграмму (рис. 11.3), которая обобщает идеи, которыми Джо только что поделился с ним.
Глава 11. Веб-сервисы Рис. 11.2. Внутренние компоненты дата-ориентированного веб-сервиса 273 Рис. 11.3. Построение внутренних частей наших систем аналогично построению внешних. Внутренние компоненты веб-сервиса взаимодействуют с данными. В качестве примера типичный поток веб-сервиса, обрабатывающий клиентский запрос: 1 — парсинг клиентского запроса JSON в данные; 2 — манипулирование данными в соответствии с бизнес-логикой; 3 — сериализация данных в JSON-запрос к базе данных и другой веб-службе; 4 — парсинг ответов JSON в данные; 5 — манипулирование данными в соответствии с бизнес-логикой; 6 — сериализация данных в ответ JSON клиенту 11.3. Представление запроса клиента в виде карты Через несколько минут Джо возвращается. Когда он смотрит на новый рисунок Тео (см. рис. 11.3), он выглядит довольным. Джо: Извините, что прервался. Давайте начнем с самого начала — разберем запрос клиента. Как вы обычно получаете параметры клиентского запроса? Тео: Это зависит от того, что параметры могут быть отправлены как параметры запроса URL в запросе GET или как полезная нагрузка JSON в теле запроса POST.
274 Часть 2. Масштабируемость Джо: Давайте предположим, что мы получаем полезные данные JSON внутри вебзапроса. Можете ли вы привести мне пример полезных данных JSON для запроса расширенного поиска? Тео: Он будет содержать текст, которому должно соответствовать название книги. Джо: И каковы подробности полей для извлечения из Open Library Books API? Тео: Они не будут переданы как часть полезных данных JSON, потому что они одинаковы для всех поисковых запросов. Джо: Я могу представить себе сценарий, в котором вы хотите, чтобы клиент сам решал, какие поля извлекать. Например, мобильный клиент предпочел бы извлекать только самые важные поля и экономить пропускную способность сети. Тео: Что ж, в таком случае у меня были бы две разные конечные точки поиска: одна для мобильных устройств и одна для компьютеров. Джо: Как насчет ситуаций, когда клиент хочет отображать разные фрагменты информации в зависимости от экрана приложения? Например, на расширенном экране результатов поиска мы отображаем все поля. На основном экране результатов поиска мы отображаем только самые важные поля. Теперь у вас есть четыре различных варианта использования: desktop extended, desktop basic, mobile extended и mobile basic. Могли бы вы создать четыре разные конечные точки? Тео: Ладно, вы убедили меня. Давайте создадим единую конечную точку поиска и позволим клиенту решать, какие поля извлекать. Джо: Можете ли вы показать мне пример полезной нагрузки JSON для поискового запроса? Тео: Конечно. Поскольку кода не так много, Тео записывает поисковый запрос на доске. Требуется очень мало времени, чтобы показать, как клиенты будут решать, какие поля извлекать для каждого результата поиска. Листинг 11.1. Пример полезных данных поискового запроса { "title": "habit", "fields": ["title", "weight", "number_of_pages"] } Джо: Превосходно! Теперь первым шагом является парсинг строки JSON в структуру данных. Тео: Позвольте мне угадать: это будет общая структура данных. Джо: Конечно! В таком случае у нас будет карта. Обычно парсинг JSON обрабатывается фреймворком веб-сервера, но я собираюсь показать вам, как это сделать вручную. Тео: Подождите! Что вы подразумеваете под фреймворком веб-сервера?
Глава 11. Веб-сервисы 275 Джо: Такие вещи, как Express в Node.js, Spring в Java, Django в Python, Ruby в Rails, ASP.net в C# и т. д. Тео: О, понятно. Итак, как вы вручную парсите строку JSON на карту? Джо: В JavaScript мы используем JSON.parse. В Java мы используем стороннюю библиотеку, такую как Gson (https://github.com/google/gson), поддерживаемый Google. Джо открывает свой ноутбук и записывает два фрагмента кода, один на JavaScript, а другой на Java с помощью Gson. Когда он заканчивает, он показывает код Тео. Листинг 11.2. Парсинг строки JSON в JavaScript var jsonString = '{"title":"habit","fields":["title","weight", "number_of_pages"]}'; JSON.parse(jsonString); Листинг 11.3. Парсинг строки JSON в Java с помощью Gson var jsonString = '{"title":"habit","fields":["title","weight", "number_of_pages"]}'; gson.fromJson(jsonString, Map.class); Джо: Можете ли вы написать схему JSON для полезных данных поискового запроса? Тео: Конечно. Это выглядело бы примерно так. Листинг 11.4. Схема JSON для поискового запроса var searchBooksRequestSchema = { "type": "object", "properties": { "title": {"type": "string"}, "fields": { "type": "array", "items": { "enum": [ "title", "full_title", "subtitle", "publisher", "publish_date", "weight", "physical_dimensions", "number_of_pages", "subjects",
276 Часть 2. Масштабируемость "publishers", "genre" ] } } }, "required": ["title", "fields"] }; Джо: Мило! Вы пометили элементы в массиве fields как перечисляемые, а не как строки. Откуда у вас список разрешенных значений? Тео: Нэнси дала мне список полей, которые она хочет предоставить пользователям. Позвольте мне показать вам ее список. Листинг 11.5. Важные поля из Open Library Books API - title full_title subtitle publisher publish_date weight physical_dimensions number_of_pages subjects publishers genre 11.4. Представление ответа сервера в виде карты Когда Тео выполняет фрагмент кода, он отображает строку JSON с расширенной информацией о «7 Habits of Highly Effective People». Листинг 11.6. Извлечение данных из API Open Library Books fetchAndLog( "https:/ /openlibrary.org/isbn/978-1982137274.json" );❶ //{ // "authors": [ // { // "key": "/authors/OL383159A", // },
Глава 11. Веб-сервисы // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // // { "key": "/authors/OL30179A", }, { "key": "/authors/OL1802361A", }, ], "created": { "type": "/type/datetime", "value": "2020-08-17T14:26:27.274890", }, "full_title": "7 Habits of Highly Effective People : Revised and Updated Powerful Lessons in Personal Change",, "isbn_13": [ "9781982137274", ], "key": "/books/OL28896586M", "languages": [ { "key": "/languages/eng", }, ], "last_modified": { "type": "/type/datetime", "value": "2021-09-08T19:07:57.049009", }, "latest_revision": 3, "lc_classifications": [ "", ], "number_of_pages": 432, "publish_date": "2020", "publishers": [ "Simon & Schuster, Incorporated", ], "revision": 3, "source_records": [ "bwb:9781982137274", ], "subtitle": "Powerful Lessons in Personal Change", "title": "7 Habits of Highly Effective People : Revised and Updated", "type": { "key": "/type/edition", }, 277
278 Часть 2. Масштабируемость // "works": [ // { /3/ // }, // ], //} "key": "/works/OL2629977W", ❶ Служебная функция, которая извлекает JSON и выводит его в консоль. Джо: Просила ли Нэнси о какой-либо специальной обработке полей, возвращаемых API? Тео: Ничего особенного, кроме сохранения только тех полей, которые я вам показал. Джо: И это все? Тео: Да. Например, вот строка JSON, возвращаемая Open Library Books API для «7 Habits of Highly Effective People» после сохранения только необходимых полей. Листинг 11.7. Ответ Open Library для «7 Habits of Highly Effective People» { "title":"7 Habits of Highly Effective People : Revised and Updated", "subtitle":"Powerful Lessons in Personal Change", "number_of_pages":432, "full_title":"7 Habits of Highly Effective People : Revised and Updated Powerful Lessons in Personal Change", "publish_date":"2020", "publishers":["Simon & Schuster, Incorporated"] } Тео: Кроме того, Нэнси хочет, чтобы мы сохранили только те поля, которые отображаются в запросе клиента. Джо: Знаете ли вы, как реализовать фильтрацию двойного поля? Тео: Да, я проанализирую строку JSON из API в хеш-карту, как мы проанализировали запрос клиента, а затем я дважды использую _.pick, чтобы сохранить только обязательные поля. Джо: По-моему, это отличный план. Можете ли вы закодить его, включая проверку данных, возвращаемых Open Library API? Тео: Конечно! Позвольте мне сначала написать схему JSON для ответа Open Library API. Тео нужно освежить в памяти материалы о составе схемы, чтобы выразить тот факт, что либо isbn_10, либо isbn_13 являются обязательными. Через несколько мгновений он показывает схему JSON Джо.
Глава 11. Веб-сервисы Листинг 11.8. Схема JSON для ответа Open Library Books API var basicBookInfoSchema = { "type": "object", "required": ["title"], "properties": { "title": {"type": "string"}, "publishers": { "type": "array", "items": {"type": "string"} }, "number_of_pages": {"type": "integer"}, "weight": {"type": "string"}, "physical_format": {"type": "string"}, "subjects": { "type": "array", "items": {"type": "string"} }, "isbn_13": { "type": "array", "items": {"type": "string"} }, "isbn_10": { "type": "array", "items": {"type": "string"} }, "publish_date": {"type": "string"}, "physical_dimensions": {"type": "string"} } }; var mandatoryIsbn13 = { "type": "object", "required": ["isbn_13"] }; var mandatoryIsbn10 = { "type": "object", "required": ["isbn_10"] }; var bookInfoSchema = { "allOf": [ basicBookInfoSchema, { "anyOf": [mandatoryIsbn13, mandatoryIsbn10] } ] }; 279
280 Часть 2. Масштабируемость Тео: Теперь, предполагая, что у меня есть функция fetchResponseBody, которая отправляет запрос и извлекает тело ответа в виде строки, позвольте мне закодить, как выполнить извлечение. Дайте мне секунду. Тео печатает в своей IDE в течение нескольких минут. Затем он показывает результат Джо. Листинг 11.9. Извлечение информации о книге из API Open Library Books var ajv = new Ajv({allErrors: true}); class OpenLibraryDataSource { static rawBookInfo(isbn) { var url = `https:/ /openlibrary.org/isbn/${isbn}.json`; var jsonString = fetchResponseBody(url);❶ return JSON.parse(jsonString); } static bookInfo(isbn, requestedFields) { var relevantFields = ["title", "full_title", "subtitle", "publisher", "publish_date", "weight", "physical_dimensions", "genre", "subjects", "number_of_pages"]; var rawInfo = rawBookInfo(isbn); if(!ajv.validate(bookInfoSchema, rawInfo)) { var errors = ajv.errorsText(ajv.errors); throw "Internal error: Unexpected result from Open Books API: " + errors; } var relevantInfo = _.pick(_.pick(rawInfo, relevantFields), requestedFields); return _.set(relevantInfo, "isbn", isbn); } } ❶ Извлекает JSON в теле ответа.  ПРИМЕЧАНИЕ. Фрагменты JavaScript в этой главе написаны так, как если бы JavaScript обрабатывал ввод-вывод синхронным способом. В реальной жизни нам нужно использовать async и await для вызовов ввода-вывода. Джо: Выглядит хорошо! Но почему вы добавили поле isbn к карте, возвращаемой bookInfo? Тео: Это позволит мне объединить информацию из двух источников об одной и той же книге. Джо: Мне это нравится!
Глава 11. Веб-сервисы 281 11.5. Дальнейшая передача информации Джо: Если я правильно понимаю, программе необходимо объединить два вида данных: базовую информацию о книге из базы данных и расширенную информацию о книге из Open Library API. Как вы собираетесь объединить их в единый фрагмент данных в ответе клиенту? Тео: В традиционном ООП я бы создал определенный класс для каждого типа информации о книге. Джо: Что вы имеете в виду? Тео: Вы знаете, у меня были бы такие классы, как DBBook, OpenLibraryBook и CombinedBook. Джо: Хм... Тео: Но это не сработает, потому что мы решили использовать динамический подход, при котором клиент сам решает, какие поля должны отображаться в ответе. Джо: Верно, и классы не приносят никакой дополнительной ценности, потому что нам нужно передавать данные дальше. Знаете ли вы историю о парне, который попросил своего друга принести цветы его невесте? Тео: Нет. Джо принимает торжественную позу, как будто собирается выступить перед собранием собратьев. Глубоко вздохнув, он рассказывает Тео следующую историю. Зачарованный, Тео внимательно слушает. ИСТОРИЯ О ПАРНЕ, КОТОРЫЙ ПОПРОСИЛ СВОЕГО ДРУГА ПРИНЕСТИ ЦВЕТЫ ЕГО НЕВЕСТЕ За несколько недель до их свадьбы Хьюго захотел послать цветы Айрис, своей невесте, которая отдыхала с семьей в соседнем городе. Не имея возможности поехать самому, потому что он был нужен на работе, чтобы исправить критическую ошибку в приложении безопасности, Хьюго попросил своего друга Вилли совершить поездку и отвезти букет цветов своей возлюбленной вместе с конвертом, содержащим любовное письмо, которое Хьюго написал для своей невесты. Вилли, которому все равно пришлось бы ехать, любезно соглашается. Прежде чем вручить цветы Айрис, Вилли позвонил своему другу Хьюго, чтобы сообщить ему, что он собирается завершить свою миссию. Радость Хьюго была невероятной, пока Вилли не сказал Хьюго, как сильно он восхищается качеством его стиля письма. Хьюго был разочарован. «Что?! Ты читал письмо, которое я написал своей невесте?» «Конечно!» — ответил Вилли. — «Это было необходимо сделать, чтобы добросовестно выполнить свой долг». Тео: В этом нет никакого смысла! Почему Вилли должен был прочитать письмо, чтобы выполнить свой долг? Джо: В этом-то и суть истории! В некотором смысле традиционный ООП похож на друга Хьюго, Вилли. Чтобы передавать информацию вперед, разработчики ООП считают, что им нужно «открыть письмо» и представить информацию с помощью определенных классов.
282 Часть 2. Масштабируемость Тео: О, я понимаю. И разработчики ДОП подражают духу того, что Хьюго ожидал от курьера; они просто передают информацию в виде общих структур данных. Джо: Именно. Тео: Это тонкая, но забавная аналогия. Джо: Давайте вернемся к вопросу объединения данных из базы данных с данными из Books API. Есть два способа сделать это — вложение и слияние. Джо подходит к доске. Он находит область, чтобы нарисовать таблицу для Тео (табл. 11.1). Таблица 11.1. Два способа объединения хеш-карт Способ Преимущества Недостатки Вложение Нет необходимости улаживать конфликты Результат не является ровным Слияние Результат — ровный Необходимость улаживать конфликты Тео: Как работает вложенность? Джо: При вложении мы добавляем поле с именем extendedInfo к информации, полученной из Open Library API. Тео: Понимаю. А как насчет слияния? Джо: При слиянии мы объединяем поля обеих карт в единую карту. Тео: Если на обеих картах есть поля с одинаковым названием, что тогда? Джо: Тогда у вас возникает конфликт слияния, и вам нужно решить, как справиться с этим конфликтом. В этом недостаток слияния.  ПРИМЕЧАНИЕ. При объединении карт нужно подумать о появлении конфликтующих полей. Тео: Будем надеяться, что в контексте расширенных результатов поиска карты не имеют никаких общих полей. Джо: Тогда давайте объединим их! Тео: Нужно ли мне писать собственный код для объединения двух карт? Джо: Нет! Как вы, возможно, помните из одного из наших предыдущих сеансов, Lodash предоставляет удобную функцию _.merge.  ПРИМЕЧАНИЕ. _.merge было введено в главе 5. Тео: Не могли бы вы освежить мою память? Джо: Конечно. Покажите мне пример карт с данными из базы данных и данными из Open Library Books API, и я покажу вам, как их объединить. Тео: Из базы данных мы получаем только два поля: isbn и available (листинг 11.10). Из Open Library API мы получаем шесть полей (листинг 11.11).
Глава 11. Веб-сервисы 283 Листинг 11.10. Карта с информацией о книге из базы данных var dataFromDb = { "available": true, "isbn": "978-1982137274" }; Листинг 11.11. Карта с информацией о книгах из Open Library Books API var dataFromOpenLib = { "title":"7 Habits of Highly Effective People : Revised and Updated", "subtitle":"Powerful Lessons in Personal Change", "number_of_pages":432, "full_title":"7 Habits of Highly Effective People : \ Revised and Updated Powerful Lessons in Personal Change", "publish_date":"2020", "publishers":["Simon & Schuster, Incorporated"] }; Джо: После вызова _.merge результатом будет карта с полями из обеих карт. Листинг 11.12. Слияние двух карт _.merge(dataFromDb, dataFromOpenLib); //{ // "available": true, // "full_title": "7 Habits of Highly Effective People :\ // Revised and Updated Powerful Lessons in Personal Change", // "isbn": "978-1982137274", // "number_of_pages": 432, // "publish_date": "2020", // "publishers": [ "Simon & Schuster, Incorporated"], // "subtitle": "Powerful Lessons in Personal Change", // "title": "7 Habits of Highly Effective People : Revised and Updated" //} Тео: Позвольте мне закодить схему JSON для ответа поиска книг. Вот как это могло бы выглядеть. Листинг 11.13. Схема JSON для ответа на поиск книг var searchBooksResponseSchema = { "type": "object", "required": ["title", "isbn", "available"], "properties": { "title": {"type": "string"},
284 Часть 2. Масштабируемость "available": {"type": "boolean"}, "publishers": { "type": "array", "items": {"type": "string"} }, "number_of_pages": {"type": "integer"}, "weight": {"type": "string"}, "physical_format": {"type": "string"}, "subjects": { "type": "array", "items": {"type": "string"} }, "isbn": {"type": "string"}, "publish_date": {"type": "string"}, "physical_dimensions": {"type": "string"} } }; Тео: Да! Я думаю, что теперь у нас есть все необходимое для расширения результатов нашего поиска. 11.6. Расширение результатов поиска в действии Джо: Можете ли вы описать шаги потока расширения данных? Тео: Конечно. Тео подходит к доске. Ему требуется мгновение, чтобы собраться с мыслями, а затем он освобождает достаточно места, чтобы было место для перечисления шагов. ШАГИ ДЛЯ ПОТОКА РАСШИРЕНИЯ ДАННЫХ РЕЗУЛЬТАТОВ ПОИСКА 1. Получение запроса от клиента. 2. Извлечение из запроса клиента запроса и поля для извлечения из Open Library. 3. Извлечение из базы данных книги, соответствующей запросу. 4. Извлечение информации из Open Library для каждого ISBN, соответствующего запросу. 5. Извлечение из Open Library ответов для обязательных полей. 6. Объединение информации о книге из базы данных с информацией из Open Library. 7. Отправка ответа клиенту. Джо: Идеально! Хотите попробовать реализовать это? Тео: Думаю, я начну с реализации поиска книг из базы данных. Это очень похоже на то, что мы делали в прошлом месяце.  ПРИМЕЧАНИЕ. Смотрите урок прошлого месяца в главе 10.
Глава 11. Веб-сервисы 285 Джо: На самом деле это даже проще, потому что вам не нужно объединять таблицы. Тео: Правильно, мне нужны значения только для столбцов isbn и available. Тео немного поработал в своей IDE. Он начинает с поиска книги из базы данных. Листинг 11.14. Извлечение книг, название которых соответствует запросу var dbSearchResultSchema = { "type": "array", "items": { "type": "object", "required": ["isbn", "available"], "properties": { "isbn": {"type": "string"}, "available": {"type": "boolean"} } } }; class CatalogDB { static matchingBooks(title) { var matchingBooksQuery = ` SELECT isbn, available FROM books WHERE title = like '%$1%'; `; var books = dbClient.query(catalogDB, matchingBooksQuery, [title]); if(!ajv.validate(dbSearchResultSchema, books)) { var errors = ajv.errorsText(ajv.errors); throw "Internal error: Unexpected result from the database: " + errors; } return books; } } Джо: Пока что все хорошо... Тео: Далее я перейду к реализации извлечения информации о книге из Open Library для нескольких книг. К сожалению, Open Library Books API не поддерживает одновременный запрос нескольких книг. Мне нужно будет отправить по одному запросу на каждую книгу. Джо: Это немного раздражает. Давайте упростим нашу жизнь и представим, что _.map работает с асинхронными функциями. В реальной жизни вам понадобится
286 Часть 2. Масштабируемость что-то вроде Promise.all, чтобы отправлять запросы параллельно и объединять ответы. Тео: Хорошо, тогда все довольно просто. Я возьму код поиска книги и добавлю функцию multipleBookInfo, которая отображает поверх bookInfo. Тео просматривает код поиска книги в листинге 11.9, а затем концентрируется, вводя текст в IDE. Когда он закончит, он показывает результат в листинге 11.15 Джо. Листинг 11.15. Извлечение информации о книге из Open Library для нескольких книг class OpenLibraryDataSource { static rawBookInfo(isbn) { var url = `https:/ /openlibrary.org/isbn/${isbn}.json`; var jsonString = fetchResponseBody(url); return JSON.parse(jsonString); } static bookInfo(isbn, requestedFields) { var relevantFields = ["title", "full_title", "subtitle", "publisher", "publish_date", "weight", "physical_dimensions", "genre", "subjects", "number_of_pages"]; var rawInfo = rawBookInfo(isbn); if(!ajv.validate(dbSearchResultSchema, bookInfoSchema)) { var errors = ajv.errorsText(ajv.errors); throw "Internal error: Unexpected result from Open Books API: " + errors; } var relevantInfo = _.pick(_.pick(rawInfo, relevantFields), requestedFields); return _.set(relevantInfo, "isbn", isbn); } static multipleBookInfo(isbns, fields) { return _.map(function(isbn) { return bookInfo(isbn, fields); }, isbns); } } Джо: Хорошо! Теперь начинается самое интересное: объединение информации из нескольких источников данных. Тео: Ага. У меня в руках два массива: один с информацией о книге из базы данных и один с информацией о книге из Open Library. Мне как-то нужно объединить массивы, но я не уверен, что могу предположить, что позиции информации о книге одинаковы в обоих массивах.
Глава 11. Веб-сервисы 287 Джо: Что бы вы хотели иметь в своих руках? Тео: Жаль, что у меня нет двух хеш-карт. Джо: И какими будут ключи в хеш-картах? Тео: Книжные номера ISBN. Джо: Что ж, у меня для вас хорошие новости: ваше желание исполнено! Тео: Как? Джо: Lodash предоставляет функцию с именем _.keyBy, которая преобразует массив в карту. Тео: Я не могу в это поверить. Можете ли вы показать мне пример? Джо: Конечно. Давайте вызовем _.keyBy для массива с двумя книгами. Листинг 11.16. Преобразование массива в карту с помощью _.keyBy var books = [ { "title": "7 Habits of Highly Effective People", "isbn": "978-1982137274", "available": true }, { "title": "The Power of Habit", "isbn": "978-0812981605", "available": false } ]; _.keyBy(books, "isbn"); Джо: А вот и результат. Листинг 11.17. Результат keyBy { "978-0812981605": { "available": false, "isbn": "978-0812981605", "title": "The Power of Habit" }, "978-1982137274": { "available": true, "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People" } }
288 Часть 2. Масштабируемость Тео: keyBy потрясающая! Джо: Не преувеличивай, мой друг; _.keyBy очень похожа на _.groupBy. Единственное отличие заключается в том, что _.keyBy предполагает, что в каждой группе есть только один элемент. Тео: Я думаю, что с помощью _.keyBy я смогу написать общую функцию joinArrays. Джо: Я рад видеть, что вы мыслите в терминах реализации бизнес-логики с помощью общих функций манипулирования данными. СОВЕТ. Многие части бизнес-логики могут быть реализованы с помощью общих функций манипулирования данными. Тео: Функция joinArrays должна получать массивы и имя поля, для которых мы определяем два элемента, которые необходимо объединить, например isbn. Джо: Помните, что, как правило, это не обязательно одно и то же имя поля для обоих массивов. Тео: Правильно! Итак, joinArrays должен получить четыре аргумента: два массива и два имени полей. Джо: Действуйте! И, пожалуйста, напишите модульный тест для joinArrays. Тео: Конечно... Листинг 11.18. Универсальная функция для объединения массивов function joinArrays(a, b, keyA, keyB) { var mapA = _.keyBy(a, keyA); var mapB = _.keyBy(b, keyB); var mapsMerged = _.merge(mapA, mapB); return _.values(mapsMerged); } Листинг 11.19. Модульный тест для joinArrays var dbBookInfos = [ { "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People", "available": true }, { "isbn": "978-0812981605", "title": "The Power of Habit", "available": false } ];
Глава 11. Веб-сервисы 289 var openLibBookInfos = [ { "isbn": "978-0812981605", "title": "7 Habits of Highly Effective People", "subtitle": "Powerful Lessons in Personal Change", "number_of_pages": 432, }, { "isbn": "978-1982137274", "title": "The Power of Habit", "subtitle": "Why We Do What We Do in Life and Business", "subjects": [ "Social aspects", "Habit", "Change (Psychology)" ], } ]; var joinedArrays = [ { "available": true, "isbn": "978-1982137274", "subjects": [ "Social aspects", "Habit", "Change (Psychology)", ], "subtitle": "Why We Do What We Do in Life and Business", "title": "The Power of Habit", }, { "available": false, "isbn": "978-0812981605", "number_of_pages": 432, "subtitle": "Powerful Lessons in Personal Change", "title": "7 Habits of Highly Effective People", }, ] _.isEqual(joinedArrays, joinArrays(dbBookInfos, openLibBookInfos, "isbn", "isbn")); Джо: Превосходно! Теперь вы готовы настроить последнюю часть конечной точки расширенного результата поиска. Тео: Это довольно просто. Мы извлекаем данные из базы данных и из Open Library и объединяем их.
290 Часть 2. Масштабируемость Тео работает довольно быстро. Затем он показывает Джо код. Листинг 11.20. Поиск книг и расширенная информация о книгах class Catalog { static enrichedSearchBooksByTitle(searchPayload) { if(!ajv.validate(searchBooksRequestSchema, searchPayload)) { var errors = ajv.errorsText(ajv.errors); throw "Invalid request:" + errors; } var title = _.get(searchPayload, "title"); var fields = _.get(searchPayload, "fields"); var dbBookInfos = CatalogDataSource.matchingBooks(title); var isbns = _.map(dbBookInfos, "isbn"); var openLibBookInfos = OpenLibraryDataSource.multipleBookInfo(isbns, fields); var res = joinArrays(dbBookInfos, openLibBookInfos); if(!ajv.validate(searchBooksResponseSchema, request)) { var errors = ajv.errorsText(ajv.errors); throw "Invalid response:" + errors; } return res; } } Теперь начинается самая сложная часть. Тео тратит несколько минут на размышления о простоте кода, реализующего конечную точку расширенного поиска. Он думает о том, что классы намного менее сложны, когда мы используем их только для агрегирования функций без состояния, которые работают с аналогичными объектами домена, а затем приступает к построению кода. Листинг 11.21. Схема для конечной точки расширенного поиска (часть Open Books API) var basicBookInfoSchema = { "type": "object", "required": ["title"], "properties": { "title": {"type": "string"}, "publishers": { "type": "array", "items": {"type": "string"} },
Глава 11. Веб-сервисы "number_of_pages": {"type": "integer"}, "weight": {"type": "string"}, "physical_format": {"type": "string"}, "subjects": { "type": "array", "items": {"type": "string"} }, "isbn_13": { "type": "array", "items": {"type": "string"} }, "isbn_10": { "type": "array", "items": {"type": "string"} }, "publish_date": {"type": "string"}, "physical_dimensions": {"type": "string"} } }; var mandatoryIsbn13 = { "type": "object", "required": ["isbn_13"] }; var mandatoryIsbn10 = { "type": "object", "required": ["isbn_10"] }; var bookInfoSchema = { "allOf": [ basicBookInfoSchema, { "anyOf": [mandatoryIsbn13, mandatoryIsbn10] } ] }; Листинг 11.22. Конечная точка расширенного поиска (часть Open Books API) var ajv = new Ajv({allErrors: true}); class OpenLibraryDataSource { static rawBookInfo(isbn) { var url = `https:/ /openlibrary.org/isbn/${isbn}.json`; var jsonString = fetchResponseBody(url); 291
292 Часть 2. Масштабируемость return JSON.parse(jsonString); } static bookInfo(isbn, requestedFields) { var relevantFields = ["title", "full_title", "subtitle", "publisher", "publish_date", "weight", "physical_dimensions", "genre", "subjects", "number_of_pages"]; var rawInfo = rawBookInfo(isbn); if(!ajv.validate(bookInfoSchema, rawInfo)) { var errors = ajv.errorsText(ajv.errors); throw "Internal error: Unexpected result from Open Books API: " + errors; } var relevantInfo = _.pick( _.pick(rawInfo, relevantFields), requestedFields); return _.set(relevantInfo, "isbn", isbn); } static multipleBookInfo(isbns, fields) { return _.map(function(isbn) { return bookInfo(isbn, fields); }, isbns); } } Листинг 11.23. Конечная точка расширенного поиска (часть базы данных) var dbClient; var dbSearchResultSchema = { "type": "array", "items": { "type": "object", "required": ["isbn", "available"], "properties": { "isbn": {"type": "string"}, "available": {"type": "boolean"} } } }; class CatalogDB { static matchingBooks(title) { var matchingBooksQuery = `
Глава 11. Веб-сервисы 293 SELECT isbn, available FROM books WHERE title = like '%$1%'; `; var books = dbClient.query(catalogDB, matchingBooksQuery, [title]); if(!ajv.validate(dbSearchResultSchema, books)) { var errors = ajv.errorsText(ajv.errors); throw "Internal error: Unexpected result from the database: " + errors; } return books; } } Листинг 11.24. Схема для реализации конечной точки расширенного поиска var searchBooksRequestSchema = { "type": "object", "properties": { "title": {"type": "string"}, "fields": { "type": "array", "items": { "type": [ "title", "full_title", "subtitle", "publisher", "publish_date", "weight", "physical_dimensions", "number_of_pages", "subjects", "publishers", "genre" ] } } }, "required": ["title", "fields"] }; var searchBooksResponseSchema = { "type": "object", "required": ["title", "isbn", "available"], "properties": { "title": {"type": "string"}, "available": {"type": "boolean"},
294 Часть 2. Масштабируемость "publishers": { "type": "array", "items": {"type": "string"} }, "number_of_pages": {"type": "integer"}, "weight": {"type": "string"}, "physical_format": {"type": "string"}, "subjects": { "type": "array", "items": {"type": "string"} }, "isbn": {"type": "string"}, "publish_date": {"type": "string"}, "physical_dimensions": {"type": "string"} } }; Листинг 11.25. Схема для конечной точки расширенного поиска (объединяет фрагменты) class Catalog { static enrichedSearchBooksByTitle(request) { if(!ajv.validate(searchBooksRequestSchema, request)) { var errors = ajv.errorsText(ajv.errors); throw "Invalid request:" + errors; } var title = _.get(request, "title"); var fields = _.get(request, "fields"); var dbBookInfos = CatalogDataSource.matchingBooks(title); var isbns = _.map(dbBookInfos, "isbn"); var openLibBookInfos = OpenLibraryDataSource.multipleBookInfo(isbns, fields); var response = joinArrays(dbBookInfos, openLibBookInfos); if(!ajv.validate(searchBooksResponseSchema, request)) { var errors = ajv.errorsText(ajv.errors); throw "Invalid response:" + errors; } return response; } }
Глава 11. Веб-сервисы 295 class Library { static searchBooksByTitle(payloadBody) { var payloadData = JSON.parse(payloadBody); var results = Catalog.searchBooksByTitle(payloadData); return JSON.stringify(results); } } СОВЕТ. Классы гораздо менее сложны, когда мы используем их как средство агрегирования функций без состояния, которые работают с аналогичными объектами домена. Джо прерывает момент медитации Тео. Просмотрев код в предыдущих списках, он поздравляет Тео. Джо: Отличная работа, мой друг! Кстати, прочитав книгу «The Power of Habit», я перестал грызть ногти. Тео: Ух ты! Это потрясающе! Может быть, мне стоит прочитать эту книгу, чтобы преодолеть свою привычку пить слишком много кофе. Джо: Спасибо, и удачи с привычкой пить кофе. Тео: Я должен был позвонить Нэнси позже сегодня и сообщить время выхода книги в Open Library Book. Интересно, какой будет ее реакция, когда я скажу ей, что функция готова. Джо: Возможно, вам следует сказать ей, что она будет готова через неделю, что даст вам время начать работу над следующим этапом. 11.7. Доставка в срок Джо был прав! Тео вспоминает историю Джо о молодом дровосеке и старике. Тео смог изучить ДОП и сдать проект в срок! Он доволен, что нашел время «наточить свою пилу и перейти на более глубокий уровень практики».  ПРИМЕЧАНИЕ. Если вы не можете вспомнить историю или пропустили ее, ознакомьтесь с вступлением к части 2. Проект Klafim увенчался успехом. Нэнси довольна. Босс Тео доволен. Тео получил повышение. Чего еще может желать человек? Тео помнит свою сделку с Джо. Прогуливаясь по магазинам Вестфилдского центра Сан-Франциско в поисках подарка для детей Джо, Нерайи и Аурелии, он преисполнен чувства достигнутой цели и огромного удовольствия. Он покупает беспилотник DJI Mavic Air 2 для Нерайи и новейшие Apple Airpod Pro для Аурелии. Он также пользуется этой возможностью, чтобы купить ожерелье и пару сережек для своей жены Джейн. Это способ поблагодарить ее за то, что она выдержала его долгие дни на работе с самого начала проекта Klafim.  ПРИМЕЧАНИЕ. История продолжается в начале части 3.
296 Часть 2. Масштабируемость Итоги  Мы строим внутреннюю часть наших систем так же, как строим внешнюю.  Компоненты внутри программы обмениваются данными, которые представлены в виде неизменяемых наборов данных, таким же образом, как компоненты обмениваются данными по проводам.  В ДОП внутренние компоненты программы слабо связаны.  Многие части бизнес-логики могут быть реализованы с помощью общих функ- ций манипулирования данными. Мы используем универсальные функции для того, чтобы: • реализовать каждый шаг потока данных внутри веб-сервиса; • проанализировать запрос от клиента; • применить бизнес-логику к запросу; • извлекать данные из внешних источников (например, базы данных и других веб-сервисов); • применить бизнес-логику к ответам из внешних источников; • сериализовать ответ клиенту.  Классы гораздо менее сложны, когда мы используем их как средство для объ- единения функций без состояния, которые работают с аналогичными объектами предметной области. Функции Lodash, представленные в этой главе Функция Описание keyBy(coll, f) Создает карту, состоящую из ключей, сгенерированных по результатам выполнения каждого элемента coll через f; соответствующее значение для каждого ключа является последним элементом, ответственным за генерацию ключа
Часть 3 Удобство сопровождения Ч ерез месяц проект Klafim вступает в то, что Albatross называет фазой сопровождения. Небольшие новые функции необходимо добавлять еженедельно. Должны быть исправлены ошибки; ничего драматичного... Моника, босс Тео, решает назначить Дейва для обслуживания проекта Klafim. В этом есть смысл. За последние несколько месяцев Дейв продемонстрировал большое любопытство и заинтересованность, и у него есть солидные навыки программирования. Тео назначает встречу с Джо и Дейвом, надеясь, что Джо захочет научить Дейва ДОП, чтобы он мог продолжать продвигать хорошую работу, которую он уже проделал над Klafim. Тео и Дейв проводят конференц-звонок с Джо. Тео: Привет, Джо. Будет ли у вас время в течение следующих нескольких недель, чтобы научить Дейва принципам ДОП? Джо: Да, но я предпочту этого не делать. Дейв: Почему? Это потому, что у меня недостаточно опыта в разработке программного обеспечения? Я могу гарантировать, что я быстро учусь. Джо: Это не имеет никакого отношения к твоему опыту, Дейв. Тео: Тогда почему нет? Джо: Тео, я думаю, что вы могли бы стать отличным наставником для Дейва. Тео: Но я даже не знаю всех частей ДОП! Дейв: Давай же! Никакой ложной скромности, мой друг. Джо: Знание никогда не бывает полным. Как говорил великий Сократ: «Чем больше я знаю, тем больше я понимаю, что ничего не знаю». Я уверен, что вы сможете самостоятельно выучить недостающие части и, возможно, даже изобрести некоторые из них. Тео: Как я смогу изобрести недостающие части? Джо: Видите ли, ДОП — это такая простая парадигма, что она является благодатным материалом для инноваций. Часть материала, которому я вас научил, я по-
298 Часть 3. Удобство сопровождения черпнул у других, а часть его была моим изобретением. Если вы продолжите практиковать ДОП, я совершенно уверен, что вы тоже придумаете какие-нибудь собственные изобретения. Тео: Что ты скажешь, Дейв? Готов ли ты научиться ДОП у меня? Дейв: Определенно! Тео: Джо, будете ли вы по-прежнему доступны, если нам время от времени понадобится ваша помощь? Джо: Конечно!
Расширенная проверка данных Подарок, сделанный своими руками В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Проверка аргументов функции.  Проверки возвращаемых функцией значений.  Проверка данных за пределами статических типов.  Автоматическое создание диаграмм модели данных.  Автоматическая генерация модульных тестов на основе схемы. По мере увеличения размера кодовой базы в проекте, который следует принципам ДОП, становится все труднее управлять функциями, которые получают и возвращают только общие данные. Трудно определить ожидаемую форму аргументов функции, и когда мы передаем недопустимые данные, мы не получаем значимых ошибок. До сих пор мы показывали, как проверять данные на границах системы. В этой главе мы проиллюстрируем, как проверять данные, когда они поступают внутри системы, определяя схемы данных для аргументов функции и их возвращаемых значений. Это позволяет нам указать ожидаемую форму аргументов функции и облегчает разработку. Мы получаем некоторые дополнительные преимущества от этого начинания, такие как автоматическое создание схем моделей данных и модульных тестов на основе схем. 12.1. Проверка аргументов функции Первая задача Дейва — внедрить пару новых конечных точек HTTP для загрузки каталога в виде CSV-файла, поиска книг по авторам и оценки книг. Как только он заканчивает с заданиями, Дейв звонит Тео для проверки кода.  ПРИМЕЧАНИЕ. Участие Дейва в проекте Klafim объясняется в начале части 3. Пожалуйста, найдите минутку, чтобы прочитать вступительное слово, если вы его пропустили.
300 Часть 3. Удобство сопровождения Тео: Было ли трудно разобраться в коде ДОП? Дейв: Не так уж и трудно. Я прочитал твои записи о встречах с Джо и должен признать, что код довольно прост для понимания. Тео: Круто! Дейв: Но есть кое-что, к чему я не могу привыкнуть. Тео: Что же это? Дейв: Я борюсь с тем фактом, что все функции получают и возвращают общие данные. В ООП я знаю ожидаемую форму аргументов для каждой функции. Тео: Проверял ли ты данные на границах системы, как это сделал я? Дейв: Абсолютно. Я определил схему данных для каждого дополнительного запроса пользователя, запроса к базе данных и ответа внешней службы. Тео: Отлично! Дейв: Действительно, когда система запущена в производство, она работает хорошо. Когда данные действительны, они проходят через систему, а когда данные недействительны, мы можем отобразить пользователю значимое сообщение об ошибке. Тео: Тогда в чем проблема? Дейв: Проблема в том, что во время разработки трудно определить ожидаемую форму аргументов функции. И когда я по ошибке передаю неверные данные, я не получаю четких сообщений об ошибках. Тео: Я понял. Помню, что, когда Джо показал мне, как проверять данные на границах системы, я поднял эту проблему на этапе разработки. Джо сказал мне тогда, что мы проверяем данные по мере их поступления внутри системы точно так же, как мы проверяем данные на границах системы: мы разделяем схему данных и представление данных. Дейв: Собираемся ли мы также использовать схему JSON? Тео: Да. Дейв: Круто... Мне нравится JSON-схема. Тео: Основная цель проверки данных на границах системы — предотвратить попадание в систему недопустимых данных, в то время как основная цель проверки данных внутри системы — это упрощение разработки системы. Позвольте мне для визуализации нарисовать таблицу на доске (табл. 12.1). Таблица 12.1. Два вида проверки данных Вид проверки данных Цель Среда Границы Защита Производство Внутри Простота разработки Разработка
Глава 12. Расширенная проверка данных 301 Дейв: Упрощая разработку системы, ты имеешь в виду помощь разработчикам в понимании ожидаемой формы аргументов функции, как в ООП? Тео: Именно. Дейв: Но я нетерпелив... Не поможешь ли ты мне разобраться, как проверить аргументы функции, которая реализует поиск по книгам? Тео: Дай мне посмотреть код реализации, и я сделаю все, что в моих силах. Дейв: У нас есть две реализации поиска книг: одна, где библиотечные данные хранятся в памяти с этапа прототипа, и другая, где библиотечные данные хранятся в базе данных. Тео: Я думаю, что схема для библиотечных данных в памяти будет более интересной, чем схема для библиотечных данных в базе данных, поскольку функция поиска книг получает данные каталога в дополнение к запросу. Дейв: Когда ты говоришь о более интересной схеме данных, ты имеешь в виду более сложную для написания? Тео: Более сложную для написания, но в то же время более проницательную. Дейв: Тогда давай перейдем к библиотечным данным в памяти. Код для Catalog. searchBooksByTitle на этапе прототипа будет выглядеть следующим образом. Дейв набирает код на своем ноутбуке и показывает его Тео. Листинг 12.1. Реализация поиска без проверки данных class Catalog { static authorNames(catalogData, book) { var authorIds = _.get(book, "authorIds"); var names = _.map(authorIds, function(authorId) { return _.get(catalogData, ["authorsById", authorId, "name"]); }); return names; } static bookInfo(catalogData, book) { var bookInfo = { "title": _.get(book, "title"), "isbn": _.get(book, "isbn"), "authorNames": Catalog.authorNames(catalogData, book) }; return bookInfo; } static searchBooksByTitle(catalogData, query) { var allBooks = _.get(catalogData, "booksByIsbn"); var matchingBooks = _.filter(allBooks, function(book) { return _.get(book, "title").includes(query); });
302 Часть 3. Удобство сопровождения var bookInfos = _.map(matchingBooks, function(book) { return Catalog.bookInfo(catalogData, book); }); return bookInfos; } } Тео: Дейв, пожалуйста, напомни мне об ожидаемых формах для catalogData и query. Дейв: query должен быть строкой, а catalogData — картой, соответствующей модели данных каталога. Тео: Что такое модель данных каталога? Дейв: Дай-ка посмотрю. Я где-то видел ее схему. Дейв немного порылся в своей папке в поисках системы управления библиотекой Klafim. Найдя то, что искал, он рисует схему на доске (рис. 12.1). Рис. 12.1. Модель данных каталога  ПРИМЕЧАНИЕ. Схемы, представленные в этой книге, используют схему JSON версии 2020-12. Тео: Можешь ли ты написать схему JSON для модели данных каталога? Дейв: Разрешено ли мне использовать внутренние переменные для схем книг и авторов, или я должен вложить все схемы в схему каталога? Тео: Схема JSON является частью кода. Если ты чувствуешь, что использование внутренних переменных сделает код более читаемым, дерзай. Дейв: Хорошо. Теперь мне нужен чертеж схемы JSON, который Джо подарил тебе.
Глава 12. Расширенная проверка данных 303 Тео берет потрепанный листок бумаги, который немного порван и довольно помят. Он дает Дейву шпаргалку схемы JSON. Листинг 12.2. Шпаргалка схемы JSON { "type": "array",❶ "items": { "type": "object",❷ "properties": {❸ "myNumber": {"type": "number"},❹ "myString": {"type": "string"},❺ "myEnum": {"enum": ["myVal", "yourVal"]},❻ "myBool": {"type": "boolean"}❼ }, "required": ["myNumber", "myString"],❽ "additionalProperties": false❾ } } ❶ На корневом уровне данные представляют собой массив. ❷ Каждый элемент массива представляет собой карту. ❸ Свойства каждого поля на карте. ❹ myNumber — число. ❺ myString — строка. ❻ MyEnum — значение перечисления с двумя возможностями: myVal и yourVal. ❼ myBool — логическое значение. ❽ Обязательными полями на карте являются myNumber и myString. Другие поля являются необязательными. ❾ Мы не разрешаем поля, которые явно не упомянуты в схеме. Дейв: Я думаю, что начну со схемы автора. Это кажется проще, чем схема книги. Быстро набрав код, Дейв показывает Тео авторскую схему. Дейв, все еще новичок в ДОП, наблюдает за реакцией Тео. Листинг 12.3. Схема автора var authorSchema = { "type": "object", "required": ["id", "name", "bookIsbns"], "properties": { "id": {"type": "string"}, "name": {"type": "string"}, "bookIsbns": { "type": "array",
304 Часть 3. Удобство сопровождения "items": {"type": "string"} } } }; Тео: Отличная работа! Давай теперь перейдем к схеме книги. Дейв: Я думаю, что собираюсь сохранить схему экземпляра книги в переменной. Листинг 12.4. Схема экземпляра книги var bookItemSchema = { "type": "object", "properties":{ "id": {"type": "string"}, "libId": {"type": "string"}, "purchaseDate": {"type": "string"}, "isLent": {"type": "boolean"} }, "required": ["id", "libId", "purchaseDate", "isLent"] }; var bookSchema = { "type": "object", "required": ["title", "isbn", "authorIds", "bookItems"], "properties": { "title": {"type": "string"}, "publicationYear": {"type": "integer"}, "isbn": {"type": "string"}, "authorIds": { "type": "array", "items": {"type": "string"} }, "bookItems": { "type": "array", "items": bookItemSchema } } };  ПРИМЕЧАНИЕ. Когда вы определяете сложную схему данных, рекомендуется хранить вложенные схемы в переменных, чтобы облегчить чтение схем. Тео: Почему ты не включил publicationYear в список обязательных полей в схеме книги? Дейв: Потому что для некоторых книг отсутствует год издания. В отличие от ООП, тогда будет легко иметь дело с полями, допускающими обнуление.
Глава 12. Расширенная проверка данных 305 Тео: Превосходно! А теперь, пожалуйста, займись заключительной частью — схемой каталога. Дейв: Здесь у меня проблема. Каталог должен представлять собой карту с двумя полями: booksByIsbn и authorsById. Оба значения должны быть индексами, представленными на диаграмме модели фигурными скобками. Я понятия не имею, как определить схему для индекса. Тео: Ты помнишь, как мы представляем индексы в ДОП? Дейв: Да, индексы представлены в виде карт. Тео: Верно, и в чем разница между этими картами и картами, которые мы используем для записей? Дейв: Для записей мы используем карты, где названия полей известны, а значения могут иметь разную форму. Для индексов мы используем карты, где названия полей неизвестны, а значения имеют общую форму. Тео: Все так. Мы называем карты для записей гетерогенными картами, а карты для индексов — гомогенными картами. СОВЕТ. В ДОП записи представлены в виде гетерогенных карт, тогда как индексы представлены в виде гомогенных карт. Дейв: Тогда как мы определяем схему гомогенной карты в схеме JSON? Тео: Я не знаю. Давай проверим онлайн-документацию по схеме JSON.  ПРИМЕЧАНИЕ. Смотрите https://json-schema.org /, чтобы получить доступ к онлайндокументации для схемы JSON версии 2020-12. После нескольких минут копания в онлайн-документации схемы JSON Тео находит статью о additionalProperties. Он некоторое время изучает информацию, прежде чем принять решение. Тео: Я думаю, мы могли бы использовать additionalProperties. Вот схема JSON для однородной карты, где значения — числа. Листинг 12.5. Схема JSON для однородной карты с численными значениями { "type": "object", "additionalProperties": {"type": "number"} } Дейв: Я думал, что AdditionalProperties должно быть логическим значением и использовалось для разрешения или запрета свойств, не упомянутых в схеме. Тео: Это правильно. Обычно AdditionalProperties является логическим значением, но в документации говорится, что это также может быть карта, определяющая схему. В этом случае это означает, что свойства, не упомянутые в схеме, должны иметь значение схемы, связанное с AdditionalProperties.
306 Часть 3. Удобство сопровождения Дейв: Ага. Но какое это имеет отношение к однородным картам? Тео: Что ж, однородную карту можно рассматривать как карту без предопределенных свойств, где все дополнительные свойства имеют ожидаемый тип. Дейв: Хитро! СОВЕТ. В схеме JSON однородные строковые карты имеют type: object без properties и additionalProperties, связанных со схемой. Тео: Верно. Теперь позволь мне показать тебе, как выглядит схема каталога. Тео быстро печатает на своем ноутбуке. Он показывает Дейву схему каталога. Листинг 12.6. Схема данных каталога var catalogSchema = { "type": "object", "properties": { "booksByIsbn": { "type": "object", "additionalProperties": bookSchema }, "authorsById": { "type": "object", "additionalProperties": authorSchema } }, "required": ["booksByIsbn", "authorsById"] }; Дейв: Готовы ли мы подключить каталог и схему запроса к реализации Catalog. searchBooksByTitle? Тео: Мы могли бы, но, думаю, мы можем добиться большего успеха, определив единую схему, которая объединяет как схемы каталога, так и схемы запроса. Дейв: Как мы объединим две схемы в одну? Тео: Ты знаешь, что такое кортеж? Дейв: Я думаю, что знаю, но не могу определить это формально. Тео: Кортеж — это массив, размер которого фиксирован, а элементы могут быть разной формы. Дейв: Хорошо. Итак, как мы определяем кортежи в схеме JSON? Еще раз Тео изучает онлайн-документацию по схеме JSON. К счастью, он добавил страницу в закладки и в кратчайшие сроки находит нужную ему информацию. Тео: Я нашел это! Например, мы используем prefixItems в определении кортежа, состоящего из строки и числа.
Глава 12. Расширенная проверка данных 307 Тео набирает код на своем ноутбуке. Закончив, он показывает Дейву схему кортежа. Листинг 12.7. Схема кортежа, состоящего из строки и числа { "type": "array", "prefixItems": [ { "type": "string" }, { "type": "number" } ] } Дейв: Понимаю. И как бы ты определил схему для аргументов Catalog. searchBooksByTitle? Тео: Ну, это кортеж размера 2, где первый элемент — это каталог, а второй — строка. Дейв: Что-то вроде этой схемы? Листинг 12.8. Схема для аргументов Catalog.searchBooksByTitle var searchBooksArgsSchema = { "type": "array", "prefixItems": [ catalogSchema, { "type": "string" }, ] }; Тео: Именно так! Дейв: Теперь, когда у нас есть схема для аргументов, как нам включить ее в реализацию поисковых книг? Тео: Это похоже на то, как мы проверяем данные на границах системы. Основное отличие заключается в том, что проверка данных, которые поступают внутри системы, должна выполняться только во время разработки, и она должна быть отключена, когда код выполняется в рабочей среде. Дейв: Почему? Тео: Потому что эти данные уже были проверены заранее на границе системы. Повторная проверка его при вызове функции является излишней, и это повлияет на производительность. Дейв: Когда ты говоришь о времени разработки, включает ли это тестирование и промежуточные среды? Тео: Да, все среды, кроме производства. Дейв: Понимаю. Это похоже на утверждения в Java. Они отключены в ujnjdjv-коде.
308 Часть 3. Удобство сопровождения СОВЕТ. Проверка данных внутри системы должна быть отключена на этапе производства. Тео: Все так. На данный момент я собираюсь предположить, что у нас есть функция dev, которая возвращает true, когда код запускается в среде разработки, и false, когда он запускается в производство. Взгляните на этот код. Листинг 12.9. Реализация поиска с проверкой аргументов функции Catalog.searchBooksByTitle = function(catalogData, query) { if(dev()) {❶ var args = [catalogData, query]; if(!ajv.validate(searchBooksArgsSchema, args)) { var errors = ajv.errorsText(ajv.errors); throw ("searchBooksByTitle called with invalid errors); } } arguments: " + var allBooks = _.get(catalogData, "booksByIsbn"); var matchingBooks = _.filter(allBooks, function(book) { return _.get(book, "title").includes(query); }); var bookInfos = _.map(matchingBooks, function(book) { return Catalog.bookInfo(catalogData, book); }); return bookInfos; }; ❶ Реализация dev() зависит от среды выполнения: она возвращает true, когда код выпол- няется в среде разработки, и false, когда он выполняется в рабочей среде. Дейв: Как думаешь, мы должны проверять аргументы всех функций? Тео: Нет. Думаю, мы должны относиться к проверке данных так же, как относимся к модульным тестам: мы должны проверять аргументы только тех функций, для которых мы будем писать модульные тесты. СОВЕТ. Относитесь к проверке данных как к модульным тестам. 12.2. Проверка возвращаемого значения Дейв: Как ты думаешь, имеет ли смысл также проверять возвращаемое значение функций? Тео: Конечно.
Глава 12. Расширенная проверка данных 309 Дейв: Класс. Позволь мне попытаться написать схему JSON для возвращаемого значения Catalog.searchBooksByTitle. Через несколько минут Дейв придумывает схему. Сделав глубокий вдох, затем выдох, он показывает код Тео. Листинг 12.10. Схема возвращаемого значения Catalog.searchBooksByTitle var searchBooksResponseSchema = { "type": "array", "items": { "type": "object", "required": ["title", "isbn", "authorNames"], "properties": { "title": {"type": "string"}, "isbn": {"type": "string"}, "authorNames": { "type": "array", "items": {"type": "string"} } } } }; Тео: Отличная работа! Не хочешь ли ты попробовать добавить проверку возвращаемого значения в код Catalog.searchBooksByTitle? Дейв: Конечно, хочу! Дейв немного работает в своей IDE. На этот раз немного более уверенно он показывает результат Тео. Листинг 12.11. Поиск с проверкой данных как для ввода, так и для вывода Catalog.searchBooksByTitle = function(catalogData, query) { if(dev()) { if(!ajv.validate(searchBooksArgsSchema, [catalogData, query])) { var errors = ajv.errorsText(ajv.errors); throw ("searchBooksByTitle called with invalid arguments: " + errors); } } var allBooks = _.get(catalogData, "booksByIsbn"); var matchingBooks = _.filter(allBooks, function(book) { return _.get(book, "title").includes(query); });
310 Часть 3. Удобство сопровождения var bookInfos = _.map(matchingBooks, function(book) { return Catalog.bookInfo(catalogData, book); }); if(dev()) { if(!ajv.validate(searchBooksResponseSchema, bookInfos)) { var errors = ajv.errorsText(ajv.errors); throw ("searchBooksByTitle returned an invalid value: " + errors); } } return bookInfos; }; Тео: Превосходно! Теперь нам нужно выяснить, как справиться с расширенной проверкой данных. 12.3. Расширенная проверка данных Дейв: Что ты подразумеваешь под расширенной проверкой данных? Тео: Я имею в виду выход за рамки статических типов. Дейв: Можешь привести пример? Тео: Конечно. Возьмем, к примеру, год издания книги. Это целое число, но что еще ты мог бы сказать об этом числе? Дейв: Это должно быть обязательно положительное число. Я бы сказал, что это положительное целое число. Тео: Давай, Дейв! Будь смелее, выйди за рамки типов. Дейв: Я не знаю. Я бы сказал, что это число должно быть больше 1900. Я не думаю, что имеет смысл иметь книгу, опубликованную до 1900 года. Тео: Точно. А как насчет более высокого предела? Дейв: Я бы сказал, что год публикации должен быть меньше текущего года. Тео: Очень хорошо! Я вижу, что схема JSON поддерживает числовые диапазоны. Вот как мы можем написать схему для целого числа, которое представляет год и должно находиться между 1900 и 2021 годами. Листинг 12.12. Схема для целого числа между 1900 и 2021 годами var publicationYearSchema = { "type": "integer", "minimum": 1900, "maximum": 2021 }; Дейв: Почему такого рода проверка данных невозможна в ООП? Тео: Я дам тебе возможность подумать об этом.
Глава 12. Расширенная проверка данных 311 Дейв: Думаю, я понял! В ДОП проверка данных выполняется во время выполнения, в то время как проверка статического типа в ООП выполняется во время компиляции. Во время компиляции у нас есть только информация о статических типах; во время выполнения у нас есть сами данные. Вот почему при проверке данных ДОП можно выйти за рамки типов.  ПРИМЕЧАНИЕ. Конечно, в традиционном ООП также возможно написать пользовательскую проверку данных во время выполнения. Здесь, однако, мы сравниваем схему данных со статическими типами. Тео: Ты действительно понял! Теперь позволь мне показать, как написать схему для строки, которая должна соответствовать регулярному выражению.  ПРИМЕЧАНИЕ. Посетите http://mng.bz/OGNP для руководства по регулярным выражениям JavaScript. Тео: Давай возьмем, к примеру, идентификатор книги. Я предполагаю, что это должен быть UUID. Дейв: Ага. Тео: Можешь ли ты написать регулярное выражение для допустимого UUID? Дейв гуглит «регулярное выражение UUID» и находит то, что, по его мнению, может сработать. Он показывает регулярное выражение Тео. Листинг 12.13. Регулярное выражение для допустимого UUID [0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12} Дейв: Как нам теперь подключить регулярное выражение к схеме JSON? Тео: Пока ты искал регулярное выражение UUID, я прочитал о поле pattern. Вот как мы можем подключить регулярное выражение UUID к схеме JSON. Листинг 12.14. Схема для UUID var uuidSchema = { "type": "string", "pattern": "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}" + "-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" }; Дейв: Супер! Позволь мне улучшить схему каталога и уточнить схему для PurchaseDate, isbn, libId и authorId с помощью регулярных выражений. Тео: Однако прежде, чем ты это сделаешь, позволь мне рассказать кое-что, что я прочитал о регулярных выражениях: некоторые из них предопределены. Например, существует предопределенное регулярное выражение для дат. Дейв: И как же это работает? Тео: С помощью поля format.
312 Часть 3. Удобство сопровождения  ПРИМЕЧАНИЕ. Согласно спецификации схемы JSON поле format предназначено только для аннотации и не влияет на проверку. Но на практике библиотеки проверки схемы JSON также используют format для проверки. Тео подходит к своему ноутбуку. Он вводит схему для даты и показывает ее Дейву. Листинг 12.15. Схема для даты { "type": "string", "format": "date" } СОВЕТ. В ДОП проверка данных выходит за рамки статических типов (например, диапазонов чисел, регулярных выражений и т. д.). Дейв: Очень круто! Есть ли у меня вся информация, необходимая для уточнения схемы каталога? Тео: Да, дерзай! Дейву требуется немного времени, чтобы написать регулярные выражения для isbn, AuthorID и libId. Но с помощью Google (опять же) и небольшого упрощения Дейв придумал схему: Листинг 12.16. Уточненная схема данных каталога (часть 1) var isbnSchema = { "type": "string", "pattern": "^[0-9-]{10,20}$" }; var libIdSchema = { "type": "string", "pattern": "^[a-z0-9-]{3,20}$" }; var authorIdSchema ={ "type": "string", "pattern": "[a-z-]{2,50}" }; var bookItemSchema = { "type": "object", "additionalProperties": { "id": uuidSchema, "libId": libIdSchema, "purchaseDate": { "type": "string", "format": "date" },
Глава 12. Расширенная проверка данных "isLent": {"type": "boolean"} } }; Листинг 12.17. Уточненная схема данных каталога (часть 2) var bookSchema = { "type": "object", "required": ["title", "isbn", "authorIds", "bookItems"], "properties": { "title": {"type": "string"}, "publicationYear": publicationYearSchema, "isbn": isbnSchema, "publisher": {"type": "string"}, "authorIds": { "type": "array", "items": authorIdSchema }, "bookItems": bookItemSchema } }; var authorSchema = { "type": "object", "required": ["id", "name", "bookIsbns"], "properties": { "id": {"type": "string"}, "name": {"type": "string"}, "bookIsbns": { "items": isbnSchema } } }; var catalogSchema = { "type": "object", "properties": { "booksByIsbn": { "type": "object", "additionalProperties": bookSchema }, "authorsById": { "type": "object", "additionalProperties": authorSchema } }, "required": ["booksByIsbn", "authorsById"] }; 313
314 Часть 3. Удобство сопровождения 12.4. Автоматическое создание диаграмм модели данных Прежде чем отправиться домой, Тео звонит Джо, чтобы рассказать ему о том, как они с Дейвом использовали проверку данных внутри системы. Джо говорит Тео, что именно так он рекомендует поступать, и предлагает ему прийти завтра навестить Тео и Дейва в офисе. Он хочет показать им несколько интересных продвинутых вещей, связанных с проверкой данных. На следующий день с кофе в руке Джо начинает дискуссию. Джо: Ребята, вы начинаете чувствовать силу проверки данных а-ля ДОП? Дейв: Да, проверять схему JSON немного менее удобно, чем писать класс аргументов функции, но этот недостаток компенсируется тем фактом, что схема JSON поддерживает условия, выходящие за рамки статических типов. Тео: Мы также поняли, что нам не нужно проверять данные для каждой функции. Джо: Верно. Теперь позвольте мне показать вам еще одну классную вещь, которую мы можем сделать с помощью схемы JSON. Дейв: Что это такое? Джо: Генерация диаграммы модели данных. Дейв: Ого! И как это работает? Джо: Существуют инструменты, которые получают схему JSON в качестве входных данных и создают диаграмму в формате модели данных. Дейв: Что такое формат модели данных? Джо: Это формат, который позволяет вам определять модель данных в виде обычного текста. После этого вы можете сгенерировать изображение из текста. Мой любимый формат данных — PlantUML.  ПРИМЕЧАНИЕ. Подробнее о PlantUML на https://plantuml.com /. Дейв: Знаете ли вы о других инструментах, которые генерируют диаграммы моделей данных? Джо: Я использовал JSON Schema Viewer и Malli.  ПРИМЕЧАНИЕ. Вы можете найти информацию JSON Schema Viewer по адресу https://navneethg.github.io/jsonschemaviewer/ и о Malli на https://github.com/metosin/malli. Джо показывает Дейву и Тео диаграмму PlantUML, сгенерированную Malli (листинг 12.18) из схемы каталога в листингах 12.16 и 12.17. Листинг 12.18. Схема PlantUML, созданная на основе схемы данных каталога @startuml Entity1 *-- Entity2 Entity1 *-- Entity4
Глава 12. Расширенная проверка данных 315 Entity2 *-- Entity3 class Entity1 { + booksByIsbn: {Entity2} + authorsById: {Entity4} } class Entity2 { + title : String + publicationYear: Number + isbn: String + authorIds: [String] + bookItems: [Entity3] } class Entity3 { + id: String + libId: String + purchaseDate: String + isLent: Boolean } class Entity4 { + id: String + name: String + bookIsbns: [String] } @enduml Дейв: Можно ли визуализировать эту диаграмму? Джо: Несомненно. Позвольте мне скопировать и вставить текст диаграммы в онлайн-инструмент PlantText.  ПРИМЕЧАНИЕ. Посетите https://www.planttext.com/, чтобы узнать об онлайн-инструменте PlantText больше. Дейв открывает свой веб-браузер и вводит URL для PlantText. Скопировав и вставив текст, он отходит в сторону, чтобы Тео и Дейв могли просмотреть диаграмму, похожую на изображение на рис. 12.2. Дейв: Это круто! Но почему объекты диаграммы называются Entity1, Entity2 и т. д.? Джо: Потому что в схеме JSON нет способа присвоить имя схеме. Malli должен автоматически генерировать случайные имена для вас. Тео: Кроме того, я вижу, что дополнительная информация, которая у нас есть в схеме, такая как диапазон номеров для publicationYear и регулярное выражение string для isbn, отсутствует на диаграмме.
316 Часть 3. Удобство сопровождения Рис. 12.2. Визуализация диаграммы PlantUML, созданной на основе схемы данных каталога Джо: Правильно, эта дополнительная информация не является частью модели данных. Вот почему она не включена в сгенерированную диаграмму модели данных. Дейв: В любом случае это очень круто! Джо: Если вам, ребята, нравится функция генерации модели данных, я уверен, что вам понравится и следующая функция. Дейв: Что она делает? Джо: Автоматическую генерацию модульных тестов. Тео: Звучит захватывающе! 12.5. Автоматическая генерация модульных тестов на основе схемы Джо: После того как вы определили схему данных для аргументов функции и ее возвращаемого значения, довольно просто сгенерировать модульный тест для этой функции. Дейв: Как? Джо: Что ж, подумайте об этом. В чем суть модульного теста для функции? Дейв: Модульный тест вызывает функцию с некоторыми аргументами и проверяет, возвращает ли функция ожидаемое значение. Джо: Точно! Теперь давайте адаптируем его к контексту схемы данных и ДОП. Допустим, у вас есть функция со схемой для их аргументов и для их возвращаемого значения.
Глава 12. Расширенная проверка данных 317 Дейв: Ладно. Джо: Вот процесс модульного теста на основе схемы. Мы вызываем функцию со случайными аргументами, которые соответствуют схеме аргументов функции. Затем мы проверяем, возвращает ли функция значение, соответствующее схеме возвращаемого значения. Позвольте мне нарисовать это. Джо подходит к доске. Он рисует диаграмму, представленную на рис. 12.3. Рис. 12.3. Поток модульного теста на основе схемы Дейв: Как вы генерируете случайные данные, соответствующие схеме? Джо: Используя такой инструмент, как JSON Schema Faker. Например, давайте начнем с простой схемы: схемы для UUID. Позвольте мне показать вам, как генерировать случайные данные, соответствующие схеме.  ПРИМЕЧАНИЕ. Вы найдете более подробную информацию о JSON Schema Faker на https://github.com/json-schema-faker/json-schema-faker. Джо некоторое время печатает на клавиатуре. Затем он показывает код для генерации случайных данных Дейву и Тео. Листинг 12.19. Генерация случайных данных, соответствующих схеме UUID var uuidSchema = { "type": "string", "pattern": "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}" + "-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}" }; JSONSchemaFaker.generate(uuidSchema); // −−>7aA8CdF3-14DF-9EF5-1A19-47dacdB16Fa9" Дейв выполняет фрагмент кода пару раз, и действительно, при каждой оценке он возвращает другой UUID. Дейв: Очень круто! Позвольте мне посмотреть, как это работает с более сложными схемами, такими как схема каталога.
318 Часть 3. Удобство сопровождения Когда Дейв вызывает JSONSchemaFaker.generate со схемой каталога, он получает довольно длинные случайные данные. Он немного удивлен результатами. Листинг 12.20. Генерация случайных данных, соответствующих схеме каталога { "booksByIsbn": { "Excepteur7": { "title": "elit veniam anim", "isbn": "5419903-3563-7", "authorIds": [ "vfbzqahmuemgdegkzntfhzcjhjrbgfoljfzogfuqweggchum", "inxmqh-", ], "bookItems": { "ullamco5": { "id": "f7dac8c3-E59D-bc2E-7B33-C27F3794E2d6", "libId": "4jtbj7q7nrylfu114m", "purchaseDate": "2001-08-01", "isLent": false }, "culpa_3e": { "id": "423DCdDF-CDAe-2CAa-f956-C6cd9dA8054b", "libId": "6wcxbh", "purchaseDate": "1970-06-24", "isLent": true } }, "publicationYear": 1930, "publisher": "sunt do nisi" }, "aliquip_d7": { "title": "aute", "isbn": "348782167518177", "authorIds": ["owfgtdxjbiidsobfgvjpjlxuabqpjhdcqmmmrjb-ezrsz-u"], "bookItems": { "ipsum__0b": { "id": "6DfE93ca-DB23-5856-56Fd-82Ab8CffEFF5", "libId": "bvjh0p2p2666vs7dd", "purchaseDate": "2018-03-30", "isLent": false } }, "publisher": "ea anim ut ex id", "publicationYear": 1928 } },
Глава 12. Расширенная проверка данных 319 "authorsById": { "labore_b88": { "id": "adipisicing nulla proident", "name": "culpa in minim", "bookIsbns": [ "6243029--7", "5557199424742986" ] }, "ut_dee": { "id": "Lorem officia culpa qui in", "name": "aliquip eiusmod", "bookIsbns": [ "0661-8-5772" ] } } } Джо: Я вижу, что у вас есть некоторые ошибки в ваших регулярных выражениях. Тео: Как вы это видите? Джо: Некоторые из сгенерированных ISBN, похоже, не являются действительными ISBN. Дейв: Вы правы. Я ненавижу регулярные выражения! Джо: Дейв, я не думаю, что вы единственный, у кого такое чувство. Позвольте мне показать вам, как реализовать поток модульного теста на основе схемы для Catalog.searchBooksByTitle. Листинг 12.21. Реализация потока модульного теста на основе схемы function searchBooksTest () { var catalogRandom = JSONSchemaFaker.generate(catalogSchema); var queryRandom = JSONSchemaFaker.generate({ "type": "string" }); Catalog.searchBooksByTitle(catalogRandom, queryRandom); } Дейв: Подождите минутку. Я не вижу, где вы проверяете, что Catalog. searchBooksByTitle возвращает значение, соответствующее схеме возвращаемого значения. Тео: Если вы присмотритесь к коду повнимательнее, вы это увидите. Дейв внимательно рассматривает код для Catalog.searchBooksByTitle. И теперь он это видит.
320 Часть 3. Удобство сопровождения Листинг 12.22. Реализация Catalog.searchBooksByTitle Catalog.searchBooksByTitle = function(catalogData, query) { if(dev()) { if(!ajv.validate(searchBooksArgsSchema, [catalogData, query])) { var errors = ajv.errorsText(ajv.errors); throw ("searchBooksByTitle called with invalid arguments: " + errors); } } var allBooks = _.get(catalogData, "booksByIsbn"); var matchingBooks = _.filter(allBooks, function(book) { return _.get(book, "title").includes(query); }); var bookInfos = _.map(matchingBooks, function(book) { return Catalog.bookInfo(catalogData, book); }); if(dev()) { if(!ajv.validate(searchBooksResponseSchema, bookInfos)) { var errors = ajv.errorsText(ajv.errors); throw ("searchBooksByTitle returned an invalid value: " + errors); } } return bookInfos; }; Дейв: Конечно! Это в коде Catalog.searchBooksByTitle. Если возвращаемое значение не соответствует схеме, оно выдает исключение, и тест завершается неудачей. Джо: Верно. Теперь давайте улучшим код нашего модульного теста и вернем значение false при возникновении исключения внутри Catalog.searchBooksByTitle. Джо редактирует тестовый код. Он показывает свои изменения Тео и Дейву. Листинг 12.23. Полный модульный тест на основе схемы данных для поиска книг function var var try searchBooksTest () { catalogRandom = JSONSchemaFaker.generate(catalogSchema); queryRandom = JSONSchemaFaker.generate({ "type": "string" }); { Catalog.searchBooksByTitle(catalogRandom, queryRandom); return true; } catch (error) { return false; }
Глава 12. Расширенная проверка данных 321 Дейв: Позвольте мне посмотреть, что произойдет, когда я запущу тест. Джо: Прежде чем мы запустим его, нам нужно кое-что исправить в вашем модульном тесте. Дейв: Что? Джо: Данные каталога и запрос являются случайными. Есть большая вероятность, что ни одна книга не будет соответствовать запросу. Нам нужно создать запрос, который соответствует хотя бы одной книге. Дейв: Как мы собираемся найти запрос, который гарантированно соответствует хотя бы одной книге? Джо: Нашим запросом будет первая буква первой книги из сгенерированных данных каталога. Джо быстро печатает и показывает Тео и Дейву свой усовершенствованный тест. Они в восторге от того, что Джо нашел время исправить их модульный тест. Листинг 12.24. Усовершенствованный модульный тест на основе схемы данных для поиска книг function var var try searchBooksTest () { catalogRandom = JSONSchemaFaker.generate(catalogSchema); queryRandom = JSONSchemaFaker.generate({ "type": "string" }); { var firstBook = _.values(_.get(catalogRandom, "booksByIsbn"))[0]; var query = _.get(firstBook, "title").substring(0,1); Catalog.searchBooksByTitle(catalogRandom, query); return true; } catch (error) { return false; } } Дейв: Понимаю. Это не так сложно, как я думал. Часто ли бывает так, что вам нужно настроить случайные данные? Джо: Нет, обычно со случайными данными все в порядке. Дейв: Хорошо, теперь мне любопытно посмотреть, что происходит, когда я выполняю модульный тест. Когда Дейв выполняет модульный тест, он завершается неудачей. Его лицо выражает недоумение. Тео просто поражен. Листинг 12.25. Выполнение модульного теста на основе схемы searchBooksTest(); // → false
322 Часть 3. Удобство сопровождения Дейв: Я думаю, что-то не так в коде модульного теста. Тео: Возможно, модульный тест выявил ошибку в реализации Catalog. searchBooksByTitle. Дейв: Давайте проверим это. Есть ли способ заставить модульный тест отображать возвращаемое значение функции? Джо: Да, вот оно. Джо снова поворачивается к своему ноутбуку, чтобы обновить код. Он показывает остальным свой новый модульный тест, который включает возвращаемое значение для Catalog.searchBooksByTitle. Листинг 12.26, включающий возвращаемое значение в выходных данных модульного теста function var var try searchBooksTest () { catalogRandom = JSONSchemaFaker.generate(catalogSchema); queryRandom = JSONSchemaFaker.generate({ "type": "string" }); { var firstBook = _.values(_.get(catalogRandom, "booksByIsbn"))[0]; var query = _.get(firstBook, "title").substring(0,1); Catalog.searchBooksByTitle(catalogRandom, query); return true; } catch (error) { console.log(error); return false; } } Дейв: Теперь давайте посмотрим, что отобразится, когда я запущу модульный тест снова. Листинг 12.27. Повторный запуск модульного теста на основе схемы searchBooksTest(); // → searchBooksByTitle returned // data[0].authorNames[0] should // data[0].authorNames[1] should // data[1].authorNames[0] should a value that doesn\'t conform to schema: be string, be string, be string Дейв: Мне кажется, я понимаю, что произошло. В наших данных случайного каталога авторы книг отсутствуют в индексе authorByIds. Вот почему у нас есть все эти неопределенные значения в значениях, возвращаемых Catalog. searchBooksByTitle, тогда как в схеме мы ожидаем строку. Тео: И как же мы исправим это? Дейв: Очень просто. Укажите, что Catalog.authorNames возвращает строку Not available, если автора нет в каталоге. Может быть, что-то вроде этого.
Глава 12. Расширенная проверка данных 323 Листинг 12.28. Исправление ошибки в реализации поиска книг Catalog.authorNames = function(catalogData, book) { var authorIds = _.get(book, "authorIds"); var names = _.map(authorIds, function(authorId) { return _.get(catalogData, ["authorsById", authorId, "name"], "Not available");❶ }); return names; }; ❶ Когда с ключом ["authorsById", AuthorID, "name"] не связано значение, мы возвращаем "Not available". Дейв снова выполняет модульный тест. К счастью, на этот раз он успешен. Листинг 12.29. Повторный запуск модульного теста на основе схемы searchBooksTest(); // → true Джо: Отлично сработано, Дейв! Дейв: Вы были правы. Автоматически сгенерированные модульные тесты смогли обнаружить ошибку в реализации Catalog.searchBooksByTitle. Джо: Не волнуйтесь. Такое случалось со мной много раз. Дейв: Проверка данных а-ля ДОП — это действительно круто! Джо: Это только начало, мой друг. Чем больше вы им пользуетесь, тем больше вам это нравится! Дейв: Должен признаться, мне все еще не хватает одной классной функции IDE от ООП. Джо: Какой же? Дейв: Автоматическое заполнение имен полей в классе. Джо: На данный момент автозаполнение имени поля для данных доступно только в Clojure через clj-kondo и интеграцию, которую он обеспечивает с Malli.  ПРИМЕЧАНИЕ. Перейдите на https://github.com/clj-kondo/clj-kondo и https://github.com/ metosin/malli для функции автозаполнения, предоставляемой clj-kondo, и ее интеграции с Malli. Дейв: Как вы думаете, когда-нибудь эта функциональность будет доступна на других языках программирования? Джо: Безусловно. IDE, такие как IntelliJ и Visual Studio Code, уже поддерживают проверку схемы JSON для файлов JSON. Это только вопрос времени, когда они
324 Часть 3. Удобство сопровождения поддержат проверку схемы JSON для аргументов функции и обеспечат автозаполнение имен полей на карте. Дейв: Я надеюсь, что это не займет у них слишком много времени. 12.6. Новый подарок Когда Джо уходит, Дейву приходит в голову интересная идея. Он делится ею с Тео. Дейв: Как ты думаешь, могли бы мы создать нашу собственную шпаргалку по схеме JSON с расширенными возможностями схемы JSON, которые мы обнаружили сегодня? Тео: Отличная идея! Но тебе придется сделать это самостоятельно. Мне нужно бежать на встречу! После своей встречи Тео возвращается к столу Дейва. Увидев Тео, Дейв берет со своего стола маленький сверток, похожий на тот, что Джо подарил Тео несколько недель назад. Этот, однако, обернут светло-голубой лентой. С торжественным видом Дейв вручает Тео подарок. Когда Тео развязывает ленту, он обнаруживает стильный листок бумаги, украшенный маленькими компьютерами разных цветов. В центре листа он читает надпись: «Шпаргалка по расширенной схеме JSON». Тео улыбается, просматривая схему JSON (листинг 12.30). Затем он переворачивает бумагу и обнаруживает, что обратная сторона также заполнена рисунками, на этот раз клавиатурами и мышами. В центре документа Тео читает надпись «Пример достоверных данных» (листинг 12.31). Листинг 12.30. Шпаргалка по расширенной схеме JSON { "type": "array",❶ "items": { "type": "object",❷ "properties": {❸ "myNumber": {"type": "number"},❹ "myString": {"type": "string"},❺ "myEnum": {"enum": ["myVal", "yourVal"]},❻ "myBool": {"type": "boolean"}❼ "myAge": {❽ "type": "integer", "minimum": 0, "maximum": 120 }, "myBirthday": {❾ "type": "string", "format": "date" },
Глава 12. Расширенная проверка данных 325 "myLetters": {❿ "type": "string", "pattern": "[a-zA-Z]*" } "myNumberMap": {⓫ "type": "object", "additionalProperties": {"type": "number"} }, "myTuple": {⓬ "type": "array", "prefixItems": [ { "type": "string" }, { "type": "number" } ] } }, "required": ["myNumber", "myString"],⓭ "additionalProperties": false⓮ } } ❶ На корневом уровне данные представляют собой массив. ❷ Каждый элемент массива представляет собой карту. ❸ Свойства каждого поля на карте. ❹ myNumber — это число. ❺ myString — это строка. ❻ MyEnum — это значение перечисления с двумя возможностями: "myVal" и "yourVal". ❼ myBool — это логическое значение. ❽ myAge — это целое число от 0 до 120. ❾ myBirthday — это строка, соответствующая формату даты. ❿ myLetters — это строка, состоящая только из букв (строчных или прописных). ⓫ myNumberMap — это однородная строковая карта, где все значения являются числами. ⓬ myTuple — это кортеж, где первым элементом является строка, а вторым элементом — число. ⓭ Обязательными полями на карте являются myNumber и myString. Другие поля являются необязательными. ⓮ Мы не разрешаем поля, которые явно не упомянуты в схеме. Листинг 12.31. Пример достоверных данных [ { "myNumber": 42, "myString": "I-love-you", "myEnum": "myVal", "myBool": true,
326 Часть 3. Удобство сопровождения "myTuple": ["Hello", 42] }, { "myNumber": 54, "myString": "Happy", "myAge": 42, "myBirthday": "1978-11-23", "myLetters": "Hello", "myNumberMap": { "banana": 23, "apple": 34 } } ] Итоги  Мы определяем схемы данных, используя язык, подобный JSON-схеме, для            аргументов функций и возвращаемых значений. Схемы аргументов функций позволяют разработчикам определить ожидаемую форму аргументов функций, которые они хотят вызвать. При передаче недопустимых данных сторонние библиотеки проверки данных выдают значимые ошибки с подробной информацией о недопустимых частях данных. В отличие от проверки данных на границах системы, проверка данных внутри системы должна выполняться только во время разработки и должна быть отключена в процессе производства. Мы визуализируем схему данных, генерируя диаграмму модели данных из схемы JSON. Для функций, которые имеют схемы данных для своих аргументов и возвращаемых значений, мы можем автоматически генерировать модульные тесты на основе схемы. Проверка данных происходит во время выполнения. Мы можем определить расширенные условия проверки данных, которые выходят за рамки статических типов, например проверить, находится ли число в пределах диапазона или соответствует ли строка регулярному выражению. Проверка данных внутри системы должна быть отключена в процессе производства. Записи представлены в виде разнородных карт, а индексы представлены в виде однородных карт. Когда вы определяете сложную схему данных, рекомендуется хранить вложенные схемы в переменных, чтобы облегчить чтение схем. Мы относимся к проверке данных как к модульным тестам.
Полиморфизм Резвясь с животными в сельской местности В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Имитация объектов с помощью мультиметодов (единичная отправка).  Реализация мультиметода для нескольких типов аргументов (множественная отправка).  Динамическая реализация мультиметодов для нескольких аргументов (динамическая отправка). ООП хорошо известно тем, что позволяет вызывать разные классы с одним и тем же интерфейсом с помощью механизма, называемого полиморфизмом. Может показаться, что единственный способ обеспечить полиморфизм в программе — это использовать объекты. На самом деле, как мы увидим в этой главе, полиморфизм возможен без объектов благодаря мультиметодам. Более того, мультиметоды обеспечивают более продвинутый полиморфизм, чем полиморфизм в ООП, поскольку такие методы поддерживают случаи, когда выбранная реализация зависит от нескольких типов аргументов (множественная отправка) и даже от динамического значения аргументов (динамическая отправка). 13.1. Сущность полиморфизма Для сегодняшней сессии Дейв пригласил Тео приехать и навестить его в доме его родителей в сельской местности. После того как Тео проехал через мост Золотые Ворота и свернул с автострады на сельские проселочные дороги, он увлекся красотой пейзажа, запахом влажной земли и звуками животных. Такое погружение в природу привело его в отличное настроение. Неплохой способ начать неделю! Дейв встретил Тео в джинсах и футболке, что резко контрастировало с элегантной одеждой, которую он носил в офисе. Соломенная шляпа завершала его деревенский образ. Тео здоровается с родителями Дейва, пенсионерами. Дейв предлагает пойти
328 Часть 3. Удобство сопровождения в сад и сорвать несколько апельсинов, чтобы выжать сок. Тео и Дейв выпили ароматного апельсинового сока, гораздо более вкусного, чем в Сан-Франциско, и приступили к работе. Дейв: Когда я ждал тебя этим утром, я подумал еще об одной вещи, которая есть в ООП и которой мне не хватает. Тео: И что это за вещь? Дейв: Полиморфизм. Тео: А какой именно полиморфизм? Дейв: Ну, ты же знаешь, мы определяем интерфейс, а разные классы реализуют один и тот же интерфейс по-разному. Тео: Понятно. И в чем, по-твоему, ценность полиморфизма? Дейв: В том, что он позволяет нам отделить интерфейс от его реализаций. Тео: Приведи, пожалуйста, конкретный пример. Дейв: Хорошо. Так как мы находимся в сельской местности, я проиллюстрирую классический полиморфизм в ООП при помощи животных. Тео: Хорошая идея! Дейв: Предположим, что каждое животное имеет свое собственное приветствие, издавая определенный звук и произнося свое имя. Тео: Прикольно, как в антропоморфных комиксах. Дейв: Антро-что? Тео: Это комиксы, где животные могут ходить, говорить и т. д., как Микки Маус. Дейв: А, ну да, но такого термина я не знал. Откуда он произошел? Тео: Слово «антропоморфизм» происходит от греческих слов anthropos, что означает «человек», и morphe, что означает «форма». Дейв: Понятно. Значит, антропоморфная книга — это книга, в которой животные обладают человеческими чертами. Это слово, видимо, связано с полиморфизмом. Тео: Безусловно. Слово «полиморфизм» происходит от греческих слов polús, что означает «много», и morphe, что, опять-таки, означает «форма». Дейв: Логично. Полиморфизм — это способность разных объектов реализовывать один и тот же метод по-разному. Это возвращает меня к моему примеру с животными. В ООП я бы определил интерфейс IAnimal с помощью метода greet, и каждый класс для животных реализовал бы метод greet по-своему. У меня как раз есть пример. Листинг 13.1. Полиморфизм в ООП, проиллюстрированный при помощи животных interface IAnimal { public void greet(); }
Глава 13. Полиморфизм 329 class Dog implements IAnimal { private String name; public void greet() { System.out.println("Woof woof! My name is " + animal.name); } } class Cat implements IAnimal { private String name; public void greet() { System.out.println("Meow! I am " + animal.name); } } class Cow implements IAnimal { private String name; public void greet() { System.out.println("Moo! Call me " + animal.name); } } Тео: Позволь-ка мне задать тебе каверзный вопрос. В чем принципиальная разница между полиморфизмом в ООП и оператором switch? Дейв: Что ты имеешь в виду? Тео: Я могу, например, представить животное с помощью карты, имеющей два поля, name и type, и вызвать другой фрагмент кода, в зависимости от значения type. Тео достает из сумки свой ноутбук и включает его. Пока ноутбук загружается, он наслаждается еще одним глотком замечательного апельсинового сока. Когда ноутбук готов к работе, Тео быстро печатает пример свитч-кейса. А Дейв свой стакан сока уже допил. Листинг 13.2. Свитч-кейс, где поведение зависит от типа function greet(animal) { switch (animal.type) { case "dog": console.log("Woof Woof! My name is: " + animal.name); break; case "cat": console.log("Meow! I am: " + animal.name); break; case "cow": console.log("Moo! Call me " + animal.name); break; }; }
330 Часть 3. Удобство сопровождения Дейв: Как именно будет выглядеть animal? Тео: Как я только что сказал, это карта с двумя полями: name и type. Сейчас я их введу. Листинг 13.3. Представление животных с помощью карт var myDog = { "type": "dog", "name": "Fido" }; var myCat = { "type": "cat", "name": "Milo" }; var myCow = { "type": "cow", "name": "Clarabelle" }; Дейв: А можно было дать другое название полю, содержащему тип для животного? Тео: Безусловно. Это может быть что угодно. Дейв: Ясно. Ты спросил меня, в чем принципиальная разница между твоим кодом с оператором switch и моим кодом с интерфейсом и тремя классами? Тео: Ага. Дейв: Прежде всего, если мы передадим твоей функции greet недопустимую карту, произойдет кое-что очень плохое. Тео: Ты прав. Я это исправлю и валидирую входные данные. Листинг 13.4. Валидация данных var animalSchema = { "type": "object", "properties": { "name": {"type": "string"}, "type": {"type": "string"} }, "required": ["name", "type"], }; function greet(animal) { if(dev()) { ❶ if(!ajv.validate(animalSchema, animal)) { var errors = ajv.errorsText(ajv.errors);
Глава 13. Полиморфизм 331 throw ("greet called with invalid arguments: " + errors); } } switch (animal.type) { case "dog": console.log("Woof Woof! My name is: " + animal.name); break; case "cat": console.log("Meow! I am: " + animal.name); break; case "cow": console.log("Moo! Call me " + animal.name); break; }; } ❶ Подробнее о валидации данных читайте в главе 12.  ПРИМЕЧАНИЕ. Не используйте такие операторы switch в готовом коде. Мы показываем их здесь только в дидактических целях как шаг к раскрытию сути полиморфизма. Дейв: А вот еще один недостаток твоего подхода: если ты захочешь изменить реализацию greet для конкретного животного, тебе придется изменить фрагменты кода, которые содержат всех животных, а при моем подходе нужно изменить только определенный класс для животного. Тео: Согласен, но я могу это исправить, создав отдельную функцию для каждого животного, например так: Листинг 13.5. Разные варианты реализации в разных функциях function greetDog(animal) { console.log("Woof Woof! My name is: " + animal.name); } function greetCat(animal) { console.log("Meow! I am: " + animal.name); } function greetCow(animal) { console.log("Moo! Call me " + animal.name); } function greet(animal) { if(dev()) { if(!ajv.validate(animalSchema, animal)) { var errors = ajv.errorsText(ajv.errors); throw ("greet called with invalid arguments: " + errors); } }
332 Часть 3. Удобство сопровождения switch (animal.type) { case "dog": greetDog(animal); break; case "cat": greetCat(animal); break; case "cow": greetCow(animal); break; } }; Дейв: Но что если ты захочешь расширить функциональность greet и добавить новое животное? Тео: Вот теперь-то ты меня поймал. Признаю, что с помощью оператора switch я не cмогу добавить новое животное без изменения исходного кода, тогда как в ООП можно добавить новый класс без необходимости изменять исходный код. Дейв: Ага, но ты помог мне понять, что главное преимущество полиморфизма заключается в том, что он делает код легко расширяемым. СОВЕТ. Основным преимуществом полиморфизма является расширяемость. Тео: Я спрошу у Джо, есть ли способ извлечь выгоду из полиморфизма без использования объектов. Тео отправляет сообщение Джо, чтобы спросить его о полиморфизме в ДОП. Джо ответил, что у него нет времени вдаваться в подробности, потому что сейчас он выступает с докладом о ДОП на технической конференции. Единственное, что он успевает посоветовать Тео, это изучить мультиметоды. Тео и Дейв немного почитали о мультиметодах в Интернете. Кажется, все просто. Они решили попробовать мультиметоды после обеда. 13.2. Мультиметоды с единичной отправкой Во время обеда Тео спрашивает Дейва, каково это — вырасти в сельской местности. Дейв начинает с восторженного описания жизни в непосредственном контакте с природой и более простого, чем в городе, уклада. Он благодарен за этот опыт, но признает, что сельским жителям порой приходится трудно без городских удобств. Но кто сказал, что просто — это то же самое, что легко? После обеда они решают выпить кофе. Дейв предлагает Тео самому перемолоть кофейные зерна. Тео с радостью соглашается. Далее Дейв объясняет, как использовать кофеварку френч-пресс, чтобы получить идеальный компромисс между горечью и насыщенным вкусом. Наслаждаясь свежезаваренным кофе в саду, Тео и Дейв продолжают свое исследование полиморфизма а-ля ДОП.
Глава 13. Полиморфизм 333 Тео: Из того, что я прочитал перед обедом, я понял, что мультиметоды — это концепция в программировании, которая обеспечивает полиморфизм без необходимости в объектах. Дейв: Я не понимаю, как такое возможно. Тео: Мультиметоды состоят из двух частей: функции отправки и набора методов, которые обеспечивают реализацию каждого значения отправки. Дейв: Все равно не могу сказать, что понял. Похожа ли функция отправки на интерфейс? Тео: Она похожа на интерфейс в том смысле, что она определяет способ вызова функции, но выходит за рамки этого. Она также отправляет значение, которое будет разным в разных реализациях. Дейв: Для меня это как-то абстрактно. Тео: Кажется, я понял, как реализовать инструменты приветствий животных. Если использовать мультиметод под названием greet, нам понадобится функция отправки и три метода. Давай вызовем функцию отправки greetDispatch. Она отправляет тип животного, будь то "dog", "cat" или "cow". Затем каждое значение отправки обрабатывается определенным методом: "dog" обрабатывается с помощью greetDog, "cat" с помощью greetCat и "cow" с помощью greetCow. Тео достает свой блокнот и открывает его на чистом листе бумаги. Он рисует диаграмму, подобную приведенной на рис. 13.1. Рис. 13.1. Блок-схема (англ. logic flow) мультиметода greet Дейв: А зачем стрелка между animal и методами в дополнение к стрелкам между animal и функциями отправки? Тео: Затем, что аргументы мультиметода передаются функции отправки и методам. СОВЕТ. Аргументы мультиметода передаются функции отправки и методам. Дейв: Аргументы? Во множественном числе? Я вижу только один аргумент. Тео: Ты прав. Прямо сейчас наш мультиметод получает только один аргумент, но скоро он получит несколько аргументов.
334 Часть 3. Удобство сопровождения Дейв: Ясно. Покажи мне, как написать код для мультиметода greet. Тео: Для этого нам понадобится библиотека. Например, в JavaScript реализацию мультиметодов предоставляет библиотека arrows/multimethod. Коротко говоря, мы вызываем функцию multi, чтобы создать мультиметод под названием method, служащий для добавления метода.  ПРИМЕЧАНИЕ. Посетите http://mng.bz/nY9v, чтобы посмотреть примеры использования и документацию этой библиотеки. Дейв: С чего же нам начинать? Тео: Мы начнем с инициализации мультиметода, создав функцию отправки greetDispatch, которая определяет сигнатуру мультиметода, валидирует аргументы и выдает тип животного. Затем мы передадим greetDispatch функции multi, чтобы создать мультиметод greet. Тогда наша функция отправки будет выглядеть следующим образом. Листинг 13.6. Функция отправки для мультиметода greet function greetDispatch(animal) { ❶ if(dev()) { if(!ajv.validate(animalSchema, animal)) { ❷ var errors = ajv.errorsText(ajv.errors); throw ("greet called with invalid arguments: " + errors); } } return animal.type; ❸ } var greet = multi(greetDispatch); ❹ ❶ Определение сигнатуры. ❷ Валидация аргументов. ❸ Значение отправки. ❹ Инициализация мультиметода. СОВЕТ. Функция отправки мультиметода отвечает за три вещи: она определяет сигнатуру мультиметода, валидирует аргументы и выдает значение отправки. Дейв: Что дальше? Тео: Теперь нам нужно реализовать метод для каждого значения отправки. Давайте начнем с метода, который занимается собаками. Мы создадим функцию greetDog, которая принимает animal, а затем добавим метод dog в мультиметод greet, используя функцию method из библиотеки arrows/multimethod. Функция method получает два аргумента: значение отправки и функцию, соответствующую значению отправки.
Глава 13. Полиморфизм 335 Листинг 13.7. Реализация метода greet для собак function greetDog(animal) { ❶ console.log("Woof woof! My name is " + animal.name); } greet = method("dog", greetDog)(greet); ❷ ❶ Реализация метода. ❷ Декларация метода. Дейв: Обязательно ли реализация метода должна находиться в том же модуле, что и инициализация мультиметода? Тео: Нет, вовсе нет! Декларации методов отделены от инициализации мультиметода точно так же, как определения классов отделены от определения интерфейса. Это именно то, что делает мультиметоды расширяемыми. СОВЕТ. Мультиметоды обеспечивают расширяемость за счет разделения между инициализацией мультиметода и реализациями методов. Дейв: А что там с кошками и коровами? Тео: Нужно добавить реализации их методов, как мы делали для собак. Через мгновение Тео представляет себе эти реализации. Затем он пишет код еще для двух методов greet для кошек и коров. Листинг 13.8. Реализация метода greet для кошек function greetCat(animal) { console.log("Meow! I am " + animal.name); } greet = method("cat", greetCat)(greet); Листинг 13.9. Реализация метода greet для коров function greetCow(animal) { console.log("Moo! Call me " + animal.name); } greet = method("cow", greetCow)(greet); СОВЕТ. В контексте мультиметодов метод — это функция, которая предоставляет реализацию для значения отправки. Дейв: Важны ли названия функций отправки и методов? Тео: Согласно тому, что я прочитал, не особенно, но мне нравится следовать простому соглашению об именовании: использовать название мультиметода (на-
336 Часть 3. Удобство сопровождения пример, greet) в качестве префикса для функции отправки (например, greetDispatch) и методов. Затем называем суффикс Dispatch для функции отправки и определенный суффикс для каждого метода (например, greetDog, greetCat и greetCow). Дейв: Как работает механизм мультиметода «под капотом»? Тео: Изнутри мультиметод обслуживает хеш-карту, где ключи — это значения отправки, а значения — это методы. Когда мы добавляем метод, добавляется запись в хеш-карту, а когда мы вызываем мультиметод, мы запрашиваем хешкарту, чтобы найти реализацию, соответствующую значению отправки. Дейв: По-моему, ты еще не говорил, как вызвать мультиметод. Тео: Он вызывается как обычная функция. Сейчас я покажу тебе пример, в котором вызывается мультиметод. Листинг 13.10. Вызов мультиметода как обычной функции greet(myDog); // → "Woof woof! My name is Fido" greet(myCat); // → "Meow! I am Milo" greet(myCow); // → "Moo! Call me Clarabelle" СОВЕТ. Мультиметоды вызываются как обычные функции. Дейв: Ты уже говорил мне, что в функции отправки нужно валидировать аргументы. Это обязательно или желательно? Тео: Желательно. Дейв: Что произойдет, если функция отправки не валидирует аргументы, а мы передадим невалидный аргумент? Тео: Вроде как когда у животного нет соответствующего метода? Дейв: Именно! Тео: В этом случае мы получим сообщение об ошибке. Например, библиотека arrows/multimethods выдает исключение NoMethodError. Дейв: Такие вещи бесят. Есть ли способ обеспечить реализацию по умолчанию? Тео: Безусловно! Чтобы определить реализацию по умолчанию, нужно передать методу (в качестве единичного аргумента) функцию, которая предоставляет реализацию по умолчанию. Тео пишет код и показывает его Дейву. Затем Дейв тестирует код Тео и, похоже, остается доволен результатом.
Глава 13. Полиморфизм 337 Листинг 13.11. Определение реализации по умолчанию function greetDefault(animal) { console.log("My name is " + animal.name); } greet = method(greetDefault)(greet); Листинг 13.12. Вызов мультиметода, когда ни один метод не соответствует значению отправки var myHorse = { "type": "horse", "name": "Horace" }; greet(myHorse); // → "My name is Horace" СОВЕТ. Мультиметоды поддерживают реализации по умолчанию, которые вызываются, когда ни один метод не соответствует значению отправки. Дейв: Круто! 13.3. Мультиметоды с множественной отправкой Тео: Итак, мы уже имитировали ООП, используя тип аргумента мультиметода в качестве значения отправки. Но если еще раз задуматься над логической последовательностью работы мультиметода, можно обнаружить кое-что интересное. Хочешь попробовать нарисовать диаграмму, описывающую ход работы мультиметода в целом? Дейв: Сейчас, возьму чистую салфетку. Та, что у меня под стаканом, уже влажная. Тео: Да ты что, Дейв, возьми и начерти в моем блокноте. Через несколько минут у Дейва получается схема как на рис. 13.2. Он возвращает блокнот Тео. Тео: Чудесно! Я надеюсь, ты увидел, что функция отправки может выдавать любое значение. Дейв: Например? Тео: Например, тип двух аргументов! Дейв: Что ты имеешь в виду? Тео: Представь, что наши животные — полиглоты. Дейв: Поли-что?
338 Часть 3. Удобство сопровождения Рис. 13.2. Логический поток мультиметодов Тео: Слово «полиглот» происходит от греческих слов polús, что означает «много», и glôssa, что означает «язык». Полиглот — это человек, который может говорить на многих языках. Дейв: На каких языках будут говорить наши животные? Тео: Я не знаю. Допустим, на английском и французском. Дейв: Окей, а как представить язык в нашей программе? Тео: В виде карты, конечно! Дейв: Какие поля у нас будут на этой языковой карте? Тео: Давай упростим пример и сделаем два поля: type и name. Дейв: Как в карте для животных? Тео: Не совсем так. На языковой карте поле type должно быть либо fr для французского, либо en для английского языка, а в карте для животных поле type должно быть либо dog, либо cat, либо cow. Дейв: Можно я попробую написать схему языковой карты и две карты для языков? Тео с радостью соглашается: его свежезаваренный кофе стынет! Дейв пишет свою реализацию кода и показывает Тео. Листинг 13.13. Схема языковой карты var languageSchema = { "type": "object", "properties": { "name": {"type": "string"}, "type": {"type": "string"} }, "required": ["name", "type"], }; Листинг 13.14. Две карты для языков var french = { "type": "fr",
Глава 13. Полиморфизм 339 "name": "Français" }; var english = { "type": "en", "name": "English" }; Тео: Отлично! Теперь давай напишем код для функции отправки и методы для наших животных-полиглотов. Назовем наш мультиметод greetLang. У нас будет одна функция отправки и шесть методов. Дейв: Так, трое животных (собака, кошка и корова) умножаются на два языка (en и fr). Перед тем как реализовать это, я хотел бы нарисовать блок-схему. Она поможет мне все основательно прояснить. Тео: Возьмешь опять мой блокнот? Не дожидаясь ответа, Тео подталкивает свой блокнот через стол к Дейву. Дейв рисует схему, подобную той, что приведена на рис. 13.3, и возвращает блокнот Тео. Рис. 13.3. Логический поток мультиметода greetLang Тео: Почему ты не нарисовал стрелку между аргументами и методами? Дейв: Чтобы сохранить диаграмму легко читаемой. В противном случае было бы слишком много стрелок. Тео: Хорошо, я понял. Ты готов писать код? Дейв: Да! Тео: Функции отправки нужно валидировать свои аргументы и вернуть массив с двумя элементами: типом животного и типом языка.
340 Часть 3. Удобство сопровождения Дейв недолго печатает на своем ноутбуке. Он инициализирует мультиметод с помощью функции отправки, которая возвращает тип своих аргументов, а затем показывает код Тео. Листинг 13.15. Инициализация мультиметода с помощью функции отправки var greetLangArgsSchema = { "type": "array", "prefixItems": [animalSchema, languageSchema] }; function greetLangDispatch(animal, language) { if(dev()) { if(!ajv.validate(greetLangArgsSchema, [animal, language])) { throw ("greetLang called with invalid arguments: " + ajv.errorsText(ajv.errors)); } } return [animal.type, language.type]; }; var greetLang = multi(greetLangDispatch); Дейв: Имеет ли значение порядок элементов в массиве? Тео: Не имеет, но он должен соответствовать внутренней структуре методов. Таким образом, реализация greetLang будет выглядеть следующим образом. Листинг 13.16. Реализация методов greetLang function greetLangDogEn(animal, language) { console.log("Woof woof! My name is " + animal.name + "andIspeak"+ language.name); } greetLang = method(["dog", "en"], greetLangDogEn)(greetLang); function greetLangDogFr(animal, language) { console.log("Ouaf Ouaf! Je m'appelle " + animal.name + " et je parle " + language.name); } greetLang = method(["dog", "fr"], greetLangDogFr)(greetLang);
Глава 13. Полиморфизм 341 function greetLangCatEn(animal, language) { console.log("Meow! I am " + animal.name + "andIspeak"+ language.name); } greetLang = method(["cat", "en"], greetLangCatEn)(greetLang); function greetLangCatFr(animal, language) { console.log("Miaou! Je m'appelle " + animal.name + " et je parle " + language.name); } greetLang = method(["cat", "fr"], greetLangCatFr)(greetLang); function greetLangCowEn(animal, language) { console.log("Moo! Call me " + animal.name + "andIspeak"+ language.name); } greetLang = method(["cow", "en"], greetLangCowEn)(greetLang); function greetLangCowFr(animal, language) { console.log("Meuh! Appelle moi " + animal.name + " et je parle " + language.name); } greetLang = method(["cow", "fr"], greetLangCowFr)(greetLang); Дейв просматривает код методов, которые занимаются французским языком. Он удивлен, увидев Ouaf Ouaf вместо Woof Woof для собак, Miaou вместо Meow для кошек и Meuh вместо Moo для коров. Дейв: Я не знал, что ономатопеи звуков, издаваемых животными, во французском языке отличаются от английских! Тео: Оно-что? Дейв: Слово «ономатопея» происходит от греческих слов ónoma, что означает «имя», и poiéo, что означает «производить». Этот термин относится к словам, которые звучат похоже на то, что они обозначают: например, «гав», «мяу» и «му». Тео: Точно, почему-то по-французски собаки говорят Ouaf, кошки Miaou, а коровы Meuh.
342 Часть 3. Удобство сопровождения Дейв: Как я вижу, в массиве тип для животного всегда стоит перед типом для языка. Тео: Это верно! Как я уже говорил, в мультиметоде, который включает множественную отправку, порядок не имеет особого значения, но он должен быть последовательным. СОВЕТ. Множественная отправка — это когда функция отправки выдает значение, зависящее более чем от одного аргумента. В мультиметоде, который включает множественную отправку, порядок элементов в массиве, генерируемом функцией отправки, должен соответствовать порядку элементов во внутренней структуре методов. Дейв: Теперь давай посмотрим, смогу ли я разобраться, как использовать мультиметод, который включает множественную отправку. Дейв вспоминает, что, как уже говорил ему Тео, мультиметоды используются как обычные функции. Держа это в уме, он пишет код для мультиметода, включающего множественную отправку. Листинг 13.17. Вызов мультиметода, который включает множественную отправку greetLang(myDog, french); // → "Ouaf Ouaf! Je m\'appelle Fido et je parle Français" greetLang(myDog, english); // → "Woof woof! My name is Fido and I speak English" greetLang(myCat, french); // → "Miaou! Je m\'appelle Milo et je parle Français" greetLang(myCat, english); // → "Meow! I am Milo and I speak English" greetLang(myCow, french); // → "Meuh! Appelle moi Clarabelle et je parle Français" greetLang(myCow, english); // → "Moo! Call me Clarabelle and I speak English" Тео: Ну теперь-то ты согласен с тем, что мультиметоды с множественной отправкой предлагают более мощный полиморфизм, чем полиморфизм в ООП? Дейв: Теперь полностью согласен. Тео: Сейчас я покажу тебе еще более мощный полиморфизм, называемый динамической отправкой. Но сначала давай выпьем еще этого замечательного кофе из френч-пресса. Дейв: Отличная идея! Заодно захватим на кухне по куску кекса со свежими апельсинами. Кажется, моя мама как раз испекла такой.
Глава 13. Полиморфизм 343 13.4. Мультиметоды с динамической отправкой Дейв снова разливает по чашкам кофе, а Тео берет два ломтика кекса и кладет их на тарелки. Перед тем как вернуться к беседе, они выходят на улицу, чтобы насладиться кофе и десертом на свежем деревенским воздухе. Дейв: Что такое динамическая отправка? Тео: Это когда функция отправки мультиметода возвращает значение, выходящее за рамки статического типа ее аргументов. Дейв: Какое это может быть значение? Тео: Например, число или булево значение. Дейв: И зачем такое нужно? Тео: Представь, что наши животные вовсе не полиглоты, а страдают дисмакрилексией. Дейв: От чего страдают?! Тео: От дисмакрилексии. Это слово происходит от греческой приставки dus, выражающей идею трудности, а также слов makrýs, что означает «длинный», и léxis, что означает «дикция». Следовательно, дисмакрилексия — это трудности с произнесением длинных слов. Дейв: Никогда о таком не слышал. Тео: Это потому, что я только что изобрел это слово. Дейв: Забавно. Что будем считать длинным словом для наших животных? Тео: Допустим, они не могут произнести свое имя, когда в нем больше пяти букв. Дейв: Немного странно, но ладно. Тео: Давай назовем наш мультиметод dysGreet. Его функция отправки возвращает массив с двумя элементами: типом животного и логическим (булевым) типом, показывающим, является ли имя длинным. Взгляни на такую инициализацию мультиметода. Листинг 13.18. Мультиметод, использующий функцию отправки с динамической отправкой function dysGreetDispatch(animal) { if(dev()) { if(!ajv.validate(animalSchema, animal)) { var errors = ajv.errorsText(ajv.errors); throw ("dysGreet called with invalid arguments: " + errors); } } var hasLongName = animal.name.length > 5;
344 Часть 3. Удобство сопровождения return [animal.type, hasLongName]; }; var dysGreet = multi(dysGreetDispatch); Дейв: Кажется, написать метод dysGreet не так и сложно. Тео протягивает руку, чтобы передать Дейву блокнот, но по пути случайно задевает свою кофейную чашку. Теперь блокнот Тео полностью пропитан кофе, и все диаграммы намокли! К счастью, у Дейва была припасена дополнительная салфетка, и она-то все еще чистая. Он нарисовал блок-схему (рис. 13.4), а затем взял свой ноутбук и напечатал реализацию методов dysGreet. Рис. 13.4. Логический поток мультиметода dysGreet Листинг 13.19. Методы dysGreet function dysGreetDogLong(animal) { console.log("Woof woof! My name is " + animal.name); } dysGreet = method(["dog", true], dysGreetDogLong)(dysGreet); function dysGreetDogShort(animal) { console.log("Woof woof!"); } dysGreet = method(["dog", false], dysGreetDogShort)(dysGreet); function dysGreetCatLong(animal) { console.log("Meow! I am " + animal.name); } dysGreet = method(["cat", true], dysGreetCatLong)(dysGreet);
Глава 13. Полиморфизм 345 function dysGreetCatShort(animal) { console.log("Meow!"); } dysGreet = method(["cat", false], dysGreetCatShort)(dysGreet); function dysGreetCowLong(animal) { console.log("Moo! Call me " + animal.name); } dysGreet = method(["cow", true], dysGreetCowLong)(dysGreet); function dysGreetCowShort(animal) { console.log("Moo!"); } dysGreet = method(["cow", false], dysGreetCowShort)(dysGreet); Тео проверил, что код работает так, как положено. Он похвалил Дейва не только за успешную реализацию метода, но и за то, что он предусмотрительно захватил из кухни дополнительную салфетку. Листинг 13.20. Тестирование dysGreet dysGreet(myDog); dysGreet(myCow); dysGreet(myCat); //"Woof woof!" //"Moo! Call me Clarabelle" //"Meow!" Тео: Отличная работа, друг мой! Наше исследование мультиметодов подошло к концу. Я думаю, мне пора ехать обратно, чтобы успеть домой до темноты и избежать пробок в час пик. Дейв: Пока ты не ушел, давай проверим, доступны ли мультиметоды на других языках программирования, кроме JavaScript. Тео: Это вопрос к Джо. Дейв: Как ты думаешь, можно позвонить ему прямо сейчас? Тео: Думаю, будет лучше отправить ему электронное письмо. Он на технической конференции, и она может продлиться весь день. Спасибо тебе за этот прекрасный день за городом и замечательные напитки. Дейв: Мне тоже всё понравилось, особенно наши дискуссии об этимологии. У нас, кажется, еще остались апельсины, возьми несколько домой, съешь попозже. Тео: Здорово! Моя жена с удовольствием их попробует. После того как Тео ушел, Дейв отправил Джо электронное письмо. Несколько минут спустя Дейв получил ответное письмо от Джо с темой «Поддержка мультиметодов на разных языках».
346 Часть 3. Удобство сопровождения ПОДДЕРЖКА МУЛЬТИМЕТОДОВ НА РАЗНЫХ ЯЗЫКАХ В Python есть библиотека под названием Multimethods (https://github.com/weissjeffm/ multimethods); у Ruby такая библиотека называется М Ruby multimethods (https:// github.com/psantacl/ruby-multimethods). Обе в общем и целом работают так же, как библиотека arrows/multimethod для JavaScript. В Java существует библиотека Java Multimethod Framework (http://igm.univ-mlv.fr /~forax/ works/jmmf), а C# нативно поддерживает мультиметоды с помощью ключевого слова dynamic. Однако как в Java, так и в C# мультиметоды работают только со статическими типами данных, а не с универсальными структурами данных. Поддержка универсальной структуры данных Язык URL JavaScript https://github.com/caderek/arrows/tree/master/ packages/multimethod Да Java http://igm.univ-mlv.fr/~forax/works/jmmf/ Нет C# Нативная поддержка Нет Python https://github.com/weissjeffm/multimethods Да Ruby https://github.com/psantacl/ruby-multimethods Да 13.5. Интеграция мультиметодов: производство Пока Тео едет домой, его мысли возвращаются к свежему загородному воздуху. Этот приятный момент прерывается телефонным звонком от Нэнси из Klafim. Нэнси: Как у тебя дела? Тео: Нормально. Я возвращаюсь из сельской местности. Нэнси: Круто. Ты готов поговорить о работе? Тео: Конечно. Нэнси: Я бы хотела добавить в каталог одну крошечную фишечку. Когда Нэнси ранее называла что-нибудь «крошечным», Тео пугался, потому что крошечное превращалось в гигантское. Разработка того, что ей казалось простым, всегда занимала на удивление много времени. Но после рефакторинга системы в соответствии с принципами ДОП «крошечные фишечки» Нэнси можно довольно легко реализовать. Тео: Что за фишечка? Нэнси: Я хочу разрешить библиотекарям просматривать список авторов, упорядоченный по фамилии, в двух форматах: HTML и Markdown. Тео: Это не очень и сложно. Нэнси: А еще мне нужно немного отформатировать текст.
Глава 13. Полиморфизм 347 Тео: Какое именно нужно форматирование? Нэнси: В зависимости от количества книг, написанных одним автором, их название должно быть выделено жирным шрифтом или курсивом. Тео: Пришли мне, пожалуйста, электронное письмо со всеми подробностями. Я взгляну на них завтра утром. Нэнси: Идёт. Хорошей поездки! Прежде чем лечь спать, Тео размышляет о сегодняшних уроках этимологии. Он вдруг подумал, что никогда не смотрел этимологию самого слова «этимология»! Он поискал термин «этимология» в Интернете и узнал, что это слово происходит от греческого слова étumon, что означает «истинный смысл», и суффикса logia, означающего «учение о чем-либо». Ночью Тео снятся собаки, кошки и коровы, программирующие на своих ноутбуках в травянистом поле. Когда Тео на следующий день пришел в офис, он открыл электронное письмо Нэнси с подробной информацией о новой фишке с форматированием текста. Все детали приведены в табл. 13.1. Таблица 13.1. Форматирование текста для имен авторов в соответствии с количеством написанных ими книг Количество книг Курсив Жирный шрифт 10 или меньше Да Нет Между 11 и 50 Нет Да 51 и больше Да Да Тео пересылает электронное письмо Нэнси Дейву и просит его заняться этим вопросом. В конце концов, делегирование обязанностей — это задача любого хорошего менеджера. Дейв решил, что самая сложная часть форматирования текста заключается в реализации функции Author.myName(author, format), которая получает два аргумента: данные об авторе и формат текста. Он размышляет над тем, можно ли реализовать эту функцию как мультиметод и использовать то, чему он вчера научился с Тео в доме своих родителей за городом. Вроде бы эта функция очень похожа на ту, которая касалась дисмакрилексии. Вместо того чтобы проверять длину строки, ему нужно проверить длину массива. Первым делом Дейву нужна схема данных для формата текста. Можно представить формат в виде карты с полем type, как Тео вчера делал для языков, но сейчас Дейву кажется, что проще будет представить формат в виде строки, которая может содержать либо markdown, либо html. Дейв пишет схему формата текста, показанную в листинге 13.21. Схему для автора он уже писал на прошлой неделе с Тео (она в листинге 13.22).
348 Часть 3. Удобство сопровождения Листинг 13.21. Схема формата текста var textFormatSchema = { "name": {"type": "string"}, "type": {"enum": ["markdown", "html"]} }; Листинг 13.22. Схема для автора var authorSchema = { "type": "object", "required": ["name", "bookIsbns"], "properties": { "name": {"type": "string"}, "bookIsbns": { "type": "array", "items": {"type": "string"} } } }; Затем Дейву нужно написать функцию отправки и инициализировать мультиметод. Дейв вспомнил, как легко Тео позволил себе придумать слово «дисмакрилексия», и решил тоже изобрести собственный неологизм, «плодовиция» (англ. prolificity), вместо общепринятого слова «плодовитость» (англ. prolificness). Он посчитал полезным использовать вспомогательную функцию Author.prolificityLevel, которая возвращает уровень «плодовиции» автора: low, medium или high (низкий, средний, высокий). Итак, он пишет функцию authorNameDispatch. Листинг 13.23. Инициализация мультиметода Author.myName Author.prolificityLevel = function(author) { var books = _.size(_.get(author, "bookIsbns")); if (books <= 10) { return "low"; }; if (books >= 51) { return "high"; } return "medium"; }; var authorNameArgsSchema = { "type": "array", "prefixItems": [ authorSchema,
Глава 13. Полиморфизм 349 {"enum": ["markdown", "html"]} ] }; function authorNameDispatch(author, format) { if(dev()) { if(!ajv.validate(authorNameArgsSchema, [author, format])) { throw ("Author.myName called with invalid arguments: " + ajv.errorsText(ajv.errors)); } } return [Author.prolificityLevel(author), format]; }; Author.myName = multi(authorNameDispatch); Затем Дейв работает над методами, сначала формата HTML. В HTML жирный текст заключается в тег <b>, а курсивный заключается в тег <i>. Например, в HTML три автора с разными уровнями плодовиции будут записаны следующим образом. Листинг 13.24. Примеры выделения жирным шрифтом и курсивом в HTML <i>Yehonathan Sharvit<i> ❶ <b>Stephen Covey</b> ❷ <b><i>Isaac Asimov</i></b> ❸ ❶ Курсивный шрифт для минимально плодовитых авторов. ❷ Жирный шрифт для умеренно плодовитых авторов. ❸ Форматирование жирным шрифтом и курсивом для очень плодовитых авторов. С этой информацией на руках Дейв пишет три метода, которые занимаются форматированием в HTML. Легкотня! Листинг 13.25. Методы, которые занимаются форматированием в HTML function authorNameLowHtml(author, format) { return "<i>" + _.get(author, "name") + "</i>"; } Author.myName = method(["low", "html"], authorNameLowHtml)(Author.myName); function authorNameMediumHtml(author, format) { return "<b>" + _.get(author, "name") + "</b>"; }
350 Часть 3. Удобство сопровождения Author.myName = method(["medium", "html"], authorNameMediumHtml)(Author.myName); function authorNameHighHtml(author, format) { return "<b><i>" + _.get(author, "name") + "</i></b>"; } Author.myName = method(["high", "html"], authorNameHighHtml)(Author.myName); Затем Дейв переходит к трем методам, которые занимаются форматированием в Markdown. В Markdown жирный текст заключается в две звездочки, а курсивный текст заключается в одну звездочку. Например, в Markdown три автора с разными уровнями плодовиции будут записаны так, как в коде из листинга 13.26. Код для методов Markdown приведен в листинге 13.27. Листинг 13.26. Примеры выделения жирным шрифтом и курсивом в Markdown *Yehonathan Sharvit* ❶ **Stephen Covey** ❷ ***Isaac Asimov*** ❸ ❶ Курсивный шрифт для минимально плодовитых авторов. ❷ Жирный шрифт для умеренно плодовитых авторов. ❸ Форматирование жирным шрифтом и курсивом для очень плодовитых авторов. Листинг 13.27. Методы, которые занимаются форматированием в Markdown function authorNameLowMarkdown(author, format) { return "*" + _.get(author, "name") + "*"; } Author.myName = method(["low", "markdown"], authorNameLowMarkdown)(Author.myName); function authorNameMediumMarkdown(author, format) { return "**" + _.get(author, "name") + "**"; } Author.myName = method(["medium", "markdown"], authorNameMediumMarkdown)(Author.myName); function authorNameHighMarkdown(author, format) { return "***" + _.get(author, "name") + "***"; } Author.myName = method(["high", "markdown"], authorNameHighMarkdown)(Author.myName);
Глава 13. Полиморфизм 351 Дейв решает протестировать получившийся код при помощи таинственного автора. В листингах 13.28 и 13.29 показаны эти тесты. Листинг 13.28. Тестирование форматирования в HTML var yehonathan = { "name": "Yehonathan Sharvit", "bookIsbns": ["9781617298578"] }; Author.myName(yehonathan, "html"); // → "<i>Yehonathan Sharvit</i>" Листинг 13.29. Тестирование форматирования в Markdown Author.myName(yehonathan, "markdown"); // → "*Yehonathan Sharvit*" Тео объявляется у стола Дейва и просит просмотреть на реализацию фичи для списка авторов. Из любопытства Тео спрашивает Дейва об авторе, который фигурирует в тесте мультиметода Author.myName. Тео: А кто такой Йонатан Шарвит? Дейв: Я даже и не знаю. Это имя я увидел вчера, когда гуглил «дата-ориентированное программирование». Шарвит написал книгу на эту тему. Я решил, что будет прикольно использовать его ISBN в этом тесте. Итоги  Основным преимуществом полиморфизма является расширяемость.  Мультиметоды позволяют извлечь выгоду из полиморфизма, когда данные представлены с помощью универсальных карт.  Любой мультиметод состоит из функции отправки и нескольких методов.  Функция отправки мультиметода выдает значение отправки.  Каждый из методов, используемых в мультиметоде, обеспечивает реализацию для определенного значения отправки.  Мультиметоды могут имитировать наследование классов ООП с помощью еди- ничной отправки.  При единичной отправке мультиметод получает одну-единственную карту, со- держащую поле type, а функция отправки мультиметода выдает значение поля type.  В дополнение к единичной отправке мультиметоды обеспечивают два вида рас- ширенного полиморфизма: множественную отправку и динамическую отправку.
352 Часть 3. Удобство сопровождения  Множественная отправка используется, когда поведение мультиметода зависит от нескольких аргументов.  Динамическая отправка используется, когда поведение мультиметода зависит от аргументов при выполнении программы.  Аргументы мультиметода передаются функции отправки и методам.  Функция отправки мультиметода отвечает за: • определение сигнатуры; • влидацию аргументов; • выдачу значения отправки.  Мультиметоды обеспечивают расширяемость за счет отделения инициализации мультиметода от вариантов реализации методов.  Мультиметоды вызываются как обычные функции.  Мультиметоды поддерживают реализации, задаваемые по умолчанию, которые вызываются, когда ни один метод не соответствует значению отправки.  В мультиметоде, который включает множественную отправку, порядок эле- ментов в массиве, генерируемом функцией отправки, должен соответствовать порядку элементов во внутренней структуре методов. Функции Lodash, представленные в этой главе Функция Описание size(coll) Доставляет размер coll
Расширенная обработка данных Всё, что хорошо задумано, ясно сказано В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Манипулирование вложенными данными.  Написание четкого и лаконичного кода для бизнес-логики.  Разделение бизнес-логики и общих манипуляций с данными.  Создание пользовательских инструментов для манипулирования данными.  Использование лучшего инструмента для работы. Когда наша бизнес-логика включает расширенную обработку данных, общих функций манипулирования данными, предоставляемых языком run time и сторонними библиотеками, может оказаться недостаточно. Вместо того чтобы смешивать детали манипулирования данными с бизнес-логикой, мы можем написать наши собственные общие функции манипулирования данными и реализовать нашу пользовательскую бизнес-логику, используя их. Отделение бизнес-логики от внутренних деталей манипулирования данными делает код бизнес-логики кратким и легким для чтения другими разработчиками. 14.1. Обновление значения на карте с помощью выразительности Дейв становится все более и более автономным в проекте Klafim. Он может реализовать большинство функций самостоятельно, обращаясь к Тео только для проверки кода. Стандарты качества кода Дейва довольно высоки. Даже когда его код функционально надежен, он, как правило, недоволен его удобочитаемостью. Сегодня он просит Тео помочь улучшить читаемость кода, который исправляет ошибку, привнесенную Тео давным-давно.
354 Часть 3. Удобство сопровождения Дейв: Я думаю, что нашел ошибку в коде, который возвращает информацию о книге из Open Library API. Тео: Какую ошибку? Дейв: Иногда API возвращает повторяющиеся имена авторов, и мы передаем дубликаты клиенту. Тео: Похоже, эту ошибку несложно исправить. Дейв: Верно, я исправил это, но я не удовлетворен удобочитаемостью кода, который я написал. Тео: Критическое отношение к собственному коду — важное качество для прогресса разработчика. Что именно тебе не нравится? Дейв: Взгляни на код. Листинг 14.1. Удаление дубликатов простым, но утомительным способом function removeAuthorDuplicates(book) { var authors = _.get(book, "authors"); var uniqAuthors = _.uniq(authors); return _.set(book,"authors", uniqAuthors); } Дейв: Я использую _.get для получения массива с именами авторов, затем _.uniq для создания версии массива без дубликатов и, наконец, _.set для создания новой версии книги без повторяющихся имен авторов. Тео: Код утомительный, потому что следующее значение authorNames должно быть основано на его текущем значении. Дейв: Но это обычный вариант использования! Разве нет более простого способа написать такой код? Тео: Твое удивление определенно делает честь тебе как разработчику, Дейв. Я согласен, должен быть более простой способ. Позволь мне позвонить Джо и узнать, доступен ли он для звонка. Джо: Как дела, Тео? Тео: Отлично! Вы вернулись со своей технической конференции? Джо: Я только что приземлился. Сейчас я еду домой на такси. Тео: Как прошел ваш разговор о ДОП? Джо: Вполне нормально. Сначала люди были немного подозрительны, но, когда я рассказал им историю Albatross и Klafim, она оказалась вполне убедительной. Тео: Да, в этом взрослые похожи на детей; они любят истории. Джо: Что насчет вас? Удалось ли вам добиться полиморфизма с помощью мультиметодов?
Глава 14. Расширенная обработка данных 355 Тео: Да! Дейву даже удалось реализовать функцию в Klafim с помощью мультиметодов. Джо: Отлично! Тео: У вас есть время помочь Дейву с вопросом о программировании? Джо: Конечно. Дейв: Здравствуйте, Джо. Как дела? Джо: Здравствуйте, Дейв. Неплохо. Какая помощь вам нужна? Дейв: Мне интересно, есть ли более простой способ удалить дубликаты внутри значения массива на карте. Использование _.get, _.uniq и _.set выглядит довольно утомительно. Джо: Вы должны создать свои собственные инструменты для манипулирования данными. Дейв: Что вы имеете в виду? Джо: Вам следует написать универсальную функцию обновления, которая обновляет значение на карте, применяя вычисление на основе его текущего значения1. Дейв: Каковы, по вашему мнению, были бы аргументы update? Джо: Вы делаете все не в том порядке, ставите лошадь вперед телеги. Дейв: Что?! Джо: Перепишите свою бизнес-логику, как если бы update уже было реализовано, и вы обнаружите, какими должны быть аргументы update. Дейв: Я понимаю, что вы имеете в виду: лошадь — это реализация update, а телега — использование update. Джо: В яблочко. Но помните: будет лучше, если вы сохраните общую функцию update. Дейв: Как? Джо: Не ограничивая его вашим конкретным вариантом использования. Дейв: Ага, понял. Реализация обновления не должна иметь дела с удалением повторяющихся элементов. Вместо этого он должен получить функцию обновления — в моем случае _.uniq в качестве аргумента. Джо: Точно! А теперь извини, Дейв, мне пора, я только что вернулся домой. Удачи! Дейв: До свидания, Джо, и спасибо! Дейв заканчивает конференц-связь. Глядя на Тео, он повторяет разговор с Джо. Дейв: Джо посоветовал мне написать собственную функцию обновления. Для этого он сказал мне начать с перезаписи removeAuthorDuplicates, как если бы обновление уже было реализовано. Это позволит нам убедиться, что мы получили правильную подпись update. 1 Lodash предоставляет реализацию update, но ради обучения мы пишем нашу собственную реализацию.
356 Часть 3. Удобство сопровождения Тео: Звучит как план. Дейв: Джо называл это «ставить телегу впереди лошади». Тео: Ох уж этот Джо и его забавные аналогии... СОВЕТ. Лучший способ найти сигнатуру пользовательской функции обработки данных — это подумать о наиболее удобном способе ее использования. Дейв: В любом случае, я бы хотел использовать update внутри removeAuthorDuplicates следующим образом. Листинг 14.2. Код, который удаляет дубликаты элегантным способом function removeAuthorDuplicates(book) { return update(book, "authors", _.uniq); } Тео: По-моему, выглядит неплохо! Дейв: Ух ты! Теперь код с обновлением намного элегантнее, чем код с _.get и _.set! Тео: Прежде чем ты реализуешь update, я предлагаю записать на простом английском языке, что именно делает эта функция. Дейв: Это довольно просто: update получает карту с именем map, путь с именем path и функцию с именем fun. Она возвращает новую версию map, где path связан с fun(currentValue), а currentValue — это значение, связанное с path в map. Размышляя вслух, Дейв одновременно рисует диаграмму, подобную приведенной на рис. 14.1. Изучая рисунок, Тео все больше и больше впечатляется своим молодым протеже. Рис. 14.1. Поведение update СОВЕТ. Прежде чем внедрять пользовательскую функцию манипулирования данными, сформулируйте на простом английском языке, что именно делает эта функция. Тео: С таким четким определением реализовать update будет проще простого!
Глава 14. Расширенная обработка данных 357 Через несколько минут Дейв придумывает код. Это не займет много времени, потому что простая английская диаграмма помогает ему упорядочить код. Листинг 14.3. Общая функция update function update(map, path, fun) { var currentValue = _.get(map, path); var nextValue = fun(currentValue); return _.set(map, path, nextValue); } Тео: Почему бы тебе не посмотреть, работает ли это в простом случае, таком как увеличение числа на карте? Дейв: Хорошая идея! Я попробую умножить значение на карте на 2 с помощью update. Как это выглядит? Листинг 14.4. Умножение значения на карте на 2 var m = { "position": "manager", "income": 100000 }; update(m, "income", function(x) { return x * 2; }); // → {"position": "manager", "income": 200000} Тео: Отлично! Кажется, это работает. 14.2. Манипулирование вложенными данными В следующий понедельник, во время еженедельной встречи Тео и Дейва по синхронизации они обсуждают предстоящие функции для Klafim. Тео с теплотой вспоминает другой понедельник, когда они встретились в загородном доме семьи Дейва. Возвращаясь к настоящему моменту, Тео начинает. Тео: В последнее время Нэнси запрашивает все больше и больше административных функций. Дейв: Типа каких? Тео: Я приведу несколько примеров... Позволь мне найти электронное письмо, которое я получил вчера от Нэнси. Дейв: ОК. Тео: Ага, вот оно. На данный момент есть три запроса функций: перечисление всех идентификаторов авторов книг, расчет коэффициента выдачи книг и группировка книг по физической библиотеке.
358 Часть 3. Удобство сопровождения Дейв: Какой функцией я должен заняться в первую очередь? Тео: Это не имеет значения, но ты должен сделать три из них до конца недели. Удачи, и не стесняйся звонить мне, если понадобится помощь. Во вторник Дейв просит помощи у Тео. Дейв недоволен тем, как выглядит его код. Дейв: Я начал работать над тремя функциями администратора, но мне не нравится код, который я написал. Позволь мне показать код для извлечения списка идентификаторов авторов из списка книг, возвращенных из базы данных. Тео: Не мог бы ты напомнить мне, как выглядит элемент в списке книг, возвращаемый из базы данных? Дейв: Каждая книга представляет собой карту с полем массива authorIds. Тео: Хорошо, так что, похоже, это должно быть сделано с помощью map поверх книг. Дейв: Это то, что я сделал, но это работает не так, как ожидалось. Вот мой код для перечисления идентификаторов авторов книг. Листинг 14.5. Получение идентификаторов авторов в книгах в виде массива массивов function authorIdsInBooks(books) { return _.map(books, "authorIds"); } Тео: В чем проблема? Дейв: Проблема в том, что он возвращает массив массивов идентификаторов авторов вместо массива идентификаторов авторов. Например, когда я запускаю authorIdsInBooks в каталоге с двумя книгами, я получаю этот результат. Листинг 14.6. Идентификаторы автора в массиве массивов [ ["sean-covey", "stephen-covey"], ["alan-moore", "dave-gibbons"] ] Тео: Это не такая уж большая проблема. Ты можешь сгладить массив массивов с помощью _.flatten и получить ожидаемый результат. Дейв: Хорошо! Это именно то, что мне нужно! Дайте мне минутку, чтобы исправить код authorIdsInBooks... вот. Листинг 14.7. Получение идентификаторов авторов в книгах в виде массива строк function authorIdsInBooks(books) { return _.flatten(_.map(books, "authorIds")); }
Глава 14. Расширенная обработка данных 359 Тео: Тебе не кажется, что отображение, а затем выравнивание заслуживает отдельной функции? Дейв: Может быть. Реализовать функцию flatMap довольно просто2. Как насчет этого? Листинг 14.8. Реализация flatMap function flatMap(coll, f) { return _.flatten(_.map(coll,f)); } Тео: Здорово! Дейв: Я не знаю... Это немного странно — иметь такую маленькую функцию. Тео: Я не думаю, что размер кода — это то, что здесь имеет значение. Дейв: В смысле? Тео: Посмотри, что происходит, когда ты переписываешь authorIdsInBooks с помощью flatMap. Дейв: Хорошо, вот как я бы использовал flatMap для перечисления идентификаторов авторов. Листинг 14.9. Получение идентификаторов авторов в виде массива строк с использованием flatMaps function authorIdsInBooks(books) { return flatMap(books, "authorIds"); } Тео: Какую реализацию ты предпочитаешь: с flatten и map (в листинге 14.7) или с flatMap (в листинге 14.9)? Дейв: Я не знаю. По-моему, они выглядят очень похожими. Тео: Верно, но какая реализация более удобочитаема? Дейв: Ну, предполагая, что я знаю, что делает flatMap, я бы сказал, что реализация с помощью flatMap. Поскольку она более лаконична и немного более читабельна. Тео: Опять же, дело не в размере кода. Речь идет о ясности намерений и силе именования вещей. Дейв: Не понял. Тео: Позволь мне привести пример из нашего повседневного языка. Дейв: Давай. 2 Lodash предоставляет реализацию flatMap, но ради обучения мы пишем нашу собственную реализацию.
360 Часть 3. Удобство сопровождения Тео: Не мог бы ты передать мне ту штуку на столе, которая используется для письма? Дейву требуется несколько секунд, чтобы понять, что Тео попросил его передать ручку на столе. После того как он передает Тео ручку, он спрашивает: Дейв: Почему ты просто не попросил ручку? Тео: Я хотел, чтобы ты прочувствовал, каково это, когда мы используем описания вместо имен, чтобы передать наше намерение. Дейв: О, теперь я понимаю. Ты имеешь в виду, что как только мы используем имя для операции, которая отображает и выравнивает, код становится понятнее. Тео: Именно так. Дейв: Давай перейдем ко второй функции администратора: вычислению коэффициента выдачи книги. Тео: Перед этим, я думаю, мы заслуживаем короткого периода отдыха, когда мы пьем напиток, приготовленный методом процеживания из обжаренных и молотых семян. Дейв: Кофе-брейк! 14.3. Использование наилучшего инструмента для работы После перерыва на кофе Дейв показывает Тео свою реализацию расчета коэффициента выдачи книги. На этот раз, похоже, ему нравится код, который он написал. Дейв: Я очень горжусь кодом, который я написал для расчета коэффициента выдачи книг. Тео: Покажи же мне! Дейв: Моя функция получает список книг из базы данных следующим образом. Листинг 14.10. Список из двух книг с элементами bookItems [ { "isbn": "978-1779501127", "title": "Watchmen", "bookItems": [ { "id": "book-item-1", "libId": "nyc-central-lib", "isLent": true } ] },
Глава 14. Расширенная обработка данных 361 { "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People", "bookItems": [ { "id": "book-item-123", "libId": "hudson-park-lib", "isLent": true }, { "id": "book-item-17", "libId": "nyc-central-lib", "isLent": false } ] } ] Тео: Довольно вложенный фрагмент данных! Дейв: Да, но теперь, когда я использую flatMap, рассчитать коэффициент выдачи довольно просто. Я просматриваю все элементы книги с помощью forEach и увеличиваю значение счетчика lent или notLent. В конце я возвращаю соотношение между lent и (lent + notLent). Вот как я это делаю. Листинг 14.11. Расчет коэффициента выдачи книг с помощью forEach function lendingRatio(books) { var bookItems = flatMap(books, "bookItems"); var lent = 0; var notLent = 0; _.forEach(bookItems, function(item) { if(_.get(item, "isLent")) { lent = lent + 1; } else { notLent = notLent + 1; } }); return lent/(lent + notLent); } Тео: Могу я откровенно сказать, что думаю о твоем коде? Дейв: Если ты задаешь этот вопрос, это означает, что тебе он не нравится. Верно? Тео: Я ничего не имею против тебя; но мне не нравится ни один фрагмент кода с forEach. Дейв: Что не так с forEach?
362 Часть 3. Удобство сопровождения Тео: Это слишком обобщенно! Дейв: Я думал, что универсальность — это положительный момент в программировании. Тео: Это когда мы создаем служебную функцию, но когда мы используем служебную функцию, должны использовать наименее универсальную функцию, которая решает нашу проблему. Дейв: Почему? Тео: Потому что мы должны выбрать правильный инструмент для работы, как в реальной жизни. Дейв: Что ты имеешь в виду? Тео: Позволь мне привести пример. Вчера мне пришлось почистить свой беспилотник изнутри. Как ты думаешь, что я использовал — отвертку или швейцарский армейский нож, чтобы отвинтить крышку дрона? Дейв: Отвертку, конечно же! Ею гораздо удобнее манипулировать. Тео: Верно. Кроме того, представь, что кто-то смотрит на меня с помощью отвертки. Им совершенно ясно, что я заворачиваю гайку. Это ясно передает мои намерения. Дейв: Хочешь сказать, что forEach подобен швейцарскому армейскому ножу для манипулирования данными? Тео: Это хороший способ выразить это. СОВЕТ. Выберите наименее универсальную служебную функцию, которая решает вашу проблему. Дейв: Какую функцию я должен использовать тогда, чтобы выполнить итерацию по коллекции элементов книги? Тео: Ты мог бы использовать _.reduce. Дейв: Я думал, что reduce — это возврат данных из коллекции. Здесь мне не нужно возвращать данные; мне нужно обновить две переменные, lent и notLent. Тео: Ты мог бы представить эти два значения на карте с помощью двух ключей. Дейв: Можешь ли ты показать мне, как переписать мою функцию lendingRatio с помощью reduce? Тео: Конечно. Начальное значение, передаваемое для уменьшения, — это карта {"lent": 0, "notLent": 0}, и внутри каждой итерации мы обновляем один из двух ключей, вот так. Листинг 14.12. Расчет коэффициента выдачи книги с использованием reduce function lendingRatio(books) { var bookItems = flatMap(books, "bookItems");
Глава 14. Расширенная обработка данных 363 var stats = _.reduce(bookItems, function(res, item) { if(_.get(item, "isLent")) { res.lent = res.lent + 1; } else { res.notLent = res.notLent + 1; } return res; }, {notLent: 0, lent:0}); return stats.lent/(stats.lent + stats.notLent); } Дейв: Вместо обновления переменных lent и notLent теперь мы обновляем поля карты lent и notLent. В чем разница? Тео: Работа с полями карты вместо переменных позволяет нам избавиться от reduce в нашем коде бизнес-логики. Дейв: Как ты мог бы выполнить итерацию по коллекции без forEach и без reduce? Тео: Я не могу избежать итерации по коллекции, но я могу скрыть reduce за вспомогательной функцией. Взгляни на то, как reduce используется в коде lendingRatio. В чем смысл вызова reduce? Дейв смотрит на код в листинге 14.12. Он долго думает, прежде чем ответить. Дейв: Я думаю, что он подсчитывает, сколько раз isLent оказывается true и false. Тео: Верно. Теперь давай воспользуемся советом Джо о создании нашего собственного инструмента для манипулирования данными. Дейв: Как именно? Тео: Я предлагаю написать служебную функцию countByBoolField, которая подсчитывает, сколько раз поле имеет значение true и false. Дейв: Хорошо, но, прежде чем реализовать эту функцию, позволь мне сначала переписать код lendingRatio, предполагая, что эта функция уже существует. Тео: Ты определенно быстро учишься, Дейв! Дейв: Спасибо! Я думаю, что при использовании countByBoolField код для вычисления коэффициента выдачи с использованием пользовательской служебной функции будет выглядеть примерно так. Листинг 14.13. Расчет коэффициента выдачи книг function lendingRatio(books) { var bookItems = flatMap(books, "bookItems"); var stats = countByBoolField(bookItems, "isLent", "lent", "notLent"); return stats.lent/(stats.lent + stats.notLent); }
364 Часть 3. Удобство сопровождения СОВЕТ. Не используйте _.reduce или любую другую низкоуровневую функцию манипулирования данными внутри кода, который имеет дело с бизнес-логикой. Вместо этого напишите служебную функцию с собственным именем, которая скрывает _.reduce. Тео: Идеально. Тебе не кажется, что этот код понятнее, чем код, использующий _.reduce? Дейв: Да! Код одновременно и более лаконичен, и цель более ясна. Позволь мне посмотреть, смогу ли я реализовать countByBoolField прямо сейчас. Тео: Я предлагаю сначала написать модульный тест. Дейв: Хорошая идея. Дейв немного печатает. Когда его все устраивает, он показывает Тео результат. Листинг 14.14. Модульный тест для countByBoolField var input = [ {"a": true}, {"a": false}, {"a": true}, {"a": true} ]; var expectedRes = { "aTrue": 3, "aFalse": 1 }; _.isEqual(countByBoolField(input, "a", "aTrue", "aFalse"), expectedRes); Тео: По-моему, выглядит неплохо. Теперь для реализации countByBoolField, думаю, тебе понадобится наша функция update. Дейв: Ты прав. На каждой итерации мне нужно увеличивать значение либо aTrue, либо aFalse, используя update и функцию, которая увеличивает число на 1. После нескольких проб и ошибок Дейв придумывает фрагмент кода, который использует reduce, update и inc. Он показывает Тео код для countByBoolField. Листинг 14.15. Реализация countByBoolField function inc (n) { return n + 1; } function countByBoolField(coll, field, keyTrue, keyFalse) { return _.reduce(coll, function(res, item) { if (_.get(item, field)) { return update(res, keyTrue, inc); }
Глава 14. Расширенная обработка данных 365 return update(res, keyFalse, inc); }, {[keyTrue]: 0,❶ [keyFalse]: 0}); } ❶ Создает карту с keyTrue и keyFalse, связанными с 0. Тео: Отличная работа! Должны ли мы двигаться дальше и рассмотреть третью функцию администратора? Дейв: Третья функция более сложная. Я хотел бы использовать доктрины из первых двух функций для реализации третьей функции. Тео: Хорошо. Позвони мне, когда будешь готов к проверке кода. 14.4. Легкое разматывание Дейв действительно боролся с реализацией последней функции администратора, группируя книги по физической библиотеке. После пары часов разочарования Дейв зовет Тео на помощь. Дейв: Мне действительно было трудно реализовать функцию группировки по библиотекам. Тео: У меня есть всего пара минут до моей следующей встречи, но я могу попытаться тебе помочь. Каково точное определение группировки по библиотекам? Дейв: Позволь мне показать модульный тест, который я написал. Листинг 14.16. Модульный тест для группировки книг по библиотекам var books = [ { "isbn": "978-1779501127", "title": "Watchmen", "bookItems": [ { "id": "book-item-1", "libId": "nyc-central-lib", "isLent": true } ] }, { "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People", "bookItems": [ { "id": "book-item-123", "libId": "hudson-park-lib",
366 Часть 3. Удобство сопровождения "isLent": true }, { "id": "book-item-17", "libId": "nyc-central-lib", "isLent": false } ] } ]; var expectedRes = { "hudson-park-lib": [ { "bookItems": { "id": "book-item-123", "isLent": true, "libId": "hudson-park-lib", }, "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People", }, ], "nyc-central-lib": [ { "bookItems": { "id": "book-item-1", "isLent": true, "libId": "nyc-central-lib", }, "isbn": "978-1779501127", "title": "Watchmen", }, { "bookItems": { "id": "book-item-17", "isLent": false, "libId": "nyc-central-lib", }, "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People", }, ], }; _.isEqual(booksByRack(books) , expectedRes);
Глава 14. Расширенная обработка данных 367 Тео: Круто... Написание модульных тестов перед внедрением сложных функций также было полезно для меня, когда я переделал Klafim с ООП на ДОП. Дейв: Писать модульные тесты для функций, которые получают и возвращают данные, гораздо интереснее, чем писать модульные тесты для методов объектов с сохранением состояния. СОВЕТ. Прежде чем внедрять сложную функцию, напишите для нее модульный тест. Тео: Что было сложного во внедрении booksByLib? Дейв: Я начал со сложной реализации, включающей merge и reduce, прежде чем вспомнил, что ты посоветовал мне скрыть reduce за универсальной функцией. Но я не мог понять, какая универсальная функция мне нужна. Тео: Действительно, это нелегко реализовать. Дейв: Я рад это слышать. Я думал, что делаю что-то не так. Тео: Проблема здесь в том, что нужно работать с элементами книги, но название книги и ISBN отсутствуют на карте элементов книги. Дейв: Именно! Тео: Это напоминает мне запрос, который мне пришлось написать год назад на MongoDB, где данные были выложены аналогичным образом. Дейв: И как выглядел твой запрос? Тео: Я использовал оператор $unwind от MongoDB. Учитывая карту m с полем <arr, myArray>, она возвращает массив, где каждый элемент является картой, соответствующей m без arr и с элементом, связанным с элементом myArray. Дейв: Для меня это немного абстрактно. Не мог бы ты привести пример? Тео подходит к доске. Он рисует диаграмму, подобную приведенной на рис. 14.2. Тео: В моем случае я имел дело с интернет-магазином, где корзина покупателя была представлена в виде карты с полем customer-id и полем массива items. Каждый элемент массива представляет товар в корзине. Я написал запрос с unwind, который извлекал элементы корзины с полем customer-id. Дейв: Удивительно! Это именно то, что нам нужно. Давайте напишем нашу собственную функцию unwind! Тео: Я был бы рад поработать с тобой в паре над этим классным материалом, но я уже опаздываю на другую встречу. Дейв: Я рад, что я не менеджер! Когда Тео уходит на свою встречу, Дейв идет на кухню и готовит себе крепкий эспрессо в награду за все, что он сделал сегодня. Он полностью наслаждается этим, поскольку работает над внедрением unwind. Как и советовал Джо, Дейв начинает с написания кода для booksByLib так, как если бы unwind уже была реализован. Ему нужно просмотреть каждую книгу и развернуть ее элементы, используя flatMap и unwind. Затем он группирует элементы книги
368 Часть 3. Удобство сопровождения Рис. 14.2. Поведение unwind по их libId, используя _.groupBy. Довольный полученным кодом, он допивает свой эспрессо. Листинг 14.17. Группировка книг по библиотекам с помощью unwind function booksByRack(books) { var bookItems = flatMap(books, function(book) { return unwind(book, "bookItems"); }); return _.groupBy(bookItems, "bookItems.libId") } Дейв не может поверить, что такая сложная функция может быть реализована так четко и компактно. Дейв говорит себе, что сложность должна заключаться в реализации unwind — но вскоре он обнаруживает, что ошибается; это будет не так сложно, как он думал! Он начинает с написания модульного теста для unwind, аналогичного сценарию Тео MongoDB customer cart. Листинг 14.18. Модульный тест для unwind var customer = { "customer-id": "joe", "items": [ { "item": "phone",
Глава 14. Расширенная обработка данных 369 "quantity": 1 }, { "item": "pencil", "quantity": 10 } ] }; var expectedRes = [ { "customer-id": "joe", "items": { "item": "phone", "quantity": 1 } }, { "customer-id": "joe", "items": { "item": "pencil", "quantity": 10 } } ] _.isEqual(unwind(customer, "items"), expectedRes) Реализация unwind определенно не так сложна, как думал Дейв. Он извлекает массив arr, связанный с f в m, и создает для каждого элемента arr версию m, где f связан с elem. Дейв с радостью вспоминает, что данные неизменяемы, поэтому нет необходимости клонировать m. Листинг 14.19. Реализация unwind function unwind(map, field) { var arr = _.get(map, field); return _.map(arr, function(elem) { return _.set(map, field, elem); }); } После нескольких минут созерцания своего прекрасного кода Дейв отправляет Тео сообщение со ссылкой на запрос на извлечение, который реализует группировку книг по библиотеке с помощью unwind. После этого он покидает офис, чтобы отправиться домой на велосипеде, усталый, но довольный.
370 Часть 3. Удобство сопровождения Итоги  Поддерживайте четкое разделение между кодом, который имеет дело с бизнес- логикой, и реализацией манипулирования данными.  Отделение бизнес-логики от манипулирования данными делает наш код не только кратким, но и легким для чтения, поскольку он четко передает намерение.  Мы разрабатываем и внедряем пользовательские функции обработки данных в рамках четырехэтапного процесса: • обнаружение сигнатуры функции, используя ее до того, как она будет реализована; • написание модульного теста для функции; • формулирование поведения функции на простом английском языке; • реализация функции.  Лучший способ найти сигнатуру пользовательской функции обработки дан- ных — это подумать о наиболее удобном способе ее использования.  Прежде чем внедрять пользовательскую функцию манипулирования данными, сформулируйте на простом английском языке, что именно делает эта функция.  Выбирайте наименее универсальную служебную функцию, которая решает вашу проблему.  Не используйте _.reduce или любую другую низкоуровневую функцию манипу- лирования данными внутри кода, который имеет дело с бизнес-логикой. Вместо этого напишите служебную функцию с собственным именем, которая скрывает _.reduce.  Прежде чем внедрять сложную функцию, напишите для нее модульный тест. Функции Lodash, представленные в этой главе Функция Описание flatten(arr) Выравнивает arr на один уровень глубже sum(arr) Вычисляет сумму значений в arr uniq(arr) Создает массив уникальных значений из arr every(coll, pred) Проверяет, возвращает ли pred значение true для всех элементов coll forEach(coll, f) Выполняет итерацию по элементам coll и вызывает f для каждого элемента sortBy(coll, f) Создает массив элементов, отсортированных в порядке возрастания, по результатам выполнения каждого элемента в coll через f
Отладка Инновации в музее В ЭТОЙ ГЛАВЕ РАССМАТРИВАЮТСЯ  Репродукция бага в коде, который использует примитивные типы данных.  Репродукция бага в коде, который использует агрегированные данные.  Воспроизведение сценария в среде REPL.  Создание модульных тестов на основе багов. Когда наши программы ведут себя не так, как мы ожидали, нужно проанализировать исходный код. Традиционным инструментом для анализа кода является отладочная программа. Такой отладчик позволяет запускать код шаг за шагом, пока мы не обнаружим строку, вызывающую баг. Однако отладчик не позволяет репродуцировать сценарий, который лежит в основе проблемы. В ДОП можно захватывать контекст сценария, который вызывает баг, и воспроизводить его в отдельном процессе, таком как REPL или модульный тест. Это позволяет извлечь выгоду из короткого цикла обратной связи между попыткой исправления кода и результатами этой попытки. 15.1. Детерминизм в программировании Через несколько месяцев Тео звонит Дейву и говорит, что он покидает Albatross. Не успел Дейв оправиться от этого сюрприза, как ему преподнесли еще один, более приятный. Тео объявил, что на совещании с командой менеджеров было решено назначить Дейва главным по ДОП в Albatross. Тео приглашает Дейва на прощальную вечеринку в офисе на следующей неделе, а также на последнюю индивидуальную рабочую сессию в Музее науки «Эксплораториум». В музее Дейву особенно понравилась выставка Cells to Self в галерее Living Systems, а Тео развлекался на выставке Colored Shadows в галерее Reflections.
372 Часть 3. Удобство сопровождения Завершив осмотр, Тео и Дейв устраиваются в заднем ряду музейного зрительного зала и открывают свои ноутбуки. Дейв: Почему ты решил назначить нашу последнюю встречу здесь, в Музее науки? Тео: Помнишь, как-то раз Джо сказал нам, что однажды мы сможем внедрять инновации в ДОП? Дейв: Помню. Тео: Что ж, возможно, этот день настал. Кажется, я обнаружил интересную связь между ДОП и наукой, и это касается отладки программ. Дейв: Я заинтригован. Тео: Ты веришь в детерминизм? Дейв: В смысле верю ли я в то, что все происходящее во вселенной предопределено и что свобода воли — это иллюзия? Тео: Нет, я не хочу вдаваться в философию. Мой вопрос, скорее, принадлежит сфере науки. Считаешь ли ты, что одни и те же причины всегда приводят к одним и тем же следствиям? Дейв: Думаю, что да. Иначе каждый раз, пользуясь лифтом, я бы до смерти боялся, что законы физики изменились и лифт может пойти вниз, когда ему нужно идти вверх, или даже разбиться! Тео: А как насчет детерминизма в программировании? Дейв: Как бы ты определил причины и следствия в программировании? Тео: Для простоты скажем, что в контексте программирования причины — это аргументы функций, а следствия — возвращаемые значения. Дейв: А куда определить побочные эффекты? Тео: Давай пока оставим их в стороне. Дейв: А что же с состоянием программы? Я говорю о том, что функция может возвращать другое значение для тех же аргументов, если изменилось состояние программы. Тео: Вот почему состояния нужно избегать, насколько это возможно. Дейв: Но в реальных приложениях не получится избежать состояния! Тео: Это факт, но можно свести к минимуму количество модулей, работа которых связана с состоянием. Ведь это именно то, к чему склоняет нас ДОП: только работа модуля SystemState связана с состоянием, а все остальные модули работают с неизменяемыми данными. Дейв: Тогда, я думаю, что в модулях, которые работают с неизменяемыми данными, присутствует детерминизм, подходящий под твое определение. Для одних и тех же аргументов функция всегда будет возвращать одно и то же значение. СОВЕТ. В модулях, которые работают с неизменяемыми данными, поведение функции детерминировано: одни и те же аргументы всегда приводят к одним и тем же возвращаемым значениям.
Глава 15. Отладка 373 Тео: Это точно. Давай дадим название значениям аргументов функции, с которыми функция вызывается: контекст времени выполнения функции или, короче говоря, контекст функции. Дейв: Кажется, я понимаю, что ты имеешь в виду. Как правило, контекст функции должен включать как аргументы функции, так и состояние программы. Но в ДОП, поскольку мы работаем с неизменяемыми данными, контекст функции состоит только из значений аргументов функции. СОВЕТ. В ДОП контекст функции состоит из значений аргументов функции. Тео: Ну вот! Теперь давай поговорим о репродуцируемости. Допустим, нам нужно захватить контекст функции и репродуцировать его в другой среде. Дейв: А не расскажешь ли поконкретнее о репродуцировании контекста функции в другой среде? Тео: Возьмем, к примеру, конечную точку веб-сервиса. Мы активируем конечную точку с некоторыми параметрами. Внутри программы, ниже по стеку, вызывается функция foo. Теперь нам нужно зафиксировать контекст, в котором вызывается foo, чтобы позже репродуцировать такое же поведение этой foo. Дейв: Мы работаем с неизменяемыми данными. Итак, если мы снова вызовем foo с теми же аргументами, она будет вести себя точно так же. Тео: Проблема в том, что мы не знаем значений аргументов этой функции. Вспомни, что мы не активировали foo напрямую. Мы активировали конечную точку. Дейв: Это не проблема. Нужно использовать отладчик и определить точку останова внутри кода для функции foo, а затем проверить аргументы в момент, когда программа делает паузу на точке останова. Тео: Допустим, foo получает три аргумента: число, строку и огромную вложенную карту. Как тогда зафиксировать аргументы и воспроизвести foo с теми же аргументами? Дейв: Я не уверен, что именно ты подразумеваешь под воспроизведением foo. Тео: Я имею в виду выполнение foo в среде REPL.  ПРИМЕЧАНИЕ. Среда REPL (англ. read-eval-print loop, цикл «чтение — вычисление — вывод»), иногда называемая языковой оболочкой, представляет собой программную среду, которая берет фрагменты кода, выполняет их и отображает результат. Смотрите табл. 15.1 со списком сред REPL для разных языков программирования. Таблица 15.1. Среды REPL для разных языков программирования JavaScript (Браузер) Консоль браузера Node.js Node CLI Java JShell C# C# REPL
374 Часть 3. Удобство сопровождения Таблица 15.1 (окончание) JavaScript (Браузер) Консоль браузера Python Интерпретатор Python Ruby Interactive Ruby Дейв: Обязательно ли цикл REPL должен быть частью процесса, который я отлаживаю? Тео: Не обязательно. Представь, что среда REPL — это научная лаборатория, где разработчики проводят эксперименты. Скажем, что для REPL используется отдельный процесс. Дейв: ОК. С числами и строками все просто: я могу скопировать их значения в буфер обмена, вставить их в REPL и выполнить foo в REPL с теми же аргументами. Тео: Это самая легкая часть. А как насчет вложенной карты? Дейв: Даже не знаю. Я, наверное, не смогу скопировать вложенную карту из отладчика в буфер обмена! Тео: На самом деле в отладчиках JavaScript это возможно. Например, в Chrome есть опция копирования, которая появляется, если щелкнуть правой кнопкой мыши на данных, отображаемых в консоли браузера. Дейв: Никогда не обращал на это внимания. Тео: Да и без этой опции можно сериализовать вложенную карту в строку JSON, скопировать строку в буфер обмена, а затем вставить строку JSON в REPL. Наконец, можно десериализовать строку в хеш-карту и вызвать foo с ней. Дейв: Хитрый фокус! Тео: Для меня это даже не фокус, а скорее фундаментальный аспект ДОП: данные представлены с помощью универсальных структур данных. Дейв: Ясно. Сериализовать универсальную структуру данных легко. СОВЕТ. Для того чтобы скопировать и вставить универсальную структуру данных, мы сериализуем и десериализуем ее. Тео: Ты только что обнаружил два условия репродуцируемости в программировании. Дейв: Так, первое заключается в том, что данные должны быть неизменяемыми. Тео: Верно, а второе? Дейв: Сериализовать и десериализовать любые данные должно быть легкой задачей. СОВЕТ. Двумя условиями репродуцируемости в программировании являются неизменяемость и легкость (де-)сериализации.
Глава 15. Отладка 375 15.2. Репродуцируемость с числами и строками Тео: На самом деле чтобы захватить контекст функции, нам вообще не нужен отладчик. Дейв: Но контекст функции в основном состоит из ее аргументов. Как можно проверить аргументы функции без отладчика? Тео: Изменив код рассматриваемой функции и выведя сериализацию аргументов на консоль. Дейв: Я ничего не понял. Тео: Сейчас покажу, что я имею в виду под функцией, которая работает с числами. Дейв: Давай. Тео: Возьмем, к примеру, функцию, которая возвращает n-ю цифру числа. Дейв: Ой, я ненавижу цифровую арифметику! Тео: Не волнуйся, код для этого мы найдем в Интернете. Тео гуглит «n-я цифра числа в JavaScript» и берет фрагмент кода из StackOverflow, который, похоже, работает. Листинг 15.1. Вычисление n-й цифры числа function nthDigit(a, n) { return Math.floor((a / (Math.pow(10, n - 1)))) % 10; } Дейв: А ты понимаешь, как это работает? Тео: Давай разбираться. Деление a на 10n–1 похоже на сдвиг а вправо на n–1 мест. Затем нам нужно получить крайнюю правую цифру. Дейв: А последняя цифра числа получается с помощью операции modulo 10? Тео: Верно! Теперь представь, что эта функция вызывается вниз по стеку при активации некоторой конечной точки. Я внесу изменения в код функции, добавив код для захвата контекста. Дейв: Что это? Тео: Код захвата контекста — это код, который мы вставляем в начало тела функции для вывода значений аргументов. Сейчас я отредактирую код для nthDigit, чтобы привести пример. Листинг 15.2. Захват контекста, состоящего из чисел function nthDigit(a, n) { console.log(a);
376 Часть 3. Удобство сопровождения console.log(n); return Math.floor((a / (Math.pow(10, n - 1)))) % 10; } Дейв: Выглядит очень просто. Тео: Пока просто, но скоро станет сложнее. Теперь скажи мне, что происходит, когда я активирую конечную точку. Дейв: Когда конечная точка активируется, программа отобразит два числа, a и n, в консоли. Тео: Именно, и что нужно сделать, чтобы воспроизвести функцию в том же контексте, что и при активации конечной точки? Дейв: Нужно скопировать значения a и n из консоли, вставить их в среду REPL и вызвать nthDigit с этими двумя значениями. Тео: А почему ты так уверен, что когда мы запустим функцию nthDigit в REPL, она точно воспроизведет то, что произошло при активации конечной точки? Вспомни, что цикл REPL может выполняться в отдельном процессе. Дейв: Я знаю точно, что nthDigit зависит только от своих аргументов. Тео: Хорошо. А еще как ты можешь быть уверен, что аргументы, которые передаешь ты, совпадают с аргументами, которые уже были переданы? Дейв: Число есть число! Тео: Я согласен с тобой. Давай пойдем дальше и посмотрим, что происходит со строками. Дейв: Полагаю, что все будет точно так же. Тео: Все будет почти точно так же. Давай напишем функцию, которая получает предложение и префикc, возвращая значение true, когда предложение содержит слово, начинающееся с этого префикса. Дейв: Зачем кому-то вообще может понадобиться такая странная функция? Тео: Она может быть полезна для Системы управления библиотекой, когда пользователь хочет найти книги, название которых содержит префикс. Дейв: Интересно. Я поговорю об этом с Нэнси. Во всяком случае, написание кода для такой функции кажется вполне очевидным. Нужно разделить строку предложения на массив слов, а затем проверить, начинается ли какое-либо слово в массиве с этого префикса. Тео: Как ты собираешься проверить, соответствует ли какой-либо элемент массива этому условию? Дейв: Пожалуй, я воспользуюсь функцией filter из Lodash и проверю длину возвращаемого массива. Тео: Это сработает, но могут возникнуть проблемы с производительностью. Дейв: Почему? Тео: А ты сам подумай.
Глава 15. Отладка 377 Дейв: Я понял! Функция filter обрабатывает все элементы в массиве, а не останавливается после первого совпадения. Есть ли в Lodash функция, которая останавливается после первого совпадения? Тео: Да, она называется find. Дейв: Круто. Ей и воспользуюсь. Сейчас. Дейв тянется к своему ноутбуку и пишет код для проверки того, содержит ли предложение слово, начинающееся с некоторого префикса. Вскоре Дейв показывает Тео свою реализацию hasWordStartingWith с использованием _.find. Листинг 15.3. Проверка того, содержит ли предложение слово, начинающееся с префикса function hasWordStartingWith(sentence, prefix) { var words = sentence.split(" "); return _.find(words, function(word) { return word.startsWith(prefix); }) != null; } Тео: Годится, теперь добавь, пожалуйста, в начало функции код захвата контекста. Дейв: Сейчас, немного отредактирую код. Вуаля! Листинг 15.4. Захват контекста, состоящего из строк function hasWordStartingWith(sentence, prefix) { console.log(sentence); console.log(prefix); var words = sentence.split(" "); return _.find(words, function(word) { return word.startsWith(prefix); }) != null; } Тео: Дай-ка я посмотрю на твой код. Мне интересно, что произойдет, если проверить, содержит ли предложение «I like the word reproducibility» слово, начинающееся с «li». Тео изучает код на ноутбуке Дейва. Код возвращает значение true, как и ожидалось, но не выводит на консоль текст, который предполагал Дейв. Оба программиста удивлены. Листинг 15.5. Тестирование hasWordStartingWith hasWordStartingWith("I like the word \"reproducibility\"", "li"); // It returns true
378 Часть 3. Удобство сопровождения // It displays the following two lines: // I like the word "reproducibility" // li Дейв: Где кавычки вокруг строк? И где обратные косые черты перед кавычками, окружающими слово «reproducibility»? Тео: Они исчезли! Дейв: Почему? Тео: Когда мы выводим строку в консоль, содержимое строки отображается без кавычек. Это более удобочитаемо для человека. Дейв: Облом! Это плохо для репродуцируемости. Итак, после того как я скопирую и вставлю строку, я должен вручную заключить ее в кавычки и добавить обратные косые черты. Тео: К счастью, есть более простое решение. Если сериализовать строку в JSON, она будет содержать кавычки и обратные косые черты. Например, вот этот код отображает строку так, как ты рассчитывал. Листинг 15.6. Вывод на консоль сериализации строки console.log(JSON.stringify( "I like the word \"reproducibility\"")); // → "I like the word \"reproducibility\"" Дейв: Я не знал, что строки считаются валидными данными в JSON-схеме. Я думал, что допустимы только объекты и массивы. Тео: Как составные, так и примитивные типы данных являются валидными данными JSON. Дейв: Круто! Я исправлю код, который фиксирует строковые аргументы для hasWordStartingWith. Вот, посмотри. Листинг 15.7. Захват контекста, состоящего из строк, с использованием сериализации JSON function hasWordStartingWith(sentence, prefix) { console.log(JSON.stringify(sentence)); console.log(JSON.stringify(prefix)); var words = sentence.split(" "); return _.find(words, function(word) { return word.startsWith(prefix); }) != null; } Тео: Замечательно! Захват строк требует чуть больше труда, чем захват чисел, но с JSON все не так уж и сложно.
Глава 15. Отладка 379 Дейв: Ну да. Теперь мне хочется посмотреть, как работает сериализация JSON для захвата контекста с числами. Тео: Она работает. На самом деле этот подход хорошо работает с любыми данными, будь то примитивный тип или коллекция. Дейв: Супер! Тео: Дальше я покажу тебе, как использовать этот подход для репродуцирования реального сценария, который происходит в контексте Системы управления библиотекой. Дейв: Больше никакой арифметики? Тео: Обещаю! 15.3. Репродуцируемость с любыми данными Суть ДОП в том, что эта методология программирования относится к данным как к привилегированным гражданам. Как следствие, мы можем репродуцировать любой сценарий, который работает с данными, с той же простотой, с какой мы репродуцируем сценарий, работающий с числами и строками. Дейв: Я только что позвонил Нэнси, чтобы рассказать ей об улучшенной версии поиска книг, в которой префикс может совпадать с любым словом в названии книги. Тео: И? Дейв: Ей понравилась эта идея. Тео: Превосходно! Давай воспользуемся этой фишкой как возможностью обеспечить репродуцируемость любых данных. Дейв: С чего начнем? Тео: Сначала нужно добавить код для захвата контекста внутрь функции, которая выполняет проверку совпадения книг. Дейв: Эта функция — Catalog.searchBooksByTitle. Тео: Каковы аргументы Catalog.searchBooksByTitle? Дейв: У нее есть два аргумента: catalogData (это большая вложенная хеш-карта) и query (это строка). Тео: Сможешь отредактировать код и добавить фрагмент, захватывающий контекст? Дейв: Конечно. Вот это подойдет? Листинг 15.8. Захват аргументов функции Catalog.searchBooksByTitle Catalog.searchBooksByTitle = function(catalogData, query) { console.log(JSON.stringify(catalogData)); console.log(JSON.stringify(query));
380 Часть 3. Удобство сопровождения var allBooks = _.get(catalogData, "booksByIsbn"); var queryLowerCased = query.toLowerCase(); var matchingBooks = _.filter(allBooks, function(book) { return _.get(book, "title") .toLowerCase() .startsWith(queryLowerCased); }); var bookInfos = _.map(matchingBooks, function(book) { return Catalog.bookInfo(catalogData, book); }); return bookInfos; }; Тео: Идеально. Теперь давай активируем конечную точку поиска. Тео активирует конечную точку поиска с запросом «Watch», надеясь получить подробную информацию о комиксе «Хранители» (Watchmen). Когда конечная точка возвращается, Тео открывает консоль, и Дейв видит две строки вывода. Листинг 15.9. Вывод на консоль при активации конечной точки поиска {"booksByIsbn":{"978-1982137274":{"isbn":"978-1982137274"\ ,"title":"7 Habits of Highly Effective People","authorIds":\ ["sean-covey","stephen-covey"]},"978-1779501127":{"isbn":\ "978-1779501127","title":"Watchmen","publicationYear":\ 1987,"authorIds":["alan-moore", "dave-gibbons"]}},\ "authorsById":{"stephen-covey":{"name":"Stephen Covey",\ "bookIsbns":["978-1982137274"]},"sean-covey":{"name":"Sean Covey",\ "bookIsbns":["978-1982137274"]},"dave-gibbons":{"name":"Dave Gibbons",\ "bookIsbns":["978-1779501127"]},"alan-moore":{"name":"Alan Moore",\ "bookIsbns":["978-1779501127"]}}} "Watch" Дейв: Я знаю, что первая строка содержит данные из каталога, но их очень трудно прочитать. Тео: Это не имеет большого значения. Нам нужно только скопировать и вставить их, чтобы репродуцировать вызов Catalog.searchBooksByTitle. Дейв: Можно я попробую? Вот. Листинг 15.10. Репродуцирование вызова функции var catalogData = {"booksByIsbn":{"978-1982137274": {"isbn":"978-1982137274","title":"7 Habits of Highly Effective People", "authorIds":["sean-covey","stephen-covey"]}, "978-1779501127":
Глава 15. Отладка 381 {"isbn":"978-1779501127","title":"Watchmen", "publicationYear":1987, "authorIds":["alan-moore","dave-gibbons"]}}, "authorsById": {"stephen-covey":{"name":"Stephen Covey","bookIsbns": ["978-1982137274"]},"sean-covey":{"name":"Sean Covey", "bookIsbns": ["978-1982137274"]},"dave-gibbons": {"name":"Dave Gibbons","bookIsbns": ["978-1779501127"]},"alan-moore": {"name":"Alan Moore","bookIsbns": ["978-1779501127"]}}}; var query = "Watch"; Catalog.searchBooksByTitle(catalogData, query); Тео: Теперь, когда у нас есть реальные данные из каталога, мы можем сделать несколько интересных вещей в среде REPL. Дейв: Например? Тео: Например, реализовать улучшенную версию поиска без необходимости покидать REPL. СОВЕТ. Воспроизводимость позволяет нам восстановить сценарий в нетронутой среде. Дейв: Без активации конечной точки поиска? Тео: Именно! Мы будем улучшать наш код до тех пор, пока он не заработает должным образом, используя короткий цикл обратной связи, предоставляемый консолью. Дейв: Круто! В каталоге у нас есть книга «7 навыков высокоэффективных людей» (7 Habits of Highly Effective People). Давай посмотрим, что произойдет, если поискать книги, соответствующие слову «Habit». Тео заменяет значение запроса в листинге 15.10 на слово «Habit». Теперь код возвращает пустой массив, как показано в листинге 15.11. Это и ожидалось, поскольку текущая реализация ищет только книги, название которых начинается со словазапроса, а название искомой книги начинается с цифры: «7 Habits». Листинг 15.11. Тестирование searchBooksByTitle Catalog.searchBooksByTitle(catalogData, 'Habit'); // → [] Тео: Хочешь реализовать улучшенный поиск? Дейв: Это не так уж сложно; мы уже реализовали hasWordStartingWith. Вот улучшенный поиск.
382 Часть 3. Удобство сопровождения Листинг 15.12. Улучшенная версия поиска книг Catalog.searchBooksByTitle = function(catalogData, query) { console.log(JSON.stringify(catalogData)); console.log(JSON.stringify(query)); var allBooks = _.get(catalogData, "booksByIsbn"); var matchingBooks = _.filter(allBooks, function(book) { return hasWordStartingWith(_.get(book, "title"), query); }); var bookInfos = _.map(matchingBooks, function(book) { return Catalog.bookInfo(catalogData, book); }); return bookInfos; }; Тео: Мне нравится. Давай посмотрим, работает ли все должным образом. Дейв собирается активировать конечную точку поиска, но внезапно Тео останавливает его. Командным голосом он говорит: Тео: Дейв, не делай этого! Дейв: Не делать чего? Тео: Не активируй конечную точку для тестирования своего кода. Дейв: Почему? Тео: Потому что среда REPL дает гораздо более быструю обратную связь, чем активация конечной точки. Главное преимущество репродуцируемости заключается в возможности восстанавливать реальные условия в более эффективной среде. Дейв выполняет свой код для улучшенного поиска по слову «Habit». И уже на этот раз он возвращает детали о книге «7 навыков высокоэффективных людей». Листинг 15.13. Повторное тестирование searchBooksByTitle Catalog.searchBooksByTitle(catalogData, 'Habit'); // → [ { "title": "7 Habits of Highly Effective People", ...}] Дейв: Сработало! Тео: Давайте попробуем другие запросы: «abit» и «bit» не должны возвращать ни одной книги, а «habit» и «7 habits» должны возвращать только одну книгу. В среде REPL Дейв пробует выполнить четыре запроса, предложенные Тео. Для «abit» и «bit» код работает так, как ожидалось, но для «habit» «и «7 Habits» он завершается сбоем. Дейв: Дай-ка я попробую исправить этот код. Тео: Я предлагаю вместо этого написать пару модульных тестов, которые проверяют различные входные данные.
Глава 15. Отладка 383 Дейв: Хорошая идея. Можно ли использовать репродуцируемость в контексте модульных тестов? Тео: Безусловно! 15.4. Модульные тесты Дейв: Как нам использовать репродуцируемость в модульном тестировании? Тео: Джо много раз рассказывал и показывал мне, что в ДОП модульные тесты очень просты. Они вызывают функцию с некоторыми данными и проверяют, что данные, возвращаемые функцией, совпадают с ожидаемыми. Дейв: Я это помню! Я написал много модульных тестов для Системы управления библиотекой, используя этот подход. Но иногда мне было трудно предоставить входные данные для тестируемых функций. Например, построение данных каталога со всеми вложенными полями легкой прогулкой не было. Тео: Вот здесь-то и может помочь репродуцируемость. Вместо того чтобы построить данные вручную, мы ставим систему в условия, которые хотим протестировать, а затем захватываем данные внутри тестируемой функции. Как только данные захвачены, они используются в модульном тесте. Дейв: Неплохо! Сейчас я напишу модульный тест для Catalog.searchBooksByTitle, используя этот подход. Дейв снова активирует конечную точку поиска. Затем он открывает консоль и копирует строку с захваченными данными каталога в буфер обмена. Наконец, он вставляет ее в код модульного теста. Листинг 15.14. Модульный тест с захваченными данными var catalogData = {"booksByIsbn":{"978-1982137274":{"isbn":"978-1982137274", "title":"7 Habits of Highly Effective People","authorIds":["sean-covey", "stephen-covey"]},"978-1779501127":{"isbn":"978-1779501127","title": "Watchmen","publicationYear":1987,"authorIds":["alan-moore", "dave-gibbons"]}},"authorsById":{"stephen-covey":{"name": "Stephen Covey","bookIsbns":["978-1982137274"]},"sean-covey": {"name":"Sean Covey","bookIsbns":["978-1982137274"]},"dave-gibbons": {"name":"Dave Gibbons","bookIsbns":["978-1779501127"]},"alan-moore": {"name":"Alan Moore","bookIsbns":["978-1779501127"]}}}; var query = "Habit"; var result = Catalog.searchBooksByTitle(catalogData, query); var expectedResult = [ { "authorNames": [ "Sean Covey", "Stephen Covey", ],
384 Часть 3. Удобство сопровождения "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People", } ]; _.isEqual(result, expectedResult); // → true Тео: Молодец! Хочешь, я покажу тебе, как сделать то же самое без копирования и вставки? Дейв: Определенно. Тео: Вместо того чтобы выводить захваченные данные на консоль, мы запишем их в файл и прочитаем данные из этого файла внутри модульного теста. Дейв: Где ты сохранишь файлы, в которых хранятся захваченные данные? Тео: Эти файлы являются частью модульных тестов. Они должны находиться в том же дереве файлов, что и сами тесты. Дейв: Так много файлов! Как же нам убедиться, что какой-либо файл не перекроет уже существующий? Тео: Следуя простому соглашению об именовании файлов. Название файла, в котором хранятся захваченные данные, должно состоять из двух частей: контекста (например, названия функции, в которой были захвачены данные) и универсального уникального идентификатора (англ. universally unique identifier, UUID). Дейв: А как сгенерировать UUID? Тео: В некоторых языках программирования это составная часть, но в других языках, таких как JavaScript, понадобится сторонняя библиотека, например, uuid. Я добавлю ее сайт тебе в закладки. У меня есть список всех библиотек для генерирования UUID. Его я тебе тоже пришлю в виде таблицы. Тео добавляет сайт сторонней библиотеки uuid (https://github.com/uuidjs/uuid) в закладки на компьютере Дейва. Затем, используя свой ноутбук, он находит весь список библиотек и отправляет его Дейву. Дейв получает электронное письмо и быстро просматривает табл. 15.2, прежде чем вернуться к беседе с Тео. Таблица 15.2. Библиотеки для генерации UUID Язык Библиотека UUID JavaScript https://github.com/uuidjs/uuid Java java.util.UUID C# Guid.NewGuid Python uuid Ruby SecureRandom
Глава 15. Отладка 385 Тео: Код для функции dataFilePath, которая получает контекст и возвращает путь к файлу, довольно прост. Посмотри-ка. Листинг 15.15. Вычисление пути к файлу для хранения захваченных данных var capturedDataFolder = "test-data"; ❶ function dataFilePath(context) { var uuid = generateUUID(); ❷ return capturedDataFolder + "/" + context + "-" + ".json"; ❸ } ❶ Корневая папка для захваченных данных. ❷ Генерация UUID зависит от языка (см. табл. 15.2). ❸ Использует json в качестве расширения файла, потому что мы сериализуем данные в JSON. Дейв: Как нам хранить фрагмент данных в файле JSON? Тео: Нужно сериализовать его и записать на диск. Дейв: Синхронно или асинхронно? Тео: Я предпочитаю записывать на диск асинхронно или в отдельном потоке во время выполнения, когда поддерживается многопоточность, чтобы не замедлялась реальная работа. Вот моя реализация dumpData. Листинг 15.16. Выгрузка данных в формат JSON function dumpData(data, context) { var path = dataFilePath(context); var content = JSON.stringify(data); fs.writeFile(path, content, function () { ❶ ❷ console.log("Data for " + context + "stored in: " + path); ❸ }); } ❶ Записывает асинхронно, чтобы предотвратить блокировку реальной работы. ❷ Третий аргумент — это функция обратного вызова, вызываемая по завершении записи. ❸ Отображает сообщение после записи данных в файл. Дейв: Мне интересно посмотреть, смогу ли я использовать dumpData внутри Catalog.searchBooksByTitle и захватить контекст в файл. Я думаю, вот так должно получиться.
386 Часть 3. Удобство сопровождения Листинг 15.17. Захват контекста в файл Catalog.searchBooksByTitle = function(catalogData, query) { dumpData([catalogData, query], 'searchBooksByTitle'); var allBooks = _.get(catalogData, "booksByIsbn"); var queryLowerCased = query.toLowerCase(); var matchingBooks = _.filter(allBooks, function(book) { return _.get(book, "title") .toLowerCase() .startsWith(queryLowerCased); }); var bookInfos = _.map(matchingBooks, function(book) { return Catalog.bookInfo(catalogData, book); }); return bookInfos; }; Тео: Активируй конечную точку. Проверим, сработает ли код. Дейв еще раз активирует конечную точку поиска и просматривает выходные данные в консоли. Открыв файл, упомянутый в сообщении журнала, он увидел однуединственную строку, которую трудно расшифровать. Листинг 15.18. Вывод консоли при активации конечной точки поиска Data for searchBooksByTitle stored in test-data/searchBooksByTitle-68e57c85-2213-471a-8442-c4516e83d786.json Листинг 15.19. Содержимое файла JSON, в котором захвачен контекст [{"booksByIsbn":{"978-1982137274":{"isbn":"978-1982137274", "title":"7 Habits of Highly Effective People","authorIds": ["sean-covey","stephen-covey"]},"978-1779501127":{"isbn": "978-1779501127","title":"Watchmen","publicationYear":1987, "authorIds":["alan-moore","dave-gibbons"]}},"authorsById": {"stephen-covey":{"name":"Stephen Covey","bookIsbns": ["978-1982137274"]},"sean-covey":{"name":"Sean Covey", "bookIsbns":["978-1982137274"]},"dave-gibbons": {"name":"Dave Gibbons","bookIsbns":["978-1779501127"]}, "alan-moore":{"name":"Alan Moore","bookIsbns": ["978-1779501127"]}}},"Habit"] Дейв: Прочитать такой файл JSON очень сложно! Тео: Если хочешь, можно сделать строку JSON более красивой. Дейв: Как?
Глава 15. Отладка 387 Тео: Передав на JSON.stringify количество пробелов, которые будут использоваться для отступа. Сколько символов ты хочешь использовать? Дейв: Два. Добавив отступы в код dumpData, Дейв снова открывает файл JSON, упомянутый в сообщении журнала (у него другое название!). Теперь он видит красивый JSONмассив с двумя элементами. Листинг 15.20. Выгрузка данных в формат JSON с отступами function dumpData(data, context) { var path = dataFilePath(context); var content = JSON.stringify(data, null, 2); ❶ fs.writeFile(path, content, function () { console.log("Data for " + context + "stored in: " + path); }); } ❶ Второй аргумент JSON.stringify игнорируется. Третий аргумент JSON.stringify указывает количество символов, используемых для отступа. Листинг 15.21. Захваченный контекст с отступами в файле JSON [ { "booksByIsbn": { "978-1982137274": { "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People", "authorIds": [ "sean-covey", "stephen-covey" ] }, "978-1779501127": { "isbn": "978-1779501127", "title": "Watchmen", "publicationYear": 1987, "authorIds": [ "alan-moore", "dave-gibbons" ] } }, "authorsById": { "stephen-covey": { "name": "Stephen Covey",
388 Часть 3. Удобство сопровождения "bookIsbns": [ "978-1982137274" ] }, "sean-covey": { "name": "Sean Covey", "bookIsbns": [ "978-1982137274" ] }, "dave-gibbons": { "name": "Dave Gibbons", "bookIsbns": [ "978-1779501127" ] }, "alan-moore": { "name": "Alan Moore", "bookIsbns": [ "978-1779501127" ] } } }, "Habit" ] Дейв: Когда я просматривал содержимое файла JSON, я подумал о том, что мы записываем данные в файл асинхронным способом. Это означает, что данные записываются конкурентно с выполнением кода функции, верно? Тео: Точно! Как я уже говорил, мы не хотим замедлять реальную работу. Дейв: Это я понял. Что произойдет, если код функции изменит данные, которые мы записываем? Запишутся ли в файл исходные или измененные данные? Тео: Я предоставлю тебе возможность поразмышлять над этим, а сам схожу в музейное кафе за чашечкой чая. А ты не хочешь кофе? Дейв: Ты что, не будешь пить кофе? Тео: Я наконец-то прочитал книгу Чарльза Дахигга «Сила привычки». Джо прочитал ее и перестал грызть ногти, поэтому я тоже решил почитать, чтобы избавиться от своей привычки постоянно пить кофе. Дейв: Впечатляет, но мне, пожалуйста, эспрессо. Пока Тео отлучился в кафе, Дейв принялся осматривать выставку «Стрелы ветра» поблизости со зрительным залом. Он надеется, что красота науки простимулирует его разум. Дейв делает несколько глубоких вдохов, чтобы расслабиться, и через
Глава 15. Отладка 389 пару минут к нему приходит вдохновение. Эврика! Он нашел ответ на вопрос о функции, изменяющей данные. Тео возвращается, осторожно неся горячие напитки, и находит Дейва в зрительном зале. Дейв улыбается Тео и говорит: Дейв: В ДОП мы никогда не изменяем данные. Таким образом, мой вопрос — это вовсе и не вопрос, потому что код функции не может изменять данные, пока мы записываем их в файл. Тео: У тебя получилось! Сейчас я покажу тебе, как использовать данные из файла JSON в модульном тестировании. Во-первых, нам нужна функция, которая считывает данные из файла JSON и десериализует их, возможно, что-то вроде ReadData. Листинг 15.22. Чтение данных из файла JSON function readData(path) { return JSON.parse(fs.readFileSync(path)); } Дейв: Почему ты читаешь синхронно, а не асинхронно, как ты делал, когда мы захватывали данные? Тео: Потому что функция ReadData предназначена для использования внутри модульного теста, и мы не можем запустить тест до того, как данные будут считаны из файла. Дейв: Логично. Использовать ReadData внутри модульного теста кажется простой задачей. Можно я попробую прочитать наши захваченные данные? Листинг 15.23. Модульный тест, который считывает захваченные данные из файла var data = readData("test-data/" + "searchBooksByTitle-68e57c85-2213-471a-8442-c4516e83d786.json"); var catalogData = data[0]; var query = data[1]; var result = Catalog.searchBooksByTitle(catalogData, query); var expectedResult = [ { "authorNames": [ "Sean Covey", "Stephen Covey", ], "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People", } ];
390 Часть 3. Удобство сопровождения _.isEqual(result, expectedResult); // → false Тео: Какую версию модульного теста ты предпочитаешь: со встроенными данными или с данными, считанными из файла? Дейв: По ситуации. Когда объем данных минимален, мне нравится, чтобы данные были встроены, потому что это позволяет мне их видеть. Но когда объем данных существенный, например это данные каталога, наличие встроенных данных затрудняет чтение кода. Тео: Понял. Давай подправим код улучшенного поиска так, чтобы он работал с двумя запросами, которые возвращают пустой результат. Дейв: Я об этом совсем забыл. Ты помнишь эти два запроса? Тео: Да, «habit» и «7 Habits». Дейв: Первый из запросов не работает, потому что код оставляет строки в их исходном регистре. Я спокойно могу это исправить, преобразовав название книги и запрос в нижний регистр. Тео: А как насчет второго запроса? Дейв: С ним гораздо труднее разобраться, потому что он состоит из двух слов. Мне нужно каким-то образом проверить, будет ли полученный заголовок содержать эти два префикса. Тео: Ты знаком с метасимволом регулярного выражения \b? Дейв: Нет. Тео: Метасимвол \b соответствует позиции, которая называется границей слова. Это позволяет нам выполнять проверку совпадения префиксов. Дейв: Круто. А приведешь мне пример? Тео: Конечно. Например, по запросам «\bHabits» и «\b7 Habits» будет выдана книга «7 Habits of Highly Effective People», а по запросу «abits» — нет. Дейв: А как насчет запроса «\bHabits of»? Тео: Тоже получится совпадение. Дейв: Прекрасно. Это именно то, что мне нужно! Сейчас я исправлю код hasWordStartingWith так, чтобы он выполнял проверку совпадения префикса без учета регистра. Листинг 15.24. Обновленная версия hasWordStartingWith function var var var hasWordStartingWith(sentence, prefix) { sentenceLowerCase = sentence.toLowerCase(); prefixLowerCase = prefix.toLowerCase(); prefixRegExp = new RegExp("\\b" + ❶ prefixLowerCase);
Глава 15. Отладка 391 return sentenceLowerCase.match(prefixRegExp) != null; } ❶ При передаче \b конструктору регулярных выражений RegExp понадобится дополнительная обратная косая черта. Тео: Теперь я напишу модульные тесты для всех случаев. Дейв: По одному тесту на запрос? Тео: Можно и так, но будет эффективнее создать модульный тест для всех запросов, которые должны возвращать книгу, и еще один для всех запросов, которые не должны возвращать книг. Минуту. Тео пишет код, и у него получаются два модульных теста. Затем он показывает тесты Дейву и с наслаждением делает еще один глоток чая. Листинг 15.25. Модульный тест для нескольких запросов, которые должны возвращать книгу var data = readData("test-data/" + "searchBooksByTitle-68e57c85-2213-471a-8442-c4516e83d786.json"); var catalogData = data[0]; var queries = ["Habit", "habit", "7 Habit", "habits of"]; var expectedResult = [ { "authorNames": [ "Sean Covey", "Stephen Covey", ], "isbn": "978-1982137274", "title": "7 Habits of Highly Effective People", } ]; _.every(queries, function(query) { var result = Catalog.searchBooksByTitle(catalogData, query); return _.isEqual(result, expectedResult); }); // → [true, true, true, true] Листинг 15.26. Модульный тест для нескольких запросов, которые не должны возвращать книгу var data = readData("test-data/" + "searchBooksByTitle-68e57c85-2213-471a-8442-c4516e83d786.json"); var catalogData = data[0]; var queries = ["abit", "bit", "7 abit", "habit of"]; var expectedResult = [ ];
392 Часть 3. Удобство сопровождения _.every(queries, function(query) { var result = Catalog.searchBooksByTitle(catalogData, query); return _.isEqual(result, expectedResult); }); // → [true, true, true, true] Дейв: Что такое _.every? Тео: Это функция Lodash, которая получает коллекцию и предикат, возвращая true, если предикат возвращает true для каждого элемента коллекции. Дейв: Неплохо! Дейв запускает модульные тесты, и они проходят успешно. Затем он вкушает глоточек эспрессо. Дейв: Так, а можно мне активировать конечную точку поиска с запросом «7 Habits», чтобы подтвердить, что улучшенный поиск работает как положено? Тео: Конечно. Я настоятельно советую не запускать систему извне только во время многократных итераций улучшений кода, чтобы воспользоваться более коротким циклом обратной связи. Как только мы закончим с отладкой и исправлением, систему обязательно нужно протестировать от начала до конца. Дейв активирует конечную точку поиска с запросом «7 Habits». Как и ожидалось, код возвращает информацию о книге «7 Habits of Highly Effective People». 15.5. Работа с внешними источниками данных Дейв: А можно ли использовать репродуцируемость, когда код включает выборку данных из внешнего источника данных, такого как база данных или внешний сервис? Тео: Почему бы и нет? Дейв: Контекст функции может быть точно таким же, но поведение может отличаться, если функция извлекает данные из источника, который возвращает другой ответ на тот же запрос. Тео: Ну, это зависит от источника данных. Некоторые базы данных неизменяемы в том смысле, что на один и тот же запрос всегда возвращается один и тот же ответ. Дейв: Я никогда не слышал о неизменяемых базах данных. Тео: Иногда их называют функциональными базами данных или базами данных только для добавления. Дейв: О таких тоже никогда не слышал. Ты имеешь в виду базы данных, доступные только для чтения? Тео: Базы данных, доступные только для чтения, конечно, неизменяемы, но они не подходят для хранения состояния приложения.
Глава 15. Отладка 393 Дейв: Как база данных может быть одновременно доступной для записи и неизменяемой? Тео: При наличии времени. Дейв: Какое отношение время имеет к неизменяемости? Тео: В неизменяемой базе данных запись имеет автоматически сгенерированную временну́ю метку, и вместо обновления записи мы создаем ее новую версию с новой временно́й меткой. Более того, запрос всегда имеет временно́й диапазон в дополнение к его основным параметрам. Дейв: И как это гарантирует, что на один и тот же запрос всегда будет возвращаться один и тот же ответ? Тео: В неизменяемой базе данных запросы не работают с самой базой данных. Вместо этого они работают с моментальным снимком базы данных, который никогда не меняется. Таким образом, на запросы с одинаковыми параметрами гарантированно возвращаются одни и те же ответы. Дейв: Существуют ли такие базы данных на самом деле? Тео: Да. Например, неизменяемая база данных Datomic используется некоторыми цифровыми банками.  ПРИМЕЧАНИЕ. Посетите https://www.datomic.com, чтобы узнать больше о транзакционной базе данных Datomic. Дейв: Но большинство баз данных такого гарантировать не могут! Тео: Это так, но на практике, когда мы отлаживаем проблему в нашей локальной среде, данные обычно не меняются. Дейв: Что ты имеешь в виду? Тео: Возьмем, к примеру, базу данных у Klafim. Теоретически между моментом активации конечной точки поиска и моментом воспроизведения кода поиска из среды REPL с одним и тем же контекстом книгу кто-то может взять почитать, и статус ее доступности в базе данных может измениться. Это значит, что ответ на поисковый запрос будет другим. Дейв: Именно. Тео: Но на практике мы единственные, кто взаимодействует с системой в нашей локальной среде. Следовательно, такого произойти не должно. Дейв: Я понял. Раз уж мы находимся в Музее науки, можно мне провести научную аналогию? Тео: Конечно! Дейв: В некотором смысле внешние источники данных подобны скрытым переменным в квантовой физике. В теории они могут изменить результат эксперимента без всякой очевидной причины. Но на практике наш физический мир останется стабильным на макроуровне.
394 Часть 3. Удобство сопровождения Закончив сегодняшнюю дискуссию, Тео роется в сумке и находит сверток, упакованный в подарочную бумагу из сувенирной лавки музея. Этот сверток он с улыбкой вручает Дейву. Дейв открывает подарок и обнаруживает футболку. На одной стороне изображен Альберт Эйнштейн и его знаменитая цитата: «Бог не играет в кости со Вселенной», на другой стороне — Алан Кэй и его цитата: «Делайте что хотите, только не вмешивайтесь во внутреннее состояние». Дейв благодарит Тео за такой подарок. У Тео к горлу подступает комок. Ему очень понравилось играть роль наставника для Дейва, ученика весьма способного. Прощание Через неделю после встречи с Дейвом в музее Тео приглашает Джо и Нэнси на свою прощальную вечеринку в офисе Albatross. Там Джо впервые встретился с Нэнси, и Тео решил сообщить предпринимательнице, что проект Klafim уложился в сроки только благодаря Джо. Всем любопытно узнать, как называется компания, в которой будет теперь работать Тео, но никто не осмеливается его спросить. Наконец, Дейв набирается храбрости. Дейв: Можно тебя спросить, в какой компании ты будешь работать? Тео: А я хочу сделать перерыв. Дейв: Серьезно? Тео: Да. Я собираюсь несколько месяцев путешествовать по миру. Дейв: А после этого ты вернешься к работе в программировании? Тео: Еще не уверен. Дейв: У тебя на примете другие проекты? Тео: Я подумываю о том, чтобы написать книгу. Дейв: Книгу? Тео: Да. Исследование ДОП наполнило мою жизнь особым смыслом. Я извлек интересные уроки о снижении сложности в программировании и теперь хочу поделиться своей историей с сообществом разработчиков. Дейв: Что ж, если ты такой же хороший рассказчик, как и учитель, я уверен, что твоя книга станет бестселлером. Тео: Спасибо тебе, Дейв! Моника, Дейв, Нэнси, Джо и все остальные сотрудники Albatross поднимают бокалы и кричат: «За Тео! За успех его книги!». Итоги  Мы репродуцируем сценарий, захватывая контекст, в котором вызывается функция, и воспроизводя его либо в среде REPL, либо в модульном тесте. В этой главе мы называем это захватом контекста.
Глава 15. Отладка 395  В ДОП контекст функции состоит только из данных.  Существуют различные локации для захвата контекста функции: буфер обмена, консоль, файл.  Мы можем захватить контекст функции, потому что данные представлены с по- мощью универсальной структуры и, следовательно, их можно легко сериализовать.  Воспроизведение сценария в среде REPL предоставляет короткий цикл обратной связи, который обеспечивает эффективность, когда необходимо исправить код.  Когда мы выполняем функцию с захваченным контекстом, ее поведение гаран- тированно будет одинаковым, если эта функция манипулирует только неизменяемыми данными, что предписано принципами ДОП.  В модулях, которые работают с неизменяемыми данными, поведение функции детерминировано: одни и те же аргументы всегда приводят к одним и тем же возвращаемым значениям.  Контекст функции состоит из значений аргументов этой функции.  Среда REPL (англ. read-eval-print loop, цикл «чтение — вычисление — вывод»), иногда называемая языковой оболочкой, представляет собой программную среду, которая берет фрагменты кода, выполняет их и отображает результат.  Чтобы скопировать и вставить универсальную структуру данных, мы сериализу- ем и десериализуем ее.  Репродуцируемость позволяет восстановить сценарий в нетронутой среде.  Двумя условиями репродуцируемости в программировании являются неизме- няемость и легкость (де-)сериализации. Функции Lodash, представленные в этой главе Функция Описание find(coll, pred) Выполняет итерацию по элементам coll, возвращая первый элемент, для которого pred возвращает true
396 Часть 3. Удобство сопровождения
Приложение А Принципы дата-ориентированного программирования Дата-ориентированное программирование (ДОП) — это парадигма программирования, направленная на упрощение проектирования и реализации программных систем, в которых в центре внимания находится информация, таких, например, как клиентские или серверные веб-приложения и веб-сервисы. Вместо того чтобы проектировать информационные системы на основе программных концепций, объединяющих код и данные (например, экземпляры объектов, созданные из классов), ДОП предписывает отделять код от данных. Кроме того, методология ДОП содержит рекомендации о том, как представлять данные и манипулировать ими. СОВЕТ. В ДОП к данным относятся как к привилегированным гражданам. Суть ДОП заключается в том, что к данным в этой парадигме относятся как к привилегированным гражданам. Это дает разработчикам возможность манипулировать данными внутри программы с той же простотой, с какой они манипулируют числами или строками. Обращаться с данными как с привилегированными гражданами становится возможным благодаря соблюдению четырех основных принципов.  Код (поведение) отделен от данных.  Данные представлены с помощью обобщенных структур.  Данные рассматриваются как неизменяемые.  Схема данных отделяется от представления данных. Когда эти четыре принципа работают сообща, они образуют единое целое, как показано на рис. А.1. Системы, построенные с использованием ДОП, проще и понятнее, поэтому жизнь разработчиков значительно улучшается. СОВЕТ. В системе, ориентированной на данные, код отделен от данных. Данные представлены с помощью обобщенных структур, которые являются неизменяемыми и имеют отдельную схему. Обратите внимание, что принципы ДОП не связаны с языком. Их можно придерживаться (или нарушать) на:  языках объектно-ориентированного программирования (ООП), таких как Java, C#, C++ и других;
398 Приложение А Рис. А.1. Принципы дата-ориентированного программирования  языках функционального программирования (ФП), таких как Clojure, OCaml, Haskell и других;  языках, поддерживающих как ООП, так и ФП, таких как JavaScript, Python, Ruby, Scala и других. СОВЕТ. Принципы ДОП не связаны с языком.  ПРИМЕЧАНИЕ. Разработчикам ООП для перехода на ДОП может потребоваться сильнее перепрограммировать свое сознание, чем разработчикам ФП, поскольку ДОП запрещает инкапсуляцию данных в классах с сохранением состояния. Данное приложение кратко иллюстрирует, как эти принципы могут быть применены или нарушены на языке JavaScript. Вкратце будут упомянуты преимущества соблюдения каждого принципа и издержки, связанные с получением этих преимуществ. В этом приложении принципы ДОП проиллюстрированы с помощью простых фрагментов кода. На протяжении всей книги подробно рассматривается применение принципов ДОП к готовым информационным системам. A.1. Принцип № 1: отделяйте код от данных Принцип № 1 — это принцип проектирования, который рекомендует четкое разделение между кодом (поведением) и данными. Может показаться, что эта идея лежит в основе ФП, но на самом деле такого принципа можно придерживаться (или нарушать его) либо в ФП, либо в ООП.
Принципы дата-ориентированного программирования 399  Соблюдать этот принцип в ООП означает агрегировать код в виде методов ста- тического класса.  Нарушать этот принцип в ФП означает скрывать состояние в лексической области действия функции. Кроме того, этот принцип не относится к способу представления данных. Представление данных регулируется принципом № 2. ПРИНЦИП № 1. Отделяйте код от данных таким образом, чтобы код заключался в функциях, поведение которых не зависит от данных, инкапсулированных в контексте функции. A.1.1. Иллюстрация к принципу № 1 Наше исследование принципа № 1 начинается с иллюстрации того, как он может быть применен к ООП и ФП. Следующие разделы иллюстрируют, как этот принцип может быть соблюден или нарушен в простой программе, которая работает с:  объектом для имени автора с полями firstName и lastName, а также количеством книг books, которые это автор написал;  фрагментом кода, вычисляющим полное имя автора;  фрагментом кода, определяющим, является ли автор плодовитым, основываясь на количестве написанных этим автором книг. Нарушение принципа № 1 в ООП Нарушение принципа № 1 в ООП происходит, когда мы пишем код, объединяющий данные и код вместе в одном объекте. Следующий листинг демонстрирует, как это выглядит. Листинг A.1. Нарушение принципа № 1 в ООП class Author { constructor(firstName, lastName, books) { this.firstName = firstName; this.lastName = lastName; this.books = books; } fullName() { return this.firstName + " " + this.lastName; } isProlific() { return this.books > 100; } } var obj = new Author("Isaac", "Asimov", 500); ❶ obj.fullName(); // → "Isaac Asimov" ❶ Да-да, Айзек Азимов написал около 500 книг!
400 Приложение А Нарушение принципа № 1 в ФП Нарушить этот принцип без классов в ФП означает скрыть данные в лексической области действия функции. В следующем листинге приведен пример такого нарушения. Листинг A.2. Нарушение принципа № 1 в ФП function createAuthorObject(firstName, lastName, books) { return { fullName: function() { return firstName + " " + lastName; }, isProlific: function () { return books > 100; } }; } var obj = createAuthorObject("Isaac", "Asimov", 500); obj.fullName(); // → "Isaac Asimov" Соблюдение принципа № 1 в ООП В листинге A.3 показан пример, в котором код придерживается принципа № 1 ООП. Исполнение этого принципа может быть достигнуто даже с классами путем написания программ таким образом, чтобы:  код состоял из статических методов;  данные инкапсулировались в классах данных (классах, которые являются только лишь контейнерами для данных). Листинг A.3. Следование принципу № 1 в ООП class AuthorData { constructor(firstName, lastName, books) { this.firstName = firstName; this.lastName = lastName; this.books = books; } } class NameCalculation { static fullName(data) { return data.firstName + " " + data.lastName; } }
Принципы дата-ориентированного программирования 401 class AuthorRating { static isProlific (data) { return data.books > 100; } } var data = new AuthorData("Isaac", "Asimov", 500); NameCalculation.fullName(data); // → "Isaac Asimov" Соблюдение принципа № 1 в ФП В листинге A.4 показан пример, в котором код придерживается принципа № 1 в ФП. Исполнение этого принципа означает необходимость отделения кода от данных. Листинг A.4. Следование принципу № 1 в ФП function createAuthorData(firstName, lastName, books) { return { firstName: firstName, lastName: lastName, books: books }; } function fullName(data) { return data.firstName + " " + data.lastName; } function isProlific (data) { return data.books > 100; } var data = createAuthorData("Isaac", "Asimov", 500); fullName(data); // → "Isaac Asimov" A.1.2. Преимущества принципа № 1 Мы проиллюстрировали, как можно следовать принципу № 1 или нарушать его в ООП и ФП. Теперь обсудим преимущества, которые принцип № 1 привносит в наши программы. Вот как тщательное отделение кода от данных идет на пользу программам:  код может быть повторно использован в различных контекстах;  код может быть протестирован изолированно;  системы, как правило, получаются менее сложными.
402 Приложение А Преимущество № 1: код может быть повторно использован в различных контекстах Представьте, что помимо объекта для сведений об авторе книги существует объект для читателя библиотеки, который не имеет никакого отношения к авторам, но имеет два таких же поля данных, что и объект для автора: firstName и lastName. Логика вычисления полного имени одинакова для авторов и читателей: получить значения двух полей с одинаковыми названиями. Однако в традиционном ООП, как и в версии с createAuthorObject в листинге A.5, код для вычисления fullName не может быть использован для имени читателя библиотеки непосредственно, поскольку он заблокирован внутри класса Author. Листинг A.5. Код для fullName заблокирован в классе Author class Author { constructor(firstName, lastName, books) { this.firstName = firstName; this.lastName = lastName; this.books = books; } fullName() { return this.firstName + " " + this.lastName; } isProlific() { return this.books > 100; } } Один из способов получить возможность повторно использовать код при смешивании кода и данных — это применить механизмы ООП, такие как наследование или композиция, чтобы разрешить классам User и Author использовать один и тот же метод для fullName. Эти методы подходят для простых случаев, но в реальных системах обилие классов (либо базовых, либо композитных) приводит к увеличению сложности. В листинге A.6 показан простой способ избежать наследования. В этом листинге мы дублируем код fullName внутри функции createUserObject. Листинг A.6. Дублирование кода в ООП во избежание наследования function createAuthorObject(firstName, lastName, books) { var data = {firstName: firstName, lastName: lastName, books: books}; return { fullName: function fullName() { return data.firstName + " " + data.lastName; } }; }
Принципы дата-ориентированного программирования 403 function createUserObject(firstName, lastName, email) { var data = {firstName: firstName, lastName: lastName, email: email}; return { fullName: function fullName() { return data.firstName + " " + data.lastName; } }; } var obj = createUserObject("John", "Doe", "john@doe.com"); obj.fullName(); // → "John Doe" В ДОП-коде, который работает с объектами для имени автора, не требуется проводить никаких модификаций: этот код и так подойдет для объектов со сведениями об имени читателя, потому что:  код, который занимается вычислением полного имени, отделен от кода, который занимается созданием данных для автора;  функция, которая вычисляет полное имя, работает с любой хеш-картой, имею- щей поля firstName и lastName. Можно выгодно использовать тот факт, что данные, относящиеся к вычислению полного имени читателя библиотеки и автора, имеют одинаковую форму. Без каких-либо изменений функция fullName корректно работает как с данными для автора, так и с данными для читателя, как показано в следующем листинге. Листинг A.7. Один и тот же код для объектов данных разных типов (в стиле ФП) function createAuthorData(firstName, lastName, books) { return {firstName: firstName, lastName: lastName, books: books}; } function fullName(data) { return data.firstName + " " + data.lastName; } function createUserData(firstName, lastName, email) { return {firstName: firstName, lastName: lastName, email: email}; } var authorData = createAuthorData("Isaac", "Asimov", 500); fullName(authorData);
404 Приложение А var userData = createUserData("John", "Doe", "john@doe.com"); fullName(userData); // → "John Doe" Когда в ООП соблюдается принцип № 1, повторно применять код становится легко, даже если используются классы. В статически типизированных языках ООП, таких как Java или C, нам придется создать общий интерфейс для AuthorData и UserData. Однако в динамически типизированном языке, таком как JavaScript, это не требуется. Код для NameCalculation.fullName() работает с данными как для автора, так и для читателя библиотеки, как показано в следующем листинге. Листинг A.8. Один и тот же код для объектов данных разных типов (в стиле ООП) class AuthorData { constructor(firstName, lastName, books) { this.firstName = firstName; this.lastName = lastName; this.books = books; } } class NameCalculation { static fullName(data) { return data.firstName + " " + data.lastName; } } class UserData { constructor(firstName, lastName, email) { this.firstName = firstName; this.lastName = lastName; this.email = email; } } var userData = new UserData("John", "Doe", "john@doe.com"); NameCalculation.fullName(userData); var authorData = new AuthorData("Isaac", "Asimov", 500); NameCalculation.fullName(authorData); // → "John Doe" СОВЕТ. Когда код отделен от данных, его можно использовать повторно в разных контекстах непосредственным образом. Это преимущество достижимо как в ФП, так и в ООП.
Принципы дата-ориентированного программирования 405 Преимущество № 2: код может быть протестирован изолированно Похожим преимуществом является возможность тестировать код в изолированном контексте. Когда код не отделен от данных, необходимо создавать экземпляр объекта для тестирования его методов. Например, чтобы протестировать код для вычисления fullName, который находится внутри функции createAuthorObject, нужно создать экземпляр объекта для автора, как показано в следующем листинге. Листинг A.9. Тестирование кода, когда код и данные смешаны var author = createAuthorObject("Isaac", "Asimov", 500); author.fullName() === "Isaac Asimov" // → true В этом простом сценарии смешение кода и данных не так уж и обременительно. Мы всего лишь загружаем (без необходимости) код для isProlific. А вот в реальной ситуации создание экземпляра объекта может потребовать сложной и утомительной настройки. По версии ДОП, где createAuthorData и fullName разделены, мы можем создавать данные для передачи на fullName изолированно, при этом тестируя fullName изолированно. В следующем листинге приведен такой пример. Листинг A.10. Тестирование кода изолированно (в стиле ФП) var author = { firstName: "Isaac", lastName: "Asimov" }; fullName(author) === "Isaac Asimov" // → true Если используются классы, необходимо просто создать экземпляр объекта данных. Нам не нужно загружать код для функции isProlific, которая не находится в том же самом классе, что fullName, чтобы протестировать fullName. В следующем листинге приведен пример с таким подходом. Листинг A.11. Тестирование кода изолированно (в стиле ООП) var data = new AuthorData("Isaac", "Asimov"); NameCalculation.fullName(data) === "Isaac Asimov" // → true СОВЕТ. Писать тесты легче, когда код отделен от данных.
406 Приложение А Преимущество № 3: системы, как правило, менее сложны Третье преимущество применения принципа № 1 к нашим программам заключается в том, что системы, как правило, получаются менее сложными. Это преимущество является самым глубоким, а также наименее простым для понимания. Та сложность, о которой я говорю, относится к типу сложности, затрудняющему понимание систем, как определено в статье Бена Мозли и Питера Маркса «Из ямы со смолой» («Out of the Tar Pit», http://mng.bz/enzq). Этот тип не имеет ничего общего со сложностью ресурсов, потребляемых программой. А также, упоминая слово «простота», я имею в виду «отсутствие сложности» (другими словами, легкость понимания).  ПРИМЕЧАНИЕ. «Сложное» в контексте этой книги означает «трудное для понимания». Имейте в виду, что сложность и простота (как и трудность и легкость) — понятия не абсолютные, а относительные. Сложность двух систем можно сравнить, чтобы определить, является ли система A более сложной (или более простой), чем система B. Когда код и данные хранятся отдельно друг от друга, систему, как правило, легче понять по двум причинам:  область действия объекта данных или объекта кода меньше, чем область дейст- вия объекта, объединяющего код и данные. Таким образом, отдельные объекты легче понять;  объекты системы разделены на непересекающиеся группы — код и данные. Та- ким образом, отдельные объекты имеют меньше связей с другими объектами. Эти открытия мы проиллюстрируем при помощи диаграммы классов нашей выдуманной Системы управления библиотекой, где код и данные смешаны. Не обязательно знать подробности о классах этой системы, чтобы увидеть, что диаграмма на рис. А.2 представляет собой сложную систему; сложную в том смысле, что ее трудно понять. Эту систему трудно понять, потому что существует множество зависимостей между объектами, составляющими систему. Наиболее сложным объектом системы на рис. А.2 является Librarian, который связан с другими объектами посредством шести отношений. Некоторые отношения являются отношениями данных (ассоциация и композиция), а другие являются отношениями кода (наследование и зависимость). Но при таком дизайне объект Librarian смешивает код с данными, и, следовательно, он должен быть вовлечен как в отношения данных, так и в отношения кода. Если каждый объект этой системы разделить на объект кода и объект данных без внесения в систему каких-либо дополнительных модификаций, в результате (рис. А.3) получатся две несвязанные части:  левая часть, состоящая только из объектов данных и отношений данных — ас- социации и композиции;  правая часть, состоящая только из объектов кода и кодовых отношений — зави- симости и наследования.
Принципы дата-ориентированного программирования 407 Рис. A.2. Обзор диаграммы классов для Системы управления библиотекой Рис. A.3. Диаграмма классов, в которой каждый класс разделен на объекты кода и объекты данных Новая система, в которой код и данные разделены, легче для понимания, чем исходная система, в которой код и данные смешаны. Таким образом, часть системы, связанную с данными, и часть системы, связанную с кодом, можно понять каждую по отдельности. СОВЕТ. Система, состоящая из разъединенных частей, менее сложна, чем система, представляющая собой единое целое.
408 Приложение А Можно, конечно, возразить, что сложность исходной системы, где код и данные смешаны, обусловлена плохим дизайном и что опытный разработчик ООП разработал бы более простую систему, используя хитроумные шаблоны проектирования. Это верно, но в известном смысле не имеет значения. Суть принципа № 1 заключается в том, что система, состоящая из объектов, которые не объединяют в себе код и данные, как правило, проще, чем система, состоящая из объектов, которые совмещают код и данные. Я не раз говорил, что простота — это трудно. Согласно первому принципу ДОП, простоты легче достичь, разделив код и данные. СОВЕТ. Простоты легче достичь, когда код отделен от данных. A.1.3. Издержки принципа № 1 В этом разделе рассматриваются издержки реализации принципа № 1. Мы платим тройную цену за то, чтобы извлечь выгоду из отделения кода от данных.  Отсутствует контроль над тем, какой код может получить доступ к каким дан- ным.  Отсутствует упаковка.  Системы состоят из большего количества объектов. Издержка № 1: отсутствие контроля над тем, какой код к каким данным может получить доступ Когда код и данные смешаны, легко понять, какие фрагменты кода могут получить доступ к каким видам данных. Например, в ООП данные инкапсулированы в объекте, что гарантирует доступ к данным только с помощью методов этого объекта. В ДОП данные существуют сами по себе. Можно сказать, что данные прозрачны, и, как следствие, к ним можно получить доступ с помощью любого фрагмента кода. При рефакторинге формы каких-либо данных нужно знать о каждом месте, из которого код обращается к этим данным. Более того, без применения принципа № 3 (обеспечение неизменяемости данных), который мы обсудим позже, получение доступа к данным с помощью любого фрагмента кода по определению небезопасно. В этом случае трудно гарантировать валидность данных. СОВЕТ. За безопасность данных отвечает другой принцип (принцип № 3), который обеспечивает неизменяемость данных. Издержка № 2: отсутствие пакетирования Одно из преимуществ смешивания кода и данных заключается в том, что готовый объект является также пакетом, содержащим как код (через методы), так и данные (через элементы класса). Как следствие, легко обнаружить, как манипулировать данными: нужно посмотреть на методы класса.
Принципы дата-ориентированного программирования 409 В ДОП код, который манипулирует данными, может находиться где угодно. Например, createAuthorData может находиться в одном файле, а fullName — в другом. Поэтому разработчикам труднее обнаружить, что функция fullName доступна. В некоторых ситуациях это может привести к напрасной трате времени и ненужному дублированию кода. Издержка № 3: системы состоят из большего количества объектов Сейчас мы займемся простой арифметикой. Представьте себе систему, состоящую из N классов, которые объединяют в себе код и данные. Когда мы разделяем систему на объекты кода и объекты данных, мы получаем систему, состоящую из 2N объектов. Однако этот расчет неверен, потому что обычно, когда мы разделяем код и данные, иерархия классов имеет тенденцию упрощаться, поскольку нам требуется меньше наследования классов и композиции. Следовательно, количество классов в получившейся системе, вероятно, будет где-то между N и 2N. С одной стороны, при соблюдении принципа № 1 объекты системы становятся проще. С другой стороны, появляется больше объектов. Эти затраты снижаются благодаря принципу № 2, который позволяет представлять данные с помощью обобщенных структур данных. СОВЕТ. При соблюдении принципа № 1 системы состоят из более простых объектов, но этих объектов больше. A.1.4. Основная суть принципа № 1 В ДОП требуется отделять код от данных. В языках ООП код агрегируется в статических методах, а данные — в классах без методов. В языках ФП нужно избегать сокрытия данных в лексической области действия функций. Отделение кода от данных имеет свою цену. Сокращается возможность контроля над тем, какие фрагменты кода получают доступ к данным, а системы могут содержать большее количество объектов. Но заплатить эту цену имеет смысл. Если мы будем придерживаться этого принципа, наш код можно будет повторно использовать в различных контекстах непосредственным способом и тестировать изолированно. Более того, система, состоящая из отдельных объектов для кода и данных, как правило, более понятна. ПРИНЦИП ДОП № 1: ОТДЕЛЯЙТЕ КОД ОТ ДАННЫХ Следуя этому принципу, мы отделяем код от данных таким образом, чтобы код заключался в функциях, поведение которых не зависит от данных, инкапсулированных в контексте функций. Диаграмма на рис. А.4 дает наглядное представление об этом. Преимущества:  Возможность повторного использования кода в различных контекстах.  Возможность тестирования кода изолированно.  Системы, как правило, получаются менее сложными.
410 Приложение А Рис. A.4. Принцип ДОП № 1: Отделяйте код от данных Издержки реализации принципа № 1:  Отсутствие контроля над тем, какой код обращается к тем или иным данным.  Отсутствие пакетирования.  Большее количество объектов. A.2. Принцип № 2: представляйте данные с помощью обобщенных структур Если соблюсти принцип № 1, код будет отделен от данных. ДОП не предписывает, какие программные конструкты следует использовать для организации кода, но определяет то, как должны быть представлены данные. В рамках этой темы и существует принцип № 2. Наиболее распространенными обобщенными структурами данных являются карты (они же словари) и массивы (или списки). Можно использовать и другие обобщенные структуры данных (например, множества, деревья и очереди). Принцип № 2 не имеет отношения к изменяемости или неизменяемости данных. В рамках этой темы существует принцип № 3. ПРИНЦИП № 2: представляйте данные приложения с помощью обобщенных структур данных. A.2.1. Иллюстрация к принципу № 2 В ДОП мы представляем данные с помощью обобщенных структур данных (таких как карты и массивы) вместо того, чтобы создавать экземпляры данных с помощью определенных классов. На самом деле, большинство объектов данных, которые появляются в стандартном приложении, могут быть представлены с помощью карт и массивов (или списков). Но существуют другие обобщенные структуры данных (например, множества, списки, очереди и т. д.), которые могут потребоваться в некоторых случаях. Давайте посмотрим на тот же простой пример, который мы использовали для иллюстрации принципа № 1 (данные, представляющие автора книги). Автор — это объект данных с полями firstName, lastName и количеством написанных им книг books. Принцип № 2 нарушается, когда мы используем определенный класс для представления автора, как показано в следующем листинге.
Принципы дата-ориентированного программирования 411 Листинг A.12. Нарушение принципа № 2 в ООП class AuthorData { constructor(firstName, lastName, books) { this.firstName = firstName; this.lastName = lastName; this.books = books; } } Принцип № 2 соблюдается при использовании карты (словаря или ассоциативного массива) в качестве обобщенной структуры данных, представляющей автора. Следующий листинг иллюстрирует, как можно следовать этому принципу в ООП. Листинг A.13. Следование принципу № 2 в ООП function createAuthorData(firstName, lastName, books) { var data = new Map; data.firstName = firstName; data.lastName = lastName; data.books = books; return data; } В таком языке, как JavaScript, мы также можем создать экземпляр карты с помощью литерала данных, а это более удобно. В следующем листинге показан такой пример. Листинг A.14. Следование принципу № 2 с литералами карты function createAuthorData(firstName, lastName, books) { return { firstName: firstName, lastName: lastName, books: books }; } A.2.2. Преимущества принципа № 2 Использование обобщенных структур данных для представления данных имеет множество преимуществ. В следующих подразделах мы более подробно рассмотрим эти преимущества:  возможность применения обобщенных функций, которые не ограничены определенным случаем использования;  гибкая модель данных.
412 Приложение А Применение функций, которые не ограничены определенным случаем использования Используя обобщенные структуры для представления данных, мы можем манипулировать данными с помощью широкого выбора функций, которые нативно доступны для этих структур в выбранном нами языке программирования. Кроме того, сторонние библиотеки предоставляют еще больше таких функций. Например, JavaScript нативно предоставляет некоторые базовые функции для карт и массивов, а сторонние библиотеки, такие как Lodash (https://lodash.com/), расширяют функциональность с помощью дополнительного набора функций. У Алана Перлиса есть известная цитата, которая резюмирует это преимущество: «Лучше сто функций, работающих с одной структурой данных, чем десять функций, работающих с десятью структурами данных» (Перлис А. Эпиграммы о программировании, 1982). Когда сведения об авторе книги представлены в виде карты, они могут быть сериализованы в JSON с помощью функции JSON.stringify(), которая является частью JavaScript. В следующем листинге приведен пример этого. Листинг A.15. [#serialize-klipse-js],reftext="A.1" var data = createAuthorData("Isaac", "Asimov", 500); JSON.stringify(data); // → "{\"firstName\":\"Isaac\",\"lastName\":\"Asimov\",\"books\":500}" Сериализация данных об авторе без учета количества книг может быть выполнена с помощью функции _.pick() из библиотеки Lodash. В следующем листинге _.pick() используется для создания объекта с подмножеством ключей. Листинг A.16. Манипулирование данными с помощью обобщенных функций var data = createAuthorData("Isaac", "Asimov", 500); var dataWithoutBooks = _.pick(data, ["firstName", "lastName"]); JSON.stringify(dataWithoutBooks); // → "{\"firstName\":\"Isaac\",\"lastName\":\"Asimov\"}" СОВЕТ. Когда мы придерживаемся принципа № 2, нам открывается широкий выбор функциональных возможностей для манипулирования данными. Гибкая модель данных При использовании обобщенных структур модель данных является гибкой, и данные не принуждаются к определенной форме. Данные могут быть созданы без какой-либо заранее заданной формы, и их форма может быть изменена по желанию. В классическом ООП, когда не соблюдается принцип № 2, экземпляр каждого фрагмента данных создается при помощи класса и должен иметь жесткую форму. Когда требуется немного иная форма данных, необходимо задать новый класс.
Принципы дата-ориентированного программирования 413 Возьмем, к примеру, AuthorData, класс, который представляет объект для автора книги, состоящий из трех полей: firstName, lastName и books. Предположим, что вы хотите добавить поле с названием fullName, содержащее полное имя автора. Если мы не будем придерживаться принципа № 2, нам придется определить новый класс AuthorDataWithFullName. Однако с использованием обобщенных структур данных можно добавлять (или удалять) поля карты на лету, как показано в следующем листинге. Листинг А.17. Добавление поля на лету var data = createAuthorData("Isaac", "Asimov", 500); data.fullName = "Isaac Asimov"; СОВЕТ. Работать с гибкой моделью данных особенно полезно в приложениях, где форма данных имеет тенденцию быть динамичной (например, веб-приложения и веб-сервисы). В части 1 книги подробно рассматриваются преимущества гибкой модели данных в реальных приложениях. Далее рассмотрим издержки соблюдения принципа № 2. A.2.3. Издержки принципа № 2 Как и в случае с любым принципом программирования, использование этого принципа сопряжено с некоторыми компромиссами. Вот цена, которую мы платим за представление данных с помощью обобщенных структур:  наблюдается небольшое снижение производительности;  не требуется схема данных;  не требуется проверка данных на валидность во время компиляции;  в некоторых языках со статической типизацией требуется приведение типов. Издержка № 1: снижение производительности Когда для создания экземпляра данных используются определенные классы, извлечение значения элемента класса происходит быстро, потому что компилятор знает, как будут выглядеть данные, и может выполнять множество оптимизаций. При использовании обобщенных структур данных оптимизацию провести сложнее, поэтому извлечение значения, связанного, например, с ключом в карте, происходит немного медленнее, чем извлечение значения элемента класса. Подобным же образом установка значения произвольного ключа в карте выполняется немного медленнее, чем установка значения элемента класса. В большинстве языков программирования это снижение производительности незначительно, но забывать о нем не стоит. СОВЕТ. Извлечение и сохранение значения, связанного с произвольным ключом, на карте происходит немного медленнее, чем в случае с элементом класса.
414 Приложение А Издержка № 2: отсутствие схемы данных Когда экземпляр данных создается из класса, информация о форме данных содержится в определении класса. Каждый фрагмент данных имеет связанную форму данных. Наличие схемы данных на уровне класса полезно для разработчиков и для сред IDE по следующим причинам:  разработчики могут легко обнаружить ожидаемую форму данных;  среды IDE предоставляют такие функции, как автозаполнение названия поля. Когда данные представлены с помощью обобщенных структур, схема данных не является частью такого представления. Как следствие, некоторые фрагменты данных могут иметь связанную схему данных, а другие фрагменты — нет (см. принцип № 4). СОВЕТ. Когда для хранения данных используются обобщенные структуры, форма данных не является частью представления данных. Издержка № 3: отсутствует проверка данных на валидность во время компиляции Еще раз взгляните на функцию fullName в следующем листинге, который изначально был создан для изучения принципа № 1. Эта функция получает данные, которыми она манипулирует, в качестве аргумента. Листинг A.18. Декларация функции fullName function fullName(data) { return data.firstName + " " + data.lastName; } Когда данные, которые передаются fullName, не соответствуют ожидаемой этой функцией форме, во время выполнения возникает ошибка. При использовании обобщенных структур данных неправильный ввод названия поля, хранящего имя автора (например, fistName вместо firstName), не приводит к ошибке во время компиляции или исключению. В этом случае поле firstName будет таинственным образом пропущено при выводе результата. В следующем листинге показано это неожиданное поведение. Листинг A.19. Неожиданное поведение с невалидными данными fullName({fistName: "Issac", lastName: "Asimov"}); // → "undefined Asimov" Когда мы создаем экземпляры данных с помощью классов с жесткой формой данных, этот тип ошибки обнаруживается во время компиляции. Этот недостаток компенсируется применением принципа № 4, который занимается валидацией данных.
Принципы дата-ориентированного программирования 415 СОВЕТ. Когда данные представлены с помощью обобщенных структур, ошибки, связанные с формой данных, обнаруживаются только во время выполнения. Издержка № 4: необходимость явного приведения типов В некоторых языках со статической типизацией требуется явное приведение типов. В этом подразделе рассматривается явное приведение типов в Java и динамические поля в C#. В статически типизированном языке, таком как Java, данные об авторе могут быть представлены в виде карты, ключи которой имеют тип string, а значения имеют типы Object. Например, в Java данные об авторе представлены картой Map<String, Object>, как показано в следующем листинге. Листинг A.20. Данные об авторе в виде строковой карты в Java var asimov = new HashMap<String, Object>(); asimov.put("firstName", "Isaac"); asimov.put("lastName", "Asimov"); asimov.put("books", 500); Поскольку информация о точном типе значений полей недоступна во время компиляции, при обращении к полю требуется явное приведение типа. Например, чтобы проверить, является ли автор плодовитым, значение поля books должно быть приведено к целому числу, как показано в следующем листинге. Листинг A.21. Приведение типа требуется при доступе к полю в Java class AuthorRating { static boolean isProlific (Map<String, Object> data) { return (int)data.get("books") > 100; } } Некоторые библиотеки JSON-сериализации для Java, такие как Gson (https:// github.com/google/gson), поддерживают сериализацию карт типа Map<String, Object>, не требуя от пользователя выполнения какого-либо приведения типа. Все волшебство происходит за кулисами! C# поддерживает динамический тип данных dynamic (см. по ссылке http://mng.bz/ voqJ), который позволяет проверять тип во время выполнения. При использовании этой возможности данные об авторе представляются в виде словаря, в котором ключи имеют тип string, а значения — тип dynamic. В следующем листинге показано такое представление.
416 Приложение А Листинг A.22. Данные об авторе в виде динамической строковой карты в C# var asimov = new Dictionary<string, dynamic>(); asimov["name"] = "Isaac Asimov"; asimov["books"] = 500; Вопрос о точном типе значений поля разрешается во время выполнения. При доступе к полю приведение типа не требуется. Например, проверяя, является ли автор плодовитым, к полю books можно получить доступ, как будто оно было декларировано как целое число (смотрите следующий листинг). Листинг A.23. Приведение типа не требуется при доступе к динамическим полям в C# class AuthorRating { public static bool isProlific (Dictionary<String, dynamic> data) { return data["books"] > 100; } } A.2.4. Основная суть принципа № 2 В ДОП для представления данных используются обобщенные структуры. Это может привести к (небольшому) снижению производительности и вызвать необходимость вручную документировать форму данных, поскольку компилятор не может проверить ее статически. Соблюдение этого принципа позволяет манипулировать данными с помощью широкого выбора обобщенных функций (предоставляемых языком и сторонними библиотеками). Кроме того, модель данных является гибкой. При таком положении дел данные могут быть либо изменяемыми, либо неизменяемыми. Следующий принцип (принцип № 3) иллюстрирует важность неизменяемости. ПРИНЦИП ДОП № 2: ПРЕДСТАВЛЯЙТЕ ДАННЫЕ С ПОМОЩЬЮ ОБОБЩЕННЫХ СТРУКТУР ДАННЫХ Чтобы исполнить этот принцип, мы представляем данные приложения с помощью обобщенных структур в основном карт и массивов (а также списков). Диаграмма на рис. А.5 дает наглядное представление об этом принципе. Рис. A.5. Принцип ДОП № 2: Представляйте данные с помощью общих структур данных
Принципы дата-ориентированного программирования 417 Преимущества:  Использование обобщенных функций, которые не ограничены определенным случаем использования.  Гибкая модель данных. Издержки реализации этого принципа:  Небольшое снижение производительности.  Схема данных не требуется.  Не требуется проверка валидности данных во время компиляции.  В некоторых языках со статической типизацией требуется явное приведение типов. A.3. Принцип № 3: данные неизменяемы Как же управлять изменениями в данных, когда данные отделены от кода и представлены с помощью обобщенных структур? ДОП придерживается очень строгих взглядов на этот вопрос. Изменение данных не допускается! В ДОП изменения в данных выполняются путем создания новых версий данных. Ссылка на переменную может быть изменена так, чтобы она указывала на новую версию данных, но значение самих данных никогда не должно изменяться. ПРИНЦИП № 3: данные неизменяемы. A.3.1. Иллюстрация к принципу № 3 Представьте себе число 42. Что происходит с этим числом, когда вы добавляете к нему 1? Превращается ли оно в 43? Нет, 42 всегда будет 42! А теперь поместите число 42 внутрь объекта: {num: 42}. Что происходит с объектом, когда вы добавляете 1 к 42? Превращается ли он в 43? А вот это зависит от языка программирования.  В Clojure, языке программирования, который поддерживает неизменяемость данных, значение поля num остается равным 42 всегда и несмотря ни на что.  Во многих языках программирования значение поля num становится 43. Например, в JavaScript изменение поля в словаре, на которую ссылаются две переменные, влияет на обе переменные. Следующий листинг это демонстрирует. Листинг А.24. Изменение данных, на которые ссылаются две переменные, влияет на обе переменные var myData = {num: 42}; var yourData = myData; yourData.num = yourData.num + 1; console.log(myData.num); // → 43 Теперь значение myData.num равно 43. Однако, согласно ДОП, данные никогда не должны меняться! Вместо изменения данных они создаются заново. Простой до
418 Приложение А безобразия (и неэффективный) способ создания новой версии данных — это клонирование данных перед их модификацией. Например, в листинге A.25 есть функция, которая изменяет значение поля внутри объекта путем клонирования объекта с помощью функции Object.assign, нативно предоставляемой JavaScript. Когда функция changeValue вызывается со значением myData, само myData не затрагивается; myData .num остается равным 42. В этом вся суть неизменяемости данных! Листинг А.25. Неизменяемость данных при помощи клонирования function changeValue(obj, k, v) { var res = Object.assign({}, obj); res[k] = v; return res; } var myData = {num: 42}; var yourData = changeValue(myData, "num", myData.num + 1); console.log(myData.num); // → 43 Для эффективного обеспечения неизменяемости с точки зрения как вычислений, так и памяти требуется сторонняя библиотека, например, Immutable.js (https:// immutable-js.com/), которая обеспечивает эффективную реализацию персистентных структур данных (также известных как неизменяемые структуры данных). В большинстве языков программирования существуют библиотеки, которые обеспечивают эффективную реализацию персистентных структур данных. При задействовании Immutable.js не используются нативные карты и массивы JavaScript, а вместо этого создаются экземпляры неизменяемых карт и неизменяемых списков с помощью Immutable.Map и Immutable.List. Доступ к элементу карты осуществляется с помощью метода get. Новая версия карты создается при модификации поля с помощью метода set. В листинге A.26 показано, как можно эффективно создавать неизменяемые данные и манипулировать ими с помощью сторонней библиотеки. В выходных данных значение yourData.get("num") равно 43, но значение myData.get("num") остается 42. Листинг А.26. Создание неизменяемых данных и манипулирование ими var myData = Immutable.Map({num: 42}) var yourData = myData.set("num", 43); console.log(yourData.get("num")); // → 43 console.log(myData.get("num")); // → 42 СОВЕТ. Когда данные неизменяемы, вместо изменения данных создается их новая версия.
Принципы дата-ориентированного программирования 419 A.3.2. Преимущества принципа № 3 Когда программы ограничены в возможности изменения данных, это выгодно во многих отношениях. В следующих подразделах подробно описываются такие преимущества:  надежность доступа к данным для всех;  предсказуемость поведения кода;  быстрая проверка равенства;  безопасность конкурентности без затрат. Преимущество № 1: надежный доступ к данным для всех Согласно принципу № 1 (отделение кода от данных) доступ к данным является прозрачным. Любой функции разрешен доступ к любому фрагменту данных. Если неизменяемость данных не гарантирована, нужно вести себя крайне осторожно при передаче данных функции в качестве аргумента. Нам придется либо точно убедиться, что функция не мутирует данные, либо клонировать данные до того, как они будут переданы функции. Но если придерживаться принципа неизменяемости данных, ничего из этого не потребуется. СОВЕТ. Когда данные неизменяемы, их можно надежно передавать любой функции, потому что данные точно никогда не поменяются. Преимущество № 2: предсказуемое поведение кода В качестве иллюстрации того, что подразумевается под предсказуемостью, вот пример непредсказуемого фрагмента кода, который не придерживается принципа неизменяемости данных. Взгляните на фрагмент асинхронного кода JavaScript в следующем листинге. Когда данные изменяемы, поведение асинхронного кода непредсказуемо. Листинг А.27. Поведение непредсказуемого асинхронного кода, когда данные изменяемы var myData = {num: 42}; setTimeout(function (data){ console.log(data.num); }, 1000, myData); myData.num = 0; Значение data.num внутри обратного вызова со временем ожидания не является предсказуемым. Оно зависит от того, были ли данные модифицированы другим фрагментом кода в течение 1000 миллисекунд времени ожидания. Однако неизменяемость данных гарантирует, что данные всегда останутся прежними, а значение data.num всегда будет равным 42 внутри обратного вызова.
420 Приложение А СОВЕТ. Когда данные неизменяемы, поведение кода, который манипулирует данными, предсказуемо. Преимущество № 3: быстрая проверка равенства При использовании фреймворков пользовательского интерфейса (англ. user interface, UI), таких как React.js, необходимы частые проверки того, какая часть данных UI была изменена с момента предыдущего цикла рендеринга. Части, которые не изменились, больше не отображаются. На самом деле в стандартном интерфейсном приложении большая часть данных UI остается неизмененной между двумя следующими друг за другом циклами рендеринга. В приложении React, которое не придерживается принципа неизменяемости данных, необходимо проверять каждую (вложенную) часть данных UI. Однако в приложении React, которое придерживается принципа неизменяемости данных, можно оптимизировать сравнение данных для случая, когда данные не модифицируются. И в самом деле, когда адрес объекта тот же, можно гарантировать, что данные не изменились. Сравнение адресов объектов выполняется намного быстрее, чем сравнение всех полей. В части 1 нашей книги быстрые проверки на равенство используются для согласования конкурентных изменений в высокомасштабируемой готовой к работе системе. СОВЕТ. Неизменяемые данные позволяют быстро проверять на равенство путем сравнения данных по ссылке. Преимущество № 4: безопасность конкурентности без затрат В многопоточной среде механизмы безопасности конкурентности (например, мьютексы) часто используются для предотвращения изменения данных в потоке A во время доступа к ним в потоке B. В дополнение к небольшому снижению производительности, которое они вызывают, механизмы безопасности конкурентности налагают дополнительную умственную нагрузку, значительно затрудняя написание и чтение кода. СОВЕТ. Следование принципу неизменяемости данных устраняет необходимость в механизме контроля конкурентности. Ваши данные никогда не меняются! A.3.3. Издержки принципа № 3 Как и в случае с предыдущими принципами, применение принципа № 3 имеет свою цену. В подразделах ниже рассматриваются следующие издержки:  снижение производительности;  потребность в библиотеке для персистентных структур данных.
Принципы дата-ориентированного программирования 421 Издержка № 1: снижается производительность Как мы упоминали ранее, варианты реализации персистентных структур данных существуют в большинстве языков программирования. Но даже самая эффективная реализация происходит немного медленнее, чем изменение данных на месте. В большинстве приложений снижение производительности и дополнительное потребление памяти, связанные с использованием персистентных структур данных, незначительны. Но забывать об этом не стоит. Издержка № 2: требуется библиотека для персистентных структур данных В таком языке как Clojure нативные структуры данных сами по себе неизменяемы. Однако в большинстве языков программирования, чтобы соблюсти принцип неизменяемости данных, потребуется сторонняя библиотека, обеспечивающая реализацию персистентных структур данных. Когда эти структуры данных не являются нативными для используемого языка, будет трудно (а порой и невозможно) обеспечить повсеместное использование неизменяемых данных. Кроме того, при интеграции сторонних библиотек (например, библиотеки диаграмм) персистентные структуры данных должны быть преобразованы в эквивалентные нативные структуры данных. A.3.4. Основная суть принципа № 3 ДОП рассматривает данные как значение, которое никогда не меняется. Если придерживаться этого принципа, код будет предсказуем даже в многопоточной среде, а проверка равенства может быть выполнена быстро. Однако вам потребуется значительно перепрограммировать себя для работы с этим принципом, а в большинстве языков программирования для обеспечения эффективной реализации персистентных структур данных необходима сторонняя библиотека. ПРИНЦИП ДОП № 3: ДАННЫЕ НЕИЗМЕНЯЕМЫ Когда мы придерживаемся этого принципа, мы представляем данные в виде неизменяемых структур. Диаграмма на рис. А.6 дает наглядное представление об этом. Рис. A.6. Принцип ДОП № 3: Данные неизменяемы Преимущества:  Надежный доступ к данным для всех.  Предсказуемое поведение кода.
422 Приложение А  Быстрые проверки на равенство.  Безопасность конкурентности без затрат. Издержки реализации принципа № 3:  Снижение производительности.  Необходимость в библиотеке персистентных структур данных. A.4. Принцип № 4: отделяйте схему данных от представления данных Данные отделены от кода и представлены с помощью обобщенных и неизменяемых структур, и далее возникает вопрос о том, как выражается форма данных. В ДОП ожидаемая форма выражается в виде схемы данных, которая хранится отдельно от самих данных. Главное преимущество принципа № 4 заключается в том, что он позволяет разработчикам решать, какие фрагменты данных должны иметь схему, а какие — нет. ПРИНЦИП № 4: отделяйте схему данных от представления данных. A.4.1. Иллюстрация к принципу № 4 Предлагаю поразмыслить о том, как обработать запрос на добавление нового автора книг в систему. Чтобы упростить задачу, представьте, что такой запрос содержит только основную информацию об авторе: его имя и фамилию и в качестве опции количество написанных им книг. Из принципа № 2 (данные представлены с помощью обобщенных структур) следует, что в ДОП данные запроса представляются в виде строковой карты, и ожидается, что карта будет содержать три поля:  firstName — это строка;  lastName — тоже строка;  books — число (в качестве опции). В ДОП ожидаемая форма данных — это данные, которые хранятся отдельно от данных запроса. Например, JSON-схема (https://json-schema.org/) может представлять схему данных запроса с помощью карты. В следующем листинге приведен пример. Листинг A.28. Схема JSON для данных запроса addAuthor var addAuthorRequestSchema = { "type": "object", ❶ "required": ["firstName", "lastName"], ❷ "properties": { "firstName": {"type": "string"}, ❸ "lastName": {"type": "string"}, ❹ "books": {"type": "integer"} ❺ } };
Принципы дата-ориентированного программирования 423 ❶ Ожидается, что данные будут представлять собой карту (в JSON карта называется объектом). ❷ Только поля firstName и lastName являются обязательными. ❸ Поле firstName должно быть строкой. ❹ Поле lastName должно быть строкой. ❺ Поле books должно быть числом (когда оно предоставлено). Библиотека валидации данных используется, чтобы проверить соответствие фрагмента схеме данных. Например, можно использовать валидатор схемы Ajv JSON (https://ajv.js.org/) для валидации данных с помощью функции validate, которая возвращает значение true, когда данные валидны, и false, когда данные невалидны. В следующем листинге показан этот подход. Листинг A.29. Валидация данных с помощью Ajv var validAuthorData = { firstName: "Isaac", lastName: "Asimov", books: 500 }; ajv.validate(addAuthorRequestSchema, validAuthorData); // ❶ // → true var invalidAuthorData = { firstName: "Isaac", lastNam: "Asimov", books: "five hundred" }; ajv.validate(addAuthorRequestSchema, invalidAuthorData); ❷ // → false ❶ Данные валидны. ❷ Поле данных называется lastNam вместо lastName, а books — это строка, а не число. Когда данные невалидны, сведения о сбоях валидации доступны в удобочитаемом формате. В следующем листинге — пример этого подхода. Листинг A.30. Получение подробной информации о сбое валидации данных var invalidAuthorData = { firstName: "Isaac", lastNam: "Asimov",
424 Приложение А books: "five hundred" }; var ajv = new Ajv({allErrors: true}); ❶ ajv.validate(addAuthorRequestSchema, invalidAuthorData); ajv.errorsText(ajv.errors); ❷ // → "data should have required property 'lastName', // → data.books should be number" ❶ По умолчанию Ajv сохраняет только первую ошибку валидации данных. Установите allErrors: true для сохранения всех ошибок. ❷ Ошибки валидации данных хранятся внутри в виде массива. Чтобы получить строку, удобную для чтения, используйте функцию errorsText. A.4.2. Преимущества принципа № 4 Отделение схемы данных от представления данных обеспечивает многочисленные преимущества. В следующих подразделах подробно описываются эти преимущества.  Свободный выбор того, какие данные должны быть валидированы.  Опциональные поля.  Расширенные условия валидации данных.  Автоматическое создание визуализации модели данных. Преимущество № 1: возможность свободно выбирать, какие данные следует валидировать Когда схема данных отделена от представления данных, можно создавать экземпляры данных, не указывая их ожидаемую форму. Такая свобода полезна в различных ситуациях. Например:  мгновенное прототипирование или экспериментирование;  рефакторинг кода и валидация данных. Рассмотрим мгновенное прототипирование. В классическом ООП нужно создавать экземпляры каждой части данных с помощью класса. На этапе исследования кода, когда окончательная форма данных еще не известна, нас будет замедлять необходимость обновления определения класса каждый раз, когда меняется модель данных. ДОП обеспечивает более быстрый темп на этапе исследования, откладывая определение схемы данных на более поздний этап. Одним из распространенных шаблонов рефакторинга является рефакторинг с разделением этапов (https://refactoring.com/catalog/splitPhase.html), когда одна большая функция разделяется на множество более мелких функций с приватной областью действия. Мы вызываем эти функции с данными, которые уже были валидированы более крупной функцией. В ДОП нет необходимости указывать
Принципы дата-ориентированного программирования 425 форму аргументов внутренних функций: можно полагаться на валидацию данных, которая уже произошла. Представьте, что нам нужно отобразить некоторую информацию об авторе, например, полное имя и то, считается ли этот автор плодовитым. Используя показанный ранее при иллюстрации принципа № 2 код для вычисления полного имени и уровня плодовитости автора, можно было бы использовать функцию displayAuthorInfo, как показано в следующем листинге. Листинг А.31. Отображение информации об авторе книги class NameCalculation { static fullName(data) { return data.firstName + " " + data.lastName; } } class AuthorRating { static isProlific (data) { return data.books > 100; } } var authorSchema = { "type": "object", "required": ["firstName", "lastName"], "properties": { "firstName": {"type": "string"}, "lastName": {"type": "string"}, "books": {"type": "integer"} } }; function displayAuthorInfo(authorData) { if(!ajv.validate(authorSchema, authorData)) { throw "displayAuthorInfo called with invalid data"; }; console.log("Author full name is: ", NameCalculation.fullName(authorData)); if(authorData.books == null) { console.log("Author has not written any book"); } else { if (AuthorRating.isProlific(authorData)) { console.log("Author is prolific"); } else { console.log("Author is not prolific"); } } }
426 Приложение А Обратите внимание: первое, что происходит внутри тела displayAuthorInfo, — это валидация того, что функции был передан аргумент. Теперь примените шаблон рефакторинга с расщепленной фазой к этому простому примеру и разделите тело displayAuthorInfo на две внутренние функции:  displayFullName отображает полное имя автора;  displayProlificity отображает, является ли автор плодовитым. В следующем листинге показан получившийся код. Листинг A.32. Применение шаблона рефакторинга с расщепленной фазой function displayFullName(authorData) { console.log("Author full name is: ", NameCalculation.fullName(authorData)); } function displayProlificity(authorData) { if(authorData.books == null) { console.log("Author has not written any book"); } else { if (AuthorRating.isProlific(authorData)) { console.log("Author is prolific"); } else { console.log("Author is not prolific"); } } } function displayAuthorInfo(authorData) { if(!ajv.validate(authorSchema, authorData)) { throw "displayAuthorInfo called with invalid data"; }; displayFullName(authorData); displayProlificity(authorData); } Когда схема данных отделена от представления данных, исчезает необходимость указывать схему данных для аргументов внутренних функций displayFullName и displayProlificity. Это делает процесс рефакторинга чуть более плавным. В некоторых случаях внутренние функции могут быть сложнее, тогда все же стоит указать схему данных для их аргументов. ДОП дает нам свободу выбора! Преимущество № 2: наличие опциональных полей В ООП непросто позволить элементу класса присутствовать в качестве опции. Например, в Java потребуется специальный конструкт, подобный классу Optional, введенному в Java 8 (http://mng.bz/4jWa). В ДОП декларировать поле на карте как
Принципы дата-ориентированного программирования 427 опциональное — это нормально. На самом деле в JSON-схеме все поля являются опциональным по умолчанию. Чтобы сделать поле не опциональным, а обязательным, его название должно быть включено в массив required, например, как в схеме для данных об авторе в листинге A.33, где обязательными полями являются только firstName и lastName, а поле books — опциональное. Обратите внимание: когда опциональное поле определено на карте, его значение валидируется на соответствие схеме. Листинг А.33. Схема с опциональным полем var authorSchema = { "type": "object", "required": ["firstName", "lastName"], ❶ "properties": { "firstName": {"type": "string"}, "lastName": {"type": "string"}, "books": {"type": "number"} ❷ } }; ❶ Поле books не включено в массив required, потому что это опциональное поле. ❷ Если поле books присутствует, его значение должно выражаться числом. Сейчас мы проиллюстрируем, как функция валидации работает с опциональными полями. Карта без поля books считается валидной, как показано в листинге A.34. При этом карта с полем books, в которой его значение не является числом, считается невалидной, что можно увидеть в листинге А.35. Листинг A.34. Валидная карта без опционального поля var authorDataNoBooks = { "firstName": "Yehonathan", "lastName": "Sharvit" }; ajv.validate(authorSchema, authorDataNoBooks); ❶ // → true ❶ Валидация проходит успешно, т. к. books является необязательным полем. Листинг A.35. Невалидная карта с невалидным опциональным полем var authorDataInvalidBooks = { "firstName": "Albert", "lastName": "Einstein", "books": "Five" };
428 Приложение А validate(authorSchema, authorDataInvalidBooks); ❶ // → false ❶ Валидация неуспешна, т. к. значение поля books не является числом. Преимущество № 3: наличие расширенных условий валидации данных В ДОП валидация данных происходит во время выполнения программы. Это позволяет определять для валидации условия, которые выходят за рамки типа поля. Например, можно проводить валидацию того, что поле является не просто строкой, а строкой с максимальным количеством символов, или числом, входящим в заданный диапазон. JSON-схема поддерживает множество других расширенных условий валидации данных, таких как валидация регулярных выражений для строковых или числовых полей, которые должны быть кратны заданному числу. В схеме данных об авторе в листинге A.36 ожидается, что поля firstName и lastName будут строками длиной менее 100 символов, а поле books — числом от 0 до 10 000. Листинг А.36. Схема с расширенными условиями валидации данных var authorComplexSchema = { "type": "object", "required": ["firstName", "lastName"], "properties": { "firstName": { "type": "string", "maxLength": 100 }, "lastName": { "type": "string", "maxLength": 100 }, "books": { "type": "integer", "minimum": 0, "maximum": 10000 } } }; Преимущество № 4: возможность автоматического создания визуализации модели данных Поскольку схема данных определена как сами данные, можно использовать несколько инструментов для создания визуализаций модели данных. С помощью таких инструментов, как JSON Schema Viewer (https://navneethg.github.io/
Принципы дата-ориентированного программирования 429 jsonschemaviewer/) и Malli (https://github.com/metosin/malli), можно сгенерировать UML-диаграмму (англ. Unified Modeling Language, унифицированный язык моделирования) из JSON-схемы. Например, JSON-схема в листинге A.37 определяет форму поля bookList, представляющего собой массив с данными о книгах, в котором каждая книга — это карта, а на рис. A.7 эта схема визуализирована в виде UML-диаграммы. Эти инструменты создают UML-диаграмму из JSON-схемы. Листинг А.37. JSON-схема с массивом объектов { "type": "object", "required": ["firstName", "lastName"], "properties": { "firstName": {"type": "string"}, "lastName": {"type": "string"}, "bookList": { "type": "array", "items": { "type": "object", "properties": { "title": {"type": "string"}, "publicationYear": {"type": "integer"} } } } } } Рис. А.7. UML-визуализация JSON-схемы из листинга А.37 A.4.3. Издержки принципа № 4 За применение принципа № 4 придется заплатить свою цену. В следующих подразделах рассматриваются такие издержки:  слабая связь между данными и их схемой;  небольшое снижение производительности.
430 Приложение А Издержка № 1: слабая связь между данными и их схемой По определению, когда схема данных и представление данных разделены, связь между данными и их схемой слабее, чем когда данные представлены классами. Более того, язык определения схемы (например, JSON-схема) не является частью используемого языка программирования. Разработчик должен самостоятельно решить, когда валидация данных необходима, а когда излишня. Как говорится, у кого много прав, у того и много ответственности. Издержка № 2: небольшое снижение производительности Как упоминалось ранее, варианты реализации для валидации JSON-схемы существуют в большинстве языков программирования. В ДОП валидация данных происходит во время выполнения, и для ее осуществления потребуется некоторое время. В ООП валидация данных обычно происходит во время компиляции. Этот недостаток компенсируется тем фактом, что даже в ООП некоторые этапы валидации данных проходят во время выполнения. Например, конвертирование полезной нагрузки, содержащееся в запросе JSON, в объект происходит во время выполнения программы. Кроме того, в ДОП довольно часто некоторые этапы валидации данных вводятся только во время разработки и деактивируются при запуске готовой системы. Как следствие, происходящее снижение производительности незначительно. A.4.4. Основная суть принципа № 4 В ДОП данные представлены при помощи неизменяемых обобщенных структур. Когда требуется дополнительная информация о форме данных, можно определить схему данных (например, с использованием схемы JSON). Отделяя схему данных от представления данных, мы получаем свободу решать, когда именно данные должны быть валидированы. Помимо этого, валидация данных происходит во время выполнения. Следовательно, могут быть поставлены условия валидации, которые выходят за рамки статических типов данных (например, длины строки). Однако много прав — это еще и много ответственности, и разработчику нельзя забывать о необходимости валидировать данные. ПРИНЦИП ДОП № 4: ОТДЕЛЯЙТЕ СХЕМУ ДАННЫХ ОТ ПРЕДСТАВЛЕНИЯ ДАННЫХ Придерживаясь этого принципа, мы проводим разделение между схемой данных и представлением данных. Диаграмма на рис. А.8 иллюстрирует это правило. Рис. A.8. Принцип ДОП № 4: отделяйте схему данных от представления данных
Принципы дата-ориентированного программирования 431 Преимущества:  Свобода выбора того, какие именно данные нужно валидировать.  Опциональные поля.  Расширенные условия валидации данных.  Автоматическое создание визуализации модели данных. Издержки реализации принципа № 4:  Слабая связь между данными и их схемой.  Небольшое снижение производительности. Заключение ДОП упрощает проектирование и реализацию информационных систем, относясь к данным как к сущностям первого класса. Это возможно благодаря четырем базовым принципам, не зависящим от языка (рис. A.9).  Код отделен от данных.  Данные приложения представлены с помощью обобщенных структур.  Данные считаются неизменяемыми.  Схема данных отделена от представления данных. В этом приложении было показано, как именно каждый принцип можно применить в языках ФП и ООП. Также мы дали высокоуровневое описание преимуществ и издержек соблюдения каждого из принципов. Рис. А.9. Принципы ДОП
432 Приложение А
Приложение B Обобщенный доступ к данным в статически типизированных языках Представление данных с помощью обобщенных структур данных естественным образом вписывается в динамически типизированные языки программирования, такие как JavaScript, Ruby или Python. Однако в статически типизированных языках программирования, таких как Java или C#, представление данных в виде строковых карт со значениями неопределенного типа не является естественным по нескольким причинам:  для доступа к полям карты требуется приведение типа;  имена полей карты не проверяются во время компиляции;  автозаполнение и другие удобные функции IDE недоступны. В этом приложении рассматриваются различные способы улучшения доступа к обобщенным данным на статически типизированных языках. Мы рассмотрим:  геттеры значений для карт, позволяющие избежать приведения типов при дос- тупе к полям карты;  типизированные геттеры для карт, позволяющие использовать проверки имен полей карты во время компиляции;  обобщенный доступ для классов, использующих отражение, чтобы воспользо- ваться преимуществами автозаполнения и другими удобными функциями IDE. B.1. Динамические геттеры для строковых карт Давайте начнем с уточнения подхода, который мы представили в части 1. А именно — мы представили записи в виде строковых карт и получили доступ к полям карты с помощью динамических геттеров и приведения типов.  ПРИМЕЧАНИЕ. Большинство фрагментов кода в этом приложении используют Java, но иллюстрированные подходы могут быть применены и к другим объектно-ориентированным статически типизированным языкам, таким как C# или Go.
434 Приложение B B.1.1. Доступ к невложенным полям карты с помощью динамических геттеров В этом приложении мы проиллюстрируем различные способы обеспечения общего доступа к данным с использованием учетной записи. Наша запись составлена из этих частей:  title (строка);  isbn (строка);  publicationYear (целое число). В листинге B.1 показано представление двух книг «Watchmen» и «7 Habits of Highly Effective People» на языке Java. Эти строковые сопоставления содержат значения типа Object. Листинг B.1. Две книги, представленные в виде карт Map watchmenMap = Map.of( "isbn", "978-1779501127", "title", "Watchmen", "publicationYear", 1987 ); Map sevenHabitsMap = Map.of( "isbn", "978-1982137274", "title", "7 Habits of Highly Effective People", "publicationYear", 2020 ); К полям карты можно получить обобщенный доступ с помощью динамического геттера. В следующем листинге показана реализация. Листинг B.2. Реализация динамического геттера для полей карты class DynamicAccess { static Object get(Map m, String k) { return (m).get(k); } } Недостатком динамических геттеров является то, что для управления значением поля карты требуется приведение типа. Например, как показано в листинге B.3, приведение к String необходимо для вызова строкового метода toUpperCase для значения поля title.
Обобщенный доступ к данным в статически типизированных языках 435 Листинг B.3. Доступ к полям карты с помощью динамического геттера и приведения типов ((String)DynamicAccess.get(watchmenMap, "title")).toUpperCase(); // → "WATCHMEN" Динамические геттеры обеспечивают обобщенный доступ к данным в том смысле, что они не требуют специальных знаний о типе данных, которые представляет строковая карта. Как следствие, имя поля может быть получено динамически (например, от пользователя), как показано в листинге B.4. Это работает, потому что для доступа к полю данных книги в строковой карте нет необходимости импортировать класс, который определяет книгу. Листинг B.4. Отображение поля карты с помощью динамического геттера и приведения типов var books = List.of(watchmenMap, sevenHabitsMap); var fieldName = "title"; books.stream() .map(x -> DynamicAccess.get(x, fieldName)) .map(x -> ((String)x).toUpperCase()) .collect(Collectors.toList()) // → ["WATCHMEN", "7 HABITS OF HIGHLY EFFECTIVE PEOPLE"] Другой аспект универсальности динамических геттеров заключается в том, что они работают с данными любого типа. Например, динамический геттер для title работает не только с книгами, но и с любым фрагментом данных, имеющим поле title. B.1.2. Доступ к вложенным полям карты с помощью динамических геттеров В листинге B.5 представлен пример результатов поиска. Предположим, что результаты поиска представлены в виде карты строк, где:  ключи — это книжные ISBN;  значения — это книжные данные, представленные в виде карт строк, как в предыдущем разделе. Листинг B.5. Результаты поиска, представленные в виде карты Map searchResultsMap = Map.of( "978-1779501127", Map.of( "isbn", "978-1779501127", "title", "Watchmen", "publicationYear", 1987 ),
436 Приложение B "978-1982137274", Map.of( "isbn", "978-1982137274", "title", "7 Habits of Highly Effective People", "publicationYear", 2020 ) ); Поля книги вложены в карту результатов поиска. Для доступа к полям вложенной карты в класс DynamicAccess в листинге B.6 добавлен метод get. Этот геттер получает список строк, представляющих информационный путь вложенного поля карты. Листинг B.6. Реализация динамического геттера для вложенных полей карты class DynamicAccess { static Object get(Map m, String k) { return (m).get(k); } static Object get(Map m, List<String> path) { Object v = m; for (String k : path) { v = get((Map)v, k); if (v == null) { return null; } } return v; } } Как и в случае с невложенными полями карты в предыдущем разделе, для управления вложенным полем карты требуется приведение типов. В листинге B.7 показано, как получить доступ к этим вложенным полям карты. В следующем разделе мы рассмотрим, как избежать приведения типов при манипулировании значениями в сопоставлении строк. Листинг B.7. Вложенные поля карты с динамическим геттером и приведением типов ((String)DynamicAccess.get(searchResultsMap, List.of("978-1779501127", "title"))).toUpperCase(); // → "WATCHMEN" B.2. Геттеры значений для карт Самый простой способ избежать приведения типов при манипулировании значением поля строковой карты — это использовать динамический тип данных (см. приложение А). Динамические типы данных поддерживаются в таких языках, как C#,
Обобщенный доступ к данным в статически типизированных языках 437 но не в таких языках, как Java. Далее мы проиллюстрируем, как геттеры значений позволяют избежать приведения типов. B.2.1. Доступ к невложенным полям карты с помощью геттеров значений В этом разделе книги по-прежнему представлены в виде строковых карт со значениями типа Object. В следующем листинге показано это представление. Листинг B.8. Две книги, представленные в виде карт Map watchmenMap = Map.of( "isbn", "978-1779501127", "title", "Watchmen", "publicationYear", 1987 ); Map sevenHabitsMap = Map.of( "isbn", "978-1982137274", "title", "7 Habits of Highly Effective People", "publicationYear", 2020 ); Идея геттеров значений довольно проста. Вместо того чтобы выполнять приведение типов вне геттера, оно выполняется внутри геттера. Геттер значения требуется для каждого типа: getAsString для строк, getAsInt для целых чисел, getAsFloat для чисел с плавающей запятой, getAsBoolean для логических значений и т. д. Геттер значений используется библиотеками Java, такими как Apache Wicket (http://mng.bz/wnqQ) и Gson (https://github.com/google/gson). В листинге B.9 показана реализация getAsString, которая извлекает значение поля карты в виде строки. Листинг B.9. Реализация геттера значений для полей карты class DynamicAccess { static Object get(Map m, String k) { return (m).get(k); } static String getAsString(Map m, String k) { return (String)get(m, k); } } Доступ к полю карты можно получить без приведения типов. Например, мы можем использовать getAsString для управления названием книги, как показано в следующем листинге.
438 Приложение B Листинг B.10. Доступ к невложенным полям с помощью геттера значений DynamicAccess.getAsString(watchmenMap, "title").toUpperCase(); // → "WATCHMEN" Сопоставление книг с помощью геттера значений немного удобнее без приведения типов. Взгляните на следующий листинг. Листинг B.11. Отображение списка карт с помощью геттера значения var books = List.of(watchmenMap, sevenHabitsMap); books.stream() .map(x -> DynamicAccess.getAsString(x, "title")) .map(x -> x.toUpperCase()) .collect(Collectors.toList()) // → ["WATCHMEN", "7 HABITS OF HIGHLY EFFECTIVE PEOPLE"] B.2.2. Доступ к вложенным полям карты с помощью геттеров значений Подход геттера значения естественным образом применяется к вложенным полям карты. Как и в разделе динамического геттера, предположим, что результаты поиска представлены в виде сопоставления строк, как в листинге B.12. Поля книги вложены в карту результатов поиска, где:  ключи — это книжные ISBN;  значения — это книжные данные, представленные в виде карт строк, как в предыдущем разделе. Листинг B.12. Результаты поиска, представленные в виде карты Map searchResultsMap = Map.of( "978-1779501127", Map.of( "isbn", "978-1779501127", "title", "Watchmen", "publicationYear", 1987 ), "978-1982137274", Map.of( "isbn", "978-1982137274", "title", "7 Habits of Highly Effective People", "publicationYear", 2020 ) ); Чтобы получить доступ к вложенным полям карты без приведения типов, мы добавили метод getAsString в класс DynamicAccess. Этот класс получает список строк,
Обобщенный доступ к данным в статически типизированных языках 439 который представляет информационный путь вложенного поля карты, как в следующем листинге. Листинг B.13. Реализация геттера значений для вложенных полей карты class DynamicAccess { static Object get(Map m, String k) { return (m).get(k); } static Object get(Map m, List<String> p) { Object v = m; for (String k : p) { v = get((Map)v, k); if (v == null) { return null; } } return v; } static String getAsString(Map m, String k) { return (String)get(m, k); } static String getAsString(Map m, List<String> p) { return (String)get(m, p); } } С помощью геттера вложенных значений заголовками книг можно манипулировать в результатах поиска без приведения типов. Следующий листинг демонстрирует это. Листинг B.14. Доступ к полям вложенной карты с помощью геттера значений var informationPath = List.of("978-1779501127", "title"); DynamicAccess.getAsString(searchResultsMap, informationPath) .toUpperCase(); // → "WATCHMEN" Геттеры значений делают доступ к данным немного более удобным, если избегать приведения типов. В следующем разделе показано, как типизированные геттеры позволяют извлекать выгоду из проверок во время компиляции, даже когда данные представлены в виде строковых карт.
440 Приложение B B.3. Типизированные геттеры для карт Типизированный подход к геттеру применим в статически типизированных языках, которые поддерживают универсальные типы, такие как Java и C#. В этом разделе мы проиллюстрируем типизированный подход к геттеру в Java. B.3.1. Доступ к невложенным полям карты с помощью типизированных геттеров Как и в предыдущих разделах, мы будем использовать представление двух книг «Watchmen» и «7 Habits of Highly Effective People» на языке Java в виде строковых карт. В следующем листинге показаны карты, значения которых имеют тип Object. Листинг B.15. Две книги, представленные в виде карт Map watchmenMap = Map.of( "isbn", "978-1779501127", "title", "Watchmen", "publicationYear", 1987 ); Map sevenHabitsMap = Map.of( "isbn", "978-1982137274", "title", "7 Habits of Highly Effective People", "publicationYear", 2020 ); Идея типизированных геттеров заключается в создании универсального объекта. Затем этот объект будет содержать информацию об:  имени поля;  типе значения поля. Теперь мы можем использовать этот объект на карте строк, чтобы получить типизированное значение поля на карте. Например, в листинге B.16 есть типизированный геттер с именем TITLE, который извлекает значение поля с именем title в виде строки. Реализация типизированного геттера приведена в листинге B.17. Листинг B.16. Доступ к полям карты с помощью типизированного геттера Getter<String> TITLE = new Getter("title"); TITLE.get(watchmenMap).toUpperCase(); // → "WATCHMEN" Листинг B.17. Реализация типизированного геттера class Getter <T> { private String key; public <T> Getter (String k) {
Обобщенный доступ к данным в статически типизированных языках 441 this.key = k; } public T get (Map m) { return (T)(DynamicAccess.get(m, key)); } } СОВЕТ. Типизированные геттеры являются универсальными объектами. В отличие от геттеров из предыдущего раздела, нет необходимости предоставлять реализацию для каждого типа. В некотором смысле типизированные геттеры поддерживают проверку во время компиляции и автозаполнение. Если имя введенного геттера TITLE написано с ошибкой, компилятор выдает ошибку. Ввод первых нескольких букв TITLE в IDE обеспечивает автоматическое заполнение символа введенного геттера. Однако когда вы создаете экземпляр типизированного геттера, имя поля должно быть передано в виде строки, и ни проверки во время компиляции, ни автозаполнение недоступны. Сопоставление по списку карт с помощью типизированного геттера довольно просто, как вы можете видеть в следующем листинге. Листинг B.18. Отображение поверх списка карт с помощью типизированного геттера var books = List.of(watchmenMap, sevenHabitsMap); books.stream() .map(x -> TITLE.get(x)) .map(x -> x.toUpperCase()) .collect(Collectors.toList()) // → ["WATCHMEN", "7 HABITS OF HIGHLY EFFECTIVE PEOPLE"] B.3.2. Доступ к вложенным полям карты с помощью типизированных геттеров Подход с типизированным геттером хорошо распространяется на вложенные поля карты. Как и в разделе получения значений, предположим, что результаты поиска, отраженные в листинге B.19, представлены в виде карты строк, где:  ключи — это книжные ISBN;  значения — это книжные данные, представленные в виде карт строк, как в пре- дыдущем разделе. Листинг B.19. Результаты поиска, представленные в виде карты Map searchResultsMap = Map.of( "978-1779501127", Map.of( "isbn", "978-1779501127",
442 Приложение B "title", "Watchmen", "publicationYear", 1987 ), "978-1982137274", Map.of( "isbn", "978-1982137274", "title", "7 Habits of Highly Effective People", "publicationYear", 2020 ) ); Для поддержки вложенных полей карты в класс Getter добавляется конструктор, который получает список строк, представляющих путь к информации. В следующем листинге показана эта реализация. Листинг B.20. Вложенный типизированный геттер class Getter private private private <T> { List<String> path; String key; boolean nested; public <T> Getter (List<String> path) { this.path = path; nested = true; } public <T> Getter (String k) { this.key = k; nested = false; } public T get (Map m) { if(nested) { return (T)(DynamicAccess.get(m, path)); } return (T)(DynamicAccess.get(m, key)); } } Вложенные поля карты обрабатываются с помощью типизированных геттеров без какого-либо приведения типов. В следующем листинге пример. Листинг B.21. Доступ к полям вложенной карты с помощью типизированного геттера var informationPath = List.of("978-1779501127", "title");
Обобщенный доступ к данным в статически типизированных языках 443 Getter<String> NESTED_TITLE = new Getter(informationPath); NESTED_TITLE.get(searchResultsMap).toUpperCase(); // → "WATCHMEN" Зачем использовать типизированные геттеры? Типизированные геттеры обеспечивают несколько преимуществ:  не требуется приведение типов;  нет необходимости в реализации геттера для каждого типа;  проверка времени компиляции во время использования;  автозаполнение во время использования. Однако во время создания доступ к полям карты осуществляется как к строкам. В следующем разделе показано, как обеспечить обобщенный доступ, когда данные представлены не в виде сопоставлений строк, а в виде классов. B.4. Обобщенный доступ к членам класса Предоставление универсального доступа к членам класса — это совершенно другой подход. С помощью этого метода мы представляем данные с помощью классов, как в традиционном ООП, и используем отражение, чтобы обеспечить обобщенный доступ к данным.  ПРИМЕЧАНИЕ. Подход общего доступа к членам класса применим в языках со статическим типом, которые поддерживают отражение, таких как Java и C#. Этот раздел иллюстрирует подход в Java. B.4.1. Обобщенный доступ к не вложенным членам класса Вместо представления в виде строковых карт данные могут быть представлены в виде классов только с элементами данных, обеспечивая обобщенный доступ к членам класса через отражение. Этот подход интересен тем, что требуется доступ только к данным для чтения. Однако при создании новых версий данных или добавлении новых полей данных лучше представлять данные с помощью карт, как в части 1 этой книги.  ПРИМЕЧАНИЕ. Подход, представленный в этом разделе, применим только для доступа к данным для чтения. Вот несколько рекомендаций для того, чтобы представить книгу как класс. Убедитесь, что:  класс содержит только элементы данных (без методов);  элементы являются общедоступными;  элементы являются неизменяемыми;  методы hashcode(), equals() и tostring() реализованы должным образом.
444 Приложение B Например, в Java пометьте элементы как public и final, как в листинге B.22. В листинге реализация методов hashCode(), equals() и toString() опущена для простоты. Листинг B.22. Представление книг классом public class BookData { public final String isbn; public final String title; public final Integer publicationYear; public BookData ( String isbn, String title, Integer publicationYear) { this.isbn = isbn; this.title = title; this.publicationYear = publicationYear; } public boolean equals(Object o) { // Опущено для простоты } public int hashCode() { // Опущено для простоты } public String toString() { // Опущено для простоты } } Начиная с Java 14, существует более простой способ представления данных с использованием записей данных (http://mng.bz/q2q2), как показано в листинге B.23. Записи данных обеспечивают:  частные поля final;  общедоступные средства доступа для чтения;  открытый конструктор, сигнатура которого получена из списка компонентов записи;  реализации методов equals() и hashcode(), которые указывают, что две записи равны, если они одного типа и их компоненты записи равны;  реализацию tostring(), которая включает строковое представление компонентов записи с их именами. Листинг B.23. Представление книг записью public record BookData (String isbn, String title,
Обобщенный доступ к данным в статически типизированных языках 445 Integer publicationYear ) {} Создадим два объекта (или записи) для «Watchmen» и «7 Habits of Highly Effective People». В следующем листинге представлен код для двух объектов. Листинг B.24. Две книжные записи BookData watchmenRecord = new BookData( "978-1779501127", "Watchmen", 1987 ); BookData sevenHabitsRecord = new BookData( "978-1982137274", "7 Habits of Highly Effective People", 2020 ); Традиционный способ доступа к элементу данных — через его метод доступа (например, watchmen.title() для получения названия «Watchmen»). Чтобы получить доступ к элементу данных, имя которого получено из динамического источника, такого как переменная (или как часть полезной нагрузки запроса), нам нужно использовать отражение. В Java доступ к полю title в книге выглядит как фрагмент кода в следующем листинге. Листинг B.25. Доступ к элементу данных с помощью отражения watchmenRecord .getClass() .getDeclaredField("title") .get(watchmenRecord) // → "watchmen" В листинге B.26 показано, как отражение может быть использовано для предоставления доступа к любому элементу данных. Список предоставляет реализацию динамического доступа к не вложенным членам класса. Листинг B.26. Динамический доступ к не вложенным членам класса class DynamicAccess { static Object get(Object o, String k) { if(o instanceof Map) { return ((Map)o).get(k); }
446 Приложение B try { return (o.getClass().getDeclaredField(k).get(o)); } catch (IllegalAccessException | NoSuchFieldExceptione) { return null; } } static String getAsString(Object o, String k) { return (String)get(o, k); } } Теперь элементы данных доступны с той же универсальностью и динамичностью, что и поля в строковой карте. Код в следующем листинге показывает, как это делается. Листинг B.27. Динамический доступ к члену класса ((String)DynamicAccess.get(watchmenRecord, "title")).toUpperCase(); // → "WATCHMEN" Без каких-либо изменений кода геттеры значений (представленные ранее в этом приложении в контексте отображения строк) теперь могут работать с классами и записями. В следующем листинге таким образом используются геттеры значений. Листинг B.28. Доступ к члену класса с помощью геттера DynamicAccess.getAsString(watchmenRecord, "title").toUpperCase(); // → "WATCHMEN" Можно сопоставлять список объектов без необходимости импортировать определение класса объектов, которые мы сопоставляем. Это показано в следующем листинге. Листинг B.29. Отображение списка объектов с помощью геттера значений var books = List.of(watchmenRecord, sevenHabitsRecord); books.stream() .map(x -> DynamicAccess.getAsString(x, "title")) .map(x -> x.toUpperCase()) .collect(Collectors.toList()) // → ["WATCHMEN", "7 HABITS OF HIGHLY EFFECTIVE PEOPLE"] Типизированные геттеры, которые мы представили ранее в приложении, могут быть использованы для объектов. Взгляните на следующий листинг, чтобы увидеть, как это делается.
Обобщенный доступ к данным в статически типизированных языках 447 Листинг B.30. Отображение списка объектов с помощью типизированного геттера var books = List.of(watchmenRecord, sevenHabitsRecord); books.stream() .map(x -> TITLE.get(x)) .map(x -> x.toUpperCase()) .collect(Collectors.toList()) // → ["WATCHMEN", "7 HABITS OF HIGHLY EFFECTIVE PEOPLE"] B.4.2. Обобщенный доступ к членам вложенного класса В предыдущем разделе было показано, как обеспечить тот же доступ к данным для классов, который мы использовали для строковых карт. Это становится действительно мощным, когда мы объединяем классы и карты. Например, листинг B.31 представляет результаты поиска в виде карты, где:  ключи — это книжные ISBN (строки);  значения — это данные книги, представленные в виде классов данных (или за- писей), как в предыдущем разделе. Листинг B.31. Результаты поиска, представленные в виде карты записей Map searchResultsRecords = Map.of( "978-1779501127", new BookData( "978-1779501127", "Watchmen", 1987 ), "978-1982137274", new BookData( "978-1982137274", "7 Habits of Highly Effective People", 2020 ) ); Для этой реализации необходимо добавить два дополнительных метода. Нам нужно объявить статические методы get и getAsString(), которые получают список строк, как показано в следующем листинге. Листинг B.32. Реализация метода получения значений для вложенных членов класса class DynamicAccess { static Object get(Object o, String k) { if(o instanceof Map) {
448 Приложение B return ((Map)o).get(k); } try { return (o.getClass().getDeclaredField(k).get(o)); } catch (IllegalAccessException | NoSuchFieldExceptione) { return null; } } static Object get(Object o, List<String> p) { Object v = o; for (String k : p) { v = get(v, k); } return v; } static String getAsString(Object o, String k) { return (String)get(o, k); } static String getAsString(Object o, List<String> p) { return (String)get(o, p); } } Теперь к элементу данных, вложенному в строковую карту, можно получить доступ через его информационный путь, как, например, в листинге B.6. В следующем листинге приведен код для доступа к элементу данных с помощью геттера. Листинг B.33. Доступ к члену класса, вложенному в карту var informationPath = List.of("978-1779501127", "title"); DynamicClassAccess .getAsString(searchResultsRecords, informationPath) .toUpperCase(); // → "WATCHMEN" Существует второй вид вложенных элементов данных, когда элемент данных сам является объектом. Например, в листинге B.34 показано, как можно создать поле bookAttributes из класса BookAttributes, а в листинге B.35 показан пример вложенного класса. Листинг B.34. Представление атрибутов книги с помощью вложенного класса public class BookAttributes { public Integer numberOfPages;
Обобщенный доступ к данным в статически типизированных языках 449 public String language; public BookAttributes(Integer numberOfPages, String language) { this.numberOfPages = numberOfPages; this.language = language; } } public class BookWithAttributes { public String isbn; public String title; public Integer publicationYear; public BookAttributes attributes; public Book ( String isbn, String title, Integer publicationYear, Integer numberOfPages, String language) { this.isbn = isbn; this.title = title; this.publicationYear = publicationYear; this.attributes = new BookAttributes(numberOfPages, language); } } Листинг B.35. Экземпляр вложенного класса BookData sevenHabitsNestedRecord = new BookWithAttributes( "978-1982137274", "7 Habits of Highly Effective People", 2020, 432, "en" ); Методы получения значений работают без каких-либо изменений во вложенных элементах данных. Мы можем сделать это с помощью кода в следующем листинге. Листинг B.36. Доступ к члену вложенного класса с геттера значения var informationPath = List.of("attributes", "language"); DynamicClassAccess.getAsString(sevenHabitsNestedRecord, informationPath) .toUpperCase(); // → "EN"
450 Приложение B B.4.3. Автоматическая сериализация объектов JSON Подход, аналогичный тому, который проиллюстрирован в предыдущем разделе, используется библиотеками сериализации JSON, такими как Gson (https:// github.com/google/gson), для того чтобы автоматически сериализовать объекты в JSON. Gson использует отражение для просмотра членов класса, генерируя JSONпредставление значения каждого члена. В листинге B.37 показан пример Gson в действии. Листинг B.37. Сериализация объекта в формате JSON с помощью Gson import com.google.gson.*; var gson = new Gson(); BookData sevenHabitsRecord = new BookData( "978-1982137274", "7 Habits of Highly Effective People", 2020 ); System.out.println(gson.toJson(sevenHabitsRecord)); // → {"title":"7 Habits of Highly Effective People", ...} В листинге B.38 показано, как это также работает с объектами, вложенными в карты. Далее в листинге B.39 приводится пример с объектами, вложенными в objects. Листинг B.38. JSON-сериализация объектов, вложенных в карту с помощью Gson Map searchResultsRecords = Map.of( "978-1779501127", new BookData( "978-1779501127", "Watchmen", 1987 ), "978-1982137274", new BookData( "978-1982137274", "7 Habits of Highly Effective People", 2020 ) ); System.out.println(gson.toJson(searchResultsRecords)); // → {"978-1779501127":{"isbn":"978-1779501127","title":"Watchmen", ...}} Листинг B.39. Сериализация объекта в формате JSON, вложенного в объект с помощью Gson BookData sevenHabitsNestedRecord = new BookWithAttributes( "978-1982137274",
Обобщенный доступ к данным в статически типизированных языках 451 "7 Habits of Highly Effective People", 2020, 432, "en" ); System.out.println(gson.toJson(sevenHabitsNestedRecord)); // → {"isbn":"978-1982137274", // → "title":"7 Habits of Highly Effective People", ...} Итоги В этом приложении представлены различные способы обеспечения общего доступа к данным на языках программирования со статическим типом. В табл. B.1 обобщены преимущества и недостатки каждого подхода. Внедряя методы DOP в свои программы, помните, что данные могут быть представлены либо в виде строковых карт, либо в виде классов (или записей) и что извлекать выгоду из общего доступа к данным можно:  через динамические геттеры;  средства получения значений;  типизированные геттеры;  отражения. Таблица B.1. Различные способы обеспечения общего доступа к данным в статически типизированных языках программирования Подход Представление Преимущества Недостатки Динамические геттеры Карта Обобщенный доступ Требуется приведение типа Геттеры значений Карта Нет приведения типа Реализация для каждого типа Типизированные геттеры Карта Проверка использования во время компиляции Нет проверки во время компиляции при создании Отражение Класс Полная проверка во время компиляции Не поддается изменению
452 Приложение B
Приложение C Дата-ориентированное программирование: звено в цепи парадигм программирования Дата-ориентированное программирование (ДОП) берет свое начало в 1950-х годах, когда был изобретен язык программирования Lisp. ДОП основывается на комбинации наилучших практическим приемов, которые можно найти как в функциональном программировании (ФП), так и в объектно-ориентированном программировании (ООП). Однако эта парадигма стала применяться в готовых системах в крупном масштабе только с 2010-х годов, когда разработчики начали эффективно реализовывать персистентные структуры данных. В этом приложении мы проследим основные идеи и открытия, которые со временем привели к появлению ДОП (рис. C.1). C.1. Хронология C.1.1. 1958 год: Lisp Джону Маккарти, автору Lisp, пришла в голову неординарная идея представить данные в виде обобщенных неизменяемых списков и изобрести язык, для которого закономерно создавать списки, и получать доступ к любой части какого-либо списка. Lisp расшифровывается как LISt Processing — «обработка списков». В некотором смысле списки Lisp являются предками объектных литералов JavaScript. Идея о том, что разумно представлять данные с помощью обобщенных структур (принцип ДОП № 2), несомненно, была взята из Lisp. Основное ограничение списков Lisp заключается в том, что при обновлении списка нужно создать новую версию путем его клонирования. Это негативно сказывается на производительности как с точки зрения процессора, так и с точки зрения памяти. C.1.2. 1981 год: значения и объекты В короткой и легко читаемой статье под названием «Значения и объекты в языках программирования» Брюс Макленнан проливает свет на различие между значениями и объектами.
454 Приложение C Рис. С.1. Хронология ДОП Вот краткое содержание его работы.  Значения (например, числа) — это вневременные абстракции, для которых кон- цепты обновления, совместного использования и создания экземпляров не имеют никакого значения.  Объекты (например, объект с данными о сотруднике в системе программного обеспечения для управления персоналом) существуют во времени и, следовательно, могут быть созданы, уничтожены, скопированы, совместно использованы и обновлены.  ПРИМЕЧАНИЕ. Значение термина «объект» в этой статье не совсем такое, как в контексте ООП. Автор объясняет, почему гораздо проще написать код, который работает со значениями, чем код, который работает с объектами. Эта статья стала источником вдохновения для ДОП, развив идею реализации систем таким образом, чтобы большая часть кода работала со значениями. Вы можете ознакомиться с полным текстом этой статьи по адресу http://mng.bz/7WNy.
Дата-ориентированное программирование: звено в цепи парадигм программирования 455 C.1.3. 2000 год: идеальные хеш-деревья Фил Бэгвелл изобрел структуру данных под названием Hash Array Mapped Trie (HAMT). В статье «Идеальные хеш-деревья» он использовал HAMT для реализации хеш-карт с почти идеальными характеристиками с точки зрения как вычислений, так и использования памяти. Как мы проиллюстрировали в главе 9, HAMT и идеальные хеш-деревья являются основой эффективных персистентных структур данных. Пройдите по ссылке https://lampwww.epfl.ch/papers/idealhashtrees.pdf, чтобы прочитать его статью целиком. C.1.4. 2006 год: «Из ямы со смолой» В своей статье «Из ямы со смолой» («Out of the Tar Pit») Бен Мозли и Питер Маркс утверждают, что сложность является единственным серьезным препятствием при разработке крупномасштабных программных систем. В контексте их статьи сложность означает «то, что делает большие системы трудными для понимания». Основная идея авторов заключается в том, что в основном элементы сложности программных систем не закономерны, а случайны: сложность возникает не из-за проблемы, которую мы должны решить, а из-за программных конструктов, которые мы используем для решения проблемы. Мозли и Маркс предлагают различные способы понижения сложности программных систем. В каком-то смысле ДОП — это способ вытащить себя из «ямы со смолой». См. http://mng.bz/mxq2, чтобы прочитать эту работу полностью. C.1.5. 2007 год: Clojure Рич Хикки, эксперт по ООП, изобрел Clojure, чтобы упростить разработку информационных систем в масштабе. Хикки резюмирует основную суть Clojure такой фразой: «Просто используй карты!» Под картами он подразумевает неизменяемые карты, которыми можно эффективно манипулировать с помощью обобщенных функций. Эти карты были реализованы с использованием структур данных, представленных Филом Бэгвеллом в статье «Идеальные хеш-деревья». Язык Clojure был главным источником вдохновения для ДОП. В некотором смысле эта книга представляет собой формализацию основополагающих принципов Clojure и того, как их применять в других языках программирования. C.1.6. 2009 год: неизменяемость для всех Эффективная реализация персистентных структур данных из Clojure была привлекательна для разработчиков и других языков программирования. В 2009 году эти структуры были перенесены на Scala. Со временем они были перенесены и на другие языки программирования такими организациями, как Facebook (создание Immutable.js), а также отдельными авторами, такими как Глен Питерсон (создание Paguro для Java). В настоящее время ДОП применимо практически на любом языке программирования!
456 Приложение C C.2. Принципы ДОП как наилучший подход Принципы ДОП в том виде, в каком мы представили их в этой книге (и формализовали в приложении А), не новы. Они основаны на лучших практических приемах, которые хорошо известны разработчикам программного обеспечения на различных языках программирования. Инновация ДОП заключается в объединении этих принципов в единое целое. В этом разделе мы рассмотрим каждый из четырех принципов ДОП в более широком контексте. C.2.1. Принцип № 1: отделяйте код от данных Отделение кода от данных было когда-то основным различием между конкурирующими ООП и ФП. Как правило, в ООП мы инкапсулируем данные вместе с кодом в объекты с сохранением состояния, в то время как в ФП мы пишем функции без состояния, получающие в качестве явного аргумента данные, которыми они манипулируют. С годами это разногласие было улажено, поскольку в ФП стало возможным писать функции с сохранением состояния, получающие данные, инкапсулированные в лексическую область действия этих функций (https://developer.mozilla.org/enUS/docs/Web/JavaScript/Closures). Более того, в языки ООП, такие как Java и C#, была добавлена поддержка анонимных функций (лямбда-функций). C.2.2. Принцип № 2: представляйте данные с помощью обобщенных структур Одним из главных нововведений JavaScript, когда он был выпущен в декабре 1995 года, была возможность простого создания хеш-карт и управления ими с помощью объектных литералов. Растущая с годами популярность JavaScript как повсеместно используемого языка (интерфейсы, серверные приложения и рабочий стол) повлияла на сообщество разработчиков, и везде, где есть возможность, они начали представлять данные с помощью хеш-карт. Это более естественно в языках программирования с динамической типизацией, но, как мы видели в приложении В, применимо также и в статически типизированных языках. C.2.3. Принцип № 3: данные неизменяемы Неизменяемость данных считается наилучшим подходом, поскольку делает поведение нашей программы более предсказуемым. Например, в книге «Эффективный Java» (O'Reilly, 2017; http://mng.bz/5K81), Джошуа Блох приводит «минимизацию изменяемости» в качестве одного из лучших приемов Java. У Алана Кэя, которого многие считают изобретателем ООП, есть известная цитата о ценности неизменяемости: Программисты, делайте, что хотите, только не вмешивайтесь во внутреннее состояние, даже если оно представлено образно. Вместо этого объекты
Дата-ориентированное программирование: звено в цепи парадигм программирования 457 должны быть представлены как локации поведения более высокого уровня, более подходящие для использования в качестве динамических компонентов. ...К сожалению, большая часть того, что сегодня называется «объектноориентированным программированием» — это обыкновенное старомодное программирование, только с более вычурными конструктами. Многие программы перегружены операциями «якобы присваивания», которые теперь выполняются более дорогостоящими дополнительными процедурами (Алан К. Кей, «Начало истории Smalltalk», 1993). К сожалению, до 2007 года и до внедрения эффективных персистентных структур данных в Clojure неизменяемость не была неприменима для масштабируемых готовых приложений. Как мы упоминали в главе 9, в настоящее время эффективные персистентные структуры данных доступны в большинстве языков программирования. Краткая информация о них дана в табл. С.1. Таблица С.1. Библиотеки персистентных структур данных Язык Библиотека Java Paguro (https://github.com/GlenKPeterson/Paguro) C# Предоставляется языком (http://mng.bz/y4Ke) JavaScript Immutable.js (https://immutable-js.com/) Python Pyrsistent (https://github.com/tobgu/pyrsistent) Ruby Hamster (https://github.com/hamstergem/hamster) Кроме того, многие языки нативно предоставляют поддержку объектов, доступных только для чтения. В Java 14 были добавлены классы записей (http://mng.bz/q2q2). В C# 9 был введен тип record. Был предложен ECMAScript, проект поддержки неизменяемых записей и кортежей в JavaScript (https://github.com/tc39/proposalrecord-tuple). Наконец, в Python 3.7 были введены неизменяемые классы данных (https://docs.python.org/3/library/dataclasses.html). C.2.4. Принцип № 4: отделяйте схему данных от представления данных Одно из наиболее яростных критических замечаний в адрес динамически типизированных языков программирования было связано с нехваткой валидации данных. В ответ на эти упреки динамически типизированные языки обычно напоминали о том, что вместо безопасности данных они предоставляют гибкость данных. После разработки языков для схем данных, таких как JSON Schema (https://jsonschema.org), стало естественным валидировать данные, даже если они представлены в виде хешкарт. Как мы видели в главах 7 и 12, валидация данных не только возможна, но и в некотором смысле более эффективна, чем при представлении данных с помощью классов.
458 Приложение C C.3. ДОП и другие парадигмы, связанные с данными В этом разделе мы поговорим о различиях между ДОП и двумя другими парадигмами программирования, названия которых также содержат слово «дата»: датаориентированная разработка (англ. data-oriented design) и дата-управляемое программирование (англ. data-driven programming). В информатике есть только две сложные вещи: инвалидация кеша и присвоение названий (Фил Карлтон). Каждая парадигма ставит свою собственную цель и преследует ее, сосредоточиваясь на разных аспектах данных. В табл. С.2 кратко изложены цели, и мы подробнее рассмотрим каждую из этих парадигм следующих подразделах. Таблица С.2 Парадигмы, связанные с данными: цели и избранный аспект данных Парадигма Цель На каком аспекте данных фокусируется Дата-ориентированная разработка Повышение производительности Макет данных Дата-управляемое программирование Увеличение понятности Описанное данными поведение Дата-ориентированное программирование Понижение сложности Представление данных C.3.1. Дата-ориентированная разработка Дата-ориентированная разработка (ДОР) — это подход к оптимизации программ, основанный на эффективном использовании кеша процессора. Такой метод используется в основном при разработке видеоигр. ДОР фокусируется на макете данных, разделении и сортировке полей в зависимости от того, когда они необходимы, и заставляет нас думать о трансформациях данных. В таком контексте важно то, как данные хранятся в памяти. Целью этой парадигмы является повышение производительности системы. C.3.2. Дата-управляемое программирование Дата-управляемое программирование (ДУП) содержит в себе идею о том, чтобы на основе описательных данных создавать языки, специфичные для предметных областей (англ. domain-specific language, DSL). Это ветвь декларативного программирования. Здесь важно описать поведение программы в терминах данных. Цель этой парадигмы — сделать код более понятным и снизить появление багов, связанных с ошибками в реализации ожидаемого поведения программы.
Дата-ориентированное программирование: звено в цепи парадигм программирования 459 C.3.3. Дата-ориентированное программирование (ДОП) Как мы проиллюстрировали во всей нашей книге, ДОП — это парадигма, которая относится к системным данным как к привилегированным гражданам. Данные представлены обобщенными неизменяемыми структурами, такими как карты и векторы, управляют которыми обобщенные функции, такие как map, filter, select, group, sort и т. д. Для ДОП важно представление данных программой. Цель этой парадигмы состоит в том, чтобы понизить сложность системы. Итоги В этом приложении мы рассмотрели идеи и тенденции, которые послужили вдохновением для создания ДОП. Мы рассмотрели открытия, которые сделали возможным применение ДОП на большинстве языков программирования для разработки готовых систем в масштабе.
460 Приложение C
Приложение D Ссылки на Lodash На протяжении всей книги мы использовали Lodash (https://lodash.com/), чтобы проиллюстрировать, как манипулировать данными с помощью универсальных функций. Но в Lodash нет ничего уникального. Точно такой же подход можно реализовать с помощью других библиотек обработки данных или пользовательского кода. Более того, мы использовали Lodash FP (https://github.com/lodash/lodash/wiki/ FPGuide) для манипулирования данными без их изменения. По умолчанию порядок аргументов в неизменяемых функциях перемешивается. Код в листинге D.1 необходим при настройке Lodash, чтобы гарантировать, что сигнатура неизменяемых функций точно такая же, как и у изменяемых функций. Листинг D.1. Настройка неизменяемых функций _ = fp.convert({ "cap": false, "curry": false, "fixed": false, "immutable": true, "rearg": false }); В этом коротком приложении перечислены 28 функций Lodash, используемых в книге, чтобы помочь вам, если вы смотрите на фрагмент кода в книге, который использует функцию Lodash, которую вы хотите понять. Функции разделены на три категории:  функции для карт в табл. D.1;  функции для массивов в табл. D.2;  функция для коллекций (как массивов, так и карт) в табл. D.3. Каждая таблица состоит из трех столбцов:  функция показывает функцию с ее сигнатурой;  описание содержит краткое описание функции;  глава — это номер главы, в которой функция появляется впервые.
462 Приложение D Таблица D.1. Функции Lodash для карт Функция Описание Глава at(map, [paths]) Создает массив значений, соответствующих paths карты 10 get(map, path) Получает значение в path карты 3 has(map, path) Проверяет, есть ли на карте поле в path 3 merge(mapA, mapB) Создает карту, полученную в результате рекурсивного слияния между mapA и mapB 3 omit(map, [paths]) Создает карту, состоящую из полей карты, которых нет в paths 10 set(map, path, value) Создает карту с теми же полями, что и map, но с добавлением поля <path, value> 4 values(map) Создает массив значений map 3 Таблица D.2. Функции Lodash для массивов Функция Описание Глава concat(arrA, arrB) Создает новый массив, объединяющий arrA и arrB 5 flatten(arr) Выравнивает arr на один уровень в глубину 14 intersection(arrA, arrB) Создает массив уникальных значений как в arrA, так и в arrB 5 nth(arr, n) Получает элемент с индексом n в arr 10 sum(arr) Вычисляет сумму значений в arr 14 union(arrA, arrB) Создает массив уникальных значений из arrA и arrB 5 uniq(arr) Создает массив уникальных значений из arr 14 Таблица D.3. Функции Lodash для коллекций (как массивов, так и карт) Функция Описание Глава every(coll, pred) Проверяет, возвращает ли pred значение true для всех элементов coll 14 filter(coll, pred) Итерирует элементы coll, возвращая массив всех элементов, для которых pred возвращает true 3 find(coll, pred) Итерирует элементы coll, возвращая первый элемент, для которого pred возвращает true 15 forEach(coll, f) Перебирает элементы массива и вызывает f для каждого элемента 14 groupBy(coll, f) Создает карту, состоящую из ключей, сгенерированных по результатам выполнения каждого элемента coll через f. Соответствующее значение для каждого ключа представляет собой массив элементов, отвечающих за генерацию ключа 10
Ссылки на Lodash 463 Таблица D.3 (окончание) Функция Описание Глава isEmpty(coll) Проверяет, пуст ли coll 5 keyBy(coll, f) Создает карту, состоящую из ключей, сгенерированных по результатам выполнения каждого элемента coll через f. Соответствующее значение для каждого ключа — это последний элемент, отвечающий за генерацию ключа 11 map(coll, f) Создает массив значений, запуская каждый элемент в coll через f 3 reduce(coll, f, initVal) Сокращает coll до значения, которое является накопленным результатом выполнения каждого элемента в coll через f, где каждому последующему вызову предоставляется возвращаемое значение предыдущего 5 size(coll) Получает размер coll 13 sortBy(coll, f) Создает массив элементов, отсортированных в порядке возрастания по результатам запуска каждого coll в столбце через f 14 isEqual(collA, collB) Выполняет глубокое сравнение между collA и collB 6 isArray(coll) Проверяет, является ли coll массивом 5 isObject(coll) Проверяет, является ли coll коллекцией 5