Варианты распределенных сценариев
Ахтунг! В первой части статьи примеры кода на С++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 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів