Перевод из книги «Designing Data-Intensive Applications, 2nd Edition» подготовлен автором сайта
Глава 5. Кодирование и эволюция
Всё изменяется и ничто не стоит на месте.
Гераклит Эфесский, цитата у Платона в «Кратиле» (360 г. до н. э.)
Приложения неизбежно со временем меняются. Новые фичи добавляются или модифицируются, когда запускаются новые продукты, пользовательские требования становятся лучше понятны или меняются бизнес-обстоятельства. В Главе 2 мы ввели понятие Эволюционности (Расширяемости): мы должны стремиться строить системы так, чтобы адаптация к изменениям была максимально простой.
В большинстве случаев изменение функциональности приложения также требует изменения данных, которые оно хранит: возможно, нужно захватить новое поле или тип записи, или, возможно, существующие данные должны быть представлены по-новому.
Модели данных по-разному справляются с такими изменениями. Реляционные базы данных обычно предполагают, что все данные в базе соответствуют одной схеме: хотя эта схема может быть изменена (через миграции схемы, т. е. операторы ALTER), в любой момент времени действует ровно одна схема. Для сравнения, базы данных со схемой «на чтение» (schema-on-read, «безсхемные») схему не навязывают, поэтому база может содержать смесь старых и новых форматов данных, записанных в разное время (см. «Гибкость схемы в документной модели»).
Когда формат данных или схема меняется, часто требуется соответствующее изменение в коде приложения (например, вы добавляете новое поле в запись, и код приложения начинает читать и записывать это поле). Однако в большом приложении изменения в коде не всегда могут быть выполнены мгновенно:
В серверных приложениях вы можете захотеть выполнить rolling upgrade (также называемый staged rollout) — выкатывать новую версию на несколько нод за раз, проверять, что новая версия работает стабильно, и постепенно проходить через все ноды. Это позволяет деплоить новые версии без даунтайма сервиса и таким образом стимулирует более частые релизы и лучшую эволюционность (расширяемость).
В клиентских приложениях вы полностью зависите от пользователя, который может не установить обновление ещё какое-то время.
Это означает, что старые и новые версии кода, а также старые и новые форматы данных потенциально могут одновременно сосуществовать в системе. Чтобы система продолжала работать корректно, необходимо поддерживать совместимость в обоих направлениях:
- Обратная совместимость
Новый код может читать данные, которые были записаны старым кодом. - Прямая совместимость
Старый код может читать данные, которые были записаны новым кодом.
Обратная совместимость обычно несложно достигается: как автор нового кода вы знаете формат данных, записанных старым кодом, и можете явно обрабатывать их (при необходимости просто сохранив старый код для чтения старых данных). Прямая совместимость может быть более сложной, так как она требует, чтобы старый код игнорировал добавления, сделанные более новой версией кода.
Ещё одна проблема с прямой совместимостью проиллюстрирована на рисунке 5-1. Допустим, вы добавляете поле в схему записи, и новый код создаёт запись с этим новым полем и сохраняет её в базу. Затем более старая версия кода (которая ещё не знает о новом поле) читает эту запись, обновляет её и записывает обратно. В этой ситуации желаемым поведением обычно является сохранение старым кодом нового поля нетронутым, даже если оно не может быть интерпретировано. Но если запись декодируется в объект модели, который явно не сохраняет неизвестные поля, данные могут быть утеряны — как на рисунке 5-1.
Рисунок 5-1. Когда более старая версия приложения обновляет данные, ранее записанные более новой версией приложения, данные могут быть потеряны, если не быть осторожным
В этой главе мы рассмотрим несколько форматов кодирования данных, включая JSON, XML, Protocol Buffers и Avro. В частности, мы посмотрим, как они обрабатывают изменения схем и как они поддерживают системы, в которых старые и новые данные и код должны сосуществовать. Затем мы обсудим, как эти форматы используются для хранения данных и для коммуникации: в базах данных, веб-сервисах, REST API, удалённых вызовах процедур (RPC), движках workflow и системах, основанных на событиях, таких как акторы и очереди сообщений.
Форматы кодирования данных
Программы обычно работают с данными в (как минимум) двух различных представлениях:
- В памяти данные хранятся в объектах, структурах (structs), списках, массивах, хэш-таблицах, деревьях и т. д. Эти структуры данных оптимизированы для эффективного доступа и манипуляций со стороны CPU (обычно с использованием указателей).
- Когда вы хотите записать данные в файл или отправить их по сети, необходимо закодировать их как некоторую самодостаточную последовательность байт (например, JSON-документ). Так как указатель не имеет смысла для любого другого процесса, это представление в виде последовательности байт зачастую выглядит совсем иначе, чем структуры данных, которые обычно используются в памяти.
Таким образом, необходим некий перевод между двумя представлениями. Преобразование из представления в памяти в последовательность байт называется кодированием (также известно как сериализация или маршаллинг), а обратное преобразование — декодированием (парсинг, десериализация, анмаршаллинг).
КОНФЛИКТ ТЕРМИНОВ
Термин «сериализация» к сожалению также используется в контексте транзакций, но с совершенно иным значением. Чтобы избежать перегрузки слова, в этой книге мы будем придерживаться термина «кодирование», хотя «сериализация» является, возможно, более распространённым термином.
Есть исключения, когда кодирование/декодирование не требуется — например, когда база данных работает напрямую с сжатыми данными, загруженными с диска, как обсуждается в разделе «Выполнение запросов: компиляция и векторизация». Существуют также zero-copy форматы данных, которые спроектированы для использования как во время выполнения, так и на диске/в сети без явного шага преобразования, такие как Cap’n Proto и FlatBuffers.
Однако большинство систем нуждаются в преобразовании между объектами в памяти и плоскими последовательностями байт. Так как это настолько распространённая задача, существует множество различных библиотек и форматов кодирования на выбор. Давайте сделаем краткий обзор.
Языко-специфичные форматы
Многие языки программирования поставляются со встроенной поддержкой кодирования объектов из памяти в последовательности байт. Например, в Java это java.io.Serializable, в Python — pickle, в Ruby — Marshal и т. д. Также существует множество сторонних библиотек, например Kryo для Java.
Эти библиотеки кодирования очень удобны, так как позволяют сохранять и восстанавливать объекты из памяти с минимальным дополнительным кодом. Однако у них есть несколько серьёзных проблем:
- Кодирование часто привязано к конкретному языку программирования, и чтение данных на другом языке крайне затруднительно. Если вы храните или передаёте данные в таком кодировании, вы фактически связываете себя с текущим языком программирования на очень долгое время и исключаете возможность интеграции ваших систем с системами других организаций (которые могут использовать другие языки).
- Чтобы восстановить данные в тех же типах объектов, процесс декодирования должен уметь инстанцировать произвольные классы. Это часто является источником проблем с безопасностью: если злоумышленник сможет заставить ваше приложение декодировать произвольную последовательность байт, он сможет инстанцировать произвольные классы, что в свою очередь зачастую позволяет делать ужасные вещи, такие как удалённое выполнение произвольного кода.
- Версионирование данных часто является второстепенной задачей в этих библиотеках: так как они предназначены для быстрого и лёгкого кодирования данных, они часто пренебрегают неудобными проблемами прямой и обратной совместимости.
- Эффективность (время CPU на кодирование или декодирование, а также размер закодированной структуры) также часто является второстепенной. Например, встроенная сериализация Java печально известна своей низкой производительностью и раздутым кодированием.
По этим причинам, как правило, плохая идея использовать встроенное в язык кодирование для чего-либо, кроме очень временных целей.
JSON, XML и бинарные варианты
При переходе на стандартизированные кодирования, которые могут быть записаны и прочитаны многими языками программирования, JSON и XML — очевидные претенденты. Они широко известны, широко поддерживаются и почти так же широко нелюбимы. XML часто критикуют за излишнюю многословность и ненужную сложность. Популярность JSON в основном связана с его встроенной поддержкой в веб-браузерах и простотой по сравнению с XML. CSV — ещё один популярный формат, независимый от языка, но он поддерживает только табличные данные без вложенности.
JSON, XML и CSV являются текстовыми форматами и, таким образом, в какой-то степени человекочитаемыми (хотя синтаксис — популярная тема для дискуссий). Помимо поверхностных синтаксических проблем, у них есть также более тонкие недостатки:
- Существует много неоднозначностей вокруг кодирования чисел. В XML и CSV невозможно различить число и строку, состоящую из цифр (кроме как ссылаясь на внешнюю схему). JSON различает строки и числа, но не различает целые и числа с плавающей точкой и не указывает точность.
Это становится проблемой при работе с большими числами; например, целые числа больше 2⁵³ не могут быть точно представлены в числе с плавающей точкой двойной точности IEEE 754, поэтому такие числа становятся неточными при парсинге в языке, который использует числа с плавающей точкой, таком как JavaScript. Пример чисел больше 2⁵³ встречается в X (ранее Twitter), где для идентификации каждого поста используется 64-битное число. JSON, возвращаемый API, включает идентификаторы постов дважды: один раз как JSON-число и один раз как десятичную строку, чтобы обойти тот факт, что числа некорректно парсятся приложениями на JavaScript.
- JSON и XML имеют хорошую поддержку строк символов в Unicode (т. е. человекочитаемого текста), но они не поддерживают бинарные строки (последовательности байт без кодировки символов). Бинарные строки — полезная фича, поэтому люди обходят это ограничение, кодируя бинарные данные в текст с помощью Base64. Схема затем используется для указания, что значение должно интерпретироваться как Base64-кодированное. Это работает, но выглядит несколько костыльно и увеличивает размер данных на 33%.
- XML Schema и JSON Schema мощные, и поэтому довольно сложные для изучения и реализации. Так как правильная интерпретация данных (например, чисел и бинарных строк) зависит от информации в схеме, приложения, которые не используют схемы XML/JSON, потенциально должны хардкодить соответствующую логику кодирования/декодирования.
- У CSV вообще нет схемы, поэтому приложение должно само определять значение каждой строки и каждого столбца. Если изменение в приложении добавляет новую строку или столбец, необходимо обрабатывать это изменение вручную. CSV также является довольно размытым форматом (что произойдёт, если значение содержит запятую или символ новой строки?). Хотя его правила экранирования формально задокументированы, не все парсеры корректно их реализуют.
Несмотря на эти недостатки, JSON, XML и CSV достаточно хороши для многих целей. Скорее всего, они останутся популярными, особенно как форматы обмена данными (т. е. для отправки данных от одной организации другой). В таких ситуациях, пока стороны согласны относительно формата, часто не имеет значения, насколько красив или эффективен этот формат. Сложность заставить разные организации договориться хоть о чём-то перевешивает большинство других соображений.
JSON Schema
JSON Schema получил широкое распространение как способ моделирования данных всякий раз, когда они обмениваются между системами или записываются в хранилище. Вы найдёте JSON-схемы в веб-сервисах (см. «Web services») как часть спецификации веб-сервисов OpenAPI, в регистрах схем, таких как Confluent Schema Registry и Red Hat Apicurio Registry, а также в базах данных, таких как расширение валидатора pg_jsonschema в PostgreSQL и синтаксис валидатора $jsonSchema в MongoDB.
Спецификация JSON Schema предлагает ряд возможностей. Схемы включают стандартные примитивные типы, включая строки, числа, целые, объекты, массивы, булевы значения или null. Но JSON Schema также предоставляет отдельную спецификацию валидации, которая позволяет разработчикам накладывать ограничения на поля. Например, поле порта может иметь минимум 1 и максимум 65535.
JSON Schema может иметь либо открытую, либо закрытую модель содержимого. Открытая модель содержимого допускает существование любых полей, не определённых в схеме, с любыми типами данных, тогда как закрытая модель содержимого разрешает только явно определённые поля. Открытая модель содержимого в JSON Schema включена, когда additionalProperties установлено в true, что является значением по умолчанию. Таким образом, JSON Schema обычно является определением того, что не разрешено (а именно, недопустимые значения для любых определённых полей), а не того, что разрешено в схеме.
Открытые модели содержимого мощные, но могут быть сложными. Например, предположим, вы хотите определить отображение от целых чисел (например, ID) к строкам. JSON не имеет типа «map» или «dictionary», только тип «object», который может содержать строковые ключи и значения любого типа. Вы можете затем ограничить этот тип с помощью JSON Schema так, чтобы ключи могли содержать только цифры, а значения могли быть только строками, используя patternProperties и additionalProperties, как показано в Примере 5-1.
Пример 5-1. Пример JSON Schema с целочисленными ключами и строковыми значениями. Целочисленные ключи представлены как строки, содержащие только целые числа, так как JSON Schema требует, чтобы все ключи были строками.
|
1 2 3 4 5 6 7 8 9 10 |
{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "patternProperties": { "^[0-9]+$": { "type": "string" } }, "additionalProperties": false } |
В дополнение к открытым и закрытым моделям содержимого и валидаторам, JSON Schema поддерживает условную if/else-логику схемы, именованные типы, ссылки на удалённые схемы и многое другое. Всё это делает язык схем очень мощным. Такие возможности также делают определения громоздкими. Может быть сложно разрешать удалённые схемы, рассуждать об условных правилах или эволюционировать схемы в направлении прямой или обратной совместимости. Подобные проблемы применимы и к XML Schema.
Бинарное кодирование
JSON менее многословен, чем XML, но оба всё равно занимают много места по сравнению с бинарными форматами. Это наблюдение привело к разработке множества бинарных кодирований для JSON (MessagePack, CBOR, BSON, BJSON, UBJSON, BISON, Hessian и Smile, например) и для XML (WBXML и Fast Infoset, к примеру). Эти форматы были приняты в различных нишах, так как они более компактны и иногда быстрее парсятся, но ни один из них не получил такого широкого распространения, как текстовые версии JSON и XML.
Некоторые из этих форматов расширяют набор типов данных (например, различают целые числа и числа с плавающей точкой или добавляют поддержку бинарных строк), но в остальном они сохраняют модель данных JSON/XML неизменной. В частности, так как они не предписывают схему, они должны включать все имена полей объекта внутри закодированных данных. То есть, в бинарном кодировании JSON-документа из Примера 5-2 им придётся где-то включить строки userName, favoriteNumber и interests.
Пример 5-2. Пример записи, которую мы будем кодировать в нескольких бинарных форматах в этой главе
|
1 2 3 4 5 |
{ "userName": "Martin", "favoriteNumber": 1337, "interests": ["daydreaming", "hacking"] } |
Давайте посмотрим на пример MessagePack, бинарного кодирования для JSON. На рисунке 5-2 показана последовательность байт, которую вы получите, если закодируете JSON-документ из Примера 5-2 с помощью MessagePack. Первые несколько байт выглядят так:
- Первый байт,
0x83, указывает, что далее идёт объект (старшие четыре бита =0x80) с тремя полями (младшие четыре бита =0x03). (Если вы задаётесь вопросом, что происходит, если объект содержит больше 15 полей, так что число полей не помещается в четыре бита, тогда используется другой индикатор типа, а число полей кодируется в двух или четырёх байтах.) - Второй байт,
0xa8, указывает, что далее идёт строка (старшие четыре бита =0xa0), которая имеет длину восемь байт (младшие четыре бита =0x08). - Следующие восемь байт — это имя поля
userNameв ASCII. Так как длина была указана ранее, нет необходимости в каком-либо маркере, чтобы сказать нам, где строка заканчивается (или в экранировании). - Следующие семь байт кодируют шестибуквенное строковое значение Martin с префиксом
0xa6, и так далее.
Бинарное кодирование имеет длину 66 байт, что всего лишь немного меньше, чем 81 байт, занимаемый текстовым JSON-кодированием (с удалёнными пробелами). Все бинарные кодирования JSON похожи в этом отношении. Неясно, стоит ли такое небольшое сокращение пространства (и, возможно, ускорение парсинга) потери человекочитаемости.
В следующих разделах мы увидим, как можно добиться гораздо лучших результатов и закодировать ту же запись всего в 32 байта.
Рисунок 5-2. Пример записи (Пример 5-2), закодированной с использованием MessagePack
Protocol Buffers
Protocol Buffers (protobuf) — это библиотека бинарного кодирования, разработанная в Google. Она похожа на Apache Thrift, который был изначально разработан Facebook; большинство того, что говорится в этом разделе о Protocol Buffers, также применимо к Thrift.
Protocol Buffers требует наличия схемы для любых данных, которые кодируются. Чтобы закодировать данные из Примера 5-2 в Protocol Buffers, вы должны описать схему на языке определения интерфейсов (IDL) Protocol Buffers следующим образом:
|
1 2 3 4 5 6 7 |
syntax = "proto3"; message Person { string user_name = 1; int64 favorite_number = 2; repeated string interests = 3; } |
Protocol Buffers поставляется с инструментом генерации кода, который принимает определение схемы, подобное показанному здесь, и генерирует классы, реализующие схему на различных языках программирования. Код вашего приложения может вызывать этот сгенерированный код для кодирования или декодирования записей схемы. Язык схемы очень прост по сравнению с JSON Schema: он определяет только поля записей и их типы, но не поддерживает другие ограничения на возможные значения полей.
Кодирование Примера 5-2 с использованием кодировщика Protocol Buffers требует 33 байт, как показано на рисунке 5-3.
Рисунок 5-3. Пример записи, закодированной с использованием Protocol Buffers
Аналогично рисунку 5-2, каждое поле имеет аннотацию типа (чтобы указать, является ли оно строкой, целым числом и т. д.) и, где необходимо, указание длины (например, длина строки). Строки, встречающиеся в данных («Martin», «daydreaming», «hacking»), также закодированы как ASCII (точнее, UTF-8), аналогично предыдущему примеру.
Большое отличие по сравнению с рисунком 5-2 заключается в том, что здесь нет имён полей (userName, favoriteNumber, interests). Вместо этого закодированные данные содержат теги полей, которые являются числами (1, 2 и 3). Это те самые числа, которые указаны в определении схемы. Теги полей подобны псевдонимам для полей — это компактный способ указать, о каком поле идёт речь, без необходимости явно указывать имя поля.
Как видно, Protocol Buffers экономит ещё больше места, упаковывая тип поля и номер тега в один байт. Используются целые числа переменной длины: число 1337 кодируется в два байта, при этом старший бит каждого байта используется для указания, есть ли ещё байты далее. Это означает, что числа от –64 до 63 кодируются в одном байте, числа от –8192 до 8191 — в двух байтах и т. д. Более крупные числа используют больше байт.
Protocol Buffers не имеет явного типа списка или массива. Вместо этого модификатор repeated у поля interests указывает, что поле содержит список значений, а не одно значение. В бинарном кодировании элементы списка представлены просто как повторяющиеся вхождения одного и того же тега поля внутри одной записи.
Теги полей и эволюция схем
Мы уже говорили, что схемы неизбежно должны меняться со временем. Мы называем это эволюцией схемы. Как Protocol Buffers обрабатывает изменения схем при сохранении обратной и прямой совместимости?
Как видно из примеров, закодированная запись — это просто конкатенация её закодированных полей. Каждое поле идентифицируется по своему номеру тега (числа 1, 2, 3 в примере схемы) и аннотируется типом данных (например, строка или целое число). Если значение поля не установлено, оно просто опускается из закодированной записи. Из этого видно, что теги полей критически важны для смысла закодированных данных. Вы можете изменить имя поля в схеме, так как закодированные данные никогда не ссылаются на имена полей, но вы не можете изменить тег поля, так как это сделает все существующие закодированные данные недействительными.
Вы можете добавлять новые поля в схему, при условии, что вы присвоите каждому полю новый номер тега. Если старый код (который не знает о новых тегах, которые вы добавили) попытается прочитать данные, записанные новым кодом, включая новое поле с номером тега, который он не распознаёт, он может просто проигнорировать это поле. Аннотация типа данных позволяет парсеру определить, сколько байт нужно пропустить, и сохранить неизвестные поля, чтобы избежать проблемы, показанной на рисунке 5-1. Это сохраняет прямую совместимость: старый код может читать записи, которые были записаны новым кодом.
А как насчёт обратной совместимости? Пока у каждого поля уникальный номер тега, новый код всегда может читать старые данные, потому что номера тегов всё ещё имеют то же значение. Если поле было добавлено в новой схеме, а вы читаете старые данные, которые ещё не содержат этого поля, оно заполняется значением по умолчанию (например, пустой строкой, если тип поля — строка, или нулём, если это число).
Удаление поля — это то же самое, что добавление поля, только с обратными требованиями по обратной и прямой совместимости. Вы никогда не можете снова использовать тот же номер тега, так как где-то могут существовать данные, записанные с этим старым номером тега, и это поле должно игнорироваться новым кодом. Номера тегов, использовавшиеся в прошлом, можно зарезервировать в определении схемы, чтобы они не были забыты.
А что насчёт изменения типа данных поля? Это возможно для некоторых типов — подробности см. в документации — но есть риск, что значения будут усечены. Например, допустим, вы изменяете 32-битное целое число на 64-битное. Новый код может легко читать данные, записанные старым кодом, так как парсер может дополнить отсутствующие биты нулями. Однако если старый код читает данные, записанные новым кодом, старый код всё ещё использует 32-битную переменную для хранения значения. Если декодированное 64-битное значение не помещается в 32 бита, оно будет усечено.
Avro
Apache Avro — ещё один бинарный формат кодирования, который примечателен своей отличием от Protocol Buffers. Он был запущен в 2009 году как подпроект Hadoop в результате того, что Protocol Buffers плохо подходил для кейсов использования Hadoop.
Avro также использует схему для задания структуры кодируемых данных. У него есть два языка схем: один (Avro IDL), предназначенный для редактирования человеком, и один (основанный на JSON), который легче читается машиной. Как и в Protocol Buffers, этот язык схемы определяет только поля и их типы, но не сложные правила валидации, как в JSON Schema.
Наша примерная схема, написанная на Avro IDL, может выглядеть так:
|
1 2 3 4 5 |
record Person { string userName; union { null, long } favoriteNumber = null; array<string> interests; } |
Эквивалентное JSON-представление этой схемы выглядит следующим образом:
|
1 2 3 4 5 6 7 8 9 |
{ "type": "record", "name": "Person", "fields": [ {"name": "userName", "type": "string"}, {"name": "favoriteNumber", "type": ["null", "long"], "default": null}, {"name": "interests", "type": {"type": "array", "items": "string"}} ] } |
Прежде всего, обратите внимание, что в схеме нет номеров тегов. Если мы закодируем нашу примерную запись (Пример 5-2) с использованием этой схемы, бинарное кодирование Avro занимает всего 32 байта — самое компактное из всех рассмотренных нами кодирований. Подробное разбиение последовательности закодированных байтов показано на рисунке 5-4.
Если вы изучите последовательность байтов, вы увидите, что там нет ничего, что идентифицировало бы поля или их типы данных. Кодировка просто состоит из значений, объединённых вместе. Строка — это просто префикс длины, за которым следуют байты UTF-8, но в закодированных данных нет ничего, что указывало бы, что это строка. Это вполне может быть целое число или что-то ещё. Целое число кодируется с использованием кодирования переменной длины.
Рисунок 5-4. Пример записи, закодированной с использованием Avro
Чтобы разобрать бинарные данные, вы проходите по полям в том порядке, в котором они указаны в схеме, и используете схему, чтобы определить тип данных каждого поля. Это означает, что бинарные данные могут быть корректно декодированы только в том случае, если код, читающий данные, использует ту же самую схему, что и код, записывающий данные. Любое несоответствие в схеме между читателем и писателем приведёт к некорректно декодированным данным.
Так каким же образом Avro поддерживает эволюцию схем?
Схема писателя и схема читателя
Когда приложение хочет закодировать какие-то данные (записать их в файл или базу данных, отправить по сети и т. д.), оно кодирует данные с использованием той версии схемы, о которой ему известно — например, эта схема может быть встроена в приложение. Это называется схемой писателя.
Когда приложение хочет декодировать какие-то данные (прочитать их из файла или базы данных, получить их из сети и т. д.), оно использует две схемы: схему писателя, которая идентична использованной для кодирования, и схему читателя, которая может отличаться. Это показано на рисунке 5-5. Схема читателя определяет поля каждой записи, которые ожидает код приложения, и их типы.
Рисунок 5-5. В Protocol Buffers кодирование и декодирование могут использовать разные версии схемы. В Avro для декодирования используются две схемы: схема писателя должна быть идентична использованной при кодировании, но схема читателя может быть более старой или новой версией.
Если схема читателя и схема писателя совпадают, декодирование простое. Если они различаются, Avro устраняет расхождения, сравнивая схему писателя и схему читателя бок о бок и преобразуя данные из схемы писателя в схему читателя. Спецификация Avro точно определяет, как работает это согласование, и это показано на рисунке 5-6.
Например, не проблема, если схема писателя и схема читателя имеют поля в разном порядке, потому что при согласовании полей они сопоставляются по имени. Если код, читающий данные, встречает поле, которое присутствует в схеме писателя, но отсутствует в схеме читателя, оно игнорируется. Если код, читающий данные, ожидает какое-то поле, но схема писателя не содержит поле с таким именем, оно заполняется значением по умолчанию, объявленным в схеме читателя.
Рисунок 5-6. Читатель Avro устраняет различия между схемой писателя и схемой читателя
Правила эволюции схем
В Avro прямая совместимость (forward compatibility) означает, что в качестве писателя вы можете использовать новую версию схемы, а в качестве читателя — старую. Обратная совместимость (backward compatibility), напротив, означает, что в качестве читателя вы можете использовать новую версию схемы, а в качестве писателя — старую.
Чтобы сохранить совместимость, вы можете добавлять или удалять только те поля, которые имеют значение по умолчанию. (Поле favoriteNumber в нашей Avro-схеме имеет значение по умолчанию null.) Например, допустим, вы добавляете поле со значением по умолчанию, так что это новое поле существует в новой схеме, но отсутствует в старой. Когда читатель, использующий новую схему, читает запись, созданную со старой схемой, для отсутствующего поля подставляется значение по умолчанию.
Если вы добавите поле без значения по умолчанию, новые читатели не смогут прочитать данные, созданные старыми писателями, и это нарушит обратную совместимость. Если вы удалите поле без значения по умолчанию, старые читатели не смогут прочитать данные, созданные новыми писателями, и это нарушит прямую совместимость.
В некоторых языках программирования null допустим в качестве значения по умолчанию для любой переменной, но в Avro это не так: если вы хотите, чтобы поле могло быть равно null, вы должны использовать объединённый тип (union type). Например,
|
1 |
union { null, long, string } field; |
указывает, что поле может быть числом, строкой или null. Вы можете использовать null в качестве значения по умолчанию только в том случае, если оно является первой ветвью объединения. Это немного более многословно, чем если бы все поля были допускающими null по умолчанию, но такая явность помогает предотвращать ошибки, точно определяя, что может, а что не может быть null.
Изменение типа данных поля возможно, при условии что Avro может преобразовать этот тип. Изменение имени поля также возможно, но немного сложнее: схема читателя может содержать псевдонимы (aliases) для имён полей, чтобы сопоставлять имена полей старой схемы писателя с этими псевдонимами. Это означает, что изменение имени поля совместимо с прошлыми версиями (backward compatible), но не с будущими (not forward compatible). Аналогично, добавление новой ветви в объединённый тип совместимо с прошлыми версиями, но не с будущими.
Но что такое схема писателя?
Здесь есть важный вопрос, который мы до сих пор обходили стороной: как читатель узнаёт схему писателя, с которой были закодированы конкретные данные? Мы не можем просто включать полную схему в каждую запись, потому что схема, скорее всего, будет намного больше самих закодированных данных, что сведёт на нет все преимущества экономии места при бинарном кодировании.
Ответ зависит от контекста, в котором используется Avro. Вот несколько примеров:
- Большой файл с множеством записей
Распространённый вариант использования Avro — хранение большого файла, содержащего миллионы записей, все закодированные с одной и той же схемой. (Мы обсудим такую ситуацию далее.) В этом случае писатель файла может просто включить схему писателя один раз в начале файла. Avro определяет файловый формат (object container files) для этого. - База данных с индивидуально записанными записями
В базе данных разные записи могут быть записаны в разное время с использованием разных схем писателей — нельзя предполагать, что у всех записей будет одинаковая схема. Самое простое решение — включать номер версии в начало каждой закодированной записи и хранить список версий схем в базе данных. Читатель может получить запись, извлечь номер версии, а затем получить схему писателя для этой версии из базы данных. Используя эту схему писателя, он может декодировать остальную часть записи. Например, реестр схем Confluent для Apache Kafka и Espresso от LinkedIn работают именно так. - Передача записей по сетевому соединению
Когда два процесса обмениваются данными по двунаправленному сетевому соединению, они могут согласовать версию схемы при установке соединения, а затем использовать эту схему в течение всего времени соединения. Протокол RPC Avro (см. «Поток данных через сервисы: REST и RPC») работает именно так.
База данных версий схем полезна в любом случае, так как она служит документацией и даёт возможность проверить совместимость схем. В качестве номера версии можно использовать простой увеличивающийся целочисленный идентификатор или хэш от самой схемы.
Динамически генерируемые схемы
Одним из преимуществ подхода Avro по сравнению с Protocol Buffers является то, что схема не содержит никаких тегов-полей (tag numbers). Но почему это важно? В чём проблема в том, чтобы хранить в схеме несколько чисел?
Разница в том, что Avro лучше подходит для динамически генерируемых схем. Например, представим, что у вас есть реляционная база данных, содержимое которой вы хотите выгрузить в файл, и вы хотите использовать бинарный формат, чтобы избежать упомянутых ранее проблем с текстовыми форматами (JSON, CSV, XML). Если вы используете Avro, то довольно легко можете сгенерировать Avro-схему (в JSON-представлении, которое мы видели ранее) из реляционной схемы и закодировать содержимое базы данных с использованием этой схемы, выгрузив всё это в объектный контейнерный файл Avro. Вы можете сгенерировать схему записи для каждой таблицы базы данных, и каждый столбец станет полем в этой записи. Имя столбца в базе данных сопоставляется с именем поля в Avro.
Теперь, если схема базы данных изменится (например, в таблицу добавят один столбец и удалят другой), вы можете просто сгенерировать новую Avro-схему из обновлённой схемы базы данных и экспортировать данные в новой Avro-схеме. Процесс экспорта данных не должен обращать внимание на изменения схемы — он может просто выполнять преобразование схемы каждый раз при запуске. Любой, кто будет читать новые файлы данных, увидит, что поля записи изменились, но так как поля идентифицируются по имени, обновлённая схема писателя всё равно сможет быть сопоставлена со старой схемой читателя.
Напротив, если бы вы использовали Protocol Buffers для этой цели, теги полей, скорее всего, пришлось бы назначать вручную: каждый раз, когда схема базы данных меняется, администратору пришлось бы вручную обновлять сопоставление между именами столбцов базы данных и тегами полей. (Теоретически это можно автоматизировать, но генератор схем должен был бы очень осторожно следить за тем, чтобы не назначить уже использовавшиеся ранее теги.) Подобные динамически генерируемые схемы просто не были целью проектирования Protocol Buffers, тогда как для Avro это было одной из задач.
Преимущества схем
Как мы видели, Protocol Buffers и Avro используют схему для описания формата бинарного кодирования. Их языки схем намного проще, чем XML Schema или JSON Schema, которые поддерживают гораздо более детализированные правила валидации (например, «строковое значение этого поля должно соответствовать этому регулярному выражению» или «целочисленное значение этого поля должно находиться в диапазоне от 0 до 100»). Так как Protocol Buffers и Avro проще реализовать и проще использовать, они получили широкую поддержку во множестве языков программирования.
Идеи, на которых основаны эти кодировки, отнюдь не новые. Например, у них много общего с ASN.1 — языком описания схем, впервые стандартизованным в 1984 году. Он использовался для определения различных сетевых протоколов, и его бинарное кодирование (DER) до сих пор используется, например, для кодирования SSL-сертификатов (X.509). ASN.1 поддерживает эволюцию схем с помощью тегов-полей, аналогично Protocol Buffers. Однако он также очень сложный и плохо документированный, поэтому ASN.1 вряд ли является хорошим выбором для новых приложений.
Многие системы данных также реализуют собственные проприетарные бинарные форматы кодирования для своих данных. Например, большинство реляционных баз данных имеют сетевой протокол, по которому вы можете отправлять запросы в базу данных и получать ответы. Эти протоколы, как правило, специфичны для конкретной базы данных, и вендор базы данных предоставляет драйвер (например, через API ODBC или JDBC), который декодирует ответы из сетевого протокола базы в структуры данных в памяти.
Таким образом, мы видим, что хотя текстовые форматы данных, такие как JSON, XML и CSV, широко распространены, бинарные кодировки, основанные на схемах, также являются жизнеспособным вариантом. У них есть ряд полезных свойств:
- Они могут быть гораздо более компактными, чем различные «бинарные JSON»-варианты, поскольку могут опускать имена полей из закодированных данных.
- Схема является ценным видом документации, и так как схема требуется для декодирования, можно быть уверенным, что она актуальна (в то время как вручную поддерживаемая документация легко может разойтись с реальностью).
- Ведение базы данных схем позволяет проверять прямую и обратную совместимость изменений схем до того, как что-либо будет развернуто.
- Для пользователей статически типизированных языков программирования возможность генерировать код из схемы полезна, поскольку она позволяет выполнять проверку типов на этапе компиляции.
В итоге, эволюция схем обеспечивает такую же гибкость, как и базы данных JSON без схемы/схемой-на-чтение (см. «Гибкость схемы в документной модели»), при этом предоставляя лучшие гарантии для ваших данных и лучшее инструментальное обеспечение.
Режимы потоков данных
В начале этой главы мы сказали, что всякий раз, когда вы хотите отправить какие-то данные другому процессу, с которым вы не разделяете память — например, когда вы хотите отправить данные по сети или записать их в файл, — вам нужно закодировать их в виде последовательности байтов. Затем мы обсудили различные способы кодирования для этого.
Мы поговорили о прямой и обратной совместимости, которые важны для эволюционируемости (возможности легко вносить изменения, позволяя обновлять разные части вашей системы независимо и не вынуждая менять всё сразу). Совместимость — это отношение между одним процессом, который кодирует данные, и другим процессом, который их декодирует.
Это довольно абстрактная идея — существует множество способов, которыми данные могут перемещаться от одного процесса к другому. Кто кодирует данные, а кто их декодирует? В оставшейся части этой главы мы рассмотрим некоторые из наиболее распространённых способов, которыми данные передаются между процессами:
- Через базы данных (см. «Потоки данных через базы данных»)
- Через вызовы сервисов (см. «Потоки данных через сервисы: REST и RPC»)
- Через движки рабочих процессов (см. «Долговременное выполнение и рабочие процессы»)
- Через асинхронные сообщения (см. «Архитектуры, управляемые событиями»)
Потоки данных через базы данных
В базе данных процесс, который записывает данные в базу, кодирует их, а процесс, который считывает данные из базы, декодирует их. Может существовать всего один процесс, обращающийся к базе данных, и в этом случае читателем будет просто более поздняя версия того же самого процесса — тогда хранение чего-либо в базе можно рассматривать как отправку сообщения самому себе в будущем.
Обратная совместимость здесь явно необходима; в противном случае вы сами в будущем не сможете декодировать то, что записали раньше.
В общем случае несколько различных процессов часто обращаются к базе данных одновременно. Эти процессы могут быть разными приложениями или сервисами, или же просто несколькими экземплярами одного и того же сервиса (работающими параллельно ради масштабируемости или отказоустойчивости). Так или иначе, в среде, где приложение изменяется, вероятно, что некоторые процессы, обращающиеся к базе данных, будут работать на более новой версии кода, а некоторые — на более старой (например, потому что новая версия развёртывается поэтапно, и часть экземпляров уже обновлена, а часть ещё нет).
Это означает, что значение в базе данных может быть записано более новой версией кода, а затем прочитано более старой версией кода, которая всё ещё работает. Таким образом, прямая совместимость также часто требуется для баз данных.
Разные значения, записанные в разное время
База данных в целом позволяет обновлять любое значение в любое время. Это означает, что в одной и той же базе у вас могут быть некоторые значения, записанные пять миллисекунд назад, и некоторые значения, записанные пять лет назад.
Когда вы разворачиваете новую версию своего приложения (по крайней мере серверного приложения), вы можете полностью заменить старую версию на новую за несколько минут. Для содержимого базы данных это не так: пятилетние данные всё ещё будут там, в своём исходном кодировании, если вы их явно не переписали с тех пор. Это наблюдение иногда суммируют выражением «данные переживают код».
Переписать (мигрировать) данные в новую схему, безусловно, возможно, но это дорогостоящая операция на больших объёмах данных, поэтому большинство баз данных избегают её, если это возможно. Большинство реляционных баз данных позволяют выполнять простые изменения схемы, например добавлять новый столбец со значением null по умолчанию, без переписывания существующих данных. Когда старая строка считывается, база данных подставляет null для любых столбцов, которых нет в закодированных данных на диске. Таким образом, эволюция схем позволяет всей базе данных выглядеть так, будто она закодирована с помощью одной схемы, даже если в нижележащем хранилище содержатся записи, закодированные с использованием различных исторических версий схемы.
Более сложные изменения схемы — например, изменение однозначного атрибута на многозначный или перенос части данных в отдельную таблицу — всё ещё требуют переписывания данных, часто на уровне приложения. Поддержание прямой и обратной совместимости при таких миграциях остаётся исследовательской проблемой.
Архивное хранилище
Возможно, вы время от времени делаете снимок своей базы данных, скажем, для целей резервного копирования или для загрузки в хранилище данных (см. «Хранилище данных»). В этом случае дамп данных, как правило, будет закодирован с использованием последней схемы, даже если исходное кодирование в исходной базе содержало смесь версий схем разных эпох. Так как вы всё равно копируете данные, имеет смысл закодировать их копию последовательно.
Так как дамп данных записывается за один раз и впоследствии является неизменным, такие форматы, как объектные контейнерные файлы Avro, хорошо подходят. Это также хорошая возможность закодировать данные в аналитически-удобном колонко-ориентированном формате, таком как Parquet (см. «Сжатие колонок»).
Потоки данных через сервисы: REST и RPC
Когда у вас есть процессы, которым нужно обмениваться данными по сети, существует несколько способов организации такого взаимодействия. Наиболее распространённый вариант предполагает две роли: клиенты и серверы. Серверы предоставляют API по сети, а клиенты могут подключаться к серверам, чтобы делать запросы к этому API. API, предоставляемый сервером, называется сервисом.
Веб работает именно так: клиенты (веб-браузеры) делают запросы к веб-серверам, выполняя GET-запросы для загрузки HTML, CSS, JavaScript, изображений и т. д., и POST-запросы для отправки данных на сервер. API состоит из стандартизированного набора протоколов и форматов данных (HTTP, URL, SSL/TLS, HTML и т. д.). Поскольку веб-браузеры, веб-серверы и авторы сайтов в основном соглашаются с этими стандартами, вы можете использовать любой веб-браузер для доступа к любому сайту (по крайней мере, в теории!).
Веб-браузеры — не единственный тип клиентов. Например, нативные приложения, работающие на мобильных устройствах и настольных компьютерах, часто взаимодействуют с серверами, а клиентские JavaScript-приложения, работающие внутри веб-браузеров, также могут делать HTTP-запросы. В этом случае ответ сервера обычно не является HTML для отображения человеку, а представляет собой данные в кодировке, удобной для дальнейшей обработки клиентским приложением (чаще всего JSON). Хотя HTTP может использоваться как транспортный протокол, API, реализуемый поверх него, является специфичным для приложения, и клиент с сервером должны договориться о деталях этого API.
В некотором смысле сервисы похожи на базы данных: они обычно позволяют клиентам отправлять и запрашивать данные. Однако, в то время как базы данных позволяют выполнять произвольные запросы с использованием языков запросов, которые мы обсуждали в Главе 3, сервисы предоставляют специфичный для приложения API, который позволяет только те входы и выходы, которые заранее определены бизнес-логикой (кодом приложения) сервиса. Это ограничение обеспечивает определённую степень инкапсуляции: сервисы могут накладывать детальные ограничения на то, что клиенты могут и не могут делать.
Ключевая цель проектирования сервис-ориентированной/микросервисной архитектуры — упростить изменение и сопровождение приложения, сделав сервисы независимо развёртываемыми и эволюционируемыми. Общий принцип заключается в том, что каждый сервис должен находиться в ведении одной команды, и эта команда должна иметь возможность часто выпускать новые версии сервиса, не координируясь с другими командами. Следовательно, мы должны ожидать, что старые и новые версии серверов и клиентов будут работать одновременно, и поэтому кодировка данных, используемая серверами и клиентами, должна быть совместима между версиями API сервиса.
Веб-сервисы
Когда HTTP используется как базовый протокол для взаимодействия с сервисом, это называется веб-сервисом. Веб-сервисы обычно применяются при построении сервис-ориентированной или микросервисной архитектуры (обсуждалось ранее в «Микросервисы и serverless»). Термин «веб-сервис» — возможно, немного неточен, потому что веб-сервисы используются не только в вебе, но и в ряде других контекстов. Например:
- клиентское приложение, работающее на устройстве пользователя (например, нативное приложение на мобильном устройстве или веб-приложение на JavaScript в браузере), отправляющее запросы к сервису по HTTP. Эти запросы обычно идут через публичный интернет;
- один сервис, отправляющий запросы другому сервису, принадлежащему той же организации, часто находящемуся в том же дата-центре, как часть сервис-ориентированной/микросервисной архитектуры;
- один сервис, отправляющий запросы сервису, принадлежащему другой организации, обычно через интернет. Это используется для обмена данными между бэкенд-системами разных организаций. В эту категорию входят публичные API, предоставляемые онлайн-сервисами, такими как системы обработки кредитных карт или OAuth для совместного доступа к пользовательским данным.
Наиболее популярная философия проектирования сервисов — REST, которая строится на принципах HTTP. Она делает акцент на простых форматах данных, использовании URL для идентификации ресурсов и использовании функций HTTP для управления кэшированием, аутентификацией и согласованием типа содержимого. API, спроектированный в соответствии с принципами REST, называется RESTful.
Код, которому нужно вызвать API веб-сервиса, должен знать, к какому HTTP-эндпоинту обращаться и какие форматы данных отправлять и ожидать в ответ. Даже если сервис использует принципы RESTful-дизайна, клиентам всё равно нужно каким-то образом узнавать эти детали. Разработчики сервисов часто используют язык описания интерфейсов (IDL), чтобы определить и задокументировать эндпоинты API своего сервиса и модели данных, а также эволюционировать их со временем. Другие разработчики могут затем использовать описание сервиса, чтобы понять, как делать к нему запросы. Два наиболее популярных IDL для сервисов — это OpenAPI (также известный как Swagger) и gRPC. OpenAPI используется для веб-сервисов, которые отправляют и принимают JSON-данные, в то время как gRPC-сервисы отправляют и принимают Protocol Buffers.
Разработчики обычно пишут описания сервисов OpenAPI в формате JSON или YAML; см. Пример 5-3. Определение сервиса позволяет разработчикам задавать эндпоинты сервиса, документацию, версии, модели данных и многое другое. Определения gRPC выглядят похоже, но задаются с использованием описаний сервисов на Protocol Buffers.
Пример 5-3. Пример описания сервиса OpenAPI в YAML
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
openapi: 3.0.0 info: title: Ping, Pong version: 1.0.0 servers: - url: http://localhost:8080 paths: /ping: get: summary: Given a ping, returns a pong message responses: '200': description: A pong content: application/json: schema: type: object properties: message: type: string example: Pong! |
Даже если философия проектирования и IDL приняты, разработчикам всё равно нужно написать код, реализующий вызовы API их сервиса. Чтобы упростить эту задачу, часто используется фреймворк сервисов. Фреймворки сервисов, такие как Spring Boot, FastAPI и gRPC, позволяют разработчикам писать бизнес-логику для каждого API-эндпоинта, в то время как код фреймворка обрабатывает маршрутизацию, метрики, кэширование, аутентификацию и так далее. Пример 5-4 показывает пример реализации на Python сервиса, определённого в Примере 5-3.
Пример 5-4. Пример сервиса FastAPI, реализующего определение из Примера 5-3
|
1 2 3 4 5 6 7 8 9 10 11 12 |
from fastapi import FastAPI from pydantic import BaseModel app = FastAPI(title="Ping, Pong", version="1.0.0") class PongResponse(BaseModel): message: str = "Pong!" @app.get("/ping", response_model=PongResponse, summary="Given a ping, returns a pong message") async def ping(): return PongResponse() |
Многие фреймворки связывают определения сервисов и серверный код. В некоторых случаях, например с популярным Python-фреймворком FastAPI, серверы пишутся в коде, а IDL генерируется автоматически. В других случаях, например с gRPC, сначала пишется определение сервиса, а затем генерируется каркас серверного кода. Оба подхода позволяют разработчикам генерировать клиентские библиотеки и SDK на различных языках из определения сервиса. Помимо генерации кода, инструменты IDL, такие как Swagger, могут генерировать документацию, проверять совместимость изменений схемы и предоставлять графический интерфейс для разработчиков для выполнения запросов и тестирования сервисов.
Проблемы удалённых вызовов процедур (RPC)
Веб-сервисы — это лишь последняя инкарнация длинной линии технологий для выполнения API-запросов по сети, многие из которых получили много шума, но имеют серьёзные проблемы. Enterprise JavaBeans (EJB) и удалённые вызовы методов Java (RMI) ограничены только Java. Distributed Component Object Model (DCOM) ограничен платформами Microsoft. Common Object Request Broker Architecture (CORBA) чрезмерно сложен и не обеспечивает обратной или прямой совместимости. SOAP и фреймворк веб-сервисов WS-* нацелены на обеспечение взаимодействия между поставщиками, но также страдают от сложности и проблем совместимости.
Все они основаны на идее удалённого вызова процедуры (RPC), существующей с 1970-х годов. Модель RPC пытается сделать так, чтобы запрос к удалённому сетевому сервису выглядел так же, как вызов функции или метода в вашем языке программирования, внутри одного процесса (эта абстракция называется прозрачность расположения). Хотя RPC поначалу кажется удобным, подход фундаментально ошибочен. Сетевой запрос сильно отличается от локального вызова функции:
Локальный вызов функции предсказуем и либо выполняется успешно, либо завершается сбоем, в зависимости только от параметров, которые находятся под вашим контролем. Сетевой запрос непредсказуем: запрос или ответ может быть потерян из-за сетевой проблемы, удалённая машина может быть медленной или недоступной, и такие проблемы полностью вне вашего контроля. Сетевые проблемы распространены, поэтому нужно предвидеть их, например, повторяя неудачный запрос.
Локальный вызов функции либо возвращает результат, либо генерирует исключение, либо не возвращает вовсе (например, зацикливается или процесс падает). У сетевого запроса есть ещё один возможный исход: он может завершиться без результата из-за таймаута. В этом случае вы просто не знаете, что произошло: если вы не получили ответ от удалённого сервиса, у вас нет способа понять, дошёл ли запрос или нет.
Если вы повторите неудачный сетевой запрос, может оказаться, что предыдущий запрос всё-таки дошёл, но был потерян только ответ. В этом случае повтор приведёт к выполнению действия несколько раз, если только вы не встроите в протокол механизм дедупликации (идемпотентность) . У локальных вызовов функций такой проблемы нет.
Каждый раз при вызове локальной функции выполнение обычно занимает примерно одно и то же время. Сетевой запрос намного медленнее вызова функции, и его задержка также крайне вариативна: в хорошие моменты он может выполняться менее чем за миллисекунду, но при перегруженной сети или перегруженном удалённом сервисе выполнение того же самого может занять много секунд.
При вызове локальной функции вы можете эффективно передавать ей ссылки (указатели) на объекты в локальной памяти. При выполнении сетевого запроса все эти параметры нужно закодировать в последовательность байтов, которую можно передать по сети. Это допустимо, если параметры — неизменяемые примитивы вроде чисел или коротких строк, но это быстро становится проблематичным при больших объёмах данных и изменяемых объектах.
Клиент и сервис могут быть реализованы на разных языках программирования, поэтому фреймворк RPC должен преобразовывать типы данных из одного языка в другой. Это может выглядеть некрасиво, так как не все языки имеют одинаковые типы — вспомните, например, проблемы JavaScript с числами больше 2⁵³ (см. «JSON, XML и бинарные варианты»). Внутри одного процесса, написанного на одном языке, такой проблемы не существует.
Все эти факторы означают, что нет смысла пытаться сделать так, чтобы удалённый сервис выглядел слишком похожим на локальный объект в вашем языке программирования, потому что это принципиально разные вещи. Часть привлекательности REST заключается в том, что он рассматривает передачу состояния по сети как процесс, отличный от вызова функции.
Балансировщики нагрузки, обнаружение сервисов и сервисные mesh
Все сервисы взаимодействуют по сети. По этой причине клиент должен знать адрес сервиса, к которому он подключается — эта задача известна как обнаружение сервисов. Самый простой подход — настроить клиент на подключение к IP-адресу и порту, на которых работает сервис. Такая конфигурация будет работать, но если сервер отключится, будет перенесён на новую машину или окажется перегружен, клиент придётся перенастраивать вручную.
Чтобы обеспечить более высокую доступность и масштабируемость, обычно запускается несколько экземпляров сервиса на разных машинах, и любой из них может обработать входящий запрос. Распределение запросов между этими экземплярами называется балансировкой нагрузки. Существует множество решений для балансировки нагрузки и обнаружения сервисов:
Аппаратные балансировщики нагрузки — это специализированное оборудование, устанавливаемое в дата-центрах. Они позволяют клиентам подключаться к одному хосту и порту, а входящие соединения перенаправляются на один из серверов, на которых запущен сервис. Такие балансировщики выявляют сетевые сбои при подключении к downstream-серверу и переключают трафик на другие серверы.
Программные балансировщики нагрузки работают почти так же, как аппаратные, но не требуют специализированного устройства. Программные балансировщики, такие как Nginx и HAProxy, представляют собой приложения, которые можно установить на стандартный сервер.
Служба доменных имён (DNS) используется для разрешения доменных имён в Интернете, когда вы открываете веб-страницу. Она поддерживает балансировку нагрузки, позволяя привязать несколько IP-адресов к одному доменному имени. Клиенты могут быть настроены на подключение к сервису по доменному имени вместо IP-адреса, а сетевая подсистема клиента выбирает, какой IP-адрес использовать при подключении. Недостаток этого подхода в том, что DNS изначально спроектирован для распространения изменений с течением времени и кэширования записей. Если серверы часто запускаются, останавливаются или перемещаются, клиенты могут видеть устаревшие IP-адреса, на которых сервис больше не работает.
Системы обнаружения сервисов используют централизованный реестр вместо DNS для отслеживания доступных конечных точек сервиса. Когда запускается новый экземпляр сервиса, он регистрируется в системе обнаружения, указывая хост и порт, на которых слушает, а также соответствующие метаданные, такие как информация о шардировании, расположение дата-центра и другое. Затем сервис периодически отправляет heartbeat-сигнал системе обнаружения, подтверждая, что он всё ещё доступен.
Когда клиент хочет подключиться к сервису, он сначала запрашивает список доступных конечных точек у системы обнаружения, а затем подключается напрямую. По сравнению с DNS, системы обнаружения лучше подходят для динамичной среды, где экземпляры сервисов часто меняются. Кроме того, они дают клиентам больше метаданных о сервисе, что позволяет им принимать более разумные решения по балансировке нагрузки.
Сервисные mesh — это сложная форма балансировки нагрузки, сочетающая программные балансировщики и системы обнаружения. В отличие от традиционных программных балансировщиков, работающих на отдельной машине, балансировщики в сервисной mesh обычно разворачиваются как встроенная клиентская библиотека или как процесс/«sidecar»-контейнер как на стороне клиента, так и на стороне сервера. Клиентские приложения подключаются к своему локальному балансировщику сервиса, который соединяется с балансировщиком на стороне сервера. Оттуда соединение маршрутизируется в локальный серверный процесс.
Хотя такая топология сложна, она имеет ряд преимуществ. Так как клиенты и серверы подключаются только через локальные соединения, шифрование соединений может полностью обрабатываться на уровне балансировщика. Это избавляет клиентов и серверы от необходимости разбираться в сложностях SSL-сертификатов и TLS. Mesh-системы также обеспечивают развитую наблюдаемость: они могут отслеживать, какие сервисы вызывают друг друга в реальном времени, выявлять сбои, фиксировать нагрузку трафика и многое другое.
Выбор подходящего решения зависит от потребностей организации. В очень динамичных средах с оркестратором, таким как Kubernetes, часто используют сервисные mesh, например Istio или Linkerd. Специализированная инфраструктура, такая как базы данных или системы обмена сообщениями, может требовать собственных специализированных балансировщиков. Более простые развёртывания лучше всего работают с программными балансировщиками нагрузки.
Кодирование данных и эволюция для RPC
Для эволюционности важно, чтобы RPC-клиенты и серверы могли изменяться и развёртываться независимо друг от друга. В отличие от потока данных через базы данных (описанных в предыдущем разделе), в случае потока данных через сервисы можно упростить задачу: разумно предположить, что сначала будут обновлены все серверы, а затем все клиенты. Таким образом, нужна только обратная совместимость для запросов и прямая совместимость для ответов.
Свойства обратной и прямой совместимости схемы RPC наследуются от используемого формата кодирования:
- gRPC (Protocol Buffers) и Avro RPC могут эволюционировать в соответствии с правилами совместимости их форматов кодирования.
- RESTful API чаще всего используют JSON для ответов и JSON или URI-encoded/form-encoded параметры запроса для запросов. Добавление необязательных параметров запроса и новых полей в объекты ответов обычно считается изменениями, сохраняющими совместимость.
Совместимость сервисов усложняется тем, что RPC часто используется для взаимодействия между организациями, и поставщик сервиса зачастую не имеет контроля над своими клиентами и не может заставить их обновиться. Поэтому совместимость должна сохраняться долго, возможно, бесконечно. Если требуется несовместимое изменение, поставщик сервиса часто вынужден поддерживать несколько версий API сервиса одновременно.
Нет единого соглашения о том, как должно работать версионирование API (то есть как клиент может указать, какую версию API он хочет использовать). Для RESTful API распространённые подходы — использовать номер версии в URL или в HTTP-заголовке Accept. Для сервисов, которые используют API-ключи для идентификации конкретного клиента, есть ещё один вариант: хранить запрашиваемую клиентом версию API на сервере и позволять обновлять этот выбор версии через отдельный административный интерфейс.
Долговременное выполнение и рабочие процессы
По определению, архитектуры на основе сервисов включают несколько сервисов, каждый из которых отвечает за разные части приложения. Рассмотрим приложение для обработки платежей, которое списывает деньги с кредитной карты и зачисляет средства на банковский счёт. Такая система, вероятно, будет иметь отдельные сервисы, отвечающие за обнаружение мошенничества, интеграцию с кредитными картами, интеграцию с банками и так далее.
Обработка одного платежа в нашем примере требует множества вызовов сервисов. Сервис процессинга платежей может вызвать сервис обнаружения мошенничества для проверки, затем вызвать сервис кредитных карт для списания средств, а затем вызвать банковский сервис для зачисления средств, как показано на Рисунке 5-7. Мы называем эту последовательность шагов рабочим процессом (workflow), а каждый шаг — задачей (task). Рабочие процессы обычно определяются как граф задач. Определения рабочих процессов могут быть написаны на языке общего назначения, на предметно-ориентированном языке (DSL) или на языке разметки, таком как Business Process Execution Language (BPEL).
ЗАДАЧИ, АКТИВНОСТИ И ФУНКЦИИ
Разные движки рабочих процессов используют разные названия для задач. Temporal, например, использует термин activity (активность). Другие называют задачи durable functions (долговременные функции). Хотя названия различаются, концепции остаются одинаковыми.
Рисунок 5-7. Пример рабочего процесса, выраженного с использованием Business Process Model and Notation (BPMN) — графической нотации
Рабочие процессы запускаются или выполняются движком рабочих процессов. Движки рабочих процессов определяют, когда запускать каждую задачу, на какой машине задача должна выполняться, что делать, если задача завершилась сбоем (например, если машина вышла из строя во время выполнения задачи), сколько задач допускается выполнять параллельно и многое другое.
Обычно движки рабочих процессов состоят из оркестратора и исполнителя. Оркестратор отвечает за планирование задач для выполнения, а исполнитель отвечает за выполнение задач. Выполнение начинается, когда рабочий процесс запускается. Оркестратор инициирует сам рабочий процесс, если пользователи определили расписание, основанное на времени, например выполнение каждый час. Также запуск выполнения рабочего процесса могут инициировать внешние источники, такие как веб-сервис или даже человек. После запуска вызываются исполнители для выполнения задач.
Существует множество видов движков рабочих процессов, которые решают разные задачи. Некоторые, такие как Airflow, Dagster и Prefect, интегрируются с системами данных и оркестрируют ETL-задачи. Другие, такие как Camunda и Orkes, предоставляют графическую нотацию для рабочих процессов (например, BPMN, используемую на рисунке 5-7), чтобы не-инженеры могли проще определять и выполнять рабочие процессы. Третьи, такие как Temporal и Restate, обеспечивают долговременное выполнение.
Долговременное выполнение
Фреймворки долговременного выполнения стали популярным способом построения архитектур на основе сервисов, которым требуется транзакционность. В нашем примере с платежами мы хотим обработать каждый платёж ровно один раз. Сбой во время выполнения рабочего процесса может привести к списанию с кредитной карты без соответствующего зачисления средств на банковский счёт. В архитектуре на основе сервисов мы не можем просто обернуть эти две задачи в транзакцию базы данных. Более того, мы можем взаимодействовать с внешними платёжными шлюзами, над которыми у нас ограниченный контроль.
Фреймворки долговременного выполнения — это способ обеспечить семантику «ровно один раз» для рабочих процессов. Если задача завершается сбоем, фреймворк перезапустит задачу, но пропустит любые RPC-вызовы или изменения состояния, которые задача успешно выполнила до сбоя. Вместо этого фреймворк «притворится», что совершает вызов, но вернёт результаты из предыдущего вызова. Это возможно потому, что фреймворки долговременного выполнения записывают все RPC и изменения состояния в надёжное хранилище, например в журнал предварительной записи (write-ahead log).
Пример 5-5. Фрагмент определения рабочего процесса в Temporal для платёжного процесса, показанного на рисунке 5-7.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
@workflow.defn class PaymentWorkflow: @workflow.run async def run(self, payment: PaymentRequest) -> PaymentResult: is_fraud = await workflow.execute_activity( check_fraud, payment, start_to_close_timeout=timedelta(seconds=15), ) if is_fraud: return PaymentResultFraudulent credit_card_response = await workflow.execute_activity( debit_credit_card, payment, start_to_close_timeout=timedelta(seconds=15), ) # ... |
Фреймворки, такие как Temporal, не лишены своих сложностей. Внешние сервисы, такие как сторонний платёжный шлюз в нашем примере, всё равно должны предоставлять идемпотентный API. Разработчики должны помнить о необходимости использовать уникальные идентификаторы для этих API, чтобы предотвратить повторное выполнение. И поскольку фреймворки долговременного выполнения логируют каждый RPC-вызов по порядку, они ожидают, что последующее выполнение будет совершать те же RPC-вызовы в том же порядке. Это делает изменения в коде хрупкими. Вы можете внести неопределённое поведение просто изменив порядок вызовов функций.
Аналогично, поскольку фреймворки долговременного выполнения ожидают воспроизведения всего кода детерминированным образом (одни и те же входные данные дают одни и те же выходные), недетерминированный код, такой как генераторы случайных чисел или системные часы, является проблемой. Фреймворки часто предоставляют собственные, детерминированные реализации таких библиотечных функций, но нужно помнить, чтобы именно их использовать. В некоторых случаях, таких как инструмент workflowcheck в Temporal, фреймворки предоставляют статические анализаторы, чтобы определить, введено ли недетерминированное поведение.
ПРИМЕЧАНИЕ
Сделать код детерминированным — мощная идея, но трудно реализуемая надёжным образом.
Архитектуры, управляемые событиями
В этом заключительном разделе мы кратко рассмотрим архитектуры, управляемые событиями, которые представляют собой ещё один способ передачи закодированных данных от одного процесса к другому. Запрос называется событием или сообщением; в отличие от RPC, отправитель обычно не ждёт, пока получатель обработает событие. Более того, события, как правило, не отправляются получателю через прямое сетевое соединение, а проходят через посредника, называемого брокером сообщений (также event broker, message queue или message-oriented middleware), который временно сохраняет сообщение.
Использование брокера сообщений имеет несколько преимуществ по сравнению с прямым RPC:
- Он может выступать в роли буфера, если получатель недоступен или перегружен, тем самым повышая надёжность системы.
- Он может автоматически повторно доставлять сообщения процессу, который вышел из строя, и тем самым предотвращает их потерю.
- Он устраняет необходимость в обнаружении сервисов, так как отправителям не нужно напрямую подключаться к IP-адресу получателя.
- Он позволяет отправить одно и то же сообщение нескольким получателям.
- Он логически отделяет отправителя от получателя (отправитель просто публикует сообщения и не заботится о том, кто их потребляет).
Общение через брокера сообщений является асинхронным: отправитель не ждёт доставки сообщения, а просто отправляет его и забывает. Однако можно реализовать синхронную модель, похожую на RPC, если заставить отправителя ждать ответа на отдельном канале.
Брокеры сообщений
В прошлом рынок брокеров сообщений был под контролем коммерческого корпоративного ПО от компаний, таких как TIBCO, IBM WebSphere и webMethods, прежде чем популярность приобрели открытые реализации, такие как RabbitMQ, ActiveMQ, HornetQ, NATS и Apache Kafka. Совсем недавно получили распространение облачные сервисы, такие как Amazon Kinesis, Azure Service Bus и Google Cloud Pub/Sub.
Подробная семантика доставки зависит от реализации и конфигурации, но в целом чаще всего используются два шаблона распределения сообщений:
Один процесс добавляет сообщение в именованную очередь, и брокер доставляет это сообщение потребителю этой очереди. Если потребителей несколько, сообщение получает один из них.
Один процесс публикует сообщение в именованную тему, и брокер доставляет это сообщение всем подписчикам этой темы. Если подписчиков несколько, сообщение получает каждый.
Брокеры сообщений, как правило, не навязывают какой-либо конкретной модели данных — сообщение представляет собой просто последовательность байтов с некоторыми метаданными, поэтому можно использовать любой формат кодирования. Общий подход — использовать Protocol Buffers, Avro или JSON, а вместе с брокером сообщений развёртывать реестр схем, чтобы хранить все допустимые версии схем и проверять их совместимость. Также можно использовать AsyncAPI, эквивалент OpenAPI для обмена сообщениями, чтобы задавать схему сообщений.
Брокеры сообщений различаются по степени долговечности хранения сообщений. Многие записывают сообщения на диск, чтобы они не были потеряны в случае сбоя брокера или необходимости его перезапуска. В отличие от баз данных, многие брокеры сообщений автоматически удаляют сообщения после того, как они были потреблены. Некоторые брокеры можно настроить на хранение сообщений бессрочно — это требуется, если вы хотите использовать event sourcing (см. “Event Sourcing and CQRS”).
Если потребитель публикует сообщения повторно в другую тему, возможно, потребуется позаботиться о сохранении неизвестных полей, чтобы предотвратить проблему, описанную ранее в контексте баз данных (рисунок 5-1).
Распределённые акторные фреймворки
Модель акторов — это модель программирования для организации параллелизма в рамках одного процесса. Вместо того чтобы работать напрямую с потоками (и связанными с ними проблемами гонок, блокировок и взаимоблокировок), логика инкапсулируется в акторах. Каждый актор обычно представляет одного клиента или сущность, может иметь некоторое локальное состояние (которое не разделяется ни с какими другими акторами) и обменивается сообщениями с другими акторами посредством отправки и получения асинхронных сообщений. Доставка сообщений не гарантируется: в определённых сценариях ошибок сообщения будут потеряны. Так как каждый актор обрабатывает только одно сообщение за раз, ему не нужно беспокоиться о потоках, и каждый актор может планироваться независимо фреймворком.
В распределённых акторных фреймворках, таких как Akka, Orleans и Erlang/OTP, эта модель программирования используется для масштабирования приложения на несколько узлов. Механизм передачи сообщений используется тот же, независимо от того, находятся ли отправитель и получатель на одном узле или на разных. Если они находятся на разных узлах, сообщение прозрачно кодируется в последовательность байтов, отправляется по сети и декодируется на другой стороне.
Прозрачность расположения работает лучше в акторной модели, чем в RPC, потому что акторная модель уже предполагает, что сообщения могут быть потеряны даже в пределах одного процесса. Хотя задержка по сети, вероятно, выше, чем в пределах одного процесса, при использовании акторной модели существует меньше фундаментального несоответствия между локальной и удалённой коммуникацией.
Распределённый акторный фреймворк по сути объединяет брокер сообщений и модель акторов в единый фреймворк. Однако если вы хотите выполнять пошаговые обновления вашего акторного приложения, вам всё равно нужно учитывать прямую и обратную совместимость, поскольку сообщения могут отправляться с узла, работающего на новой версии, на узел со старой версией, и наоборот. Это можно реализовать с помощью одного из форматов кодирования, рассмотренных в этой главе.
Резюме по главе 5
В этой главе мы рассмотрели несколько способов преобразования структур данных в байты в сети или байты на диске. Мы увидели, что детали этих кодировок влияют не только на их эффективность, но, что более важно, на архитектуру приложений и ваши возможности по их развитию.
В частности, многим сервисам необходимо поддерживать пошаговые обновления, при которых новая версия сервиса развёртывается постепенно на нескольких узлах, а не сразу на всех. Пошаговые обновления позволяют выпускать новые версии сервиса без простоев (тем самым поощряя частые небольшие релизы вместо редких крупных) и делают развёртывания менее рискованными (так как ошибочные релизы могут быть обнаружены и откатаны до того, как они затронут большое количество пользователей). Эти свойства чрезвычайно полезны для эволюционности — простоты внесения изменений в приложение.
Во время пошаговых обновлений или по разным другим причинам мы должны предполагать, что разные узлы запускают разные версии кода нашего приложения. Таким образом, важно, чтобы все данные, циркулирующие в системе, кодировались таким образом, чтобы обеспечивать обратную совместимость (новый код может читать старые данные) и прямую совместимость (старый код может читать новые данные).
Мы обсудили несколько форматов кодирования данных и их свойства совместимости:
- Зависимые от языка программирования кодировки ограничены одним языком и часто не обеспечивают прямой и обратной совместимости.
- Текстовые форматы вроде JSON, XML и CSV широко распространены, и их совместимость зависит от того, как именно вы их используете. У них есть необязательные языки схем, которые иногда помогают, а иногда мешают. Эти форматы несколько расплывчаты в отношении типов данных, поэтому нужно быть осторожным с числами и бинарными строками.
- Бинарные форматы, управляемые схемами, такие как Protocol Buffers и Avro, позволяют выполнять компактное и эффективное кодирование с чётко определёнными правилами прямой и обратной совместимости. Схемы могут быть полезны для документации и генерации кода в статически типизированных языках. Однако у этих форматов есть недостаток: данные нужно декодировать, прежде чем они станут читаемыми человеком.
Мы также обсудили несколько моделей передачи данных, показывающих различные сценарии, в которых кодировки данных имеют значение:
- Базы данных, где процесс записи в базу кодирует данные, а процесс чтения из базы их декодирует.
- RPC и REST API, где клиент кодирует запрос, сервер декодирует запрос и кодирует ответ, а клиент в конце концов декодирует ответ.
- Архитектуры, управляемые событиями (с использованием брокеров сообщений или акторов), где узлы обмениваются сообщениями, закодированными отправителем и декодированными получателем.
Мы можем заключить, что при должном внимании обратная/прямая совместимость и пошаговые обновления вполне достижимы. Пусть эволюция вашего приложения будет быстрой, а развёртывания — частыми.


















Leave a Reply