Многие разработчики игр сталкиваются с проблемой описания и реализации протокола общения клиента и сервера, особенно если пишут свои велосипеды для работы с сокетами. Ниже я расскажу о моей попытке решить задачу как можно элегантнее и удобнее для дальнейшего использования и масштабирования приложения. Будет много 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); }
