Со времени публикации первых двух статей мой проект сменил имя и концепцию. Теперь он называется TentaCLI и это название, являющееся игрой слов tentacle и cli, полностью отражает новую суть проекта. Хотя tentacli по прежнему может быть скачан с github и использоваться, как отдельное клиентское приложение, он и его части так же доступны в виде крэйтов. Внедряемость, а так же возможность добавлять собственные модули в tentacli делает его подходящим для создания собственных приложений. В частности, у меня таких два: мини wow сервер для тестирования tine и скрытый проект binary army, в котором tentacli полностью раскрывает свой потенциал как щупальца-исполнителя - и для управления которыми я пишу сердце.
А сердце tentacli - это чтение и обработка TCP пакетов и для облегчения работы с ними я использую макросы.
Мотивация
Давным-давно, в далекой-предалекой первой версии парсинг и создание пакетов представляли собой весьма муторную задачу:
// чтение пакета с опкодом SMSG_MESSAGECHAT
let mut reader = Cursor::new(input.data.as_ref().unwrap()[4..].to_vec());
let message_type = reader.read_u8()?;
let language = reader.read_u32::<LittleEndian>()?;
let sender_guid = reader.read_u64::<LittleEndian>()?;
// skip
reader.read_u32::<LittleEndian>()?;
// условное поле раз
let mut channel_name = Vec::new();
if message_type == MessageType::CHANNEL {
reader.read_until(0, &mut channel_name)?;
}
let channel_name = match channel_name.is_empty() {
true => String::new(),
false => {
String::from_utf8(
channel_name[..(channel_name.len() - 1) as usize].to_owned()
).unwrap()
},
};
let target_guid = reader.read_u64::<LittleEndian>()?;
let size = reader.read_u32::<LittleEndian>()?;
// условное поле два
let mut message = vec![0u8; (size - 1) as usize];
reader.read_exact(&mut message)?;
let message = String::from_utf8_lossy(&message);
Возможно, вы подумали, что это специальный код для наказаний и, вобщем-то, так оно и было - масштабировать подобный код было тем еще испытанием. Как итог, были реализованы обработчики только для самых базовых пакетов. Но этой ситуации суждено было измениться.
Начало великого перехода
В процессе исследований я нашел крэйты serde
и bincode
. Однако, концепция, которую я задумал, не могла быть реализована с помощью данных крэйтов - мне нужна была условная десериализация. Пример кода выше - идеальный для отражения проблемы, поскольку в нем представлены сразу два случая условной десериализации: когда поле (channel_name
) может быть прочитано только в случае, если совпало некое условие и когда чтение поля (message
) зависит от ранее прочитанного поля (size
). Я размышлял над максимально лаконичной формой описания таких полей.
Итогом моих экспериментов и исследований, а так же значительной помощи со стороны официального Rust комьюнити стал вот такой макрос - который заменил код выше:
#[derive(WorldPacket, Serialize)]
struct Incoming {
message_type: u8,
language: u32,
sender_guid: u64,
skip: u32,
#[conditional]
channel_name: String,
target_guid: u64,
message_length: u32,
#[depends_on(message_length)]
message: String,
}
impl Incoming {
fn channel_name(instance: &mut Self) -> bool {
instance.message_type == MessageType::CHANNEL
}
}
Устройство макроса
Теперь по порядку разберем, как он устроен. Фундаментом для чтения/записи данных служит трейт BinaryConverter
:
pub trait BinaryConverter {
fn write_into(&mut self, buffer: &mut Vec<u8>) -> AnyResult<()>;
fn read_from<R: BufRead>(
reader: &mut R,
dependencies: &mut Vec<u8>
) -> AnyResult<Self> where Self: Sized;
}
Этот трейт я имплеменчу на каждый тип, который хочу использовать в полях сериалайзера:
impl BinaryConverter for u8 {
fn write_into(&mut self, buffer: &mut Vec<u8>) -> AnyResult<()> {
buffer.write_u8(*self).map_err(|e| FieldError::CannotWrite(e, "u8".to_string()).into())
}
fn read_from<R: BufRead>(reader: &mut R, _: &mut Vec<u8>) -> AnyResult<Self> {
reader.read_u8().map_err(|e| FieldError::CannotRead(e, "u8".to_string()).into())
}
}
В некоторых случаях требуется чуть больше кода, к примеру, для строк:
impl BinaryConverter for String {
fn write_into(&mut self, buffer: &mut Vec<u8>) -> AnyResult<()> {
buffer.write_all(self.as_bytes())
.map_err(|e| FieldError::CannotWrite(e, "String".to_string()))?;
Ok(())
}
fn read_from<R: BufRead>(
reader: &mut R,
dependencies: &mut Vec<u8>
) -> AnyResult<Self> {
let mut cursor = Cursor::new(dependencies.to_vec());
let size = match dependencies.len() {
1 => ReadBytesExt::read_u8(&mut cursor)
.map_err(|e| FieldError::CannotRead(e, "String u8 size".to_string()))? as usize,
2 => ReadBytesExt::read_u16::<LittleEndian>(&mut cursor)
.map_err(|e| FieldError::CannotRead(e, "String u16 size".to_string()))? as usize,
4 => ReadBytesExt::read_u32::<LittleEndian>(&mut cursor)
.map_err(|e| FieldError::CannotRead(e, "String u32 size".to_string()))? as usize,
_ => 0,
};
let buffer = if size > 0 {
let mut buffer = vec![0u8; size];
reader.read_exact(&mut buffer)
.map_err(|e| FieldError::CannotRead(e, "String".to_string()))?;
buffer
} else {
let mut buffer = vec![];
reader.read_until(0, &mut buffer)
.map_err(|e| FieldError::CannotRead(e, "String".to_string()))?;
buffer
};
let string = String::from_utf8(buffer)
.map_err(|e| FieldError::InvalidString(e, "String".to_string()))?;
Ok(string.trim_end_matches(char::from(0)).to_string())
}
}
То же самое справедливо и для пользовательских типов. Благодаря этому при объявлении сериалайзера можно использовать тип Player
напрямую в качестве типа для поля:
// пакет с опкодом SMSG_CHAR_ENUM
#[derive(WorldPacket, Serialize, Debug)]
struct Incoming {
characters_count: u8,
#[depends_on(characters_count)]
characters: Vec<Player>,
}
Теперь, при получении пакета с сервера, с помощью сериалайзера из примера выше мы можем прочитать список персонажей - в переменную characters
:
let (Incoming { characters, .. }, json) = Incoming::from_binary(&input.data)?;
Метод from_binary
возвращает tuple из двух элементов - инстанс текущего struct
и json представление его полей.
Рассмотрим, откуда взялся этот метод и при чем же здесь trait BinaryConverter
.
Изнанка сериалайзера
Есть два макроса: один для Login сервера, второй - для World сервера. Но выбирать из них мы не будем и рассмотрим только один, поскольку они сильно похожи.
#[proc_macro_derive(WorldPacket, attributes(depends_on, conditional))]
pub fn world_packet(input: TokenStream) -> TokenStream {
let ItemStruct { ident, fields, .. } = parse_macro_input!(input);
// формируем список полей
// формируем список зависимостей для полей
// формируем список значений
// формируем то, что вернет макрос
TokenStream::from(output)
}
Любой proc-macro
скорее всего будет выглядеть как-то так.
Начать я хотел бы с конца, а именно - с объяснения, что такое output
. Если вкратце, то это - переменная, которая содержит код, обернутый с помощью макроса quote!
. Т.е. для того, чтобы struct
, к которому я применяю мой макрос, получал некий метод, назовем его from_binary
, понадобится добавить следующие строки в эту переменную:
#[proc_macro_derive(WorldPacket, attributes(depends_on, conditional))]
pub fn world_packet(input: TokenStream) -> TokenStream {
let ItemStruct { ident, fields, .. } = parse_macro_input!(input);
// формируем список полей
// формируем список зависимостей для полей
// формируем список значений
let output = quote! {
impl #ident {
pub fn from_binary(buffer: &[u8]) -> #result<(Self, String)> {
println!("It works !");
// а здесь нужно вернуть результат
}
}
};
TokenStream::from(output)
}
В коде выше ident
- это идентификатор того struct
, к которому применен макрос. Знак решетки служит для интерполяции выражений - таким образом, в контексте текущей серии примеров, ident
означает Incoming
, как если бы я написал:
impl Incoming {
pub fn from_binary(buffer: &[u8]) -> AnyResult<(Self, String)> {
println!("It works !");
// а здесь нужно вернуть результат
}
}
Помимо переменных, интерполировать можно так же и импорты, к примеру, переменнаяresult
- это не что иное, как quote!(anyhow::Result)
.
Теперь добавим формирование списка полей и списка значений. Поскольку задача метода from_binary
- сформировать struct из пакета байт (ну, а так же json), нужно, чтобы внутри метода было что-то вроде:
let binary_converter = quote!(tentacli_traits::BinaryConverter);
let cursor = quote!(std::io::Cursor);
let output = quote! {
impl #ident {
pub fn from_binary(buffer: &[u8]) -> #result<(Self, String)> {
println!("It works !");
let mut reader = #cursor::new(buffer);
let json = String::new();
let instance = Self {
characters_count: #binary_converter::read_from(&mut reader, &mut vec![]),
characters: #binary_converter::read_from(&mut reader, &mut vec![]),
};
Ok((instance, json))
}
}
};
Благодаря этому коду получился одноразовый макрос.
Теперь нужно сделать, чтобы он обрабатывал любой набор полей:
// эту строку я уже указывал в примерах выше, но просто добавлю ее
// для ясности - откуда взялся fields
let ItemStruct { ident, fields, .. } = parse_macro_input!(input);
let field_names = fields.iter().map(|f| {
// в этом случае ident - это уже идентификатор поля !
f.ident.clone()
}).collect::<Vec<Option<Ident>>>();
let initializers = fields.iter()
.map(|f| {
let field_name = f.ident.clone();
let field_type = f.ty.clone();
quote! {
{
let value: #field_type = #binary_converter::read_from(&mut reader, &mut vec![])?;
value
}
}
});
let binary_converter = quote!(tentacli_traits::BinaryConverter);
let cursor = quote!(std::io::Cursor);
let output = quote! {
impl #ident {
pub fn from_binary(buffer: &[u8]) -> #result<(Self, String)> {
println!("It works !");
let mut reader = #cursor::new(buffer);
let json = String::new();
// а теперь магия развертывания
let mut instance = Self {
#(#field_names: #initializers),*
};
Ok((instance, json))
}
}
};
Вот этот код (развертывание):
let mut instance = Self {
#(#field_names: #initializers),*
};
Будет компилятором преображен примерно в такой:
let mut instance = Self {
field1: {
let value: i32 = binary_converter::read_from(&mut reader, &mut vec![])?;
value
},
field2: {
let value: String = binary_converter::read_from(&mut reader, &mut vec![])?;
value
},
// ...
};
Т.е. иными словами, благодаря развертыванию происходит сопоставление каждого элемента из field_names
элементу с тем же порядковым номером из initializers
, затем каждая пара подставляется в Self
- и разделяется запятой.
Атрибуты depends_on и conditional
Чтобы сформировать список полей, которые содержат заданные атрибуты, можно использовать обычный вектор или какой-нибудь hashmap/btreemap:
// этот struct объявлен вне макроса
struct DependsOnAttribute {
pub name: Ident,
}
impl Parse for DependsOnAttribute {
fn parse(input: ParseStream) -> syn::Result<Self> {
let name: Ident = input.parse()?;
Ok(Self { name })
}
}
// дальнейший код уже внутри макроса
let mut depends_on: BTreeMap<Option<Ident>, Vec<Ident>> = BTreeMap::new();
let mut conditional: Vec<Option<Ident>> = vec![];
for field in fields.iter() {
let ident = field.ident.clone();
if field.attrs.iter().any(|attr| attr.path().is_ident("depends_on")) {
let mut dependencies: Vec<Ident> = vec![];
field.attrs.iter().for_each(|attr| {
if attr.path().is_ident("depends_on") {
let parsed_attrs = attr.parse_args_with(
Punctuated::<DependsOnAttribute, Token![,]>::parse_terminated
).unwrap();
for a in parsed_attrs {
dependencies.push(a.name);
}
}
});
depends_on.insert(ident.clone(), dependencies);
}
if field.attrs.iter().any(|attr| attr.path().is_ident("conditional")) {
conditional.push(ident);
}
}
С помощью переменных depends_on
и conditional
мы просто формируем списки идентификаторов, которые будут использованы в дальнейшем (см. в конце статьи).
Но перед тем, как мы перейдем к завершающей фазе, хочу рассмотреть еще одну вещь.
В свое время parse_terminated
и парсинг атрибутов в целом вызвал у меня много вопросов и непоняток, поэтому рассмотрим его подробнее с примерами.
Как вообще парсить атрибуты
Метод parse_terminated
принимает два generic параметра: то, что мы ищем и то, чем это разделяется (separator).
Для начала сделаем макрос, атрибут которого будет принимать список чисел, которые затем можно будет вывести в консоль:
#[derive(Simple)]
#[numbers(1, 2, 3, 4)]
struct MyStruct;
fn main() {
MyStruct::output()
}
// и код макроса:
#[proc_macro_derive(Simple, attributes(numbers))]
pub fn simple(input: TokenStream) -> TokenStream {
let DeriveInput { ident, attrs, .. } = parse_macro_input!(input);
let mut numbers = vec![];
for attr in attrs {
if attr.path().is_ident("numbers") {
let number_list = attr.parse_args_with(
Punctuated::<LitInt, Token![,]>::parse_terminated
).unwrap();
for number in number_list {
numbers.push(number.base10_parse::<i32>().unwrap());
}
}
}
// поскольку на вектор при интерполяции накладываются некоторые ограничения
// для вывода мы можем предварительно привести его к строке
let numbers_str = format!("{:?}", numbers);
let output = quote! {
impl #ident {
pub fn output() {
println!("{:?}", #numbers_str);
// либо можно вывести вектор вот так:
println!("{:?}", [ #( #numbers ),* ]);
}
}
};
TokenStream::from(output)
}
Каждый атрибут мы парсим с помощью attr.parse_args_with
, который принимает парсер в качестве параметра. Собственно, парсером в нашем случае выступает вышеупомянутый parse_terminated
.
Можно немного облагородить процесс парсинга и создать кастомный struct:
struct NumberList {
numbers: Punctuated<LitInt, Token![,]>,
}
impl syn::parse::Parse for NumberList {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
Ok(NumberList {
numbers: Punctuated::parse_terminated(input)?
})
}
}
// и в самом макросе number_list будет читаться как-то так:
let number_list = attr.parse_args::<NumberList>().unwrap().numbers;
В таком случае использование parse_terminated
можно вынести из общего кода. Концепция кастомного struct
нам понадобится далее.
Теперь усложним задачу. Будем парсить список параметров, где есть пары ключ и значение:
#[derive(Middle)]
#[values(tentacli=works, join=us, on=discord)]
struct BetterStruct;
// для этого я применю уже рассмотренный выше подход с кастомным struct
struct ValuesList {
pub items: Vec<(String, String)>,
}
impl syn::parse::Parse for ValuesList {
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
let mut items = vec![];
while !input.is_empty() {
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
let value: Ident = input.parse()?;
items.push((key.to_string(), value.to_string()));
if input.peek(Token![,]) {
input.parse::<Token![,]>().expect(",");
}
}
Ok(Self { items })
}
}
#[proc_macro_derive(Middle, attributes(values))]
pub fn middle(input: TokenStream) -> TokenStream {
// ...
for attr in attrs {
if attr.path().is_ident("values") {
let items_list = attr.parse_args::<ValuesList>().unwrap();
// ...
}
}
// ...
TokenStream::from(output)
}
То, что мы парсим - это по сути просто набор токенов, поэтому можно воспринимать процесс парсинга как их последовательный перебор - и если какой-то элемент в последовательности будет пропущен (скажем, в нашем случае - пропущен знак "=") - произойдет ошибка на этапе компиляции.
Поскольку параметры, указанные в скобках атрибута, передаются без кавычек, они воспринимаются парсером, как идентификаторы, в противном случае каждый key/value нужно было бы парсить как строку с помощью LitStr
вместо Ident
.
Это все были атрибуты для struct. Для полноты картины рассмотрим так же атрибуты полей. С ними все то же самое, единственное отличие - эти атрибуты парсятся из fields
.
#[derive(Hard)]
#[values(tentacli=works, join=us, on=discord)]
struct TopStruct {
#[value("Tentacli")]
name: String,
#[value("https://github.com/idewave/tentacli")]
github_link: String,
#[value("https://crates.io/crates/tentacli")]
crates_link: String,
}
#[proc_macro_derive(Hard, attributes(values, value))]
pub fn hard(input: TokenStream) -> TokenStream {
// чтобы получить fields вместо DeriveInput используем ItemStruct
let ItemStruct { ident, fields, attrs, .. } = parse_macro_input!(input);
for field in fields.iter() {
field.attrs.iter().for_each(|attr| {
if attr.path().is_ident("value") {
let value = attr.parse_args::<LitStr>().unwrap();
values.push(value.value());
}
});
}
TokenStream::from(output)
}
Я создал репу на гитхабе, в которой содержатся все три примера.
Заключение
Теперь с полным (я надеюсь) пониманием, как функционирует макрос - предлагаю дописать код для переменной initializers
и для метода from_binary
:
let initializers = fields
.iter()
.map(|f| {
let field_name = f.ident.clone();
let field_type = f.ty.clone();
let output = if let Some(dep_fields) = depends_on.get(&field_name) {
quote! {
{
let mut data: Vec<u8> = vec![];
#(
#binary_converter::write_into(
&mut cache.#dep_fields,
&mut data,
)?;
)*
#binary_converter::read_from(&mut reader, &mut data)?
}
}
} else {
quote! {
{
let value: #field_type = #binary_converter::read_from(
&mut reader, &mut vec![]
)?;
cache.#field_name = value.clone();
value
}
}
};
if conditional.contains(&field_name) {
quote! {
{
if Self::#field_name(&mut cache) {
#output
} else {
Default::default()
}
}
}
} else {
output
}
});
let output = quote! {
impl #ident {
pub fn from_binary(buffer: &[u8]) -> #result<(Self, String)> {
println!("It works !");
let mut cache = Self {
#(#field_names: Default::default()),*
};
let mut reader = #cursor::new(buffer);
let mut instance = Self {
#(#field_names: #initializers),*
};
let details = instance.get_json_details()?;
Ok((instance, details))
}
}
};
Первый вопрос, который может у вас появиться: что это за reader
, cache
и прочие разные переменные, которые не объявлены перед initializers
, но почему-то используются внутри этой переменной. Ответ достаточно прост: содержимое переменной initializers
будет подставлено в том месте переменной output
, где мы ее указали. А все, что мы передали внутрь TokenStream::from(output)
- будет скомпилировано одним куском. Таким образом, в коде выше, - переменная cache
объявлена на 52 строке, переменная reader
- на 56 и все они - объявлены ДО того, как initializers
попал в код.
Второй вопрос: что есть cache. Это реплика инстанса текущего struct
за исключением того, что запись туда ведется до первого поля с атрибутом depends_on
. Благодаря этому подходу можно сделать запрос к ранее прочитанным полям, не дожидаясь окончания чтения всех полей. И на этапе билда решить, как правильно читать следующее поле. К примеру, возьмем самый первый код, где описан пакет SMSG_MESSAGECHAT
. Есть там условное поле channel_name
, если на этапе чтения мы его прочтем тогда, когда этого делать было не нужно, то следующее поле (и все дальнейшие) уже будет прочитано неправильно, что приведет к ошибке.
А третий вопрос задавайте в комментариях.