Статья написана по следам недавнего вопроса, который можно сформулировать следующим образом: "Можно ли в SObjectizer написать обработчик, который бы обрабатывал сразу нескольких типов сообщений?"
Вопрос интересный.
Автор вопроса любезно описал свой сценарий: ему нужно собирать изображения с множества промышленных камер, а затем эти изображения должны проходить по цепочке блоков обработки изображений. Используются разные типы камер, соответственно, каждое изображение имеет собственный формат и может иметь кучу сопутствующей специфической информации. Какие-то блоки обработки рассчитаны на работу только с изображениями определенного формата. Какие-то могут работать сразу с несколькими форматами. Какие-то блоки вообще безразличны к типу изображения (например, блок считает общее количество прошедших изображений).
Если изображения передаются в виде SObjectizer-овских сообщений, а блоками обработки являются SObjectizer-овские агенты, то можно ли сделать как-то так:
void some_image_processor::so_define_agent() {
so_subscribe(image_mbox_)
.event([this](const image_vendor_A & cmd) {…}) // Изображение типа A.
.event([this](const image_vendor_B & cmd) {…}) // Изображение типа B.
.event([this](any_other_image_type) {
// Отказываемся обрабатывать другие типы.
throw unsupported_image_type{};
});
}
…
void image_counter::so_define_agent() {
so_subscribe(image_mbox_)
.event([this](any_image_type) { // Тип изображения не важен.
++captured_images_;
});
}
Итак, сценарий понятен. Давайте поговорим насколько он реализуем в SObjectizer.
Так можно ли в SObjectizer повесить один разработчик сразу на несколько типов сообщений?
Нет. Написать что-то вроде:
void some_image_processor::so_define_agent() {
so_subscribe(image_mbox_)
.event([this](const image_vendor_A & cmd) {...}) // Изображение типа A.
.event([this](const image_vendor_B & cmd) {...}) // Изображение типа B.
.event([this](any_other_image_type) {
// Отказываемся обрабатывать другие типы.
throw unsupported_image_type{};
});
}
в текущем SObjectizer-5 нельзя. В принципе.
Во-первых, в SObjectizer-5 ключем для поиска обработчика сообщения является триплет из состояния агента, идентификатора почтового ящика (mbox-а) и типа сообщения.
Грубо говоря, когда есть вот такой агент:
class demo final : public so_5::agent_t {
so_5::state_t st_free{this};
so_5::state_t st_busy{this};
const so_5::mbox_t command_board_;
...
void so_define_agent() override {
st_free // Подписки для состояния st_free.
.event([this](const some_msg &) {...})
.event(command_board_, [this](const report_status &) {...});
st_busy // Подписки для состояния st_busy.
.event(command_board_, [this](const report_status &) {...});
}
...
};
то информация о сделанных вso_define_agent
подписках может быть представлена приблизительно такой картинкой (на самом деле там все несколько хитрее, но общая схема именно такая):
Соответственно, когда для агента приходит сообщение, то формируется триплет из текущего состояния агента, идентификатора mbox-а из которого сообщение пришло и типа самого сообщения. После чего в таблице подписок агента ищется вхождение этого триплета.
Если в таблице подписок триплет найден, то сообщение обрабатывается. Если нет, то отбрасывается (не совсем так, но для простоты будем считать что просто отбрасывается).
Поиск в большой таблице подписок по составному ключу хорош тем, что он позволяет эффективно обрабатывать большое количество подписок. Скажем, нужно агенту подписаться на одно сообщение из 100500 mbox-ов? Нет проблем. Будет большая таблица подписок с эффективным поиском обработчиков в ней.
Необработанные сообщения в SObjectizer-е просто выбрасываются. Нет специальных обработчиков для подобных сообщений, нет никаких mbox-ов, в которые бы подобные сообщения пересылались бы... Ничего подобного нет.
Корни этого решения уходят на десятилетия назад в буквальном смысле. Подобная логика была использована еще в предтече SObjectizer-а, проекте SCADA Objectizer, который создавался под нужды АСУТП в середине 1990-х. И в котором именно такая логика и нужна была: если агент не заинтересован в каком-то сообщении в своем текущем состоянии, то это сообщение безжалостно выбрасывается.
Эта логка отлично работала на протяжении 25 лет. И ситуаций, когда хотелось бы как-то обрабатывать проигнорированные сообщения за эти годы встречалось не очень много. А для случаев, когда в этом был смысл, в SObjectizer-5 были добавлены т.н. deadletter handler-ы.
Так что можно спорить о том, оправдан ли такой жесткий подход к проигнорированным сообщениям или нет. Но сейчас дела обстоят именно так. Так что если на конкретный тип сообщение нет подписки, то это сообщение просто выбрасывается.
Если нельзя, но очень нужно, то как?
Итак, SObjectizer не позволяет сделать так, чтобы какой-то агент из множества сообщений типа image_vendor_A
, image_vendor_B
,image_vendor_C
,image_vendor_D
и т.д. мог бы подписаться лишь на image_vendor_A
и image_vendor_B
, а все остальные сообщения image_vendor_* обрабатывать каким-то одним обработчиком.
Но если нам нужна именно такая логика, то как же нам быть?
Пожалуй, единственный выход -- это иметь один тип сообщения для всех изображений.
Самый простой вариант, который лично мне сразу приходит в голову -- это использовать ООП-иерархии, т.е. ввести базовый тип для изображения:
class image_base {
public:
virtual ~image_base() = default;
... // Какой-то набор общих для всех изображений методов.
};
от которого уже будут наследоваться конкретные типы изображений:
class image_vendor_A : public image_base {
...
};
class image_vendor_B : public image_base {
...
};
class image_vendor_C : public image_base {
...
};
Затем вводится тип сообщения image
в котором конкретный экземпляр сообщения передается по указателю (для простоты приведем в примере shared_ptr
):
struct image {
std::shared_ptr<image_base> image_;
};
Соответственно, агенты будут подписываться на сообщение image, а уже с конкретным типом изображения они будут разбираться внутри обработчика тем или иным способом:
void some_image_processor::so_define_agent() {
so_subscribe(image_mbox_)
.event([this](const image & cmd) {
if(auto * p = dynamic_cast<image_vendor_A*>(cmd.image_.get())) {
... // Изображение типа A.
}
else if(auto * p = dynamic_cast<image_vendor_B*>(cmd.image_.get())) {
... // Изображение типа B.
}
else {
// Отказываемся обрабатывать другие типы.
throw unsupported_image_type{};
}
});
}
...
void image_counter::so_define_agent() {
so_subscribe(image_mbox_)
.event([this](const image &) { // Тип изображения не важен.
++captured_images_;
});
}
Конечно, вариант с ручными dynamic_cast
-ами выглядит криво и в реальном коде, как по мне, лучше было бы использовать паттерн visitor. Но для иллюстрации мысли вполне сгодится.
Парочка вариаций на эту тему
Если кому-то не нравится иметь сообщение image
, внутри которого будет лежать shared_ptr на экземпляр изображения, то можно воспользоваться одной из нижеописанных вариаций.
Использование std::variant
Когда список типов изображений конечен и зафиксирован на этапе компиляции, то в качестве типа сообщения можно задействовать std::variant:
class image_vendor_A {...};
class image_vendor_B {...};
class image_vendor_C {...};
...
class image_vendor_Last {...};
using image = std::variant<
image_vendor_A,
image_vendor_B,
image_vendor_C,
...
image_vendor_Last>;
void some_image_processor::so_define_agent() {
so_subscribe(image_mbox_)
.event([this](const image & cmd) {
... // Какой-то способ работы с std::variant.
});
}
...
void image_counter::so_define_agent() {
so_subscribe(image_mbox_)
.event([this](const image &) { // Тип изображения не важен.
++captured_images_;
});
}
Отсылка сообщений по базовому типу
Если же список типов изображений на этапе компиляции не зафиксирован (например, может расширяться со временем по мере реализации новых модулей), то можно использовать такой трюк, как отсылку сообщения по его базовому типу:
class image : public so_5::message_t { // Наследование от message_t важно.
public:
... // Какие-то общие для всех изображений свойства.
};
class image_vendor_A : public image {...};
class image_vendor_B : public image {...};
...
// Сперва создаем экземпляр конкретного типа...
so_5::message_holder_t<image_vendor_A> msg{
std::make_unique<image_vendor_A>(...)
};
// ...а затем отсылаем его так, как будто его тип -- image.
so_5::send<image>(std::move(msg));
Фокус здесь в том, что при отсылке в качестве типа сообщения фиксируется именно тот тип, который был задан в виде первого шаблонного параметра для so_5::send
. Так, если мы пишем so_5::send<image>
, то типом сообщения внутри SObjectizer-а будет считаться именно image, а не image_vendor_A
.
Вот здесь можно найти полный код примера, который иллюстрирует этот трюк.
А есть ли надежда на появление такой фичи?
С одной стороны, ответ на этот вопрос прост и уже традиционен в последние несколько лет: в SObjectizer сейчас попадает лишь то, в чем возникла необходимость. Наполнение SObjectizer-а фичами просто "шоб було" уже давно закончилось.
Так что если это кому-то нужно, то следует нам об этом сказать. Крайне желательно с описанием сценария, в котором вам такая функциональность была бы полезна.
Но, с другой стороны, добавить подобную функциональность в SObjectizer не так-то просто. По крайней мере первые прикидки пока не подсказали каких-то хороших способов достижения цели.
Не сработало: явная отметка возможности сделать upcast для типа сообщений
Был сделан неудачный подход вот с такой идеей: пусть пользователя будет указывать для некоторых типов сообщений возможность upcasting-а к базовому типу.
Т.е. для примера с изображениями это могло бы выглядеть, например, так:
class image_base : public so_5::upcastable_message_root_t<image_base>
{
... // Какие-то общие для всех изображений свойства.
};
class image_vendor_A
: public so_5::upcastable_message_t<image_vendor_A, image_base>
{...};
class image_vendor_B
: public so_5::upcastable_message_t<image_vendor_B, image_base>
{...};
...
Когда к агенту прилетает сообщение, у которого в базовых классах есть so_5::upcastable_message<T>
, то поиск обработчика выполняется по более сложной процедуре:
Сперва ищется обработчик для актуального типа сообщения. Если найден, то он вызывается и цикл поиска обработчика прерывается.
Если обработчик не найден, то берется тип, к которому можно сделать upcasting. Если такой тип есть, то идем к шагу №1. Если же иерархия upcastable-сообщений закончилась, но обработчик так и не найден, то цикл поиска обработчика прерывается.
При таком алгоритме поиска обработчиков если агент делает подписку вот так:
void some_image_processor::so_define_agent() {
so_subscribe(image_mbox_)
.event([this](const image_vendor_A & cmd) {... /* (1) */})
.event([this](const image_base &) {... /* (2) */});
}
то для сообщения image_vendor_A
будет найден обработчик (1), а для сообщения image_vendor_B
будет найден обработчик сообщения (2).
Данный подход выглядит привлекательным и вроде бы он даже не несет очень уж больших накладных расходов при диспетчеризации сообщений (фактически, добавляется один дополнительный if в начале процедуры поиска обработчика). Более того, этот подход интересен еще и тем, что он (вроде бы) может использоваться и при работе с mchain-ами.
Но засада встретилась там, где не ждали.
На самом деле есть несколько таблиц подписки
Выше показывалась таблика подписок для агента. Но это не единственная таблица подписок, которая участвует в диспетчеризации сообщений.
Еще одна таблица, но более простая, хранится в mbox-е. Поэтому полная картина подписок выглядит как-то так:
Т.е. обычный mbox знает на какие типы сообщений у него есть подписчики. Например, на сообщение типа M1 подписаны агенты Alice и Bob, а на сообщение M2 -- только Bob.
Благодаря такой информации mbox знает, что когда в него отсылают сообщение M1, то это сообщение должно быть доставлено и Alice, и Bob-у. А вот когда отсылают сообщение M2, то оно доставляется только Bob-у.
Так вот, засада в том, что когда агент подписывается вот таким образом:
void some_image_processor::so_define_agent() {
so_subscribe(image_mbox_)
.event([this](const image_vendor_A & cmd) {... /* (1) */})
.event([this](const image_base &) {... /* (2) */});
}
то у mbox-а image_mbox_
есть информация только о подписке на сообщение типа image_vendor_A
и на сообщение типа image_base
. Подписок на сообщения других типов для этого агента у image_mbox_
нет.
Соответственно, если в image_mbox_
отсылается image_vendor_B
, то это сообщение агенту вообще отправлено не будет.
И это более чем серьезная засада. Особенно с учетом того, что mbox-ы в SObjectizer-е предназначены в том числе и для того, чтобы разработчики могли создавать свои специфические mbox-ы под свои собственные нужды (примеры специализированных mbox-ов можно найти в so5extra). И заставить разработчиков принимать во внимание наличие сообщений, которые могут быть приведены к базовому типу... Как-то уж это все слишком уж. Слишком уж геморрно, что ли.
Что еще можно было бы попробовать: принудительный upcasting к базовому типу
Есть еще одна мысль, которая выглядит вполне себе реализуемой, но которая еще не проверялась на практике (и вряд ли будет в ближайшее время). Здесь так же используется наследование от специальных типов для того, чтобы SObjectizer знал откуда и куда можно делать upcasting:
class image_base : public so_5::upcastable_message_root_t<image_base>
{
... // Какие-то общие для всех изображений свойства.
};
class image_vendor_A
: public so_5::upcastable_message_t<image_vendor_A, image_base>
{...};
class image_vendor_B
: public so_5::upcastable_message_t<image_vendor_B, image_base>
{...};
...
При отсылке любого сообщения SObjectizer смотрит на то, можно ли для сообщения сделать upcasting. Так, если отсылается сообщение image_vendor_A
, то SObjectizer уже в компайл-тайм понимает, что здесь возможен upcasting до типа image_base
. Поэтому внутри send-а SObjectizer выполняет приведение к базовому типу и отсылает сообщение как имеющее тип image_base
, а не image_vendor_A.
Далее, когда агент подписывается на сообщение какого-то типа, то SObjectizer опять смотрит на то, можно ли для этого сообщения делать upcasting. Если можно, то SObjectizer делает хитрую подписку: в таблицы подписки добавляется самый верхний тип, к которому можно делать upcasting.
Допустим, что агент infrared_image_processor
подписывается на сообщение image_vendor_A
и image_vendor_B
из почтового ящика incoming_images
. SObjectizer понимает, что здесь возможен upcasting до image_base
. Поэтому в почтовый ящик добавляется подписка для сообщения image_base
, а не image_vendor_A
или image_vendor_B
. В таблицу подписок агента так же добавляется подписка только на image_base
, но в этом случае подписывается не простой обработчик сообщения, а специальный:
Этот специальный обработчик берет экземпляр поступившего сообщения и смотрит, относится ли оно к типу image_vendor_A
(или производному от него). Если относится, то вызывает обработчик для image_vendor_A
. Если же сообщение относится к типу image_vendor_B
, то вызывается обработчик для image_vendor_B
. Если же ни одно из условий не выполнилось, то сообщение игнорируется.
Причем все эти фокусы с upcasting-ом SObjectizer делает только в том случае, если сообщение это допускает (т.е. наследуется от so_5::upcastable_message_t<T>
). Если же используются обычные сообщение, то никакой хитрой магии SObjectizer не делает.
Вместо заключения
Так уж получилось, что я занимаюсь SObjectizer-ом уже очень давно. А когда работаешь над одним и тем же долгое время, то рано или поздно ты оказываешься в ситуации, когда твои же проектные решения, но принятые много-много лет назад, обнаруживают свои последствия вот прямо здесь и сейчас.
Описанный в статье сценарий обработки сообщений невозможен в текущем варианте SObjectizer-а как раз из-за проектных решений, принятых мной сперва в 2002-ом году в SObjectizer-4, а затем и в 2010-ом году в SObjectizer-5...
А теперь важный вопрос, ради которого эта статья и была написана: нужна ли подобная функциональность кому-нибудь из попробовавших SObjectizer читателей? Следует ли внести ее в список хотелок для будущих версий SObjectizer-а?
PS. Возможно читатели, которые интересуются нашими открытыми проектами RESTinio и SObjectizer (+so5extra), уже знают, что мы вынуждены приостановить их развитие. К сожалению, целевое финансирование для этих открытых проектов найти не удалось. Поэтому мы постараемся поднакопить средства на заказных разработках, чтобы затем вернуться к работам над RESTinio/SObjectizer/so5extra. И если кому-то нужна помощь опытных разработчиков, то у нас как раз есть пара свободных рук. Не самых плохих ;)