Message dispatching на D

в 14:09, , рубрики: compile-time, dlang, game development, Gamedev, Программирование, метки: , ,

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

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

Клиент и сервер постоянно перекидываются сообщениями, но эти сообщения нужно сначала подготовить, переслать и потом восстановить в читаемый вид. Краткая cхемка ниже:

Message dispatching на D

Главная проблема возникает между этапом чтения сообщения и десериализацией, к получателю приходит поток байтов, а для корректной десериализации нужно знать структуру сообщения, то есть тип. Все операции над типами завершились в 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 getted 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 getted 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("Getted 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("Getted 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...
	// getted 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 getted 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 getted 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("Getted 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...
	// getted at other side
	readMsg(stream);

	stream = constructMessage!AMsg(10, "Hello World!");
	readMsg(stream);

	stream = constructMessage!CMsg(5., "Some usefull string");
	readMsg(stream);
}

Автор: NCrashed

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js