Здесь мы вступаем в своего рода нишевую тему, но я хочу сделать ее более доступной, если смогу. На момент написания этого сообщения на моей работе мы уже несколько лет используем GRPC, потому что на момент принятия решения он был простым, быстрым, имел некоторую поддержку Rust и корпоративное спонсорство (раньше G означало Google…). Не то чтобы мы остановились на использовании GRPC, но я продолжаю задаваться вопросом, что еще возможно, поскольку контекст нашей работы требует, чтобы мы работали все быстрее и быстрее.
Заглавная фотография Emre Karataş на Unsplash
GRPC — это бинарный протокол RPC, который чертовски быстро сериализуется и передает данные более чем достаточно быстро в нашей глобальной системе. Мы могли бы использовать что-то более быстрое, но до сих пор у нас была другая рыба для жарки в области оптимизации.
Однако сегодня у меня есть несколько личных проектов, которым нужен протокол передачи данных, и я склоняюсь к RPC. Мне нужна скорость, и я хочу, чтобы он хорошо поддерживался и был прост в интеграции.
Хотя было бы легко снова использовать GRPC, я хочу попробовать другой протокол RPC — Cap’n Proto.
Почему? Потому что автор Cap’n Proto был одним из первоначальных авторов ProtoBuf 2 (Protocol Buffers 2), который является форматом сериализации с открытым исходным кодом, используемым в GRPC. Также Cap’n Proto утверждает, что после создания сообщения сериализация/десериализация вообще отсутствует, что означает, что он должен быть очень быстрым для передачи данных в распределенной системе. Это включает в себя и сохранение той же структуры данных, которая не нуждается в сериализации. Очевидно, что все это решается в определении протокола, вплоть до учета эндиана для сохраняемых данных.
Сайт Cap’n Proto забавен и полон информации о том, зачем он был создан — но я не могу сразу найти способ запустить пример или понять, как преобразовать типы моего приложения в типы Cap’n Proto. Я думаю, что все, что мне нужно, должно быть там, поскольку я вижу раздел «Кодирование», который должен объяснить это.
Единственное препятствие, с которым я столкнулся, заключается в том, что, хотя документация обширна, местами она немного запутана и в основном посвящена C++ и системе C++ RPC, которая немного отличается от кода Rust. В официальном репозитории есть примеры Rust, которые я попытаюсь использовать здесь.
Установка Cap’n Proto
На их сайте есть примечание, что Homebrew может быть использован для установки на Mac. Но на момент написания статьи я не мог понять, что нужно установить.
После некоторых поисков я обнаружил, что нам нужен соответствующий инструмент для обработки файлов схемы Cap’n Proto (capnp): https://capnproto.org/capnp-tool.html.
Я обнаружил, что это можно установить на Mac с помощью:
brew install capnp
Если у вас нет Homebrew для Mac, зайдите сюда: https://brew.sh/.
Если у вас нет Mac, есть инструкции по установке здесь: https://capnproto.org/install.html.
После установки мы можем убедиться, что программа работает, и посмотреть справку:
capnp --help
Выход
Usage: capnp [<option>...] <command> [<arg>...]
Command-line tool for Cap'n Proto development and debugging.
Commands:
compile Generate source code from schema files.
convert Convert messages between binary, text, JSON, etc.
decode DEPRECATED (use `convert`)
encode DEPRECATED (use `convert`)
eval Evaluate a const from a schema file.
id Generate a new unique ID.
See 'capnp help <command>' for more information on a specific command.
Options:
-I<dir>, --import-path=<dir>
Add <dir> to the list of directories searched for non-relative imports
(ones that start with a '/').
--no-standard-import
Do not add any default import paths; use only those specified by -I.
Otherwise, typically /usr/include and /usr/local/include are added by
default.
--verbose
Log informational messages to stderr; useful for debugging.
--version
Print version information and exit.
--help
Display this help text and exit.
Все работает. Что мне теперь делать?
Думаю, мы можем начать с примера сообщения.
В документации сказано:
Сообщения Cap’n Proto сильно типизированы и не являются самоописывающимися. Вы должны определить структуру сообщения на специальном языке, а затем вызвать компилятор Cap’n Proto.
Хорошо, давайте посмотрим на документацию по инструменту компилятора.
Там говорится, что я могу сделать следующее:
capnp compile -oc++ myschema.capnp
Это хорошо, но мне нужен Rust, а не C++ код, который эта команда, похоже, генерирует. Оглядевшись вокруг, я обнаружил кучу Rust crates, которые, как мне кажется, помогут, плюс папку с примерами, все в этом репозитории:
https://github.com/capnproto/capnproto-rust
Но пример содержит ID в файле схемы, так что я не уверен, нужно ли мне генерировать его или он генерируется инструментом и… вставляется в схему?
Еще немного поиска и текстового поиска по слову «generate» привели меня на страницу языка, где я нашел это:
Похоже, что мне нужно сгенерировать хотя бы 1 идентификатор и вставить его в схему.
❯ capnp id
@0xb068ff5fb1c4f77e;
Давайте воспользуемся примером из репозитория capnproto-rust, но с нашим ID:
Я назову этот файл src/schema/point.capnp
@0xb068ff5fb1c4f77e;
struct Point {
x @0 :Float32;
y @1 :Float32;
}
interface PointTracker {
addPoint @0 (p :Point) -> (totalPoints :UInt64);
}
Что это описывает? Это выглядит как вызов RPC для добавления точки (с координатами x & y, определенными как f32) к чему-то вроде списка точек, и возвращает totalPoints, который является u64. Поскольку этот тип не является коллекцией, я буду считать, что это означает общее количество точек.
Быстрый обзор основ схемы:
- В комментариях Capnp используется символ «#»
-
Типы capnp следующие:
- Void: Void
- Boolean: Bool
- Целые числа: Int8, Int16, Int32, Int64
- Беззнаковые целые числа: UInt8, UInt16, UInt32, UInt64
- С плавающей точкой: Float32, Float64
- Блоки: Текст (UTF8 с завершением NUL), Данные
- Списки: List(T) — T является встроенной или определенной capnp схемой Capnp Schema Struct
-
Поля Struct последовательно нумеруются (как protobuf) — но со знаком «@»
-
Существуют Enums, а также Unions.
-
Интерфейсы обертывают методы (интерфейс
PointTracker
выше содержит методaddPoint
). -
Файлы «.capnp» могут импортировать другие файлы «.capnp».
-
Типы для поля объявляются с помощью :двоеточия
План
В качестве грубого плана, я хочу иметь возможность обслуживать этот интерфейс и использовать или сохранять файл каким-либо образом в качестве демонстрации возможностей capnp. Задача будет состоять в том, чтобы сделать его как можно более простым, чтобы он способствовал тому, что является исследовательской ссылкой (по крайней мере, для меня) и, надеюсь, некоторой информацией/обучением для любого другого человека, рассматривающего этот протокол или изучающего/исследующего Rust.
Сейчас я создал папку проекта cargo new
и добавил папку src/schema для файла выше.
Если генерация идентификатора capnp покажется вам мучением — расширение vscode-capnp для vs-code может генерировать идентификатор capnp в любое время, когда вам это нужно.
(На самом деле, я случайно узнал позже, что если вы забыли, компилятор выдает ошибку и генерирует ID для вас, так что вы можете просто скопировать и вставить его).
Генерация схемы Cap’n Proto
Давайте посмотрим, что теперь говорит инструмент cli о компиляции:
❯ capnp help compile
Usage: capnp compile [<option>...] <source>...
Compiles Cap'n Proto schema files and generates corresponding source code in one
or more languages.
Options:
-I<dir>, --import-path=<dir>
Add <dir> to the list of directories searched for non-relative imports
(ones that start with a '/').
--no-standard-import
Do not add any default import paths; use only those specified by -I.
Otherwise, typically /usr/include and /usr/local/include are added by
default.
-o<lang>[:<dir>], --output=<lang>[:<dir>]
Generate source code for language <lang> in directory <dir> (default:
current directory). <lang> actually specifies a plugin to use. If
<lang> is a simple word, the compiler searches for a plugin called
'capnpc-<lang>' in $PATH. If <lang> is a file path containing slashes,
it is interpreted as the exact plugin executable file name, and $PATH is
not searched. If <lang> is '-', the compiler dumps the request to
standard output.
--src-prefix=<prefix>
If a file specified for compilation starts with <prefix>, remove the
prefix for the purpose of deciding the names of output files. For
example, the following command:
capnp compile --src-prefix=foo/bar -oc++:corge foo/bar/baz/qux.capnp
would generate the files corge/baz/qux.capnp.{h,c++}.
--verbose
Log informational messages to stderr; useful for debugging.
--version
Print version information and exit.
--help
Display this help text and exit.
Ага:
компилятор ищет плагин под названием ‘capnpc-‘ в $PATH…
Не уверен, что он у меня есть. Посмотрим, что найдет автозаполнение:
❯ capnpc
capnpc capnpc-c++ capnpc-capnp
Неа. Хорошо, давайте установим capnpc-rust:
?
Я не смог найти ничего о необходимости установки этой программы. Может быть, это волшебное средство, и я могу просто выбрать Rust в качестве языка:
❯ capnp compile -orust src/schema/point-schema.capnp
rust: no such plugin (executable should be 'capnpc-rust')
rust: plugin failed: exit code 1
Ага, это не волшебно.
Хм… может быть, это ящик с грузом?
❯ cargo install capnpnc-rust
Updating crates.io index
error: could not find `capnpnc-rust` in registry `crates-io` with version `*`
Неа.
Возможно, я иду не тем путем. Думаю, я мог бы скомпилировать capnpc-rust в двоичный файл, клонировав репозиторий, но это может быть излишним, поскольку я действительно хочу скомпилировать его из моего собственного кода. Не так ли? 🤷 — Это просто предположение, сделанное на основе чтения репозитория capnproto-rust:
На это также сильно намекается в документации capnproto-rust:
Мы можем попробовать…
crate::Cargo.toml
:
[package]
name = "capnproto-demo"
version = "0.1.0"
edition = "2021"
build = "build.rs"
[dependencies]
[build-dependencies]
capnpc = "0.14"
crate::build.rs:
fn main() {
capnpc::CompilerCommand::new()
.src_prefix("src/schema")
.file("src/schema/point.capnp")
.run()
.expect("schema compiler command failed");
}
И он компилирует и запускает сборку cargo build
! Но ничего не делает. 😞 Или, может быть, делает, и где-то на диске есть схема?
Возможно, это тот самый недостающий Env-var из примеров:
…но мне кажется, что я хочу сам указать папку вывода:
fn main() {
capnpc::CompilerCommand::new()
.src_prefix("src/schema")
.file("src/schema/point.capnp")
.output_path("src/schema")
.run()
.expect("schema compiler command failed");
}
Хорошо! Теперь у нас есть сгенерированный файл схемы, состоящий примерно из 500 строк кода:
Я собираюсь загрузить сборку снова, чтобы посмотреть, что произойдет, когда схема уже существует:
❯ ll src/schema
total 56
-rw-r--r-- 1 kushaljoshi staff 159B 30 Apr 15:58 point.capnp
-rw-r--r-- 1 kushaljoshi staff 20K 30 Apr 17:54 point_capnp.rs
❯ cargo build
Finished dev [unoptimized + debuginfo] target(s) in 0.04s
❯ ll src/schema
total 56
-rw-r--r-- 1 kushaljoshi staff 159B 30 Apr 15:58 point.capnp
-rw-r--r-- 1 kushaljoshi staff 20K 30 Apr 17:54 point_capnp.rs
Ничего (я запустил вторую cargo build
в 18:00)! пока что все выглядит хорошо. Я не хочу бессмысленно регенерировать схему при каждой сборке.
Итак, теперь у нас есть схема и автоматически сгенерированный код в нашей сборке. Это довольно приятно. Теперь как мы его используем?
Использование сгенерированного кода
В сгенерированном коде есть pub mod point
обертка модуля, так что это кажется хорошим местом для начала. Давайте используем
этот модуль в нашем проекте:
Мы будем действовать просто и аккуратно. Сначала мы можем создать серверный модуль, который будет сервером capnp.
Cargo.toml
:
...
[dependencies]
capnp = "0.14"
...
main.rs:
mod server;
fn main() {
println!("Hello, world!");
}
Я оставил код нового проекта по умолчанию, чтобы новички могли видеть, что происходит и как мы создаем проект.
server.rs:
#[path = "./schema/point_capnp.rs"]
mod point_capnp;
use point_capnp::{point, point_tracker};
Я предполагаю, что нам нужно указать компилятору, где находится код.
Есть небольшая проблема, когда мы пытаемся собрать это. Сгенерированный код ожидает, что мод point_capnp будет находиться на верхнем уровне, и ему не нравится, что он объявлен внутри server::
:
Это немного раздражает. Сгенерированный код жестко привязан к crate::point_np
.
Я читал проблемы в течение нескольких часов и обнаружил, что это было решено, хотя и, как мне кажется, халтурным способом, и было поднято/найдено как проблема в старой статье блога Hoverbear, которая очень помогла здесь (спасибо Ana!).
Простой ответ для нас сейчас (если есть лучшее/простое решение, пожалуйста, прокомментируйте) — это добавить этот файл — rust.capnp в папку schema и включить его в каждую схему следующим образом:
point.capnp:
@0xb068ff5fb1c4f77e;
using Rust = import "rust.capnp";
$Rust.parentModule("server");
struct Point {
x @0 :Float32;
y @1 :Float32;
}
interface PointTracker {
addPoint @0 (p :Point) -> (totalPoints :UInt64);
}
Это раздражает, поскольку приходится вручную вносить изменения в каждый файл схемы, но она отлично работает и компилируется, при этом генерируемый код получает массу предупреждений «ассоциированная функция не используется». Добавление #![allow(dead_code)]
в начало файла server.rs устранило эту проблему. Эта схема работает пока, но, вероятно, не будет масштабироваться — я позволю моему серверному модулю «владеть» capnp генерировать код для каждой схемы, для которой сервер является хостом.
На данный момент я делаю первый коммит в репо, так как у меня есть компилируемая схема capnp 🎉.
Ближе к делу
На этом этапе мы почти исчерпали большую часть доступной документации по Rust, но в репо capnproto-rust есть примеры сериализации и RPC. Деконструировав их, я надеюсь сделать здесь самую простую реализацию, какую только смогу.
Давайте сделаем вывод из нашей Точки. В документации сказано:
В Rust сгенерированный код для примера выше включает в себя структуру point::Reader<‘a> с методами get_x() и get_y(), и структуру point::Builder<‘a> с методами set_x() и set_y().
Чтобы понять, как их использовать, мы должны вернуться в начало документации, чтобы понять, как работает capnp:
Cap’n Proto генерирует классы с методами доступа, которые вы используете для обхода сообщения.
Итак, нам нужно создать сообщение, которое будет содержать нашу Точку. Я думаю.
В примере с адресной книгой capnp::serialized_packed
используется для чтения и записи этого сообщения в поток. Документация по этому вопросу находится здесь.
Мы можем скопировать эту структуру кода адресной книги для создания нашей Точки.
server.rs:
#![allow(dead_code)]
#[path = "./schema/point_capnp.rs"]
mod point_capnp;
pub mod point_demo {
use crate::server::point_capnp::point;
use capnp::serialize_packed;
pub fn write_to_stream() -> ::capnp::Result<()> {
let mut message = ::capnp::message::Builder::new_default();
let mut demo_point = message.init_root::<point::Builder>();
demo_point.set_x(5_f32);
demo_point.set_y(10_f32);
serialize_packed::write_message(&mut ::std::io::stdout(), &message)
}
}
main.rs:
mod server;
fn main() {
let _ = server::point_demo::write_to_stream();
}
Выход:
❯ cargo run
Compiling capnproto-demo v0.1.0 (/Users/kushaljoshi/code/rust/capnproto/capnproto-demo)
Finished dev [unoptimized + debuginfo] target(s) in 1.13s
Running `target/debug/capnproto-demo`
̠@ A%
Фантастика! Мы «сериализовали» наш Point и упаковали его в сообщение capnp. Сообщение не читается (это символ подчеркивания, символ at, пробел, заглавная буква A, символ процента), потому что это бинарный тип capnp, который не нуждается в дальнейшей сериализации/десериализации через поток для использования в приложении. Можем ли мы проверить это?
Да! Инструмент capnp
предоставляет функцию декодирования, которой нужна схема и структура данных:
❯ cargo run | capnp decode ./src/schema/point.capnp Point
Finished dev [unoptimized + debuginfo] target(s) in 0.04s
Running `target/debug/capnproto-demo`
capnp decode: The input is not in "binary" format. It looks like it is in "packed" format. Try that instead.
Try 'capnp decode --help' for more information.
Итак, это не сработало, потому что нам нужно либо сказать capnp, что это упакованное (сжатое) сообщение, либо вывести необработанное сообщение в STDOUT. Давайте сделаем и то, и другое, чтобы лучше понять, что здесь происходит. Сначала нам просто нужно добавить —packed к команде CLI:
❯ cargo run | capnp decode ./src/schema/point.capnp Point --packed
Finished dev [unoptimized + debuginfo] target(s) in 0.04s
Running `target/debug/capnproto-demo`
(x = 5, y = 10)
Теперь мы видим, что capnp может распаковать (декомпрессировать) сообщение и распечатать заданные нами координаты точек. Но у нас не всегда есть упакованные данные, поэтому давайте отправим Point в необработанном формате сообщения и убедимся, что мы можем декодировать его так, как ожидаем. Для этого нам нужно внести изменения в сервер:
server.rs
...
pub mod point_demo {
use crate::server::point_capnp::point;
use capnp::serialize;
pub fn write_to_stream() -> ::capnp::Result<()> {
let mut message = ::capnp::message::Builder::new_default();
let mut demo_point = message.init_root::<point::Builder>();
demo_point.set_x(5_f32);
demo_point.set_y(10_f32);
serialize::write_message(&mut ::std::io::stdout(), &message)
}
}
Выход:
❯ cargo run | capnp decode ./src/schema/point.capnp Point --packed
Compiling capnproto-demo v0.1.0 (/Users/kushaljoshi/code/rust/capnproto/capnproto-demo)
Finished dev [unoptimized + debuginfo] target(s) in 0.65s
Running `target/debug/capnproto-demo`
capnp decode: The input is not in "packed" format. It looks like it is in "binary" format. Try that instead.
Try 'capnp decode --help' for more information.
Очень полезное сообщение, подтверждающее то, что мы уже сделали. Теперь мы можем убрать флаг --packed
.
Выход:
❯ cargo run | capnp decode ./src/schema/point.capnp Point
Finished dev [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/capnproto-demo`
(x = 5, y = 10)
Потрясающе.
Если вы следовали за нами и добились того, что это работает, вы можете захотеть увидеть выгоду более четко, поэтому для этого мы можем сохранить данные и загрузить их обратно без дальнейшей сериализации/десериализации.
server.rs:
pub mod point_demo {
use crate::server::point_capnp::point;
use capnp::serialize;
use std::fs::File;
pub fn write_to_stream() -> std::io::Result<()> {
let mut message = ::capnp::message::Builder::new_default();
let mut demo_point = message.init_root::<point::Builder>();
demo_point.set_x(5_f32);
demo_point.set_y(10_f32);
// This Result should be consumed properly in an actual app
let _ = serialize::write_message(&mut ::std::io::stdout(), &message);
// Save the point
{
let file = File::create("point.txt")?;
let _ = serialize::write_message(file, &message);
}
// Read the point from file
{
let point_file = File::open("point.txt")?;
// We want this to panic in our demo incase there is an issue
let point_reader =
serialize::read_message(point_file, ::capnp::message::ReaderOptions::new())
.unwrap();
let demo_point: point::Reader = point_reader.get_root().unwrap();
println!("n(x = {}, y = {})", demo_point.get_x(), demo_point.get_y());
}
Ok(())
}
}
Выход:
❯ cargo run
Compiling capnproto-demo v0.1.0 (/Users/kushaljoshi/code/rust/capnproto/capnproto-demo)
Finished dev [unoptimized + debuginfo] target(s) in 0.63s
Running `target/debug/capnproto-demo`
@ A
(x = 5, y = 10)
Итак… не похоже, что здесь произошло что-то особенное, и вывод, по замыслу, выглядит так же.
Однако, вы могли не заметить, что только что произошло и как это здорово 😄 !!!
Давайте разберемся:
- Мы создали сериализованный Point из нашей схемы Point Schema.
- Мы установили данные внутри сериализованной Point (нет необходимости десериализовывать Point или сериализовывать значения x & y float 32)
- Мы сохранили сериализованные данные на диск с помощью стандартных инструментов работы с файлами
- Мы прочитали сериализованные данные, используя стандартные средства работы с файлами (эндианальность учитывается в типе файла)
- Мы использовали методы доступа к сериализованным данным и вывели значение без десериализации данных.
Это нормально — если вы думаете «ну и что?», то, возможно, ваши случаи использования проекта до сих пор не были критичны к производительности. Если же они были, то это должно быть просто чудо!
Для особо скептически настроенных: Reader
выше — это не десериализатор. Это буквально Reader. Ему нужна схема и некоторые данные, и он знает, как установить указатели в данных (которые состоят из упорядоченных сегментов), чтобы методы доступа указывали на нужные части данных. Для получения дополнительной информации ознакомьтесь со страницей кодирования capnp.
Вы можете декодировать данные файла так же, как и вывод STDOUT, описанный выше:
cat point.txt | capnp decode ./src/schema/point.capnp Point
(x = 5, y = 10)
Это действительно интересно; если данные можно сохранить, их можно передать по сети и использовать любым клиентом, который имеет соответствующую карту (схему) для чтения полученных данных, без каких-либо промежуточных шагов десериализации.
Именно это мы и попробуем сделать в части 2.