Необычное применение Fork API
Table of Contents
Введение
Когда заходит разговор о применении fork API, обычно вспоминают серверный рендеринг и тестирование логики. В этой статье спешу рассказать и показать более широкий спектр возможных применений этого API.
Да кто такой этот ваш Fork API
🔨
Проблема
Обычно effector хранит значения сторов непосредственно внутри объектов созданных через createStore
. Хуки React или SolidJS вытаскивают значение напрямую из этого объекта по ссылке.
const $name = createStore('Sergey Sova');
function Component() {
const name = useStore($name);
return <div>Name: {name}</div>;
}
В случае с SSR, на стороне сервера одновременно могут обрабатываться множество клиентских запросов и серверу нужно одновременно отдавать разным клиентам изолированное состояние. Как мы помним Node.JS/Deno/Bun — однопоточная среда, но за счет асинхронности удается добиться так называемой одновремености исполнения. Пока обработчик клиента А ждет ответа от базы данных или HTTP-запроса, сервер будет обрабатывать запрос клиентов Б или В.
Мы не можем полагаться на синхронную обработку логики, ведь эффекты буквально являются контейнерами для асинхронной логики. Довольно легко представить себе ситуацию, когда одновременный доступ разных клиентов к значениям общего стора может все сломать.
Пока у нас один клиент работает с этими сторами, все окей.
const reset = createEvent();
const run = createEvent();
const waitFx = createEffect(() => {
return new Promise((resolve) => setTimeout(resolve, 100));
});
const $counter = createStore([]);
$counter.reset(reset);
$counter.on(run, (list, name) => [...list, `manual ${name}`]);
sample({
clock: run,
target: waitFx,
});
$counter.on(waitFx.done, (list, {params: name}) => [
...list,
`fx done ${name}`,
]);
$counter.watch((i) => console.log('counter', i));
// counter []
run('A');
// counter ["manual A"]
run('B');
// counter ["manual A", "manual B"]
// counter ["manual A", "manual B", "fx done A"]
// counter ["manual A", "manual B", "fx done A", "fx done B"]
Допустим run('A')
и run('B')
это вызовы разных клиентов. Здесь наглядно видно, что модифицируется общий массив, и в итоге по окончанию вычислений клиенты получат смешанное состояние. Если бы это был стор с обычными значениями, то клиенты получали бы чужое состояние. Вызов ивента reset
между запусками никак не спасет ситуацию:
run('A');
// counter ["manual A"]
reset();
// counter []
run('B');
// counter ["manual B"]
// counter ["manual B", "fx done A"]
// counter ["manual B", "fx done A", "fx done B"]
Теперь просто потеряно одно из значений массива, тогда как "fx done A"
все еще будет добавлен.
Решение
А что если хранить значения сторов для каждого клиента в отдельном месте?
// Псевдокод
const clientA = {stores: new Map()};
const clientB = {stores: new Map()};
// Когда клиент A запускает свои вычисления,
// читать и записывать значения сторов в объект clientA
run('A');
clientA.stores.set($counter, ['manual A']);
Примерно так и работает fork API. С помощью вызова fork()
мы создаем специальный объект — scope, в котором будут храниться все значения сторов. Именно для этого, в SSR необходимо заворачивать компоненты React или SolidJS в Provider
.
const scope = fork();
export function Init() {
return (
<Provider value={scope}>
<App />
</Provider>
);
}
Чтобы запустить вычисления в скоупе необходимо использовать allSettled
или useEvent
:
await allSettled(run, {
scope,
params: 'A',
});
Работает это относительно просто: юнит переданный в allSettled
первым аргументом будет хранить в себе ссылку на scope
, сторы прочитают значение из этого объекта, если значения еще не было, используют .defaultState
, и запишут изменения обратно в scope. Эффекты работают чуть сложнее, ведь в них необходимо корректно сохранять значение scope между асинхронными вызовами, но идея думаю понятна.
Как еще можно использовать
Я решил сделать приложение для игры с друзьями в настолку — Munchkin level counter. Самостоятельно реализовать альтернативу официальному веб-приложению с эффектором и вебсокетами.
Суть приложения можно описать в нескольких параграфах:
Игра — вспомогательное веб приложения. Игроки открывают веб-страницу, заходят в общую комнату, видят счет всех игроков, могут менять свои характеристики, отслеживают чей сейчас ход и получают обновления в реальном времени.
В веб-приложении есть своя логика, не очень сложная, но имеется: я отслеживаю направление хода, подсчет очков, автоматический сброс очков при смерти персонажа, не позволяю опустить характеристики ниже доступных и так далее. Можно было бы считать все это у каждого игрока отдельно, но я очень не хотел заморачиваться с синхронизацией или реализовывать CRDT. Плюс, игроки не всегда хотят держать вкладку открытой на телефоне, а актуальное состояние нужно отображать в любой момент игры.
Сразу оговорюсь, что я не борюсь за безопасность, потому что приложение разработано для друзей. Точно так же я не переживаю о высоких нагрузках, ведь больше 6 игроков в комнате обычно не бывает. Возможно, в будущем я добавлю новые возможности, но сейчас в приложении даже нет регистрации — зашел и играешь.
Как именно реализовывать я сначала не знал, но мне пришла в голову такая схема: игроки отправляют вебсокет-события в комнату — я нажал то-то, а сервер присылает им по вебсокетам полное актуальное состояние, каждый раз как оно изменяется.
И если посмотреть на эту схему в терминах эффектора, то видно очень понятный механизм — представление отправляет события и рендерит состояние, сервер выполняет роль модели — реагирует на события и обновляет состояние.
Как устроено
Я выделил всю логику игры в отдельный файл и написал на неё тесты, как для обычной модели фронтенд приложения:
test('player can grow level', async () => {
const scope = fork();
await allSettled(munchkin.playerJoined, {scope, params: John});
await allSettled(munchkin.playerJoined, {scope, params: Alba});
await allSettled(munchkin.levelUp, {scope, params: John.id});
expect(scope.getState(munchkin.$players)).toStrictEqual([
{...John, level: 2},
Alba,
]);
});
test('player can lose level', async () => {
const scope = fork();
await allSettled(munchkin.playerJoined, {scope, params: John});
await allSettled(munchkin.playerJoined, {scope, params: Alba});
await allSettled(munchkin.levelUp, {scope, params: John.id});
await allSettled(munchkin.levelDown, {scope, params: John.id});
expect(scope.getState(munchkin.$players)).toStrictEqual([
John,
Alba,
]);
});
Как отправлять эти события из браузера на сервер? Можно было бы сделать эффект, отправляющий type и payload на сервер, где мы через switch/case будем находить соответствующий ивент модели и вызывать с payload.
// client
const stepFinishedFx = createEffect(() =>
sendWSEvent({type: 'stepFinished'}),
);
const gearIncreaseFx = createEffect(() =>
sendWSEvent({type: 'gearIncrease'}),
);
const gearDecreaseFx = createEffect(() =>
sendWSEvent({type: 'gearDecrease'}),
);
const deadFx = createEffect(() => sendWSEvent({type: 'dead'}));
const levelUpFx = createEffect(() =>
sendWSEvent({type: 'levelUp'}),
);
// server
const {type, value} = JSON.parse(message.toString());
switch (type) {
case 'gameEvent': {
const {type: gameType, payload} = value;
switch (gameType) {
case 'stepFinished': {
const player = getCurrentPlayer();
const room = player.getRoom();
gameFinished(); // Bang!
sendUpdates();
break;
}
}
}
}
Но выглядит слишком муторно, при добавлении новых юнитов придется исправлять код сервера и клиента.
А что, если я буду вызывать напрямую те же события из модели игры в браузере и заставлю эффектор автоматически отправлять их на сервер, где буду пробрасывать их в нужную комнату без дополнительных действий для каждого действия?
Для этого я выделил набор публичных ивентов из этой модели в отдельный файл, импортировал его в клиентский код.
// common
export const common = createDomain();
export const levelUp = common.createEvent<Uuid>();
export const levelDown = common.createEvent<Uuid>();
export const playerKill = common.createEvent<Uuid>();
export const gearIncrease = common.createEvent<Uuid>();
export const gearDecrease = common.createEvent<Uuid>();
export const gearReset = common.createEvent<Uuid>();
export const $gameMode = common.createStore<GameMode>('common');
export const $players = common.createStore<Player[]>([]);
Для серверного и клиентского бандла будут проставлены идентичные sid'ы каждому стору. Ведь исходный код файла не отличается для сервера и клиента. SID — уникальный идентификатор юнита, зависящий исключительно от положения юнита в исходном коде проекта.
Совет: чтобы было проще отлаживать имена ивентов и сторов передаваемых по websocket, можно установить опцию
debugSids: true
вeffector/babel-plugin
.
Затем, с помощью домена прошелся по каждому событию и отправил вызов события в эффект отправки на сервер.
// client
munchkin.common.onCreateEvent((event) => {
sample({
clock: event,
fn: (payload) => ({sid: event.sid, payload}),
target: sendGameEventFx,
});
});
На сервере все гораздо проще, когда прилетает событие, я нахожу в объекте событие с нужным .sid
и вызываю его на скоупе комнаты через allSettled
.
import * as munchkin from '../common/munchkin';
switch (type) {
case 'gameEvent': {
const {sid, payload} = value;
const room = getRoomOfPlayer(me);
if (room) {
for (const unit of munchkin.common.history.events) {
if (unit.sid === sid) {
await allSettled(unit, {
scope: room.scope,
params: payload,
});
}
}
}
break;
}
// ...
}
Скоупы комнат
Когда игрок создает новую комнату, я сразу же выполняю fork()
и сохраняю ссылку в объекте комнаты.
function roomCreate(id: string): Room {
const room = {
id,
name: sentenceCase(createRoomName()),
teammates: [],
scope: fork(),
};
}
Конечно же, при рестарте сервера мне не хотелось бы терять все данные о процессе игры, особенно если мы с друзями договорились продолжить игру позже.
Поэтому при сохранении игры на диск, я выполняю сериализацию каждого скоупа. Затем, при старте сервера и восстановлении из диска, я загружаю в скоуп данные через передачу в values.
// backup
const gameState = serialize(room.scope);
// restore
room.scope = fork({values: backup.gameState});
Примечание: этот трюк не сработает, если пытаться загружать с диска состояние после изменения исходного кода игры. Тогда поменяются sid'ы сторов. По хорошему сохранение и загрузку состояния нужно реализовывать другим способом.
После того как сообщение прилетело, я отправляю его в скоуп комнаты, там запускается логика на эффекторе и обновляет сторы:
// ...
await allSettled(unit, {scope: room.scope, params: payload});
// ...
Теперь мне надо отправить измененные сторы обратно в браузер, каждому игроку.
Для этого я добавил несколько строк в инициализацию объекта комнаты. Так как там уже есть объект скоупа, я могу подписаться на обновление каждого стора в нём.
Для этого есть новейшее апи createWatch
добавленное в 22.3.0.
Примечание:
createWatch
следует использовать только для реализации крайне нетривиальной логики при реализации библиотечного кода, напримерeffector-react
. ЗдесьcreateWatch
используется только для примера. По хорошему, следует реализовать эту логику на эффектах.
В данном случае, при обновлении стора в скоупе, будет вызван коллбек. В этом коллбеке я буду отправлять сериализованное состояние скоупа каждому активному игроку.
import * as munchkin from '../common/munchkin';
// подписка на один стор
createWatch({
unit: munchkin.$players,
scope: room.scope,
fn: () => sendUpdates(room),
});
// подписка на все сторы в объекте
Object.values(munchkin).forEach((unit) => {
if (is.store(unit)) {
createWatch({
unit,
scope: room.scope,
fn: () => sendUpdates(room),
});
}
});
// подписка через домен
munchkin.common.onCreateStore((unit) => {
createWatch({
unit,
scope: room.scope,
fn: () => sendUpdates(room),
});
});
Так как нам нужно отправлять много сторов, то проще поместить их в единый домен и обработать все разом, используя хук onCreateStore
. А если будут сторы, которые должны быть приватными для сервера, то они будут созданы не в домене.
function sendUpdates(room) {
const states = serialize(room.scope);
forEach(room.players, (player) =>
player.send('gameUpdate', states),
);
}
В браузерном коде, все довольно примитивно.
Когда с сервера прилетают события, я их разбираю на составные события по полю type
. Для этого крайне полезен метод split
const messageReceived = createEvent<Message>();
split({
source: messageReceived,
match: (message: Message) => message.type,
cases: {
gameUpdated,
roomsUpdated,
roomLeft,
__: messageUnknown,
},
});
Сервер отправит каждому клиенту gameUpdated
, но только с теми сторами, которые действительно были обновлены. Метод spread
из патронум перенаправит соответствующие значения из объекта в сторы по их .sid
import * as munchkin from '../common/munchkin';
// ручная обработка по .sid
spread({
source: gameUpdated,
targets: {
[munchkin.$players.sid]: munchkin.$players,
[munchkin.$gameMode.sid]: munchkin.$gameMode,
},
});
// создание всех веток через цикл
spread({
source: gameUpdated,
targets: Object.fromEntries(
Object.values(munchkin)
.filter((unit) => is.store(unit))
.map((unit) => [unit.sid, unit]),
),
});
// на каждый стор создается один sample
munchkin.common.onCreateStore((store) => {
sample({
clock: gameUpdated,
filter: (states) => typeof states[store.sid] !== 'undefined',
target: store,
});
});
Но используя домен, можно обойтись и без spread
. Еще один плюс хуков.
Вкратце
- На сервере список комнат со скоупами и списком игроков на сервере.
- В браузерах игроков канал websocket, публичное API ивентов и сторов с точно такими же SID, как на сервере.
- Когда срабатывает событие в браузере, берем его
sid
и payload, отправляем через websocket на сервер. - Сервер знает в какой комнате сейчас игрок, ищет подходящий event по его
sid
и вызывает черезallSettled
на скоупе комнаты. - Логика крутится внутри скоупа, в следствии чего обновляются сторы, срабатывает коллбек
createWatch
. - Внутри комнаты есть ссылки на websocket-каналы каждого игрока, а значит мы можем отправить каждому игроку сериализованное состояние скоупа с помощью
serialize()
. - Браузеры игроков ловят событие по websocket-каналу, парсят содержимое,
split
вызывает нужный нам ивент. - Метод
spread
обновит стор по ключу в сообщении, ведь этоsid
. - Интерфейс перерисовывается из-за обновления сторов.
Что получил в итоге
В процессе разработки мне нужно было только создать шину общения сервера и браузеров. Любые новые события в домене munchkin
будут пробрасываться из браузера на сервер автоматически, а сторы в обратном направлении.
Также typescript сможет проверить типы и мне не придется верить серверу на слово, ведь используется буквально тот же набор ивентов и сторов. Остается только деплоить это вместе, чтобы версия браузерного и серверного кода совпадала, но это уже вопрос другой статьи.