Многие разработчики игр сталкиваются с проблемой описания и реализации протокола общения клиента и сервера, особенно если пишут свои велосипеды для работы с сокетами. Ниже я расскажу о моей попытке решить задачу как можно элегантнее и удобнее для дальнейшего использования и масштабирования приложения. Будет много compile-time'a с автоматической кодогенерацией, нежно приправленный щепоткой run-time'a.
Клиент и сервер постоянно перекидываются сообщениями, но эти сообщения нужно сначала подготовить, переслать и потом восстановить в читаемый вид. Краткая cхемка ниже:

Главная проблема возникает между этапом чтения сообщения и десериализацией, к получателю приходит поток байтов, а для корректной десериализации нужно знать структуру сообщения, то есть тип. Все операции над типами завершились в compile-time и у нас больше нет помощи компилятора. Самое первое, самое брутальное решение, которое приходит на ум, это написать огромный switch, связывающий id сообщения и конкретную функции для распаковки сообщения. Думаю, не надо объяснять почему это решение приводит к головной боли при переработке протокола и огромному числу сложно обнаруживаемых ошибок. Эту проблему и будем решать.
Для начала необходимо определить, что же хотим получить:
В нашем проекте используется собственный сериализатор, тоже активно использующий compile-time (Это тема для отдельного поста). Договоримся, что у нас есть некий черный ящик, который умеет переводить классы и их поля в байты и обратно вот такими вызовами:
Также для простоты изложения будем наивно предполагать, что сообщения не шифруются и все проблемы безопасности решает сериализатор, если сообщение не совпадает с заявленной структурой, кидается исключение, и мы игнорируем проблемное сообщение.
Весь код, который пойдет дальше тестировался на dmd 2.060 и на 2.059 наверно уже не скомпилируется (очень неприятная детская болезнь D2).
Каждое сообщение — это некий класс, у которого перегружен функциональный оператор и есть конструктор без параметров (требование для десериализации). Первое требование легко формализовать, любое сообщение должно реализовывать вот такой интерфейс:
Второй конструктор нужен для сборки сообщения, об этом и о проверке наличия конструктора без параметров ниже.
В C++ я бы использовал много-много структур с шаблонно шаблонными параметрами, но в D есть и другие способы исполнять код в compile-time. Я буду использовать шаблоны и mixin'ы, чтобы как можно меньше compile-time кода осело в исполняемом файле. Итого весь код будет находится в template mixin, его можно будет легко использовать снова в другом приложении или в другой версии этого же.
IndexType — это тип индекса, который мы будем использовать. SerializerBackend — бекэнд для сериализатора, вполне возможно, что для другого приложения будет использоваться другой механизм сериализации в байты или, даже, не в байты, а xml/json.
pairs... — Самый интересный параметр, тут будут записаны пары: id и тип сообщения. Пример ниже:
Но пользователь может запихнуть в pairs что угодно, нарушить это хрупкое соглашение, и тогда проблемы не заставят себя ждать. Нужно проверять корректность. Поэтому вставим в шаблон еще один шаблон, который будет пробегать по парам и останавливать компиляцию с красивым и понятным сообщением об ошибке.
Тут могут быть непонятны вызовы __traits(compiles, sometext), это явный запрос компилятору проверить, компилируются ли sometext вообще или нет. Про встроенные Traits можно подробнее почитать здесь. И сразу после объявления шаблона, вызыва��м его через static assert. Можно было бы просто вызвать этот шаблон, но компилятор ругается на явно бессмысленные выражения, что иногда немного мешает.
Внимательный читатель (если вообще кто-нибудь добрался до этой строчки) наверняка заметил, что я не дал определение шаблону CountValInList, который считает число вхождений значения в список.
Отлично, все неправильные использования отсечены и правильно обработаны. По таким сообщениям об ошибках вполне можно найти правильный способ использования методом научного тыка (от написания документации это не спасет!). Теперь нужно подумать о самой задаче. Нам нужен компромисс между удобством использования и скоростью работы, стоп, мы можем получить и то и то одновременно! Мы будем генерировать гигантский switch автоматически без участия программиста:
Этот шаблон будет генерировать строку, похожую на эту:
Теперь осталось подмешать полученную строку в функцию для диспетчиризации:
Как будет выглядеть вызов этой функции в коде:
Собственно самая сложная часть написана, остались только всякие вкусности для удобного конструирования сообщения. Никто же не хочет делать это вручную?! Гораздо удобнее делать это так:
Никаких id, никаких других лишних вещей. Параметры сразу передадутся конструктору сообщения, и сообщение сериализируется в поток байтов. Осталось это написать… Нужно уметь искать id сообщения по типу, для этого нужен еще один шаблончик:
К этому моменту у моей крохотной по числу публики должна возникнуть мысль, что я страдаю манией к функциональному программированию. Я уважаю все парадигмы, но в compile-time шаблонах нету никакого mutable состояния, поэтому тут естественным образом возникает функциональный стиль. Теперь не составит труда сконструировать сообщение, зная только его тип:
Теперь, когда у нас есть эта навороченная система, нужно ее проверить на практике. Для этого я написал unittest:
Для целостности картины ниже находится полный исходник под Boost лицензией. Для нормальной работы модулю нужен сериализатор, можно прикрутить свой или воспользоваться Orange.
Постановка задачи
Клиент и сервер постоянно перекидываются сообщениями, но эти сообщения нужно сначала подготовить, переслать и потом восстановить в читаемый вид. Краткая cхемка ниже:

Главная проблема возникает между этапом чтения сообщения и десериализацией, к получателю приходит поток байтов, а для корректной десериализации нужно знать структуру сообщения, то есть тип. Все операции над типами завершились в compile-time и у нас больше нет помощи компилятора. Самое первое, самое брутальное решение, которое приходит на ум, это написать огромный switch, связывающий id сообщения и конкретную функции для распаковки сообщения. Думаю, не надо объяснять почему это решение приводит к головной боли при переработке протокола и огромному числу сложно обнаруживаемых ошибок. Эту проблему и будем решать.
Для начала необходимо определить, что же хотим получить:
- Один раз связать id сообщения и конкретный класс-обработчик сообщения. И все, больше никогда не вспоминать далее про id. Примерно таким образом:
0 AMessage
1 BMessage
2 CMessage
- Корректно обрабатывать ошибки использования и пресекать попытки испортить код еще на этапе компиляции. Например, в C++ практически невозможно добиться вывода понятных сообщений об ошибках, когда имеешь дело с compile-time структурами.
- Простое использование, один вызов функции и поток байтов превратился в готовое к обработке сообщение.
Зависимости
В нашем проекте используется собственный сериализатор, тоже активно использующий compile-time (Это тема для отдельного поста). Договоримся, что у нас есть некий черный ящик, который умеет переводить классы и их поля в байты и обратно вот такими вызовами:
auto stream = serialize!(ByteBackend)(SomeObject, "name");
auto object = deserialize!(ByteBackend, SomeClass)(stream, "name");
Также для простоты изложения будем наивно предполагать, что сообщения не шифруются и все проблемы безопасности решает сериализатор, если сообщение не совпадает с заявленной структурой, кидается исключение, и мы игнорируем проблемное сообщение.
Весь код, который пойдет дальше тестировался на dmd 2.060 и на 2.059 наверно уже не скомпилируется (очень неприятная детская болезнь D2).
Сообщения
Каждое сообщение — это некий класс, у которого перегружен функциональный оператор и есть конструктор без параметров (требование для десериализации). Первое требование легко формализовать, любое сообщение должно реализовывать вот такой интерфейс:
Код
Пример сообщения:
interface Message
{
void opCall();
}
Пример сообщения:
class AMsg : Message
{
int a;
string b;
this() {}
this(int pa, string pb)
{
a = pa;
b = pb;
}
void opCall()
{
writeln("AMsg call with ", a, " ", b);
}
}
Второй конструктор нужен для сборки сообщения, об этом и о проверке наличия конструктора без параметров ниже.
Начинаем творить магию
В C++ я бы использовал много-много структур с шаблонно шаблонными параметрами, но в D есть и другие способы исполнять код в compile-time. Я буду использовать шаблоны и mixin'ы, чтобы как можно меньше compile-time кода осело в исполняемом файле. Итого весь код будет находится в template mixin, его можно будет легко использовать снова в другом приложении или в другой версии этого же.
mixin template ProtocolPool(IndexType, SerializerBackend, pairs...)
{
}
IndexType — это тип индекса, который мы будем использовать. SerializerBackend — бекэнд для сериализатора, вполне возможно, что для другого приложения будет использоваться другой механизм сериализации в байты или, даже, не в байты, а xml/json.
pairs... — Самый интересный параметр, тут будут записаны пары: id и тип сообщения. Пример ниже:
mixin ProtocolPool!(int, BinaryBackend,
0, AMsg,
1, BMsg,
2, CMsg
);
Обработка ошибок
Но пользователь может запихнуть в pairs что угодно, нарушить это хрупкое соглашение, и тогда проблемы не заставят себя ждать. Нужно проверять корректность. Поэтому вставим в шаблон еще один шаблон, который будет пробегать по парам и останавливать компиляцию с красивым и понятным сообщением об ошибке.
Код
template CheckPairs(tpairs...)
{
static if(tpairs.length > 1)
{
static assert(__traits(compiles, typeof(tpairs) ),
"ProtocolPool expected index first, but got some type");
static assert(is(typeof(tpairs[0]) == IndexType),
"ProtocolPool expected index first of type "~
IndexType.stringof~
" not a "~typeof(tpairs[0]).stringof);
static assert(is(tpairs[1] : Message),
"ProtocolPool expected class implementing Message"~
" interface following index not a "~tpairs[1].stringof);
static assert(CountValInList!(tpairs[0], pairs) == 1,
"ProtocolPool indexes must be unique! One message,"~
"one index.");
enum CheckPairs = CheckPairs!(tpairs[2..$]);
}
else
{
static assert(tpairs.length == 0,
"ProtocolPool expected even number of parameters. Index and message type.");
enum CheckPairs = 0;
}
}
Тут могут быть непонятны вызовы __traits(compiles, sometext), это явный запрос компилятору проверить, компилируются ли sometext вообще или нет. Про встроенные Traits можно подробнее почитать здесь. И сразу после объявления шаблона, вызыва��м его через static assert. Можно было бы просто вызвать этот шаблон, но компилятор ругается на явно бессмысленные выражения, что иногда немного мешает.
Код
mixin template ProtocolPool(IndexType, SerializerBackend, pairs...)
{
template CheckPairs(tpairs...)
{
// скрыл, чтобы не мешало
}
static assert(CheckPairs!pairs == 0,
"Parameters check failed! If code works well, you never will see this message!");
}
Внимательный читатель (если вообще кто-нибудь добрался до этой строчки) наверняка заметил, что я не дал определение шаблону CountValInList, который считает число вхождений значения в список.
Код
// returns count of val occurenes in list
template CountValInList(IndexType val, list...)
{
static if(list.length > 1)
{
static if(list[0] == val)
enum CountValInList = 1 + CountValInList!(val, list[2..$]);
else
enum CountValInList = CountValInList!(val, list[2..$]);
}
else
enum CountValInList = 0;
}
Кодогенерация
Отлично, все неправильные использования отсечены и правильно обработаны. По таким сообщениям об ошибках вполне можно найти правильный способ использования методом научного тыка (от написания документации это не спасет!). Теперь нужно подумать о самой задаче. Нам нужен компромисс между удобством использования и скоростью работы, стоп, мы можем получить и то и то одновременно! Мы будем генерировать гигантский switch автоматически без участия программиста:
Код
// generating switch
template GenerateSwitch()
{
template GenerateSwitchBody(tpairs...)
{
static if(tpairs.length > 0)
{
enum GenerateSwitchBody =
"case("~to!string(tpairs[0])~
"): return cast(Message)(func!(SerializerBackend, "~
tpairs[1].stringof~")(args)); break; \n" ~
GenerateSwitchBody!(tpairs[2..$]);
}
else
enum GenerateSwitchBody = "";
}
enum GenerateSwitch = "switch(id)\n{\n"~
GenerateSwitchBody!(pairs) ~ "default: " ~
" break;\n}";
}
Этот шаблон будет генерировать строку, похожую на эту:
Код
switch(id)
{
case(0): return cast(Message)(func!(SerializerBackend, AMsg)(args)); break;
case(1): return cast(Message)(func!(SerializerBackend, BMsg)(args)); break;
case(2): return cast(Message)(func!(SerializerBackend, CMsg)(args)); break;
default: break;
}
Теперь осталось подмешать полученную строку в функцию для диспетчиризации:
Код
// С радостью поместил бы эту затычку внутрь функции, но мой сериализатор не увидит nested class и выдаст ошибку, поэтому польза от проверки нивелируется
private class dummyClass {}
// func - это функция, которая будет вызвана внутри сгенеренного свитча с аргументами args и типом сообщения
Message dispatchMessage(alias func, T...)(IndexType id, T args)
{
static assert(__traits(compiles,
func!(SerializerBackend, dummyClass)(args)),
"ChooseMessage func must be callable with got args "
~T.stringof);
// можно распечатать, чтобы убедиться в правильности генерации
//pragma(msg, GenerateSwitch!());
mixin(GenerateSwitch!());
throw new Exception(
"Cannot find corresponding message for id "~to!string(id)~"!");
}
Как будет выглядеть вызов этой функции в коде:
Код
void readMsg(Stream stream)
{
int id;
stream.read(id);
writeln("Got message id is ",id);
auto message = dispatchMessage!(deserialize)(id, stream, "MSG");
writeln("Calling message");
message();
}
Собственно самая сложная часть написана, остались только всякие вкусности для удобного конструирования сообщения. Никто же не хочет делать это вручную?! Гораздо удобнее делать это так:
auto stream = constructMessage!AMsg(10, "Hello World!");
Никаких id, никаких других лишних вещей. Параметры сразу передадутся конструктору сообщения, и сообщение сериализируется в поток байтов. Осталось это написать… Нужно уметь искать id сообщения по типу, для этого нужен еще один шаблончик:
Код
template FindMessageId(Msg, tpairs...)
{
static if(tpairs.length > 0)
{
static if(is(tpairs[1] == Msg))
enum FindMessageId = tpairs[0];
else
enum FindMessageId =
FindMessageId!(Msg, tpairs[2..$]);
} else
static assert(false, "Cannot find id for message "~
Msg.stringof~". Check protocol list.");
}
К этому моменту у моей крохотной по числу публики должна возникнуть мысль, что я страдаю манией к функциональному программированию. Я уважаю все парадигмы, но в compile-time шаблонах нету никакого mutable состояния, поэтому тут естественным образом возникает функциональный стиль. Теперь не составит труда сконструировать сообщение, зная только его тип:
Код
Stream constructMessage(Msg, T...)(T args)
{
static assert(is(Msg : Message), Msg.stringof~
" must implement Message interface!");
static assert(__traits(compiles, new Msg(args)), Msg.stringof~
" should implement constructor with formal parameters "~
T.stringof);
auto msg = new Msg(args);
IndexType sendId = FindMessageId!(Msg, pairs);
auto stream = serialize!SerializerBackend(msg, "MSG");
auto fullStream = new MemoryStream;
fullStream.write(sendId);
fullStream.copyFrom(stream);
fullStream.position = 0;
return fullStream;
}
Использование
Теперь, когда у нас есть эта навороченная система, нужно ее проверить на практике. Для этого я написал unittest:
Код
version(unittest)
{
class AMsg : Message
{
int a;
string b;
this() {}
this(int pa, string pb)
{
a = pa;
b = pb;
}
void opCall()
{
writeln("AMsg call with ", a, " ", b);
}
}
class BMsg : Message
{
double a;
double b;
this() {}
this(double pa, double pb)
{
a = pa;
b = pb;
}
void opCall()
{
writeln("BMsg call with ", a, " ", b);
}
}
class CMsg : Message
{
double a;
string s;
this() {}
this(double pa, string ps)
{
a = pa;
s = ps;
}
void opCall()
{
writeln("CMsg call ", a, " ", s);
}
}
mixin ProtocolPool!(int, GendocArchive,
0, AMsg,
1, BMsg,
2, CMsg
);
}
unittest
{
void readMsg(Stream stream)
{
int id;
stream.read(id);
writeln("Got message id is ",id);
auto message = dispatchMessage!(deserialize)(id, stream, "MSG");
writeln("Calling message");
message();
}
// serializing
auto stream = constructMessage!BMsg(4.0,8.0);
// sending...
// got at other side
readMsg(stream);
stream = constructMessage!AMsg(10, "Hello World!");
readMsg(stream);
stream = constructMessage!CMsg(5., "Some usefull string");
readMsg(stream);
}
Полный исходный код
Для целостности картины ниже находится полный исходник под Boost лицензией. Для нормальной работы модулю нужен сериализатор, можно прикрутить свой или воспользоваться Orange.
Код
// Copyright Gushcha Anton 2012.
// Distributed under the Boost Software License, Version 1.0.
// (See accompanying file LICENSE_1_0.txt or copy at
// http://www.boost.org/LICENSE_1_0.txt)
module protocol;
import std.stdio;
import std.conv;
import std.stream;
// Злополучный сериализатор, которы�� не вошел в статью
import util.serialization.serializer;
interface Message
{
void opCall();
}
mixin template ProtocolPool(IndexType, SerializerBackend, pairs...)
{
// returns count of val occurenes in list
template CountValInList(IndexType val, list...)
{
static if(list.length > 1)
{
static if(list[0] == val)
enum CountValInList = 1 + CountValInList!(val, list[2..$]);
else
enum CountValInList = CountValInList!(val, list[2..$]);
}
else
enum CountValInList = 0;
}
// check pairs to be correct
template CheckPairs(tpairs...)
{
static if(tpairs.length > 1)
{
static assert(__traits(compiles, typeof(tpairs) ), "ProtocolPool expected index first, but got some type");
static assert(is(typeof(tpairs[0]) == IndexType), "ProtocolPool expected index first of type "~IndexType.stringof~" not a "~typeof(tpairs[0]).stringof);
static assert(is(tpairs[1] : Message), "ProtocolPool expected class implementing Message interface following index not a "~tpairs[1].stringof);
static assert(CountValInList!(tpairs[0], pairs) == 1, "ProtocolPool indexes must be unique! One message, one index.");
enum CheckPairs = CheckPairs!(tpairs[2..$]);
}
else
{
static assert(tpairs.length == 0, "ProtocolPool expected even number of parameters. Index and message type.");
enum CheckPairs = 0;
}
}
// generating switch
template GenerateSwitch()
{
template GenerateSwitchBody(tpairs...)
{
static if(tpairs.length > 0)
{
enum GenerateSwitchBody = "case("~to!string(tpairs[0])~"): return cast(Message)(func!(SerializerBackend, "~tpairs[1].stringof~")(args)); break; \n" ~
GenerateSwitchBody!(tpairs[2..$]);
}
else
enum GenerateSwitchBody = "";
}
enum GenerateSwitch = "switch(id)\n{\n"~GenerateSwitchBody!(pairs) ~
`default: ` ~
" break;\n}";
}
template FindMessageId(Msg, tpairs...)
{
static if(tpairs.length > 0)
{
static if(is(tpairs[1] == Msg))
enum FindMessageId = tpairs[0];
else
enum FindMessageId = FindMessageId!(Msg, tpairs[2..$]);
} else
static assert(false, "Cannot find id for message "~Msg.stringof~". Check protocol list.");
}
// actual check
static assert(CheckPairs!pairs == 0, "Parameters check failed! If code works well, you never will see this message!");
private class dummyClass {}
Message dispatchMessage(alias func, T...)(IndexType id, T args)
{
static assert(__traits(compiles, func!(SerializerBackend, dummyClass)(args)), "ChooseMessage func must be callable with got args "~T.stringof);
//pragma(msg, GenerateSwitch!());
mixin(GenerateSwitch!());
throw new Exception("Cannot find corresponding message for id "~to!string(id)~"!");
}
Stream constructMessage(Msg, T...)(T args)
{
static assert(is(Msg : Message), Msg.stringof~" must implement Message interface!");
static assert(__traits(compiles, new Msg(args)), Msg.stringof~" should implement constructor with formal parameters "~T.stringof);
auto msg = new Msg(args);
IndexType sendId = FindMessageId!(Msg, pairs);
auto stream = serialize!SerializerBackend(msg, "MSG");
auto fullStream = new MemoryStream;
fullStream.write(sendId);
fullStream.copyFrom(stream);
fullStream.position = 0;
return fullStream;
}
}
version(unittest)
{
class AMsg : Message
{
int a;
string b;
this() {}
this(int pa, string pb)
{
a = pa;
b = pb;
}
void opCall()
{
writeln("AMsg call with ", a, " ", b);
}
}
class BMsg : Message
{
double a;
double b;
this() {}
this(double pa, double pb)
{
a = pa;
b = pb;
}
void opCall()
{
writeln("BMsg call with ", a, " ", b);
}
}
class CMsg : Message
{
double a;
string s;
this() {}
this(double pa, string ps)
{
a = pa;
s = ps;
}
void opCall()
{
writeln("CMsg call ", a, " ", s);
}
}
mixin ProtocolPool!(int, BinaryBackend,
0, AMsg,
1, BMsg,
2, CMsg
);
}
unittest
{
void readMsg(Stream stream)
{
int id;
stream.read(id);
writeln("Got message id is ",id);
auto message = dispatchMessage!(deserialize)(id, stream, "MSG");
writeln("Calling message");
message();
}
// serializing
auto stream = constructMessage!BMsg(4.0,8.0);
// sending...
// Got at other side
readMsg(stream);
stream = constructMessage!AMsg(10, "Hello World!");
readMsg(stream);
stream = constructMessage!CMsg(5., "Some usefull string");
readMsg(stream);
}
