SObjectizer: що це, для чого це і чому це виглядає саме так?

Розробка багатопоточних програм на C++ — це не просто. Розробка великих багатопоточних програм на C++ — це дуже не просто. Але, як це зазвичай буває в C++, життя сильно спрощується, якщо вдається підібрати або зробити «заточений» під конкретну задачу інструмент. Чотирнадцять років тому вибирати було особливо ні з чого, тому ми самі для себе зробили такий «заточений» інструмент і назвали його SObjectizer. Досвід повсякденного використання SObjectizer-а в комерційному софтостроении поки не дозволяє шкодувати про скоєне. А раз так, то чому б не спробувати розповісти про те, що це, для чого це і чому у нас вийшло саме так, а не інакше…

Що це?

SObjectizer — це невеликий OpenSource інструмент, вільно розповсюджується під 3-пунктной BSD-ліцензією. Основна ідея, що лежить в основі SObjectizer, — це побудова програми з дрібних сутностей-агентів, які взаємодіють між собою через обмін повідомленнями. SObjectizer при цьому бере на себе відповідальність за:
  • доставку повідомлень агентам-одержувачам усередині одного процесу;
  • управління робочими нитками, на яких агенти обробляють адресовані їм повідомлення;
  • механізм таймерів (у вигляді відстрочених та періодичних повідомлень);
  • можливості налаштування параметрів роботи перерахованих вище механізмів.
Можна сказати, що SObjectizer є однією з реалізацій Actor Model. Однак, головна відмінність SObjectizer від інших подібних розробок, — це поєднання елементів Actor Model з елементами з інших моделей, зокрема, Publish-Subscribe і CSP.

У «класичної» Actor Model кожен актор і є безпосередній адресат в операції send. Тобто якщо ми хочемо відіслати повідомлення якомусь актору, ми повинні мати посилання на актора-одержувача або ідентифікатор цього актора. Операція send просто додає повідомлення в чергу повідомлень актора-одержувача.

У SObjectizer операція send отримує посилання не на актора, а на таку штуку, як mbox (message box, поштова скринька). Mbox можна розглядати як якийсь проксі, приховує реалізацію процедури доставки повідомлення до одержувачів. Таких реалізацій може бути кілька, і вони залежать від типу mbox-а. Якщо це multi-producer/single-consumer mbox, то, як і в «класичної» Actor Model, повідомлення буде доставлено єдиного одержувачу, власнику mbox-а. А от якщо це multi-producer/multi-consumer mbox, то повідомлення буде доставлено всім одержувачам, які підписалися на даний mbox.

Тобто операція send в SObjectizer більше схожа на операцію publish з моделі Publish-Subscribe, ніж на send з Actor Model. Наслідком чого є наявність такої корисною на практиці можливості, як широкомовна розсилка повідомлень.

Механізм доставки повідомлень в SObjectizer схожий на модель Publish-Subscribe ще й процедурою підписки. Якщо агент хоче отримувати повідомлення типу A, то він повинен підписатися на повідомлення типу A з відповідного mbox-а. Якщо хоче отримувати повідомлення типу B — повинен підписатися на повідомлення типу B. І т. д. При цьому тип повідомлення відіграє ту ж саму роль, як і назва топіка в моделі Publish-Subscribe. Ну і як в моделі Publish-Subscribe, де одержувач може підписатися на будь-яку кількість топіків, агент в SObjectizer може бути підписаний на будь-яку кількість типів повідомлень з різних mbox-ів:

class example : public so_5::agent_t {
...
public :
virtual void so_define_agent() override {
// Підписка різних обробників подій на різні повідомлення
// з одного mbox-а.
// Тип повідомлення автоматично виводиться з типу аргументу
// обробника події.
so_subscribe(some_mbox)
.event( &example::first_handler )
.event( &example::second_handler )
// Обробник може бути заданий і у вигляді лямбда-функції.
.event( []( const third_message & msg ){...} );

// Підписка одного і того ж події на повідомлення
// з різних mbox-ів.
so_subscribe(another_mbox).event( &example::first_handler );
so_subscribe(yet_another_mbox).event( &example::first_handler );
...
}
...
private :
void first_handler( const first_message & msg ) {...}
void second_handler( const second_message & msg ) {...}
};

Наступним важливим відзнакою SObjectizer від інших реалізацій «класичної» Actor Model є те, що в SObjectizer у агента немає своєї черги повідомлень. Черга повідомлень у SObjectizer належить робочого контексту, на якому обслуговується агент. А робочий контекст визначається диспетчером, до якого прив'язаний агент.

Диспетчер — це одне з наріжних понять в SObjectizer. Диспетчери визначають де і коли агенти будуть обробляти свої повідомлення.

Найпростіший диспетчер володіє лише однією робочою ниткою. Всі прив'язані до такого диспетчеру агенти працюють на цій загальній нитки. Ця нитка володіє однією єдиною чергою повідомлень, і повідомлення для всіх агентів, прив'язаних до диспетчера, поміщаються в цю єдину чергу. Робоча нитка бере повідомлення з черги, викликає обробник повідомлення у відповідного агента-одержувача, після чого переходить до наступного повідомлення і т. д.

Є й інші типи диспетчерів. Наприклад, диспетчери з пулами робочих потоків, диспетчери з підтримкою пріоритетів агентів і різними політиками обробки цих пріоритетів і т. д. У всіх випадках робочий контекст і черга повідомлень для агента призначається диспетчером, до якого прив'язаний агент.

Наступною відмінною рисою SObjectizer є наявність такого поняття, як "кооперація агентів". Кооперація — це група агентів, яка спільно виконує якусь прикладну задачу. І ці агенти повинні починати і завершувати свою роботу одноразово. Тобто якщо якийсь агент не може стартувати, то не стартують і всі інші агенти кооперації. Якщо якийсь агент не може продовжувати роботу, то не можуть продовжувати свою роботу і всі інші агенти кооперації.

Кооперації з'явилися в SObjectizer тому, що у великій кількості випадків для виконання більш-менш складних дій доводиться створювати не одного, а відразу декількох взаємопов'язаних агентів. Якщо створювати їх по одному, то потрібно вирішити, наприклад:
  • хто стартує першим і в якому порядку він створює інших;
  • як вже стартували агенти визначать, що всі інші агенти вже створені і можна починати свою роботу;
  • що робити, якщо при старті чергового агента виникає якась проблема.
У випадку з кооперациями все простіше: взаємопов'язані агенти створюються всі відразу, включаються в кооперацію і кооперація реєструється в SObjectizer. А вже сам SObjectizer стежить, щоб реєстрація кооперації виконувалася транзакционно (тобто щоб всі агенти кооперації стартували, або щоб не стартував жоден):

// Створюємо і реєструємо кооперацію з 3-х агентів:
// - перший обслуговує TCP-підключення до AMQP-брокеру та реалізує AMQP-протокол;
// - другий виконує взаємодія з СУБД.
// - третій виконує обробку прикладних повідомлень від брокера
// (використовуючи при цьому функціональність перших двох агентів);
so_5::environment_t & env = ...;
// Сам примірник кооперації, в якому будуть зберігатися агенти.
// У кожній кооперації повинно бути унікальне ім'я, яке, як в даному
// разі, може бути згенеровано самим SObjectizer-му.
auto coop = env.create_coop( so_5::autoname );
// Наповнюємо кооперацію...
coop.make_agent< amqp_client >(...);
coop.make_agent< db_worker >(...);
coop.make_agent< message_processor >(...);
// Реєструємо кооперацію.
// Подальшої її долею буде займатися сам SObjectizer.
env.register_coop( std::move(coop) );

Частково кооперації вирішують ту саму проблему, що й система супервізорів в Erlang: вхідні в кооперацію агенти як би знаходяться під контролем супервізора all-for-one. Тобто збій одного з агентів призводить до дерегистрации всіх інших агентів кооперації.

Наступною важливою рисою SObjectizer є те, що агенти в SObjectizer — це кінцеві автомати. Агент може мати довільну кількість станів, одне з яких у конкретний момент часу є поточним станом. Реакція агента на зовнішній вплив залежить як від вхідного повідомлення, так і від поточного стану. Агент може обробляти одне і те ж повідомлення по-різному в різних станах, для чого він підписує різні обробники повідомлень у кожному зі станів.

Агенти в SObjectizer можуть представляти із себе досить складні кінцеві автомати: підтримується вкладеність станів, тимчасові обмеження на перебування агента в стані, стану з deep — і shallow-history, а також обробники входу і виходу в стан.

class device_handler : public so_5::agent_t {
// Перелік станів, в яких може перебувати агент.
// Є два стани верхнього рівня...
state_t st_idle{ this }, st_activated{ this },
// ... і три стани, які є подсостояниями
// стану st_activated.
st_cmd_sent{ initial_substate_of{ st_activated } },
st_cmd_accepted{ substate_of{ st_activated } },
st_failure{ substate_of{ st_activated } };
...
public :
virtual void so_define_agent() override {
// Починаємо працювати в змозі st_idle.
st_idle.activate();

// При отриманні команди в стані st_idle просто міняємо
// стан і делегуємо обробку команди станом st_activated.
st_idle.transfer_to_state< command >( st_activated );

// У стані st_activated реагуємо лише на одне повідомлення:
// по приходу turn_off повертаємося в стан st_idle.
// При цьому реакція на повідомлення turn_off успадковується
// усіма дочірніми станами.
st_activated
.event( [this](const turn_off & msg) {
turn_device_off();
st_idle.activate();
} );
// У стан st_cmd_sent "провалюємося" відразу після входу
// у стан st_activated, т. к. st_cmd_sent є початковим
// подсостоянием.
st_cmd_sent
.event( [this](const command & msg) {
send_command_to_device(msg);
// Перевіримо, що сталося з пристроєм через 150ms.
send_delated<check_status>(*this, 150ms);
} )
.event( [this](const check_status &) {
if(command_accepted())
st_cmd_accepted.activate();
else
st_failure.activate();
} );
...
// У стані st_failure знаходимося не більше 50ms,
// після чого повертаємося в st_idle.
// При вході в це стан примусово скидаємо
// налаштування пристрою.
st_failure
.on_enter( [this]{ reset_device(); } )
.time_limit( 50ms, st_idle );
}
...
};

З CSP-моделі SObjectizer запозичив таку штуку, як канали, які SObjectizer називаються message chains. CSP-ві канали були додані SObjectizer як інструмент для рішення однієї специфічної проблеми: взаємодія між агентами будується через обмін повідомленнями, тому дуже просто дати якусь команду агенту або передати якусь інформацію агенту з будь-якої частини програми — досить відсилати повідомлення допомогою send. Однак, як агенти можуть впливати на не-SObjectizer частиною програми?

Цю проблему вирішують message chains (mchains). Message chain може виглядати зовсім як mbox: відсилати повідомлення у mchain потрібно допомогою все того ж send-а. А ось витягуються повідомлення з mchain функціями receive і select, для роботи з якими не потрібно створювати SObjectizer-івських агентів.

Робота з message chain в SObjectizer схожа на роботу з каналами в мові Go. Хоча є і серйозні відмінності:
  • mchains в SObjectizer можуть одночасно зберігати повідомлення будь-яких типів, тоді як Go-шні канали типизированы;
  • Go-шної конструкції select можна використовувати як send і receive-операції. Тоді як в SObjectizer-вському select-е допускаються тільки receive-операції (принаймні у версіях 5.5.17 включно);
  • mchains в SObjectizer можуть мати, а можуть і не мати обмежень на розмір черги повідомлень. Тоді як в Go розмір каналу обмежений завжди. Для mchain-а з обмеженим розміром SObjectizer змушує вибрати відповідну поведінку для спроби помістити нове повідомлення в повний mchain (наприклад, почекати якийсь час і викинути найстаріші повідомлення з mchain або нічого не чекати і відразу породити виняток).
Message chains — це відносно недавнє додавання в SObjectizer. Однак, штука виявилася вельми корисною і досить несподіваним наслідком її додавання стало те, що деякі багатопотокові програми на SObjectizer стало можливо розробляти навіть без застосування агентів:

void parallel_sum_demo()
{
using namespace std;
using namespace so_5;

// Тип повідомлення, яке відішле кожна робоча нитка в кінці своєї роботи.
struct consumer_result
{
thread::id m_id;
size_t m_values_received;
uint64_t m_sum;
};

wrapped_env_t sobj;

// Канал для відсилання повідомлень робочим ниткам.
auto values_ch = create_mchain( sobj,
// Канал має обмеження на розмір, тому призначаємо
// паузу в 5м на спробу додати повідомлення в повний канал.
chrono::minutes{5},
// Не більше 300 повідомлень в каналі.
300u,
// Пам'ять під внутрішню чергу каналу виділяється заздалегідь.
mchain_props::memory_usage_t::preallocated,
// Якщо місце у каналі не з'явилося навіть після 5м очікування,
// то просто перериваємо усі додаток.
mchain_props::overflow_reaction_t::abort_app );

// Простий канал для відповідних повідомлень від робочих потоків.
auto results_ch = create_mchain( sobj );

// Робочі потоки.
vector< thread > workers;
for( size_t i = 0; i != thread::hardware_concurrency(); ++i )
workers.emplace_back( thread{ [&values_ch, &results_ch] {
// Послідовно читаємо значення вхідного
// каналу, підраховуємо кількість значень і
// їх суму.
size_t received = 0u;
uint64_t sum = 0u;
receive( from( values_ch ), [&sum, &received]( unsigned int v ) {
++received;
sum += v;
} );

// Відсилаємо результуючі значення тому.
send< consumer_result >( results_ch,
this_thread::get_id(), received, sum );
} } );

// Відсилаємо кілька значень у вхідний канал. Ці значення будуть
// розподіляться між робочими потоками, що висять на читанні даних
// з вхідного каналу.
for( unsigned int i = 0; i != 10000; i++)
send< unsigned int >( values_ch, i );

// Закриваємо вхідний канал і даємо можливість дочитати його вміст
// до самого кінця.
close_retain_content( values_ch );

// Отримуємо результуючі значення від всіх робочих ниток.
receive(
// Ми точно знаємо, скільки значень має бути прочитано.
from( results_ch ).handle_n( workers.size() ),
[]( const consumer_result & r ) {
cout << "Thread: " << r.m_id
<< ", values: " << r.m_values_received
<< ", sum: " << r.m_sum
<< endl;
} );

for_each( begin(workers), end(workers), []( thread & t ) { t.join(); } );
}


Навіщо це?

Напевно у читачів, які ніколи раніше не використовували Actor Model і Publish-Subscribe, вже виникло питання: «І що, все вищеперелічене дійсно спрощує розробку багатопоточних додатків на C++?»

Так. Спрощує. Перевірено на людях. Багаторазово.

Ясна річ, що спрощує не для всіх програм. Адже багатопоточність — це інструмент, який використовується у двох дуже різних напрямках. Перший напрямок, зване parallel computing, використовує потоки для завантаження всіх наявних обчислювальних ресурсів і скорочення загального часу розрахунку обчислювальних завдань. Наприклад, прискорення перекодування відео за рахунок завантаження всіх обчислювальних ядер, при цьому кожне ядро виконує одну і ту ж задачу, але на своєму наборі даних. Це не той напрямок, для якого створювався SObjectizer. Для спрощення вирішення такого класу задач призначені інші інструменти: OpenMP, Intel Threading Building Blocks, HPX і т. д.

Другий напрямок, зване concurrent computing, використовує багатопоточність для забезпечення паралельного виконання безлічі (майже) незалежних активностей. Наприклад, поштовий клієнт в одному потоці може відправляти вихідну пошту, у другому — завантажувати вхідну, в третьому — редагувати новий лист, в четвертому — виконувати фонову перевірку правопису у новому листі, у п'ятому — проводити повнотекстовий пошук по поштовому архіву і т. д.

SObjectizer створювався як раз для направлення concurrent computing, а перераховані вище можливості SObjectizer дозволяють зменшити обсяг головного болю у розробника.

Насамперед за рахунок вибудовування взаємодії між агентами через асинхронний обмін повідомленнями.

Взаємодія незалежних потоків через черги повідомлень набагато простіше, ніж через ручну роботу з розділяються даними, захищеними семафорами або мутексами. Причому тим простіше, чим більше робочих потоків у додатку і чим частіше і різноманітніше їх взаємодія.

Заплутатися в мутексах і умовних змінних нескладно навіть на десятці робочих потоків. А вже коли рахунок йде на сотні робочих потоків, то ручна метушня з низькорівневими примітивами синхронізації взагалі опиняється за межею можливостей навіть досвідчених розробників. Тоді як сотня ниток, взаємодіючих через черги повідомлень, як показала практика, абсолютно не проблема.

Так що головне, що дає розробнику SObjectizer (як і будь-яка інша реалізація Actor Model) — це можливість подання незалежних активностей усередині додатку у вигляді агентів, спілкуються з навколишнім світом тільки через повідомлення.

Наступний ключовий момент — це зв'язування агентів з відповідними робочими контекстами.

Здоровий змив підказує (і практика це підтверджує), що видати всім агентам з власної робочої нитки не є добре. Додатком може знадобитися десять тисяч незалежних агентів. Або сто тисяч. Або навіть мільйон. Очевидно, що наявність такої кількості робочих ниток в системі ні до чого доброго не призведе. Навіть якщо ОС і буде здатна створити їх (дивлячись яка ОС і на якому обладнанні), то накладні витрати на забезпечення їх роботи все одно виявляться занадто великими, щоб побудоване таким чином додаток працювало з прийнятною продуктивністю і чуйністю.

Протилежність, коли всі агенти прив'язуються до однієї загальної нитки або до одного єдиного пулу ниток, також не є ідеальним рішенням для всіх випадків. Наприклад, у програмі може виявитися десяток агентів, яким доводиться працювати зі стороннім синхронним API (робити запити до БД, спілкуватися з підключеним до комп'ютера пристроями, виконувати важкі обчислювальні операції і т. д.). Кожен такий агент здатний загальмувати роботу всіх інших агентів, які знаходяться з ним на одній робочої нитки. Кілька таких агентів запросто можуть загальмувати всі додаток, якщо воно використовує в роботі єдиний пул робочих потоків: просто кожен з агентів займе один з потоків пулу…

Саме для вирішення цих проблем в SObjectizer є диспетчери і така дуже важлива операція, як прив'язка агентів до відповідних диспетчерам. Усе разом це дає належну свободу і гнучкість розробнику, при цьому позбавляючи розробника від турбот з управління цими потоками.

Програміст може створити стільки диспетчерів, скільки йому потрібно, і так розподілити своїх агентів між цими диспетчери, як йому здається правильним. Наприклад, у додатку можуть бути:
  • один диспетчер типу one_thread, на якому якийсь агент працює AMQP-клієнтом;
  • один диспетчер типу thread_pool, на якому працюють агенти, відповідальні за опрацювання повідомлень з AMQP-шних топіків;
  • один диспетчер типу active_obj, до якого прив'язуються агенти для взаємодії з СУБД;
  • ще один диспетчер типу active_obj, на якому будуть працювати агенти, які спілкуються з підключеними до комп'ютера HSM-ами;
  • і ще один thread_pool-диспетчер для агентів, які стежать і керують усією описаною вище кухнею.
Ще однією штукою, яку самі користувачі SObjectizer відзначають, як одну з найважливіших, є підтримка відкладених і періодичних повідомлень. Тобто робота з таймерами.

Дуже часто буває потрібно виконати якусь дію через N мілісекунд. А потім через M мілісекунд перевірити наявність результату. І, якщо результату немає, почекати K мілісекунд і повторити все наново. Нічого складного: є send_delayed, яка робить відкладену на вказаний час надсилання повідомлення.

Часто агенти працюють на тактовій основі. Скажімо, раз в секунду агент прокидається, виконує пачку накопичилися за останню секунду операцій, після чого засинає до настання чергового такту. Знову нічого складного: є send_periodic, яка повторює доставку одного і того ж повідомлення з заданим темпом.

Чому SObjectizer саме такий?

SObjectizer ніколи не був експериментальним проектом, він завжди застосовувався для спрощення повсякденної роботи з C++. Кожна нова версія SObjectizer одразу йшла на роботу, SObjectizer постійно використовувався в розробці комерційних проектів (зокрема, в кількох business-critical проектах компанії Интервэйл, але не тільки). Це накладало свій відбиток на його розвиток.

Роботи над останнім варіантом SObjectizer (ми його називаємо SObjectizer-5), почалися в 2010-му, коли стандарт C++11 ще не був прийнятий, якісь речі C++11 подекуди вже підтримувалися, а якихось довелося чекати більше п'яти років.

В таких умовах не все виходило зробити зручно і лаконічно з першого разу. Місцями бракувало досвіду використання C++11. Дуже часто нас обмежували можливості компіляторів, з якими доводилося мати справу. Можна сказати, що рух вперед йшло методом проб і помилок.

При цьому нам потрібно було ще й дбати про сумісність: коли SObjectizer лежить в основі business-critical додатків, не можна просто викинути якийсь шматок з SObjectizer або якимось кардинальним чином змінити частину його API. Тому навіть якщо час показувало, що де-то ми помилилися і що можна робити простіше і зручніше, то можливості «взяти і переписати» не було. Ми рухалися і рухаємося еволюційним шляхом, поступово додаючи нові можливості, але не викидаючи відразу старі шматки. В якості невеликої ілюстрації: якогось серйозного порушення зворотної сумісності не було з моменту виходу версії 5.5.0 восени 2014-го року, хоча відтоді відбулося вже близько 20 релізів в рамках розвитку версії 5.5.

SObjectizer придбав свої унікальні риси в результаті багаторічного використання SObjectizer в реальних проектах. На жаль, ця унікальність «вилазить боком» при спробах розповісти про SObjectizer широкій публіці. Надто вже SObjectizer не схожий на Erlang та інші проекти, створені за образом і подобою Erlang-а (наприклад, C++ Actor Framework або Akka).

Ось, скажімо, є у нас можливість запустити кілька незалежних примірників SObjectizer-а в одному додатку. Можливість досить екзотична. Але додана вона була тому, що на практиці іноді таке буває необхідно. Для підтримки цієї можливості в SObjectizer з'явилося таке поняття, як SObjectizer Environment. І цей SObjectizer Environment знадобилося «протягувати» через неабияку частину API SObjectizer-а, що не могло не позначитися на лаконічності коду.

А ось в C++ Actor Framework такої можливості спочатку не було. API акторів у CAF виглядав набагато простіше, а приклади коду — коротше. Із-за чого ми часто зустрічаємося з твердженнями, що CAF сприймається простіше і зрозуміліше, ніж SObjectizer.

Іронія, однак, у тому, що з часом розробники CAF-а так же прийшли до висновку, що їм потрібно мати щось на зразок SObjectizer Environment (вони це називають actor_system). І в наступній версії CAF очікується додавання цієї штуки. З черговою поломкою сумісності між різними версіями CAF-а. У цих поломки, до речі кажучи, CAF так само сильно випереджає SObjectizer.

Ще одна річ, яка виникає з досвіду використання SObjectizer, яка нам здається правильною і природною, але яка викликає нездорову реакцію публіки: відсутність підтримки в SObjectizer-5 вбудованих засобів для роздрібненості. Ми часто чуємо щось на кшталт «Ну як же так? Ось в Erlang-е є, Akka — є, CAF — є, а у вас немає?!!»

Немає. З дуже простої причини: в SObjectizer-4 така підтримка була, але згодом з'ясувалося, що не буває транспорту, який би ідеально підходив під різні умови. Якщо вузли розподіленого додатку ганяють один одному великі шматки відеофайлів — це одне. Якщо обмінюються сотнями тисяч дрібних пакетів — зовсім інше. Якщо C++ додаток повинен спілкуватися з додатками Java — це третє. І т. д.

Тому ми вирішили не додавати у SObjectizer-5 універсальний транспорт, який міг би виявитися вельми посереднім в кожному з реальних сценаріїв використання, а задіяти ті комунікаційні можливості, які потрібні під завдання. Десь це AMQP, десь MQTT, десь REST. Просто все це реалізується сторонніми інструментами. Що в підсумку обходиться простіше, дешевше і ефективніше.

Якщо вже мова зайшла про інших подібних розробках, то можна сказати, що живих і розвиваються проектів подібного роду для C++ буквально раз-два і край:
  • насамперед відносно молодий і ще не стабилизировавшийся CAF, який є настільки близькою реалізацією Erlang-а засобами C++, наскільки це можливо;
  • старий, промислового якості проект QP, сильно заточений під розробку вбудованих систем;
  • минималистичная і тривіальна реалізація Actor Model в комерційній бібліотеці Just::Thread Pro Actor Edition.
У кожного з цих проектів є свої сильні і слабкі сторони. Але, взагалі кажучи, на їх фоні SObjectizer зі своїми можливостями, крос-платформенностью, производительностью, кількістю тестів, прикладів і обсягом документації виглядає аж ніяк не бідним родичем. Що, знову ж таки, є наслідком того, що SObjectizer завжди був інструментом для вирішення практичних завдань.

Післямова

У цій невеликій статті ми спробували дати короткі відповіді на найбільш поширені питання, з якими ми стикаємося, намагаючись розповідати про SObjectizer-е широкій публіці: «Що це таке?», «Навіщо це?» і «Чому воно саме таке?» Для збереження прийнятного обсягу статті ми контурно позначили лише найбільш значущі моменти і яскраві риси SObjectizer-а. Якщо ж заглиблюватися в деталі, то можна написати ще з десяток-інший статтею такого обсягу. І якщо читачам буде цікаво, то ми з задоволенням розповімо ще більше про SObjectizer-е, про принципи його роботи, про досвід розробки софта з його допомогою, про перспективи подальшого розвитку і т. д, і т. п.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.