Варианты распределенных сценариев
Ахтунг! В первой части статьи примеры кода на С++20 и вынос моска; в целях безопасности туда лучше не смотреть — общие рассуждения на этот код не опираются.
Статья рассматривает сценарии (многоэтапные последовательности действий) в асинхронных (распределенных) системах — какие реализации бывают, какие плюсы и минусы у разных подходов. Стиль написания логики сценария будем характеризовать по двум параметрам: активный или реактивный и явный или неявный. Функционального программирования не будет — я его не знаю.
Трактатъ может быть интересен: новичкам — как обзорник по подходам к асинхронным сценариям (код можно пропустить); синьорам, работающим с такой гадостью — подумать, почему на проекте используют именно этот подход, а не какой-то другой (тоже код нафиг); плюсовикам и эмбедерам — глянуть на возможности нового стандарта, которые скоро выкатятся в мейнстрим.
Содержание:
- Активные и реактивные сценарии (с примерами на С++)
- Явные и неявные сценарии (саги в картинках)
- Сравнение вышеизложенного
Терминология (установившейся нет, поэтому здесь договоримся о понятиях):
Явный (explicit) сценарий — когда есть единый объект, функция или другая сущность, содержащая всю логику выполнения сценария.
Неявный (implicit) сценарий — когда логика выполнения конкретного сценария «размазана» по разным сущностям и перемешана с логикой других сценариев.
Активный (императивный) сценарий — когда отсылка запросов и получение их результатов записаны в коде последовательно и обычно совмещены.
Пассивный (реактивный) сценарий — когда сценарий представлен как цепочка обработчиков событий, каждый из которых может сохранить данные или запустить следующее действие.
Например:
Если функция добавляет 1 к значениям х, у и еще какой-то переменной — это явный (вся логика в одном месте) активный (функция управляет событиями) сценарий.
Если у подписан на изменение значения х, мы добавляем 1 к х, у узнает об этом и добавляет к своему значению 1 — это неявный (логика размазана по методам х и у) пассивный (логика в колбеках) сценарий.
Тестовый сценарий: Будем красить дом, гараж и собачью будку.
Итак, погнали.
***** Активные и реактивные сценарии: обработчики событий, промисы и корутины на С++ *****
Есть асинхронная система (акторы или микросервисы) с несколькими объектами:
class Handler { public: virtual void Post(Message* const msg) = 0; }; Handler* g_house; Handler* g_garage; Handler* g_doghouse;
которым мы можем отсылать сообщения:
class PaintHouseReqMsg : public HouseMsg { public: PaintHouseReqMsg(const Cookie id, Handler& source); }; class PaintGarageReqMsg : public GarageMsg { public: PaintGarageReqMsg(const Cookie id, Handler& source); }; class PaintDoghouseReqMsg : public DoghouseMsg { public: PaintDoghouseReqMsg(const Cookie id, Handler& source); };
Запрос на покраску всех компонентов и ответы от компонентов падают нам в очередь сообщений и диспатчатся в соответствующие обработчики:
class MainHandler : public Handler { public: virtual void OnPaintAllReq(const Cookie id, Handler& source) = 0; virtual void OnPaintHouseCfm(const Cookie id, const bool result) = 0; virtual void OnPaintGarageCfm(const Cookie id, const bool result) = 0; virtual void OnPaintDoghouseCfm(const Cookie id, const bool result) = 0; }; Handler* g_main;
Когда нас просят покрасить все, нужно разослать запросы компонентам и ответить, всех ли смогли успешно покрасить:
class PaintAllCfmMsg : public ClientMsg { public: PaintAllCfmMsg(const Cookie id, const bool result); };
Обработчики событий (aka проактор или реактор, колбеки) — все гениальное тупо (почти неявный пассивный сценарий)
enum PaintResult { DOING, SUCCESS, FAILURE, LAST }; struct PaintData { PaintData(Handler& cl, const Cookie co) : client(cl), id(co), house(DOING), garage(DOING), doghouse(DOING) {} bool operator==(const Cookie co) const {return id == co;} Handler& client; const Cookie id; PaintResult house; PaintResult garage; PaintResult doghouse; }; class MainOnReactor : public MainHandler { public: MainOnReactor() {} virtual void OnPaintAllReq(const Cookie id, Handler& source) { assert(!requests_.In(id)); requests_.Add(source, id); printf("Request paint all %d\n", id); // multicast the subrequests g_house->Post(new PaintHouseReqMsg(id, *this)); g_garage->Post(new PaintGarageReqMsg(id, *this)); g_doghouse->Post(new PaintDoghouseReqMsg(id, *this)); } virtual void OnPaintHouseCfm(const Cookie id, const bool result) { PaintData* const state = requests_.TryFind(id); assert(state); assert(state->house == DOING); printf("Paint house %d result %d\n", id, result); state->house = result ? SUCCESS : FAILURE; ProcessState(state); } virtual void OnPaintGarageCfm(const Cookie id, const bool result) { PaintData* const state = requests_.TryFind(id); assert(state); assert(state->garage == DOING); printf("Paint garage %d result %d\n", id, result); state->garage = result ? SUCCESS : FAILURE; ProcessState(state); } virtual void OnPaintDoghouseCfm(const Cookie id, const bool result) { PaintData* const state = requests_.TryFind(id); assert(state); assert(state->doghouse == DOING); printf("Paint doghouse %d result %d\n", id, result); state->doghouse = result ? SUCCESS : FAILURE; ProcessState(state); } private: void ProcessState(const PaintData* const state) { bool failed = false; // gather the results switch(state->house) { case DOING: return; case FAILURE: failed = true; } switch(state->garage) { case DOING: return; case FAILURE: failed = true; } switch(state->doghouse) { case DOING: return; case FAILURE: failed = true; } state->client.Post(new PaintAllCfmMsg(state->id, !failed)); requests_.Delete(state); } List<PaintData> requests_; };
При получении запроса в OnPaintAllReq() добавляем в список сценариев в обработке структуру, в которую собираем ответы от субподрядчиков. Когда все ответы получены — отсылаем суммарный результат заказчику и забываем о выполненной работе.
Штука самая быстрая из того, что вообще можно написать. При этом код плохо читаемый, так как куски сценария разбросаны по всем методам класса. Если разных сценариев будет несколько — то код в обработчиках событий усложнится (надо будет разбираться, какой именно сценарий захотел красить будку — может, ее перекрашивают для того, чтобы потом снести забор). Для поддержки нескольких видов сценариев логику реакции на ответы переносим в объект состояния запроса —
Оркестратор — у нас ООП (явный пассивный сценарий)
Вот интерфейс логики сценария:
class Orchestrator { public: Orchestrator(const Cookie id, Handler& client) : id_(id), client_(client) {} virtual ~Orchestrator() {} bool operator==(const Cookie id) const {return id_ == id;} virtual void Run() = 0; virtual bool OnHouseResult(const bool result) = 0; virtual bool OnGarageResult(const bool result) = 0; virtual bool OnDoghouseResult(const bool result) = 0; protected: const Cookie id_; Handler& client_; };
Конкретный сценарий покраски всего наследуем от него, и прописываем логику:
class PaintAllOrchestrator : public Orchestrator { public: PaintAllOrchestrator(const Cookie id, Handler& source) : Orchestrator(id, source), house_res_(false), garage_res_(false), doghouse_res_(false) {} virtual void Run() { g_house->Post(new PaintHouseReqMsg(id_, *g_main)); g_garage->Post(new PaintGarageReqMsg(id_, *g_main)); g_doghouse->Post(new PaintDoghouseReqMsg(id_, *g_main)); } virtual bool OnHouseResult(const bool result) { if(!result) { client_.Post(new PaintAllCfmMsg(id_, false)); return true; } else { house_res_ = true; return CheckDone(); } } virtual bool OnGarageResult(const bool result) { if(!result) { client_.Post(new PaintAllCfmMsg(id_, false)); return true; } else { garage_res_ = true; return CheckDone(); } } virtual bool OnDoghouseResult(const bool result) { if(!result) { client_.Post(new PaintAllCfmMsg(id_, false)); return true; } else { doghouse_res_ = true; return CheckDone(); } } private: bool CheckDone() { if(house_res_ && garage_res_ && doghouse_res_) { client_.Post(new PaintAllCfmMsg(id_, true)); return true; } else return false; } bool house_res_; bool garage_res_; bool doghouse_res_; };
Когда приходят запросы — создаем конкретный сценарий, соответствующий типу запроса, когда приходит ответ от периферии — находим его сценарий по id и вызываем его колбек, не зная типа сценария (полиморфизм сценариев):
class MainOnOrchestrator : public MainHandler { public: MainOnOrchestrator() {} virtual void OnPaintAllReq(const Cookie id, Handler& source) { assert(!requests_.In(id)); printf("Request paint all %d\n", id); Orchestrator* const req = requests_.Add(new PaintAllOrchestrator(id, source)); req->Run(); } virtual void OnPaintHouseCfm(const Cookie id, const bool result) { printf("Paint house %d result %d\n", id, result); Orchestrator* const req = requests_.TryFind(id); if(req) { if(req->OnHouseResult(result)) requests_.Delete(req); } else printf("Paint house %d is obsolete\n", id); } virtual void OnPaintGarageCfm(const Cookie id, const bool result) { printf("Paint garage %d result %d\n", id, result); Orchestrator* const req = requests_.TryFind(id); if(req) { if(req->OnGarageResult(result)) requests_.Delete(req); } else printf("Paint garage %d is obsolete\n", id); } virtual void OnPaintDoghouseCfm(const Cookie id, const bool result) { printf("Paint doghouse %d result %d\n", id, result); Orchestrator* const req = requests_.TryFind(id); if(req) { if(req->OnDoghouseResult(result)) requests_.Delete(req); } else printf("Paint doghouse %d is obsolete\n", id); } private: PtrList<Orchestrator> requests_; };
Если обработчик события в сценарии возвращает true — сценарий завершился, его можно удалить из списка.
Почти такой же быстрый подход, как колбеки. Плюс — прячет всю логику сценария в выделенный класс, что позволит не заморачиваясь поддерживать десятки различных сценариев. Минус — логика все еще разбросана по нескольким методам оркестратора. Чтобы попытаться собрать ее вместе, нам нужно выделить поток выполнения под обработку каждого запроса — используем
Промисы (с фьючерами) — у нас настоящая многопоточность (явный активный сценарий)
Промисы и фьючеры — механизм синхронизации, типа семафоры на стероидах. В промис можно пихать, а из фьючерса — вынимать значение. При этом если значения там еще нет — то вынимающий поток тормознет до тех пор, пока значение не появится. Итак, заводим потоки, по одному на запрос:
struct Promises { std::promise<Handler&> run; std::promise<bool> house; std::promise<bool> garage; std::promise<bool> doghouse; }; class FutureProcessor { public: FutureProcessor() : idle_(true), id_(-1), promises_(new Promises), thread_(&FutureProcessor::Run, this) {} bool operator==(const Cookie id) const {return id_ == id;} const bool idle() const {return idle_;} void OnRequest(const Cookie id, Handler& source) { idle_ = false; id_ = id; promises_->run.set_value(source); } void OnHouseResult(const bool result) {promises_->house.set_value(result);} void OnGarageResult(const bool result) {promises_->garage.set_value(result);} void OnDoghouseResult(const bool result) {promises_->doghouse.set_value(result);} private: void Run() { while(true) { // wait for the request Handler& client = promises_->run.get_future().get(); std::future<bool> house_res = SendToHouse(); std::future<bool> garage_res = SendToGarage(); std::future<bool> doghouse_res = SendToDoghouse(); // gather the results const bool success = house_res.get() && garage_res.get() && doghouse_res.get(); client.Post(new PaintAllCfmMsg(id_, success)); delete promises_; promises_ = new Promises; id_ = -1; idle_ = true; } } std::future<bool> SendToHouse() { g_house->Post(new PaintHouseReqMsg(id_, *g_main)); return promises_->house.get_future(); } std::future<bool> SendToGarage() { g_garage->Post(new PaintGarageReqMsg(id_, *g_main)); return promises_->garage.get_future(); } std::future<bool> SendToDoghouse() { g_doghouse->Post(new PaintDoghouseReqMsg(id_, *g_main)); return promises_->doghouse.get_future(); } bool idle_; Cookie id_; Promises* promises_; std::thread thread_; }; class MainOnPromises : public MainHandler { public: MainOnPromises() {} virtual void OnPaintAllReq(const Cookie id, Handler& source) { printf("Request paint all %d\n", id); for(unsigned i = 0; i < numslaves_; ++i) if(slaves_[i].idle()) { slaves_[i].OnRequest(id, source); return; } printf("Out of resources for %d\n", id); source.Post(new PaintAllCfmMsg(id, false)); } virtual void OnPaintHouseCfm(const Cookie id, const bool result) { printf("Paint house %d result %d\n", id, result); for(unsigned i = 0; i < numslaves_; ++i) if(slaves_[i] == id) { slaves_[i].OnHouseResult(result); return; } printf("Paint house %d is obsolete\n", id); } virtual void OnPaintGarageCfm(const Cookie id, const bool result) { printf("Paint garage %d result %d\n", id, result); for(unsigned i = 0; i < numslaves_; ++i) if(slaves_[i] == id) { slaves_[i].OnGarageResult(result); return; } printf("Paint garage %d is obsolete\n", id); } virtual void OnPaintDoghouseCfm(const Cookie id, const bool result) { printf("Paint doghouse %d result %d\n", id, result); for(unsigned i = 0; i < numslaves_; ++i) if(slaves_[i] == id) { slaves_[i].OnDoghouseResult(result); return; } printf("Paint doghouse %d is obsolete\n", id); } private: static const unsigned numslaves_ = 10; FutureProcessor slaves_[numslaves_]; };
Это выглядит примерно как предыдущий пример (оркестратор на колбеках), но запросы обрабатываются потоками из пула.
Здесь вся логика сценария уместилась в небольшой метод Run(), вернее — в 6 строк внутри него. Очень хорошо по сравнению с предыдущими вариантами. Но цена высокая: кроме кучи вспомогательного кода вокруг, мы пользуемся очень дорогими потоками, и даже не можем себе позволить создавать потоки во время работы — а берем их из ограниченного пула. И количество запросов, которые у нас могут одновременно быть в работе, ограничено размером пула потоков. Попробуем решить эту проблему, используя
Корутины — назад к однопоточности (явный активный сценарий)
Корутины переключают поток выполнения на другой стек, там что-то происходит, потом поток переключается назад, а на дополнительном стеке осталось сохраненным то состояние, в котором функция находилась при переключении. Если переключиться обратно — функция запускается не с начала, а с середины кода с сохраненными переменными и прочими плюшками. Таким образом, один поток может делать вид, что его несколько. Что нам и надо — чтобы не плодить настоящие большие потоки, как у промисов.
Итак, много С++20 магии (объяснение тут blog.panicsoftware.com/co_awaiting-coroutines )
struct CoMagicPromise; typedef std::coroutine_handle<CoMagicPromise> CoHandle; struct CoMagicPromise { auto get_return_object() {return CoHandle::from_promise(*this);} auto initial_suspend() {return std::suspend_never();} auto final_suspend() {return std::suspend_always();} constexpr void return_void() {} void unhandled_exception() {std::terminate();} }; struct CoMagicResumable { typedef CoMagicPromise promise_type; CoMagicResumable() : valid_(false) {} CoMagicResumable(CoHandle handle) : valid_(true), handle_(handle) {} CoMagicResumable(CoMagicResumable&) = delete; CoMagicResumable(CoMagicResumable&& other) = delete; ~CoMagicResumable() { if(valid_) handle_.destroy(); } void operator=(CoMagicResumable&& other) { if(&other == this) return; if(valid_) handle_.destroy(); valid_ = other.valid_; handle_ = other.handle_; other.valid_ = false; } bool done() const {return handle_.done();} private: bool valid_; CoHandle handle_; }; template<class T> struct Awaiter { Awaiter(T& master) : master_(master) {} bool await_ready() const {return master_.Ready();} void await_suspend(CoHandle handle) {master_.Suspend(handle);} bool await_resume() {return master_.Resume();} private: T& master_; }; class Awaitable { public: Awaitable() {} Awaitable(Awaitable&) = delete; Awaitable(Awaitable&&) = delete; Awaiter<Awaitable> operator co_await() {return *this;} bool Ready() const {return result_.has_value();} void Suspend(CoHandle handle) { assert(!waiter_); waiter_ = handle; } bool Resume() { assert(waiter_); waiter_.reset(); return *move(result_); } bool Push(const bool result) { assert(!result_); result_ = result; if(waiter_) { CoHandle waiter = *move(waiter_); assert(!waiter.done()); waiter.resume(); return waiter.done(); } printf("Double cfm\n"); return false; } private: std::optional<CoHandle> waiter_; std::optional<bool> result_; };
Магия позволяет использовать Awaitable как пару промис/фьючер:
// One by one operations class LinearCoWorker { public: LinearCoWorker(const Cookie id) : id_(id) {} LinearCoWorker(LinearCoWorker&) = delete; LinearCoWorker(LinearCoWorker&&) = delete; bool operator==(const Cookie id) const {return id_ == id;} void OnRequest(Handler& source) {runner_ = std::move(Run(source));} bool OnHouseResult(const bool result) {return house_.Push(result);} bool OnGarageResult(const bool result) {return garage_.Push(result);} bool OnDoghouseResult(const bool result) {return doghouse_.Push(result);} private: CoMagicResumable Run(Handler& client) { bool success = false; if(co_await SendToHouse()) if(co_await SendToGarage()) if(co_await SendToDoghouse()) success = true; client.Post(new PaintAllCfmMsg(id_, success)); } Awaitable& SendToHouse() { g_house->Post(new PaintHouseReqMsg(id_, *g_main)); return house_; } Awaitable& SendToGarage() { g_garage->Post(new PaintGarageReqMsg(id_, *g_main)); return garage_; } Awaitable& SendToDoghouse() { g_doghouse->Post(new PaintDoghouseReqMsg(id_, *g_main)); return doghouse_; } const Cookie id_; CoMagicResumable runner_; Awaitable house_; Awaitable garage_; Awaitable doghouse_; }; class MainOnCoroutines : public MainHandler { typedef LinearCoWorker Worker; //typedef GatherCoWorker Worker; public: MainOnCoroutines() {} virtual void OnPaintAllReq(const Cookie id, Handler& source) { printf("Request paint all %d\n", id); assert(!coworkers_.In(id)); Worker* const worker = coworkers_.Add(id); worker->OnRequest(source); } virtual void OnPaintHouseCfm(const Cookie id, const bool result) { printf("Paint house %d result %d\n", id, result); Worker* const worker = coworkers_.TryFind(id); if(worker) { if(worker->OnHouseResult(result)) coworkers_.Delete(worker); } else printf("Paint house %d is obsolete\n", id); } virtual void OnPaintGarageCfm(const Cookie id, const bool result) { printf("Paint garage %d result %d\n", id, result); Worker* const worker = coworkers_.TryFind(id); if(worker) { if(worker->OnGarageResult(result)) coworkers_.Delete(worker); } else printf("Paint garage %d is obsolete\n", id); } virtual void OnPaintDoghouseCfm(const Cookie id, const bool result) { printf("Paint doghouse %d result %d\n", id, result); Worker* const worker = coworkers_.TryFind(id); if(worker) { if(worker->OnDoghouseResult(result)) coworkers_.Delete(worker); } else printf("Paint doghouse %d is obsolete\n", id); } private: List<Worker> coworkers_; };
Код весьма похож на код с фьючерсами (даже тоже 6 строк логики), со следующими отличиями:
- Мы позволили себе создавать корутину при получении запроса, так как под капотом там обычное выделение памяти, а не вызов в ядро. То есть, мы практически не ограничены в количестве одновременно обрабатываемых сценариев.
- Запросы к периферии выполняются по-очереди, а не одновременно. Фьючер можно сначала создать, а позже — вынуть результат. С Awaitable такое ленивое доставание не прокатит.
Чтобы починить второй пункт, придется вспомнить оркестратор и переписать LinearCoWorker:
// Multicast support class GatherCoWorker { public: GatherCoWorker(const Cookie id) : id_(id) {} GatherCoWorker(LinearCoWorker&) = delete; GatherCoWorker(LinearCoWorker&&) = delete; bool operator==(const Cookie id) const {return id_ == id;} void OnRequest(Handler& source) {runner_ = std::move(Run(source));} bool OnHouseResult(const bool result) { house_res_ = result; return TryGather(); } bool OnGarageResult(const bool result) { garage_res_ = result; return TryGather(); } bool OnDoghouseResult(const bool result) { doghouse_res_ = result; return TryGather(); } private: CoMagicResumable Run(Handler& client) { g_house->Post(new PaintHouseReqMsg(id_, *g_main)); g_garage->Post(new PaintGarageReqMsg(id_, *g_main)); g_doghouse->Post(new PaintDoghouseReqMsg(id_, *g_main)); client.Post(new PaintAllCfmMsg(id_, co_await gatherer_)); } bool TryGather() { if(house_res_ && garage_res_ && doghouse_res_) return gatherer_.Push(*house_res_ && *garage_res_ && *doghouse_res_); else return false; } const Cookie id_; CoMagicResumable runner_; Awaitable gatherer_; std::optional<bool> house_res_; std::optional<bool> garage_res_; std::optional<bool> doghouse_res_; };
Мы починили скорость обработки запроса, но теперь логика размазалась по двум методам: Run() и TryGather().
Итак, по корутинам: они несколько менее удобные, чем промисы с потоками, но на порядки легковеснее по ресурсам.
Усложнение сценария — откат изменений при ошибке
Добавляем требование: Если один из компонентов не покрасился, остальным нужно вернуть старый цвет. Это значит:
- Нужно ждать, пока придут все ответы на отправленные запросы
- Если хоть один из ответов неудачен — нужно поотправлять запросы об откате
В пассивных вариантах (на колбеках) это легко решается расширением энума с состоянием запроса к компоненту {спрашиваем, успех, ошбика, откатываем} и несколькими строками кода в разных местах.
На фьючерех и корутинах после сбора результатов если есть хоть одна ошибка — рассылаем запрос на откат тем компонентам, которые успешно покрасились. Тоже без проблем.
Еще усложнение — отмена сценария
Пользователь хочет уметь отменить запрос, который сейчас в обработке. То есть:
- Нужно сообщить сценарию, что его отменяют
- Сценарий должен откатить уже сделанные успешные изменения
- Сценарий должен отправить отмену текущего запроса модулям, которые поддерживают отмену
- Сценарий должен дождаться результатов от модулей, не поддерживающих отмену, и откатить успешные изменения
С пассивными типами сценариев (обработчики событий и оркестратор) все настолько же просто, как и в прошлый раз. Несколько строк кода, и либо новый статус в энум, либо новый флаг о том, что запрос отменен.
С активными сценариями (промисы и корутины) какой-то треш. Надо разбудить поток (вероятно, отправив ему исключение), понять, какие переменные на стеке у него еще не заполнены результатами, отослать в незаполненные отмену, а заполненные должны быть обернуты в RAII классы, отправляющие запрос на откат из деструктора (который вызовется исключением). В промис можно послать исключение, но для этого нужно знать, на каком из фьючеров в данный момент ожидает поток. Либо можно разослать во все незаполненные. Для отправления исключения в корутину нужно написать метод Awaitable::SetException() по подобию Awaitable::Push() и кинуть сохраненное исключение из Awaitable::Resume(). И опять, нужно знать, на каком Awaitable корутина ожидает, иначе огребем мусор в переменных корутины, которая проснется на незаполненном результате.
Осложнение 3 — соревнование за ресурсы
Если несколько сценариев возьмутся красить дом в разные цвета, а дом большой, и красится в несколько этапов — можно получить очень разноцветный результат в горошек. Либо два заказчика будут долго наперегонки пытаться перекрасить результаты друг друга (livelock).
Для защиты нужен прокси, моделирующий дом (знающий его текущее состояние) и пропускающий, кеширующий или отклоняющий запросы. При этом в него переползает часть логики дома и сценариев. И мы понемногу сползаем к самому первому подходу (обработчики событий), когда на каждого субподрядчика была своя функция с бизнес-логикой, совмещающей знания о возможных сценариях. А сценарии в виде отдельных объектов не могут таким заниматься, так как не знают друг о друге, и работают независимо (а на промисах — еще и многопоточно).
Сводка
Параметр | Колбеки | Оркестратор | Промисы | Корутины |
Простота логики | -Плохо- | Боль-мень | ++ Супер ++ | + Хорошо + |
Простота обертки | ++ Идеально (нет обертки) ++ | + Простая + | Так себе | — - Сложная с магией — - |
Потребление ресурсов | + Малое + | + Малое + | — - Очень большое — - | Среднее |
Скорость | ++ Супер ++ | + Высокая + | -Низкая- | Средняя |
Откат | + Хорошо + | + Хорошо + | ++ Супер ++ | ++ Супер ++ |
Отмена | + Хорошо + | + Хорошо + | — - Треш — - | — - Угар — - |
Общие ресурсы | С пуэром покатит | -Жесть- | — - Угар — - | — - Треш — - |
Интересно, что ситуация с общими ресурсами выбивается из общей картины — если по остальным параметрам явно видны две пары: колбеки+оркестратор против промисов с корутинами, то в соревновании за ресурсы колбеки стоят особняком. И связано это с тем, что данная проблема упирается не в измерение активный<->пассивный сценарий, а в:
***** Явные и неявные сценарии: хореография и оркестрация в сагах *****
Сага — это распределенная транзакция, то есть — сценарий, который либо применяет изменения ко всем вовлеченным объектам, либо — ни к одному. В литературе (вспомним Microservices Patterns by Chris Richardson) описаны два вида:
Хореография (неявный пассивный сценарий)
Это когда объекты знают друг о друге и управляют друг другом. В нашем случае — дом говорит гаражу покраситься, гараж говорит покраситься будке, потом красит себя, и если себя покрасить не получилось — тогда гараж сам откатывает покраску будки, говорит дому, что не вышло, дом себя не красит, а сообщает нам, что такая вот фигня. В результате никто не покрасился.
Оркестрация (явный пассивный сценарий)
А это мы сегодня уже видели. Создаем объект, который раздает приказы, слушает ответы, и откатывает изменения, если кто-то обломился.
В книжках пишут, что хореография работает быстрее (меньше сообщений) и проще написать (не нужно заморачиваться с инфраструктурой для оркестратора), но когда много сценариев — все запутывается. И мы это сегодня уже проходили между колбеками и оркестратором в первой части статьи.
То есть:
- явных сценариев можно наплодить много, а неявные запутываются при большом количестве
- неявные проще заимплементить — не нужна дополнительная инфраструктура
- неявные сценарии позволяют объектам самим контролировать свое поведение
Последний пункт довольно интересен. Если сценарий сложный и меняет состояние одних компонентов системы в зависимости от совокупного состояния других компонентов, его логику будет нереально записать одной функцией, кроме того — за время асинхронного сбора данных реальное состояние участников сценария может измениться. В результате принятое в явном сценарии решение будет устаревшим. Выход — дать участникам максимум автономности в принятии решений, и оставить в сценарии только высокоуровневую логику. Но это значит, что сценарий становится гибридным — часть логики содержится в явном виде в объекте сценария, часть — неявно размазана по компонентам системы.
Попробуем скомпоновать закономерности:
***** Рисовашки *****
Пускай по оси Х явность-неявность, а по У — активность-реактивность. Вспомним, что мы уже рассмотрели:
Остается пустым сектор активных неявных сценариев. Если подумать — что бы это могло такое быть? Когда нет единого объекта или функции с логикой и состоянием сценария, но при этом действия императивны. Да, это RPC chain. Мы говорим дому краситься и блокируемся до получения результата. Дом говорит краситься гаражу и тоже блокируется. Когда гараж покрасился — стек вызовов размотался и все пошли жить дальше. Также, раз вспомнили об RPC, нужно добавить на картинку RPC iteration когда мы говорим краситься дому и ждем его, потом блокируемся на покраске гаража, потом — будки.
Если отвлечься от нашей модели с домохозяйством, можно попробовать набросить на картинку serverless и pub/sub подходы, для которых я не смогу подобрать юз кейс покраски:
Теперь можно попробовать замапить свойства систем и их положение на диаграмме:
Активные распределенные сценарии:
- — Требуют инфраструктуру в коде для запуска —
- — Тяжелые по системным ресурсам и скорости —
- — Почти невозможно отменить сценарий —
- + Код идет подряд, легко читать и дебажить +
Пассивные сценарии, напротив:
- + Проще в инфраструктуре +
- + Поэтому легче и быстрее +
- + Можно откатить на любом шаге +
- — Код разбросан по куче методов, плохо дебажить —
Явные подходы:
- + Код в одном объекте, легче читать +
- + Можно относительно просто написать много сценариев +
- — Проблемы, когда сценарии борются за участников —
- — Проблемы с очень сложными сценариями —
Неявные подходы:
- — Код сценария разбросан по 100500 файлов — нечитаемый —
- — Поддержка только пары сценариев —
- + Участники контролируют свое состояние +
- + Сложность сценария распределяется между участниками +
Что с этим делать? Наверное, на старте проекта посмотреть, сколько и каких сценариев предполагается. Понятно, что они потом наверняка станут сложнее, и размножатся. Но это будет потом, а сейчас нужно понять, как с наименьшими усилиями стартануть.
- Если сценарии отменяемы — активные системы под большим вопросом.
- Если нужно выжать все из железа — тоже идем в более легкие пассивные.
- Если сценарии сложные, и/или вероятны конфликты сценариев за участников — участники должны быть умными, чтобы разгрузить высокоуровневую логику. То есть — появляется неявный компонент в лучшем стиле OOP и DDD.
- Если сценариев много — значит, их нужно описывать в явном виде, иначе код быстро протухнет до неузнаваемости.
Что делать, если много сложных или конфликтных сценариев? Разбить логику системы на два уровня:
- неявная логика в доменных объектах: поведение компонентов — для поддержки инварианта и устранения конфликтов и высокоуровневый интерфейс — для упрощения использования компонентов в сценариях
- явные сценарии использования компонентов в транзакционных скриптах с логикой, опирающейся на высокоуровневые интерфейсы из первого пункта
Можно почитать в Domain Driven Design by Eric Evans. Там разделяют транзакционные скрипты и логику сценариев при использовании умных доменных объектов, но я вот так сходу не найду правильный термин для последних.
delete this;
40 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів