Message dispatching на D

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

Поделиться публикацией

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 16

    0
    А почему большой switch? По-моему, таблица была бы гораздо эффективнее. Заодно можно было бы поименовать сообщения не по id, а с осмысленными именами, а еще лучше по самому имени класса-приемника.
      0
      Я не понял как именно вы хотите заменить id на строки в самом пакете сообщения. В начале сообщения всегда идет константное количество байтов для записи id, которое однозначно определяет то, что пойдет дальше. Можно заменить на строку постоянной длины, но она опять таки должна быть как можно меньше по размерам, чтобы не было большого overhead'а по памяти, так почему просто не использовать uint для этого?

      В большинстве случаев сериализатору (это все сериализаторы, которые я видел) нужно знать о типе класса сообщения еще на этапе компиляции. Допустим у нас есть таблица, которая переводит название сообщения в его id, который пойдет в поток на отправку, и наоборот. Как с помощью этой таблицы вызвать сериализатор с нужным типом? Я вижу тут 2 пути: написание опять таки switch и использование перегрузок функций с шаблоном Int2Type (Подсмотрел в книге Александреску «Современное программирование на С++»). Switch будет работать быстрее, а с помощью автоматической генерации мы получаем и скорость и удобство.
        0
        Мне просто подумалось, что
        <source lang="cpp"> mixin ProtocolPool!(int, BinaryBackend, 0, AMsg, 1, BMsg, 2, CMsg ); </source>
        Можно было бы заменить на
        <source lang="cpp"> mixin ProtocolPool!(int, BinaryBackend, "AMsg", "BMsg", "CMsg"); </source>
        С автоматической генерацией id по именам классов. Если id-строки не подойдут, то можно генерировать его по порядку от 0 до N или же как целочисленную функцию строки.
          0
          Ну все, сдаюсь, я не понимаю, как парсер работает…
      0
      А, понял. Да, можно сделать вот так:
      mixin ProtocolPool!(int, BinaryBackend, AMsg, BMsg, CMsg); 
      


      Но тут возникает подводный камень, так как индексы для сообщения вычисляются сами. Очень возможна ситуация, когда программист случайно перепутает местами сообщения в списке, и все… все ломается. Поэтому я решил специально заставлять явно указывать индексы, чтобы можно было проверить одинаковы ли протоколы на клиенте и сервере.
        0
        промахнулся
          0
          То есть вы решили, что программист вероятнее перепутает позицию типа, чем число?
          Вообще, я тоже об этом думал, потому и предложил изначально генерировать строковый id. Оверхед не такой уж и значительный, зато точно ничего никто не перепутает. Передавать строку как {len: uint, data: char[]} и все ОК, тем более, что типичные имена не такие уж и большие.
            0
            Ну можно использовать строки как id, главное следить за своими именами, чтобы не раздувать head сообщения. Сейчас меня посетила мысль, что можно просто брать хеш от имени и это будет уникальный id, который не зависит от порядка перечисления сообщений.
              0
              Да, я об этом тоже думал, только ведь коллизии. Как думаете решать этот вопрос?
                0
                Тут будет много подводных камней. Можно обнаружить коллизию на этапе компиляции и добавить к имени, например, последний символ и опять взять кеш. Главное продумать ситуацию, когда есть два разных клиента с немного разными наборами сообщений, а сервер имеет самый полный набор. Тогда обойти коллизии нереально…
          0
          Я не знаю D. Но применительно к C++, как бы можно решить эту же задачу?
          Я вижу так:
          С посылкой сообщения все довольно понятно, просто и красиво.
          А вот как быть с приемом сообщения.
          Мы получили строку(массив байт), в нем содержится тип и определенная структура. Эта структура у разных сообщения, очевидно, может быть совершенно разной. Как мы можем вызовом одной функции вернуть разные объекты? Вижу только одно решение, возвращать указатель на базовый класс, а в реальности там будет находится объект дочернего класса. Но тогда в базовом классе нужно для каждого типа сообщения создавать свой метод для чтения определенной структуры. Или же использоваться dynamic_cast от базового к дочернему. Первый способ — жесть, второй — мне не нравится, считаю, что dynamic_cast — это плохо. Как бы вы поступили?
            0
            В предыдущем проекте на С++ я решал эту проблему по первому варианту. У каждого сообщения была своя реализация чтения из потока байтов, а в менеджере была map<int, AbstractMessage*>. Хотя я как можно упростил создание реализаций методов чтения, такой вариант меня не радовал. И простым для использования нельзя назвать и эффективным тоже.
            Тут на самом деле большая проблема с сериализатором. У меня так и не получилось создать удобный сериализатор (я про C++), который не требовал бы вмешательства в классы (макросы или регистрации полей). Если допустить, что имеется общий алгоритм для чтения классов из байтов, то копать нужно в сторону шаблонов для автоматического построения иерархий классов, на каждом уровне проверять id на совпадение с заданным на этапе компиляции, и при совпадении вызывать сериализатор с нужным типом.
              +1
              Не смотрели в сторону google protocol buffer? code.google.com/p/protobuf/
                0
                Насколько я понял, нашу проблему он не решает. Сериализация/десериализация это не проблема. Тут проблема стоит в построении гибкой архитектуры управления сетевыми сообщениями. Вероятно, есть все же красивое решение, но мы пока о нем не знаем.
                  0
                  Или Thrift thrift.apache.org/ что лучше
                  0
                  Понятно.
                  Я сейчас решаю похожим образом, но не исключено, что более плохим.
                  class Message
                  {
                    enum MessageType
                    {
                      ..
                    };
                    
                    Message(MessageType type);
                    void add_param(..);
                    void add_param(..);
                    void get_param(..);
                    void get_param(..);
                    
                    MessageType get_message_tyoe();
                  };
                  


                  Да, не совсем безопасно, ошибки в compile-time не поймаешь. Зато добавление методов лишь в одном классе и никаких dynamic_cast.

                  Кстати от switch в итоге все равно ведь не избавишься. Можно(и нужно) лишь заменить его на словарик, ТипСообщения => ФункцияОбработки. Ведь нам не просто нужно получить сообщение, а еще каким-то образом его в дальнейшем использовать(сохранить/отобразить/..).

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое