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

Постановка задачи


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