В последние 4.5 года я много рассказывал на Хабре про такие OpenSource проекты, как SObjectizer и RESTinio. Но вот об использовании SObjectizer и/или RESTinio в реальных проектах пока еще ни разу не удавалось поговорить (была лишь одна статья от стороннего автора). Причина простая: мы не можем обсуждать те проекты, в которых мы сами применяли SObjectizer/RESTinio, ибо NDA. Равно как и не можем рассказывать о тех чужих проектах, о которых узнали в частном общении. Так что с наглядными примерами использования SObjectizer/RESTinio в реальной жизни всегда была напряженка. Дабы как-то улучшить ситуацию пару лет назад мы даже сделали небольшой демо-проект Shrimp и опубликовали здесь серию статей о нем (раз, два, три). Но все-таки это было не более чем демонстрация. К счастью или к несчастью, но далеко не самый удачный 2020-й год предоставил нам возможность показать как же выглядит реальный проект, в разработке которого SObjectizer и RESTinio активно используются. И в данной статье я попробую рассказать о том, как и для чего SObjectizer и RESTinio применяются в arataga, исходники которого можно найти на GitHub. Тем, кто хочет больше узнать об arataga и причинах его появления на GitHub-е, рекомендую прочитать этот блог-пост. Здесь же в двух словах скажу лишь, что arataga -- это socks5+http/1.1 прокси-сервер, который затачивался под использование в следующих условиях (перечислены те из них, которые актуальны с точки зрения внутренней архитектуры arataga): много точек входа, счет идет на тысячи (8 тысяч нужно было поддерживать сразу). У каждой точки входа уникальное сочетание IP+port; подключения на одну точку входа могут идти с темпом в несколько десятков в секунду, параллельно на одной точке входа могут "висеть" сотни подключений; одновременно могут существовать десятки тысяч подключений, общий темп появления новых подключений на всех точках входа может быть от 1500 в секунду; аутентификация клиентов должна укладываться в единицы миллисекунд; обновленная конфигурация и новые списки пользователей, которым разрешена работа с прокси-сервером, периодически доставляется через HTTP POST на специальный административный HTTP-вход. Далее в статье я сосредоточусь на архитектурных и технических моментах, не затрагивая тему "зачем это все было нужно?" Вероятно, для того, чтобы лучше понимать изложенное ниже, нужно иметь общие представления о SObjectizer-овских агентах, диспетчерах и почтовых ящиках (mbox-ах). Старая обзорная статья может в этом помочь (хотя сам SObjectizer за это время несколько изменился). Прежде всего нам предстояло сделать выбор между реализацией arataga в виде мультипроцессного или мультипоточного приложения. В случае мультипроцессного приложения потребовалось бы иметь процесс-родитель, который бы запускал N дочерних процессов-исполнителей. Процесс-родитель отвечал бы за управление дочерними процессами и за распределение конфигурации/списков пользователей между дочерними процессами. Дочерние же процессы открывали бы точки входа и обслуживали бы входящие соединения от клиентов и исходящие к целевым хостам. Каждый процесс при этом мог бы быть простым однопоточным процессом (может быть за исключением родительского, в котором могло бы потребоваться больше одного рабочего потока). Достаточно простая и надежная схема, почти что в духе unix way. Но связываться с ней не очень хотелось из-за большого количества IPC-взаимодействия между родительским и дочерними процессами: в одну сторону должна идти управляющая информация, в другую -- диагностическая. Вариант в виде многопоточного приложения выглядел проще, т.к. все взаимодействующие сущности оказывались в рамках одного общего адресного пространства и распространение любой информации в любых направлениях не представляло вообще никакой проблемы. Плюс к тому, у нас в руках был большой молоток, SObjectizer, с помощью которого разрабатывать многопоточные приложения гораздо проще, чем при ручной работе с std::thread, std::mutex и std::condition_variable и т.п. Если выразить основные сущности приложения в виде SObjectizer-овских агентов, распределить этих агентов должным образом по рабочим нитям и организовать их взаимодействие на базе асинхронных сообщений, то ужасы многопоточности можно спокойно обойти стороной. Что, собственно, в очередной раз и произошло. Здесь все было понятно изначально, еще даже до начала разработки arataga, поскольку старый прокси-сервер, на замену которого arataga и разрабатывался, использовал модель thread-per-connection. Когда счет одновременно живущих соединений идет на десятки тысяч, то thread-per-connection не есть хорошо. Поэтому об использовании в arataga схемы thread-per-connection речь не шла вообще. Сразу же был взят курс на применение чего-то похожего на thread-per-core. Было решено под обслуживание подключений отводить не все доступные вычислительные ядра, а использовать формулу (nCPU-2), где nCPU -- это количество ядер. Так, если на машине 8 ядер, то в arataga будет создано 6 рабочих потоков, которые будут отвечать за обслуживание соединений. Т.е. работой с трафиком будут заняты 6 ядер. Два оставшихся ядра будут доступны для других рабочих потоков внутри arataga. Плюс и самой ОС нужно что-то оставить, а то не очень приятно удаленно подключаться к работающему серверу по ssh, когда у него все ядра загружены под 100%… Изначально в работе arataga выделялось несколько типов операций, которые должны были выполняться на разных рабочих контекстах: разбор конфигурации. Обработка конфигурации для нескольких тысяч точек входа может занимать некоторое время (например, 5ms). Сюда входит и парсинг/валидация конфигурации, и сохранение ее локальной копии, и удаление устаревших точек входа, которых нет в новой конфигурации, и создание новых точек входа, добавленных в обновленной конфигурации; разбор списка пользователей и формирование структур для представления этого списка в памяти. Это также может занимать некоторое время, поскольку списки пользователей могут насчитывать несколько десятков тысяч строк; обработка точек входа. Т.е. создание серверных сокетов на заданных IP+port, прием новых подключений с учетом ограничений на максимальное количество параллельных подключений и т.д.; обслуживание принятых подключений. Т.е. вычитывание данных от клиента, определение типа протокола, осуществление проксирования подключения в зависимости от типа протокола и команды, пришедшей от клиента. Первые три операции явно напрашивались на то, чтобы быть представленными в виде отдельных агентов, которые привязываются к тому или иному диспетчеру. А вот по поводу последнего, т.е. обслуживания уже принятых подключений, можно было поступить по-разному. Можно было попытаться представить каждое подключение в виде отдельного агента. Но этот подход меня лично не вдохновлял по нескольким причинам. Во-первых, дело в том, что обслуживание разных команд в рамках одного протокола (не говоря уже про разные протоколы) может существенным образом отличаться. И если попытаться все эти различия запихнуть в одного агента, то этот агент распухнет до неприличия. Во-вторых, не очень понятно, в чем смысл использовать здесь агента. Взаимодействие с сетью предполагалось делать с использованием Asio и асинхронных операций. Т.е., если вызывается Asio-шный async_read_some, то когда-то Asio вызовет для этой операции completion_handler. И если мне нужно передать результат async_read_some агенту, то в completion_handler нужно будет отослать агенту сообщение, которое агент обработает. Что, вообще-то говоря, не бесплатно, т.к. сперва на каком-то io_context будет запланирован вызов completion_handler, а затем, когда до completion_handler дойдет очередь, будет запланирован вызов обработчика события у агента. И только затем когда-то будет вызван этот самый обработчик. Что наводит на мысль "а не слишком ли много приходится платить за концептуальную чистоту?" Тем более, что по прошлому опыту я знал, что когда операции чтения/записи сокета доставляются до агента в виде сообщений, то как-то кардинально это работу с сетью не упрощает. Может быть даже наоборот. Добавим сюда еще и то, что регистрация/дерегистрация агентов в SObjectizer все-таки не самая дешевая операция (это связано с механизмом коопераций и гарантиями транзакционности операции регистрации). Конечно, если принимать новые подключения с темпом в 2000-3000 в секунду, то "тормоза" SObjectizer-а здесь не проявятся, SObjectizer может регистрировать/дерегистрировать кооперации с гораздо более высоким темпом. Но все-таки у частого создания/удаления агентов есть своя цена, и если ее можно не платить, то лучше ее не платить. В результате было принято решение, что агентом будет точка входа, но вот отдельные подключения агентами не будут. Вместо этого, агент, представляющий точку входа, будет обслуживать все подключения к этой точке. Изначально предполагалось, что посредством RESTinio будет реализован административный HTTP-вход. Причем не в виде SObjectizer-овского агента, а просто единственная отдельная нить, на которой будет работать RESTinio-сервер. Библиотека RESTinio, действительно, была использована таким очевидным образом. А вот о паре менее очевидных способов применения RESTinio в arataga речь пойдет ниже. В этой части бегло пройдемся по некоторым моментам использования SObjectizer в коде arataga. Выбор чисто субъективный, мне показалось, что именно они наиболее показательны и/или не обычны. В рамках одной статьи невозможно обсудить все аспекты применения SObjectizer в проекте arataga, поэтому что-то неизбежно останется "за кадром". Так что если у кого-то из читателей возникнут вопросы, то постараюсь ответить в комментариях. Работа arataga начинается с запуска агента startup_manager. Этот агент отвечает за последовательный запуск основных "компонентов" arataga: сперва стартует агент user_list_processor, затем агент config_processor, затем уже запускается отдельная нить с RESTinio-сервером для административного HTTP-входа. Каждый из запускаемых startup_manager агентов должен затем подтвердить свой успешный запуск. Если этого не произошло за отведенное время, то startup_manager прерывает работу приложения вообще. В агенте startup_manager можно обнаружить один непривычный трюк: запуск при старте периодического сообщения one_second_timer_t, которое отсылается раз в секунду на специальный mbox. В какой-то мере это преждевременная оптимизация. Дело в том, что всем точкам входа нужно время от времени контролировать тайм-ауты выполняемых операций. Например, нужно принудительно закрывать соединения, в которых долго нет никакой активности. Обычно это выполняется посредством отложенного сообщения: агент отсылает самому себе отложенное на 1 секунду сообщение, а когда оно приходит, то проверяет времена жизни подключений, времена отсутствия активностей в них и т.д. После чего шлет себе следующее отложенное сообщение. Или же вместо перепосылки отложенных сообщений один раз запускает периодическое сообщение. Но когда агентов, нуждающихся в подобных периодических проверках, будут тысячи, то это будет означать, что раз в секунду будут порождаться тысячи отложенных/периодических сообщений. Ну и зачем, спрашивается, это делать, если достаточно всего одного сообщения, которое будет отсылаться в отдельный mbox один раз в секунду? И на которое смогут подписаться столько получателей, сколько потребуется. Вот как раз для того, чтобы каждый агент, обслуживающий точку входа, не запускал свое собственное периодическое сообщение для контроля тайм-аутов, агент startup_manager делает это посредством one_second_timer_t. Выше говорилось, что в реализации arataga было решено придерживаться модели, похожей на thread-per-core, при этом (nCPU-2) рабочих потока будет выделено под выполнение операций с сетью. Такие рабочие нити получили условное название io_thread (далее io_thread == "I/O нить" и поэтому "io_thread" будет иметь женский род). Т.к. работа с сетью в arataga работа идет посредством Asio, то для следования идее thread-per-core было решено сделать так, чтобы на каждой io_thread работал свой собственный экземпляр asio::io_context. Т.е. нам нужна была рабочая нить, которая создает экземпляр asio::io_context, а затем вызывает для него run(). После чего на этой нити нужно было бы еще как-то разместить и агентов, реализующих точки входа. И чтобы одна и та же нить обслуживала и операции Asio, и события SObjectizer-овских агентов. В arataga для этих целей просто используются экземпляры asio_one_thread-диспетчера из so5extra. Каждый такой диспетчер -- это отдельный рабочий поток (т.е. io_thread). Агент config_processor, о котором речь пойдет ниже, создает N экземпляров asio_one_thread-диспетчеров, а затем просто привязывает к этим экземплярам агентов, обслуживающих точки входа. Подключающихся к arataga клиентов нужно аутентифицировать. Эта задача была поручена отдельному агенту authentificator. Когда агенту, обслуживающему точку входа, нужно аутентифицировать клиента, то отсылается сообщение агенту authentificator, который выполняет аутентификацию и шлет назад результат аутентификации. Еще агентам, которые обслуживают точки входа, нужно выполнять резолвинг доменных имен. За эту операцию отвечает еще один специальный агент -- dns_resolver. Тут используется подобная схема: агент-точка-входа отсылает агенту dns_resolver сообщение, а тот присылает назад результат поиска доменного имени. Мне показалось, что если уж мы пытаемся следовать модели thread-per-core, то логичным было бы сделать отдельную копию агентов dns_resolver и authentificator для каждой из io_thread. Поэтому эти агенты создаются сразу же вслед за очередным asio_one_thread-диспетчером и привязываются к только что созданному диспетчеру. В результате получилась следующая картинка:  У агентов config_processor и user_list_processor есть собственные рабочие потоки, которые реализуются посредством штатного диспетчера SObjectizer под названием one_thread. Каждая io_thread представлена отдельным диспетчером asio_one_thread из so5extra. И на каждом таком диспетчере работает сразу несколько (десятков, сотен, тысяч) агентов acl_handler. Агент config_processor отвечает за обработку конфигурации arataga, поддержание списка существующих точек входа, их распределение между io_threads, и за создание/удаление точек входа при изменении конфигурации. При своем старте config_processor пытается прочитать локальную копию конфигурационного файла. Если это удалось, то config_processor принимает поднятую из конфига информацию за текущую конфигурацию и создает описанные в ней точки входа (регистрирует агентов acl_handler). Если же локальной копии конфигурации нет или же прочитать ее не удалось, то config_processor ждет, пока придет новая конфигурация от административного HTTP-входа. Когда от административного HTTP-входа поступает новая конфигурация, что после ее успешной обработки config_processor рассылает уведомления об изменении параметров arataga на отдельный multi-producer/multi-consumer mbox. Так изменения в конфигурации становятся доступны всем, кто в них заинтересован. В работе config_processor с acl_handler есть важная особенность: сейчас в arataga агент acl_handler намертво привязывается к конкретному asio_one_thread и живет на этом asio_one_thread до тех пор, пока не будет удален. Эта особенность может привести к ситуации, когда из конфигурации удалили, скажем, 1000 точек входа, и в результате на одной из io_threads осталось заметно меньше работающих агентов, чем на других io_threads. Эта диспропорция может быть устранена при добавлении новых точек входа. В этом случае config_processor при создании новых агентов acl_handler будет привязывать их к тем диспетчерам asio_one_thread, на которых сейчас меньше всего живых acl_handler. Но если новые точки входа не создаются, то диспропорция останется и config_processor не будет перераспределять старые acl_handler между диспетчерами asio_one_thread. По двум причинам: во-первых, в SObjectizer нет возможности перепривязать агента с одного диспетчера на другой. Поэтому к какому диспетчеру агента изначально привязали, на таком он и будет продолжать работать; во-вторых, в каждом агенте acl_handler может существовать множество Asio-шных объектов, завязанных на конкретный экземпляр asio::io_context. Если перемещать содержимое acl_handler с одной io_thread на другую, то нужно будет и переконструировать эти Asio-шные объекты. Чтобы не возиться со всем этим добром было решено смириться с такими диспропорциями в первой версии aragata. Может на практике они вообще не будут столь серьезными, чтобы оказывать влияние на производительность. Ну а если таки будут, вот тогда можно было бы и озадачиться проблемой миграции точки входа с одной io_thread на другую. Агент user_list_processor отвечает за работу со списком пользователей. При своем старте user_list_processor пытается прочитать локальную копию списка пользователей. Если это удалось, то локальная копия становится актуальным списком. Если не удалось, то user_list_processor ждет поступления нового списка от административного HTTP-входа. Когда от административного HTTP-входа прилетает новый список пользователей, то user_list_processor пытается его обработать. И, если это получается успешно, то обновленный список рассылается на отдельный multi-producer/multi-consumer mbox на которые подписаны агенты authentificator. Что позволяет authentificator-ам получать обновленные списки пользователей сразу после того, как user_list_processor завершит их обработку. Агент acl_handler является, наверное, самым сложным и объемным агентом в arataga (hpp-файл, cpp-файл). Его задача -- это создать серверный сокет для точки входа, принимать и обслуживать подключения к этому серверному сокету. В связи с этим в рамках данной статьи можно выделить несколько аспектов в реализации acl_handler. Во-первых, acl_handler инициирует асинхронные I/O операции с помощью Asio, в частности, вызывает async_accept у Asio-шного asio::ip::tcp::acceptor. В async_accept передается completion-handler в виде лямбды, где напрямую вызываются методы агента. Такие вызовы безопасны потому, что агент привязан к asio_one_thread диспетчеру. Этот диспетчер специально создан для того, чтобы на одном рабочем контексте можно было работать и с агентами, и с Asio. Если бы acl_handler привязывался к какому-то другому диспетчеру (например, к штатному one_thread-диспетчеру), то этого делать было бы нельзя. Во-вторых, логика у acl_handler достаточно тривиальная, но даже и здесь нашлось место для применения иерархического конечного автомата (которые в SObjectizer-е уже довольно давно поддерживаются): Иерархический автомат потребовался здесь потому, что acl_handler должен ограничивать количество одновременных подключений к точке входа. Поэтому, когда это количество достигает разрешенного максимума, агент переходит в состояние st_too_many_connections, в котором он делает практически всю свою обычную работу, за исключением новых вызовов async_accept. В-третьих, агент acl_handler не занимается сам обслуживанием принятых подключений и обработкой протоколов socks5/http. Вместо этого для каждого соединения создается т.н. connection_handler. Это объект, в который отдается принятое подключение и который уже с этим подключением непосредственно работает. Т.е. читает из него данные, пытается определить протокол, пытается обслуживать подключение согласно того или иного протокола. Т.е. когда в результате async_connect у acl_handler появляется новый asio::ip::tcp::socket, то создается объект, реализующий интерфейс connection_handler_t, и новый socket отдается этому только что созданному connection_handler-у. Больше про новое подключение acl_handler ничего не знает. Далее acl_handler лишь хранит у себя умный указатель на connection_handler и лишь время от времени дергает у connection_handler-а метод on_timer (тот самый "глобальный таймер" о котором речь шла выше). А вот connection_handler дальше живет своей собственной и весьма непростой жизнью. Вообще говоря, как раз тот кусок arataga, который относится к connection_handler-ам, является самым мудреным в проекте. Вероятно, далеко не самым удачным. И, скорее всего, именно он мог бы стать первым кандидатом на рефакторинг по итогам опытной эксплуатации. Поскольку первая версия arataga создавалась в высоком темпе, в условиях сжатых сроков, то ничего лучше сходу придумать не удалось. Возможно, здесь следовало бы применить stackful-короутины. Но экспериментировать с ними не было времени. Так что, если история с arataga получит продолжение (что вряд ли, но вдруг), к этому куску arataga нужно будет сделать еще один, более вдумчивый подход. Пока что получилось то, что получилось :( Примечательным моментом использования SObjectizer-овских mbox-ов в arataga является применение retained_mbox из so5extra для распространения информации о текущей конфигурации и списках пользователей. Особенностью retained_mbox является то, что retained_mbox хранит у себя последние отосланные сообщения (по одному для каждого типа отосланных сообщений). И когда кто-то делает новую подписку на некий тип сообщения, то retained_mbox сразу же после завершения подписки отсылает этому подписчику последнее сохраненное сообщение этого типа. В arataga это свойство retained_mbox используется для того, чтобы агенты authentificator сразу же могли получить текущий список пользователей. Сценарий работы такой: стартует агент user_list_processor. Он читает локальную копию списка пользователей и отсылает сообщение с этим списком в retained_mbox. На этом этапе подписчиков у retained_mbox еще нет, но нас это не волнует; затем стартует агент config_processor, который читает локальную копию конфигурации и создает io_threads вместе с authentificator-ами; каждый запущенный authentificator должен получить список пользователей, для чего authentificator-ы подписываются на соответствующее сообщение из retained_mbox-а; поскольку в retained_mbox уже есть сообщение со списком пользователей, то это сообщение сразу же отсылается каждому новому подписчику. Благодаря этому authentificator-у при старте не нужно ни у кого ничего явно запрашивать. Правда, платить за это нужно тем, что authentificator вынужден делать подписку в so_evt_start, а не в предназначенном для этого so_define_agent. Теперь попробуем пробежаться по нескольким аспектам применения RESTinio в arataga. Тем более, что одно из применений было очевидным, тогда как одно совсем не очевидным. Очевидное применение, ради которого RESTinio и подтягивался в проект arataga, -- это реализация административного HTTP-входа. Как раз здесь простота асинхронной обработки входящих запросов посредством RESTinio и была нужна с самого начала. В этом месте RESTinio используется классическим способом. Запускается RESTinio-сервер, этот сервер принимает входящие запросы, выполняет их первичную валидацию. После чего пересылает запрос во внутрь arataga и ждет ответа. Когда ответ сформирован, то RESTinio отсылает его клиенту. Упомянуть здесь можно разве лишь то, что для взаимодействия RESTinio- и SObjectizer-частей приложения были сделаны вспомогательные интерфейсы (раз, два). Когда startup_manager запускает RESTinio-сервер, то отдает серверу реализацию интерфейса requests_mailbox_t. RESTinio-сервер через этот интерфейс передает в SObjectizer-часть arataga входящие запросы. И каждый входящий запрос сопровождается реализацией интерфейса replier_t. Посредством этого интерфейса реальные обработчики запроса могут отвечать RESTinio-серверу. Два эти интерфейса скрывают от RESTinio-части существование SObjectizer-части arataga и наоборот. Что мне показалось полезным. Хотя бы плане сокращения времени компиляции отдельных cpp-файлов. Второе применение стало для меня лично неожиданным, хотя оно было вполне себе логичным. Дело в том, что применить RESTinio в его текущем виде для проксирования HTTP-соединений не представляется возможным в силу нынешних архитектурных особенностей RESTinio. Поэтому работу с проксируемыми HTTP-соединениями в arataga пришлось делать "с нуля". И одной из составляющей этой работы является разбирательство с HTTP-заголовками в запросах/ответах, а также с содержимым некоторых из них. И вот когда у меня руки дошли до обработки HTTP-заголовков в проксируемых соединениях, то я внезапно (c) осознал, что в RESTinio уже есть инструментарий для этого. Чем и воспользовался, хотя изначально о таком даже и не думал. А вот третье, совсем не очевидное, применение RESTinio не то, чтобы было сразу мной задумано. Но такой вариант начал просматриваться чуть ли не с самого начала. В arataga нужно было парсить два типа конфигурационных файлов (собственно конфигурация и список пользователя). У каждого из которых был свой, уже устоявшийся к тому времени синтаксис. Плюс к тому в этот синтаксис хотелось внести небольшие, но важные с точки зрения простоты использования, дополнения. Вроде суффиксов, которые бы обозначали объем данных или времена таймаутов. Например:  Ну и для того, чтобы не делать парсинг конфигов вручную только штатными средствами стандартной библиотеки C++ (очень куцей в этом плане), нужно было задействовать какой-то генератор парсеров. А зачем тащить в проект еще что-то, если нечто подобное уже есть в RESTinio? Так что easy_parser из RESTinio, который изначально появился в RESTinio для упрощения разбора HTTP-заголовков, пригодился в arataga и для разбора конфигурации. Любопытный, на мой взгляд, пример использования easy_parser-а из RESTinio можно найти здесь. Именно этот transfer_speed_p() используется для реализации команд конфигурации вроде: Реальные проекты, подобные arataga, являются отличным полигоном, на котором разработчик библиотеки может увидеть насколько удобна его библиотека вне тепличных условий уютного манямирка. Как правило, при использовании библиотеки в новом проекте отлично проявляются просчеты в дизайне библиотеки и/или же обнажаются подводные камни, о которых ты пока что даже и не подозревал. Из чего затем вырастают вполне себе конкретные идеи о том, куда следует двигаться дальше. С RESTinio это сработало на 100%. Опыт применения RESTinio в arataga дал пинка под за толчок в сторону разработки пусть приблизительного, но аналога middleware из ExpressJS. Первый результат уже доступен в 0.6.13. И, если пойти на слом API в ветке 0.7, то можно будет поддержать и цепочки асинхронных обработчиков. Но еще более важным итогом использования RESTinio в arataga стало изменение лично моих взглядов на позиционирование RESTinio в мире аналогичных фреймворков для C++. Так что для RESTinio испытание arataga оказалось исключительно полезным. А вот с SObjectizer-ом получилось скучно и обыденно. Он просто работал. Ничего не пришлось подкручивать или добавлять. Кой-какие мысли о потенциально привлекательных направлениях дальнейшего движения появились. Но пока не вполне оформившиеся и не имеющие однозначно четкого подтверждения того, что это реально необходимо делать. Я же надеюсь, что данная статья даст читателям, которые с любопытством посматривают на RESTinio и/или SObjectizer(+so5extra), но пока что сами эти проекты не пробовали, возможность взглянуть на реальный код, написанный с применением наших инструментов. И сделать собственные выводы о том, нравится ли увиденное или нет. Что касается перспектив самих RESTinio и SObjectizer/so5extra, то тут все не так однозначно и без внешней поддержки, боюсь, их развитие приостановится. Впрочем, это уже совсем другая история… Велосипедостроитель, программист-камикадзе