Pull to refresh

Как писать unit-тесты для акторов? Подход SObjectizer-а

Reading time17 min
Views2.1K
Акторы упрощают многопоточное программирование за счет ухода от общего разделяемого изменяемого состояния. Каждый актор владеет собственными данными, которые никому не видны. Взаимодействуют акторы только посредством асинхронных сообщений. Поэтому самые кошмарные ужасы многопоточности в виде гонок и дедлоков при использовании акторов не страшны (хотя у акторов есть свои заморочки, но сейчас не об этом).

В общем, писать многопоточные приложения с использованием акторов легко и приятно. В том числе и потому, что сами акторы пишутся легко и непринужденно. Можно даже сказать, что написание кода актора — это самая простая часть работы. Но вот когда актор написан, то возникает очень хороший вопрос: «Как проверить правильность его работы?»

Вопрос, действительно, очень хороший. Нам его регулярно задают когда мы рассказываем про акторов вообще и про SObjectizer в частности. И до недавнего времени мы могли отвечать на этот вопрос лишь общими словами.

Но вот вышла версия 5.5.24, в которой появилась экспериментальная поддержка возможности unit-тестирования акторов. И в данной статье мы попытаемся рассказать о том, что это, как этим пользоваться и с помощью чего это было реализовано.

Как выглядят тесты для акторов?


Мы рассмотрим новые возможности SObjectizer-а на паре примеров, попутно рассказывая что к чему. Исходные тексты к обсуждаемым примерам могут быть найдены в этом репозитории.

По ходу рассказа будут попеременно использоваться термины «актор» и «агент». Обозначают они одно и тоже, но в SObjectizer-е исторически используется термин «агент», поэтому далее «агент» будет использоваться чаще.

Простейший пример с Pinger-ом и Ponger-ом


Пример с акторами Pinger и Ponger является, наверное, самым распространенным примером для акторных фреймворков. Можно сказать, классика. Ну а раз так, то давайте и мы начнем с классики.

Итак, у нас есть агент Pinger, который в начале своей работы отсылает сообщение Ping агенту Ponger. А агент Ponger отсылает в ответ сообщение Pong. Вот так это выглядит в C++ном коде:

// Types of signals to be used.
struct ping final : so_5::signal_t {};
struct pong final : so_5::signal_t {};

// Pinger agent.
class pinger_t final : public so_5::agent_t {
	so_5::mbox_t m_target;
public :
	pinger_t( context_t ctx ) : so_5::agent_t{ std::move(ctx) } {
		so_subscribe_self().event( [this](mhood_t<pong>) {
			so_deregister_agent_coop_normally();
		} );
	}

	void set_target( const so_5::mbox_t & to ) { m_target = to; }

	void so_evt_start() override {
		so_5::send< ping >( m_target );
	}
};

// Ponger agent.
class ponger_t final : public so_5::agent_t {
	so_5::mbox_t m_target;
public :
	ponger_t( context_t ctx ) : so_5::agent_t{ std::move(ctx) } {
		so_subscribe_self().event( [this](mhood_t<ping>) {
			so_5::send< pong >( m_target );
		} );
	}

	void set_target( const so_5::mbox_t & to ) { m_target = to; }
};

Наша задача написать тест, который бы проверял, что при регистрации этих агентов в SObjectizer-е Ponger получит сообщение Ping, а Pinger в ответ получит сообщение Pong.

OK. Пишем такой тест с использованием unit-тест-фреймворка doctest и получаем:

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include <doctest/doctest.h>

#include <ping_pong/agents.hpp>

#include <so_5/experimental/testing.hpp>

namespace tests = so_5::experimental::testing;

TEST_CASE( "ping_pong" )
{
	tests::testing_env_t sobj;

	pinger_t * pinger{};
	ponger_t * ponger{};
	sobj.environment().introduce_coop([&](so_5::coop_t & coop) {
		pinger = coop.make_agent< pinger_t >();
		ponger = coop.make_agent< ponger_t >();

		pinger->set_target( ponger->so_direct_mbox() );
		ponger->set_target( pinger->so_direct_mbox() );
	});

	sobj.scenario().define_step("ping")
		.when(*ponger & tests::reacts_to<ping>());

	sobj.scenario().define_step("pong")
		.when(*pinger & tests::reacts_to<pong>());

	sobj.scenario().run_for(std::chrono::milliseconds(100));

	REQUIRE(tests::completed() == sobj.scenario().result());
}

Вроде бы несложно. Давайте посмотрим, что же здесь происходит.

Прежде всего, мы загружаем описания средств поддержки тестирования агентов:

#include <so_5/experimental/testing.hpp>

Все эти средства описаны в пространстве имен so_5::experimental::testing, но чтобы не повторять такое длинное имя мы вводим более короткий и удобный псевдоним:

namespace tests = so_5::experimental::testing;

Далее идет описание единственного test-кейса (а более нам здесь и не нужно).

Внутри test-кейса можно выделить несколько ключевых моментов.

Во-первых, это создание и запуск специального тестового окружения для SObjectizer-а:

tests::testing_env_t sobj;

Без этого окружения «тестовый прогон» для агентов выполнить не получится, но об этом мы поговорим чуть ниже.

Класс testing_env_t очень похож на имеющийся в SObjectizer-е класс wrapped_env_t. Точно так же в конструкторе запускается SObjectizer, а в деструкторе останавливается. Так что при написании тестов не приходится задумываться о запуске и останове SObjectizer-а.

Далее нам нужно создать и зарегистрировать агентов Pinger и Ponger. При этом нам нужно использовать этих агентов при определении т.н. «тестового сценария». Поэтому мы отдельно сохраняем указатели на агентов:

pinger_t * pinger{};
ponger_t * ponger{};
sobj.environment().introduce_coop([&](so_5::coop_t & coop) {
	pinger = coop.make_agent< pinger_t >();
	ponger = coop.make_agent< ponger_t >();

	pinger->set_target( ponger->so_direct_mbox() );
	ponger->set_target( pinger->so_direct_mbox() );
});

И вот дальше мы начинаем работать с «тестовым сценарием».

Тестовый сценарий — это состоящая из прямой последовательности шагов штука, которая должна быть выполнена от начала до конца. Фраза «из прямой последовательности» означает, что в SObjectizer-5.5.24 шаги сценария «срабатывают» строго последовательно, без каких-либо ветвлений и циклов.

Написание теста для агентов — это определение тестового сценария, который должен быть исполнен. Т.е. должны сработать все шаги тестового сценария, начиная от самого первого и заканчивая самым последним.

Поэтому в своем test-кейсе мы определяем сценарий из двух шагов. Первый шаг проверяет, что агент Ponger получит и обработает сообщение Ping:

sobj.scenario().define_step("ping")
	.when(*ponger & tests::reacts_to<ping>());

Второй шаг проверяет, что агент Pinger получит сообщение Pong:

sobj.scenario().define_step("pong")
	.when(*pinger & tests::reacts_to<pong>());

Этих двух шагов для нашего test-кейса вполне достаточно, поэтому после их определения мы переходим к исполнению сценария. Запускаем сценарий и разрешаем ему работать не дольше 100ms:

sobj.scenario().run_for(std::chrono::milliseconds(100));

Ста миллисекунд должно быть более чем достаточно для того, чтобы два агента обменялись сообщениями (даже если тест будет запущен внутри очень тормозной виртуальной машины, как это иногда бывает на Travis CI). Ну а если мы ошиблись в написании агентов или неправильно описали тестовый сценарий, то ждать завершения ошибочного сценария больше 100ms нет смысла.

Итак, после возврата из run_for() наш сценарий может быть либо успешно завершен, либо нет. Поэтому мы просто проверяем результат работы сценария:

REQUIRE(tests::completed() == sobj.scenario().result());

Если сценарий не был успешно завершен, то это приведет к провалу нашего test-кейса.

Немного пояснений и дополнений


Если бы мы запустили вот такой код внутри нормального SObjectizer-а:

pinger_t * pinger{};
ponger_t * ponger{};
sobj.environment().introduce_coop([&](so_5::coop_t & coop) {
	pinger = coop.make_agent< pinger_t >();
	ponger = coop.make_agent< ponger_t >();

	pinger->set_target( ponger->so_direct_mbox() );
	ponger->set_target( pinger->so_direct_mbox() );
});

то, скорее всего, агенты Pinger и Ponger успели бы обменяться сообщениями и завершили бы свою работу еще до возврата из introduce_coop (чудеса многопоточности — они такие). Но внутри тестового окружения, которое создается благодаря testing_env_t, этого не происходит, агенты Pinger и Ponger терпеливо ждут, пока мы не запустим наш тестовый сценарий. Как такое происходит?

Дело в том, что внутри тестового окружения агенты оказываются как бы в замороженном состоянии. Т.е. после регистрации они в SObjectizer-е присутствуют, но ни одно свое сообщение обработать не могут. Поэтому у агентов даже so_evt_start() не вызывается до того, как будет запущен тестовый сценарий.

Когда же мы запускаем тестовый сценарий методом run_for(), то тестовый сценарий сперва размораживает всех замороженных агентов. А потом сценарий начинает получать от SObjectizer-а уведомления о том, что с агентами происходит. Например, о том, что агент Ponger получил сообщение Ping и что агент Ponger это сообщение обработал, а не отверг.

Когда к тестовому сценарию начинают приходить такие уведомления, сценарий пытается «примерить» их к самому первому шагу. Так, у нас есть уведомление о том, что Ponger получил и обработал Ping — это нам интересно или нет? Оказывается, что интересно, ведь в описании шага именно так и сказано: срабатывает когда Ponger реагирует на Ping. Что мы и видим в коде:

.when(*ponger & tests::reacts_to<ping>())

OK. Значит первый шаг сработал, переходим к следующему шагу.

Следом прилетает уведомление о том, что агент Pinger среагировал на Pong. И это как раз то, что нужно, чтобы сработал второй шаг:

.when(*pinger & tests::reacts_to<pong>())

OK. Значит и второй шаг сработал, есть ли у нас что-то еще? Нет. Значит и весь тестовый сценарий завершен и можно возвращать управление из run_for().

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

Пример «Обедающие философы»


Более сложные примеры тестирования агентов можно увидеть в решении широко известной задачи «Обедающие философы». На акторах эту задачу можно решить несколькими способами. Далее мы будем рассматривать самое тривиальное решение: в виде акторов представлены и сами философы, и вилки, за которые философам приходится бороться. Каждый философ некоторое время думает, затем пытается взять вилку слева. Если это удалось — он пытается взять вилку справа. Если и это удалось, то философ некоторое время ест, после чего кладет вилки и начинает думать. Если же вилку справа взять не удалось (т.е. она взята другим философом), то философ возвращает вилку слева и думает еще какое-то время. Т.е. это не самое хорошее решение в том плане, что какой-то философ может слишком долго голодать. Но зато оно очень простое. И обладает простором для демонстрации возможностей по тестированию агентов.

Исходные коды с реализацией агентов Fork и Philosopher могут быть найдены здесь, в статье мы их рассматривать не будем для экономии объема.

Тест для Fork


Первый тест для агентов из «Обедающих философов» мы напишем для агента Fork.

Этот агент работает по простой схеме. У него есть два состояния: Free и Taken. Когда агент находится в состоянии Free, он реагирует на сообщение Take. При этом агент переходит в состояние Taken и отвечает ответным сообщением Taken.

Когда агент находится в состоянии Taken, он реагирует на сообщение Take уже по другому: состояние агента не меняется, а в качестве ответного сообщения отсылается Busy. Также в состоянии Taken агент реагирует на сообщение Put: агент возвращается в состояние Free.

В состоянии Free сообщение Put игнорируется.

Вот этот вот все мы и попробуем протестировать посредством следующего test-кейса:

TEST_CASE( "fork" )
{
	class pseudo_philosopher_t final : public so_5::agent_t {
	public:
		pseudo_philosopher_t(context_t ctx) : so_5::agent_t{std::move(ctx)} {
			so_subscribe_self()
				.event([](mhood_t<msg_taken>) {})
				.event([](mhood_t<msg_busy>) {});
		}
	};

	tests::testing_env_t sobj;

	so_5::agent_t * fork{};
	so_5::agent_t * philosopher{};
	sobj.environment().introduce_coop([&](so_5::coop_t & coop) {
		fork = coop.make_agent<fork_t>();
		philosopher = coop.make_agent<pseudo_philosopher_t>();
	});

	sobj.scenario().define_step("put_when_free")
		.impact<msg_put>(*fork)
		.when(*fork & tests::ignores<msg_put>());

	sobj.scenario().define_step("take_when_free")
		.impact<msg_take>(*fork, philosopher->so_direct_mbox())
		.when_all(
			*fork & tests::reacts_to<msg_take>() & tests::store_state_name("fork"),
			*philosopher & tests::reacts_to<msg_taken>());

	sobj.scenario().define_step("take_when_taken")
		.impact<msg_take>(*fork, philosopher->so_direct_mbox())
		.when_all(
			*fork & tests::reacts_to<msg_take>(),
			*philosopher & tests::reacts_to<msg_busy>());

	sobj.scenario().define_step("put_when_taken")
		.impact<msg_put>(*fork)
		.when(
			*fork & tests::reacts_to<msg_put>() & tests::store_state_name("fork"));

	sobj.scenario().run_for(std::chrono::milliseconds(100));

	REQUIRE(tests::completed() == sobj.scenario().result());

	REQUIRE("taken" == sobj.scenario().stored_state_name("take_when_free", "fork"));
	REQUIRE("free" == sobj.scenario().stored_state_name("put_when_taken", "fork"));
}

Кода много, поэтому будем разбираться с ним по частям, пропуская те фрагменты, которые уже должны быть понятны.

Первое, что нам здесь понадобится — это замена реального агента Philosopher. Агент Fork должен от кого-то получать сообщения и кому-то отвечать. Но мы не можем в этом test-кейсе использовать настоящего Philosopher-а, ведь у реального агента Philosopher своя логика поведения, он сам отсылает сообщения и эта самостоятельность нам здесь будет мешать.

Поэтому мы делаем mocking, т.е. вводим вместо реального Philosopher-а его заменитель: пустой агент, который ничего сам не отсылает, а отосланные сообщения только принимает, без какой-либо полезной обработки. Это и есть реализованный в коде псевдо-Философ:

class pseudo_philosopher_t final : public so_5::agent_t {
public:
	pseudo_philosopher_t(context_t ctx) : so_5::agent_t{std::move(ctx)} {
		so_subscribe_self()
			.event([](mhood_t<msg_taken>) {})
			.event([](mhood_t<msg_busy>) {});
	}
};

Далее мы создаем кооперацию из агента Fork и агента PseudoPhilospher и начинаем определять содержимое нашего тестового сценария.

Первый шаг сценария — это проверка того, что Fork, будучи в состоянии Free (а это его начальное состояние), не реагирует на сообщение Put. Вот как эта проверка записывается:

sobj.scenario().define_step("put_when_free")
	.impact<msg_put>(*fork)
	.when(*fork & tests::ignores<msg_put>());

Первая штука, которая обращает на себя внимание — это конструкция impact.

Нужна она потому, что наш агент Fork сам ничего не делает, он только реагирует на входящие сообщения. Поэтому сообщение агенту кто-то должен отослать. Но кто?

А вот сам шаг сценария и отсылает посредством impact. По сути, impact — это аналог привычной функции send (и формат такой же).

Отлично, сам шаг сценария будет отсылать сообщение через impact. Но когда он это будет делать?

А будет он это делать, когда до него дойдет очередь. Т.е. если шаг в сценарии первый, то impact будет выполнен сразу после входа в run_for. Если шаг в сценарии не первый, то impact будет выполняться как только сработает предыдущий шаг и сценарий перейдет к обработке очередного шага.

Вторая штука, которую нам здесь нужно обсудить — это вызов ignores. Эта вспомогательная функция говорит, что шаг срабатывает когда агент оказывается от обработки сообщения. Т.е. в данном случае агент Fork должен отказаться обрабатывать сообщение Put.

Рассмотрим еще один шаг тестового сценария подробнее:

sobj.scenario().define_step("take_when_free")
	.impact<msg_take>(*fork, philosopher->so_direct_mbox())
	.when_all(
		*fork & tests::reacts_to<msg_take>() & tests::store_state_name("fork"),
		*philosopher & tests::reacts_to<msg_taken>());

Во-первых, мы здесь видим when_all вместо when. Это потому, что для срабатывания шага нам нужно выполнение сразу нескольких условий. Нужно, чтобы агент fork обработал Take. И нужно, чтобы Philosopher обработал ответное Taken. Поэтому мы и пишем when_all, а не when. Кстати говоря, есть еще и when_any, но в рассматриваемых сегодня примерах мы с ним не встретимся.

Во-вторых, нам здесь нужно еще и проверить тот факт, что после обработки Take агент Fork окажется в состоянии Taken. Проверку мы делаем следующим образом: сперва указываем, что как только агент Fork закончит обрабатывать Take, имя его текущего состояние должно быть сохранено с использованием тега-маркера «fork». Вот эта конструкция как раз и предписывает сохранить имя состояния агента:

& tests::store_state_name("fork")

А далее, уже когда сценарий завершен успешно, мы проверяем это сохраненное имя:
REQUIRE("taken" == sobj.scenario().stored_state_name("take_when_free", "fork"));

Т.е. мы просим у сценария: дай нам имя, которое было сохранено с тегом-маркером «fork» для шага с именем «take_when_free», после чего сравниваем имя с ожидаемым значением.

Вот, пожалуй, и все, что можно было бы отметить в test-кейсе для агента Fork. Если у читателей остались какие-то вопросы, то задавайте в комментариях, с удовольствием ответим.

Тест успешного сценария для Philosopher


Для агента Philosopher мы рассмотрим только один test-кейс — для случая, когда Philosopher сможет взять обе вилки и поесть.

Выглядеть этот test-кейс будет следующим образом:

TEST_CASE( "philosopher (takes both forks)" )
{
	tests::testing_env_t sobj{
		[](so_5::environment_params_t & params) {
			params.message_delivery_tracer(
					so_5::msg_tracing::std_cout_tracer());
		}
	};

	so_5::agent_t * philosopher{};
	so_5::agent_t * left_fork{};
	so_5::agent_t * right_fork{};

	sobj.environment().introduce_coop([&](so_5::coop_t & coop) {
		left_fork = coop.make_agent<fork_t>();
		right_fork = coop.make_agent<fork_t>();
		philosopher = coop.make_agent<philosopher_t>(
			"philosopher",
			left_fork->so_direct_mbox(),
			right_fork->so_direct_mbox());
	});

	auto scenario = sobj.scenario();

	scenario.define_step("stop_thinking")
		.when( *philosopher
				& tests::reacts_to<philosopher_t::msg_stop_thinking>()
				& tests::store_state_name("philosopher") )
		.constraints( tests::not_before(std::chrono::milliseconds(250)) );

	scenario.define_step("take_left")
		.when( *left_fork & tests::reacts_to<msg_take>() );

	scenario.define_step("left_taken")
		.when( *philosopher
				& tests::reacts_to<msg_taken>()
				& tests::store_state_name("philosopher") );

	scenario.define_step("take_right")
		.when( *right_fork & tests::reacts_to<msg_take>() );

	scenario.define_step("right_taken")
		.when( *philosopher
				& tests::reacts_to<msg_taken>()
				& tests::store_state_name("philosopher") );

	scenario.define_step("stop_eating")
		.when( *philosopher
				& tests::reacts_to<philosopher_t::msg_stop_eating>()
				& tests::store_state_name("philosopher") )
		.constraints( tests::not_before(std::chrono::milliseconds(250)) );

	scenario.define_step("return_forks")
		.when_all( 
				*left_fork & tests::reacts_to<msg_put>(),
				*right_fork & tests::reacts_to<msg_put>() );

	scenario.run_for(std::chrono::seconds(1));

	REQUIRE(tests::completed() == scenario.result());

	REQUIRE("wait_left" == scenario.stored_state_name("stop_thinking", "philosopher"));
	REQUIRE("wait_right" == scenario.stored_state_name("left_taken", "philosopher"));
	REQUIRE("eating" == scenario.stored_state_name("right_taken", "philosopher"));
	REQUIRE("thinking" == scenario.stored_state_name("stop_eating", "philosopher"));
}

Довольно объемно, но тривиально. Сперва проверяем, что Philosopher закончил думать и начал готовится к еде. Затем проверяем, что он попытался взять левую вилку. Далее он должен попытаться взять правую вилку. Затем он должен поесть и прекратить это занятие. После чего он должен положить обе взятые вилки.

В общем-то все просто. Но следует заострить внимание на двух вещах.

Во-первых, класс testing_env_t, как и его прообраз, wrapped_env_t, позволяет настроить SObjectizer Environment. Мы этим воспользуемся для того, чтобы включить механизм message delivery tracing:

tests::testing_env_t sobj{
	[](so_5::environment_params_t & params) {
		params.message_delivery_tracer(
				so_5::msg_tracing::std_cout_tracer());
	}
};

Данный механизм позволяет «визуализировать» процесс доставки сообщений, что помогает при разбирательстве с поведением агентов (об этом мы уже рассказывали подробнее).

Во-вторых, ряд действий агент Philosopher выполняет не сразу, а спустя какое-то время. Так, начав работать агент должен отсылать себе отложенное сообщение StopThinking. Значит прийти это сообщение к агенту должно спустя сколько-то миллисекунд. Что мы и указываем задавая для определенного шага нужное ограничение:

scenario.define_step("stop_thinking")
	.when( *philosopher
			& tests::reacts_to<philosopher_t::msg_stop_thinking>()
			& tests::store_state_name("philosopher") )
	.constraints( tests::not_before(std::chrono::milliseconds(250)) );

Т.е. здесь мы говорим, что нас интересует не любая реакция агента Philosopher на StopThinking, а только та, которая произошла не раньше, чем через 250ms после начала обработки этого шага.

Ограничение вида not_before указывает сценарию, что все события, которые происходят до истечения заданного тайм-аута, должны игнорироваться.

Есть также ограничение вида not_after, оно работает наоборот: учитываются лишь те события, которые происходят пока не истек заданный тайм-аут.

Ограничения not_before и not_after могут комбинироваться, например:

.constraints(
	tests::not_before(std::chrono::milliseconds(250)),
	tests::not_after(std::chrono::milliseconds(1250)))

но в этом случае SObjectizer не проверяет непротиворечивость заданных значений.

Как это удалось реализовать?


Хотелось бы сказать пару слов о том, как получилось все это заставить работать. Ведь, по большому счету, перед нами стоял один большой идеологический вопрос «Как в принципе тестировать агентов?» и один вопрос поменьше, уже технический: «Как это реализовать?»

И если по поводу идеологии тестирования можно было изгаляться как угодно, то вот по поводу реализации ситуация была сложнее. Требовалось найти такое решение, которое, во-первых, не требовало бы кардинальной переделки внутренностей SObjectizer-а. И, во-вторых, это должно было быть решение, которое можно было бы реализовать в обозримое и, крайне желательно, небольшое время.

В результате непростого процесса выкуривания бамбука решение таки было найдено. Для этого потребовалось, по сути, внести всего одно небольшое нововведение в штатное поведение SObjectizer-а. А основу решения составляет механизм конвертов для сообщений, который был добавлен в версии 5.5.23 и о котором мы уже рассказывали.

Внутри тестового окружения каждое отсылаемое сообщение оборачивается в специальные конверт. Когда конверт с сообщением отдается агенту на обработку (либо, наоборот, отвергается агентом), об этом становится известно тестовому сценарию. Именно благодаря конвертам тестовый сценарий знает, что происходит и может определять моменты, когда «срабатывают» шаги сценария.

Но как заставить SObjectizer оборачивать каждое сообщение в специальный конверт?

Вот это был интересный вопрос. Решился он следующим образом: было придумано такое понятие, как event_queue_hook. Это специальный объект с двумя методами — on_bind и on_unbind.

Когда какой-либо агент привязывается к конкретному диспетчеру, диспетчер выдает агенту персональный event_queue. Через этот event_queue заявки для агента попадают в нужную очередь и становятся доступны диспетчеру для обработки. Когда агент работает внутри SObjectizer-а, у него хранится указатель на event_queue. Когда агент изымается из SObjectizer-а, у него указатель на event_queue обнуляется.

Так вот, начиная с версии 5.5.24 агент при получении event_queue обязан вызвать метод on_bind у event_queue_hook-а. Куда агент должен передать полученный указатель на event_queue. А event_queue_hook в ответ может вернуть либо тот же самый указатель, либо другой указатель. И агент должен использовать возвращенное значение.

Когда агент изымается из SObjectizer-а, то он обязан вызвать on_unbind у event_queue_hook-а. В on_unbind агент передает то значение, которое было возвращено методом on_bind.

Вся эта кухня выполняется внутри SObjectizer-а и пользователю ничего этого не видно. Да и, в принципе, об этом можно вообще не знать. Но тестовое окружение SObjectizer-а, тот самый testing_env_t, эксплуатирует именно event_queue_hook. Внутри testing_env_t создается специальная реализация event_queue_hook. Эта реализация в on_bind оборачивает каждый event_queue в специальный прокси-объект. И уже этот прокси-объект кладет отсылаемые агенту сообщения в специальный конверт.

Но это еще не все. Можно вспомнить, что в тестовом окружении агенты должны оказаться в замороженном состоянии. Это также реализуется посредством упомянутых прокси-объектов. Пока тестовый сценарий не запущен, прокси-объект хранит отосланные агенту сообщения у себя. Но когда сценарий запускается, прокси-объект передает все ранее накопленные сообщения в актуальную очередь сообщений агента.

Заключение


В заключение статьи хочется сказать две вещи.

Во-первых, мы реализовали в SObjectizer-е свой взгляд на то, как можно тестировать агентов. Свой взгляд потому, что хороших образцов для подражания вокруг не так уж и много. Мы смотрели в сторону Akka.Testing. Но Akka и SObjectizer слишком уж различаются чтобы переносить в SObjectizer подходы, которые работают в Akka. Да и C++ не Scala/Java, в которых какие-то вещи, связанные с интроспекцией, можно делать за счет рефлексии. Так что пришлось придумывать подход, который лег бы на SObjectizer.

В версии 5.5.24 стала доступна самая первая, экспериментальная реализация. Наверняка можно сделать лучше. Но как понять, что окажется полезным, а что бесполезные фантазии? К сожалению, никак. Нужно брать и пробовать, смотреть, что получается на практике.

Вот мы и сделали минималистичную версию, которую можно взять и попробовать. Что мы и предлагаем сделать всем желающим: попробуйте, поэкспериментируйте и поделитесь с нами своими впечатлениями. Что вам понравилось, что не понравилось? Может чего-то не хватает?

Во-вторых, еще более актуальными стали слова которые были сказаны в начале 2017-го года:
… набор подобных вспомогательных инструментов в акторном фреймворке, на мой взгляд, является некоторым признаком, который определяет зрелость фреймворка. Ибо реализовать в своем фреймворке какую-то идею и показать ее работоспособность — это не так уж и сложно. Можно потратить несколько месяцев труда и получить вполне себе работающий и интересный инструмент. Это все делается на чистом энтузиазме. Буквально: понравилась идея, захотел и сделал.

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

Поэтому мой совет тем, кто ищет готовый акторный фреймворк: обратите внимание не только на оригинальность идей и красоту примеров. Посмотрите также на всякие вспомогательные вещи, которые помогут вам разобраться, что же происходит в вашем приложении: например, узнать, сколько сейчас внутри акторов вообще, какие у них размеры очередей, если сообщение не доходит до получателя, то куда оно девается… Если фреймворк что-то подобное предоставляет, то вам же будет проще. Если не предоставляет, то значит у вас будет больше работы.
Все вышесказанное имеет еще большее значение когда дело касается тестирования акторов. Поэтому, при выборе акторного фреймворка для себя обращайте внимание на то, что в нем есть, а чего нет. Вот в нашем, например, уже и средства для упрощения тестирования есть :)
Tags:
Hubs:
Total votes 14: ↑12 and ↓2+10
Comments0

Articles