Проектные решения Rust Proto

Объясняет некоторые проектные выборы, которые делает реализация Rust Proto.

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

На этой странице рассматриваются некоторые более крупные проектные решения, которые принимает реализация Rust Protobuf, и соображения, которые привели к этим решениям.

Разработано для «поддержки» другими реализациями Protobuf, включая C++ Protobuf

Protobuf Rust — это не чистая Rust реализация protobuf, а безопасный Rust API, реализованный поверх существующих реализаций protobuf, или, как мы называем эти реализации: ядра (kernels).

Самым большим фактором, повлиявшим на это решение, была возможность нулевой стоимости добавления Rust в предсуществующий бинарный файл, который уже использует не-Rust Protobuf. Благодаря возможности реализации быть ABI-совместимой с сгенерированным кодом C++ Protobuf, можно делиться сообщениями Protobuf через языковую границу (FFI) как простыми указателями, избегая необходимости сериализовать на одном языке, передавать массив байт через границу и десериализовать на другом языке. Это также уменьшает размер бинарного файла для этих случаев использования, избегая избыточного встраивания информации о схеме в бинарный файл для одних и тех же сообщений для каждого языка.

Google рассматривает Rust как возможность постепенно получить memory safety (безопасность памяти) для ключевых частей предсуществующих brownfield (унаследованных) серверов на C++; стоимость сериализации на языковых границах предотвратила бы внедрение Rust для замены C++ во многих из этих важных и чувствительных к производительности случаев. Если бы мы создали greenfield (новую) Rust реализацию Protobuf, которая не имела бы этой поддержки, это в конечном итоге заблокировало бы внедрение Rust и потребовало бы, чтобы эти важные случаи оставались на C++.

Protobuf Rust в настоящее время поддерживает три ядра:

  • Ядро C++ - сгенерированный код поддерживается C++ Protocol Buffers ( "полная" реализация, обычно используемая для серверов). Это ядро предлагает in-memory interoperability (совместимость в памяти) с кодом на C++, который использует среду выполнения C++. Это значение по умолчанию для серверов внутри Google.
  • Ядро C++ Lite - сгенерированный код поддерживается C++ Lite Protocol Buffers (обычно используется для мобильных устройств). Это ядро предлагает in-memory interoperability с кодом на C++, который использует среду выполнения C++ Lite. Это значение по умолчанию для мобильных приложений внутри Google.
  • Ядро upb - сгенерированный код поддерживается upb, высокопроизводительной и малой по размеру бинарника библиотекой Protobuf, написанной на C. upb предназначена для использования как деталь реализации средами выполнения Protobuf на других языках. Это значение по умолчанию в open source сборках, где мы ожидаем, что статическая линковка с кодом, уже использующим C++ Protobuf, будет более редкой.

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

Нет чистого Rust ядра

Учитывая, что мы разработали API для возможности реализации несколькими базовыми реализациями, естественным вопросом является то, почему единственные поддерживаемые ядра написаны на небезопасных в отношении памяти языках C и C++ на сегодняшний день.

Хотя Rust, будучи memory-safe (безопасным в отношении памяти) языком, может значительно снизить подверженность критическим проблемам безопасности, ни один язык не immune (неуязвим) к проблемам безопасности. Реализации Protobuf, которые мы поддерживаем в качестве ядер, были тщательно изучены и протестированы fuzzing-ом до такой степени, что Google комфортно использует эти реализации для выполнения неизолированного (unsandboxed) разбора ненадежных входных данных в наших собственных серверах и приложениях.

Новый бинарный парсер, написанный на Rust в настоящее время, был бы понят как гораздо более вероятно содержащий критические уязвимости, чем наши предсуществующие C++ Protobuf или upb парсеры, которые были extensively (тщательно) протестированы fuzzing-ом, протестированы и проверены.

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

Мы ожидаем, что Google будет поддерживать чистую Rust реализацию с тем же открытым API в какой-то более поздний срок, но у нас нет concrete roadmap (конкретного плана) для этого на данный момент. Вторая официальная Rust реализация Protobuf, которая имеет «лучший» API за счет избежания ограничений, возникающих из-за поддержки C++ Proto и upb, не планируется, поскольку мы не хотели бы fragment (дробить) собственное использование Protobuf в Google.

Типы-посредники View/Mut

API Rust Proto разработан с непрозрачными "Proxy" типами. Для файла .proto, который определяет message SomeMsg {}, мы генерируем Rust типы SomeMsg, SomeMsgView<'_> и SomeMsgMut<'_>. Простое правило заключается в том, что мы ожидаем, что типы View и Mut будут заменять &SomeMsg и &mut SomeMsg во всех использованиях по умолчанию, при этом все равно получая все проверки заимствований/Send/и т.д. поведение, которое вы ожидаете от этих типов.

Другой взгляд для понимания этих типов

Чтобы лучше понять нюансы этих типов, может быть полезно думать о этих типах следующим образом:

#![allow(unused)]
fn main() {
struct SomeMsg(Box<cpp::SomeMsg>);
struct SomeMsgView<'a>(&'a cpp::SomeMsg);
struct SomeMsgMut<'a>(&'a mut cpp::SomeMsg);
}

Под этим углом зрения вы можете видеть, что:

  • Имея &SomeMsg, можно получить SomeMsgView (аналогично тому, как имея &Box<T>, вы можете получить &T)
  • Имея SomeMsgView, не возможно получить &SomeMsg (аналогично тому, как имея &T, вы не могли бы получить &Box<T>).

Так же, как и в примере с &Box, это означает, что в аргументах функций generally better (как правило, лучше) по умолчанию использовать SomeMsgView<'a>, а не &'a SomeMsg, так как это позволит superset (надмножеству) вызывающих сторон использовать функцию.

Почему

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

Преимущество возможности оптимизации

Protobuf, будучи такой основной и широко распространенной технологией, необычно как склонен к тому, что все возможные наблюдаемые поведения зависят от кого-то, так и относительно небольшие оптимизации имеют необычно большое совокупное влияние в масштабе. Мы обнаружили, что большая непрозрачность типов дает необычно высокую пользу: они позволяют нам быть более deliberate (преднамеренными) в отношении того, какие именно поведения предоставляются, и дают нам больше возможностей для оптимизации реализации.

SomeMsgMut<'_> предоставляет те возможности, которые &mut SomeMsg не предоставил бы: а именно, что мы можем создавать их лениво и с деталью реализации, которая не совпадает с представлением owned message (сообщения, которым владеют). Это также по своей природе позволяет нам контролировать определенные поведения, которые мы не смогли бы иначе ограничить или контролировать: например, любой &mut можно использовать с std::mem::swap(), что является поведением, которое накладывало бы строгие ограничения на то, какие инварианты вы можете поддерживать между родительской и дочерней структурой, если &mut SomeChild предоставляется вызывающим сторонам.

Присуще дизайну ядра

Другая причина использования proxy types — это скорее inherent limitation (присущее ограничение) нашего дизайна ядра; когда у вас есть &T, должен существовать реальный Rust тип T в памяти где-то.

Наш дизайн ядра C++ позволяет вам разобрать сообщение, которое содержит вложенные сообщения, и создать только небольшой Rust stack-allocated (выделенный на стеке) объект, представляющий корневое сообщение, при этом вся остальная память хранится в C++ Heap (куче). Когда вы позже обращаетесь к дочернему сообщению, не будет уже выделенного Rust объекта, который соответствует этому дочернему элементу, и поэтому нет экземпляра Rust для заимствования в этот момент.

Используя proxy types, мы можем по требованию создавать Rust proxy types, которые семантически действуют как заимствования, без какого-либо eagerly allocated (заранее выделенного) Rust памяти для этих экземпляров.

Не-Std типы

Простые типы, которые могут иметь непосредственно соответствующий Std тип

В некоторых случаях API Rust Protobuf может выбрать создание наших собственных типов, где существует соответствующий std тип с тем же именем, при этом текущая реализация может даже просто оборачивать std тип, например, protobuf::UTF8Error.

Использование этих типов вместо std типов дает нам больше гибкости в оптимизации реализации в будущем. Хотя наша текущая реализация использует валидацию UTF-8 из Rust std сегодня, создавая наш собственный тип protobuf::Utf8Error, это позволяет нам изменить реализацию на использование highly optimized (сильно оптимизированной) C++ реализации валидации UTF-8, которую мы используем из C++ Protobuf, которая быстрее, чем валидация UTF-8 в Rust std.

ProtoString

Типы Rust str и std::string::String поддерживают strict invariant (строгий инвариант), что они содержат только valid UTF-8 (валидный UTF-8), но тип C++ std::string не обеспечивает никаких таких гарантий. Поля Protobuf с типом string предназначены для содержания только валидного UTF-8, и C++ Protobuf действительно использует корректный и highly optimized (сильно оптимизированный) валидатор UTF8. Однако, API поверхность C++ Protobuf не настроена на strict enforcement (строгое обеспечение) как runtime invariant (инварианта времени выполнения) того, что его поля string всегда содержат валидный UTF-8, вместо этого, в некоторых случаях он позволяет установку не-UTF8 данных в поле string и валидация произойдет только позже, когда происходит сериализация.

Чтобы позволить интеграцию Rust в предсуществующие codebases (кодовые базы), которые используют C++ Protobuf, при этом позволяя zero-cost boundary crossings (пересечения границ с нулевой стоимостью) без риска undefined behavior (неопределенного поведения) в Rust, мы, к сожалению, должны избегать типов str/String для геттеров полей string. Вместо этого используются типы ProtoStr и ProtoString, которые являются эквивалентными типами, за исключением того, что они могут содержать невалидный UTF-8 в редких ситуациях. Эти типы позволяют коду приложения выбрать, хочет ли он выполнить валидацию по требованию, чтобы рассматривать поля как Result<&str>, или работать с сырыми байтами, чтобы избежать какой-либо валидации во время выполнения. Все пути сеттеров все еще разработаны так, чтобы позволять вам передавать типы &str или String.

Мы aware (осознаем), что vocabulary types (словарные типы), такие как str, очень важны для идиоматичного использования, и намерены следить, является ли это решение правильным по мере развития деталей использования Rust.