Хабрахабр

Как в Яндекс.Практикуме побеждали рассинхрон на фронтенде: акробатический номер с Redux-Saga, postMessage и Jupyter

Меня зовут Артём Несмиянов, я фулстек-разработчик в Яндекс.Практикуме, занимаюсь в основном фронтендом. Мы верим в то, что учиться программированию, дата-аналитике и другим цифровым ремёслам можно и нужно с удовольствием. И начинать учиться, и продолжать. Любой не махнувший на себя рукой разработчик — всегда «продолжающий». Мы тоже. Поэтому рабочие задачи воспринимаем в том числе как формат учёбы. И одна из недавних помогла мне и ребятам лучше понять, в какую сторону развивать наш фронтенд-стек.

Кем и из чего сделан Практикум

Команда разработки у нас предельно компактная. На бэкенде вообще всего два человека, на фронтенде — четыре, считая меня, фулстека. Периодически к нам в усиление присоединяются ребята из Яндекс.Учебника. Работаем мы по Scrum с двухнедельными спринтами.
В основе фронтенда у нас — React.js в связке с Redux/Redux-Saga, для связи с бэкендом используем Express. Backend-часть стека — на Python (точнее, Django), БД — PostgreSQL, для отдельных задач — Redis. С помощью Redux мы храним сторы с информацией, посылаем actions, которые обрабатываются Redux и Redux-Saga. Все сайд-эффекты, такие как запросы к серверу, обращения к Яндекс.Метрике и редиректы, обрабатываются как раз в Redux-Saga. А все модификации данных происходят в редьюсерах (reducers) Redux.

Как не проглядеть бревно в своём iframe

Сейчас на нашей платформе открыто обучение по трём профессиям: фронтенд-разработчик, веб-разработчик, аналитик данных. И мы активно пилим для каждого курса инструменты.

Это классная оболочка для интерактивных вычислений, которую справедливо любят дата-сайентисты. Для полугодового курса «Аналитик данных» мы сделали интерактивный тренажёр, где учим пользователей работать с Jupyter Notebook. Все операции в среде выполняются внутри notebook, она же по-простому — тетрадка (так её и буду называть дальше).

В том числе в плане рабочей среды. Опыт подсказывает, и мы уверены: важно, чтобы учебные задачи были близки к реальным. Поэтому требовалось сделать так, чтобы внутри урока весь код можно было писать, запускать и проверять прямо в тетрадке.

Поселили саму тетрадку в отдельном iframe, на бэкенде прописали логику её проверки. С базовой реализацией трудностей не возникло.


Сама ученическая тетрадка (справа) — это просто iframe, чей URL ведёт на конкретный notebook в JupyterHub.

Однако при тестировании вылезли несуразности. В первом приближении всё функционировало без сучка, без задоринки. А почему — загадка. Например, ты вбиваешь в тетрадку гарантированно правильный вариант кода, однако после нажатия на кнопку «Проверить задание» сервер отвечает, что ответ якобы неверный.

Сама тетрадка не успевала сохраниться, а мы тормошили бэкенд, чтобы он проверил задание в ней. Ну, что происходит, мы сообразили в тот же день, когда нашли баг: обнаружилось, что на сервер улетало не текущее, только что вбитое в форму Jupyter Notebook решение, а предыдущее, уже стёртое. Чего он сделать, разумеется, не мог.

Загвоздка оказалась в том, что требовалось заставить iframe тетрадки общаться с родительским окном, то есть с фронтендом, на котором крутился урок в целом. Нам надо было избавиться от рассинхрона между сохранением тетрадки и отправкой запроса к серверу на её проверку. Само собой, напрямую пробросить какой-то event между ними было нельзя: обитают они на разных доменах.

Существует юпитеровский объект — тетрадка, — которым можно оперировать. В поисках решения я узнал, что Jupyter Notebook допускает подключение своих плагинов. Разобравшись в нутре Jupyter (пришлось: нормальной документации к нему нет), мы с ребятами так и поступили — соорудили собственный плагин к нему и с помощью механизма postMessage добились согласованной работы элементов, из которых собран урок Практикума. Работа с ним предусматривает события, в том числе сохранение тетрадки, а также вызов соответствующих экшенов.

Например, сохранение тетрадки — это как раз нечто вроде такого сайд-эффекта. Обходной путь мы продумывали с учётом того, что в наш стек изначально входит уже упомянутая Redux-Saga — упрощённо говоря, middleware над Redux, которая даёт возможность более гибко работать с сайд-эффектами. Вся эта движуха и обрабатывается внутри Redux-Saga: она-то и кидает события фронтенду, диктуя ему, как что отобразить в UI. Мы что-то отправляем на бэкенд, чего-то ждём, что-то получаем.

Создаётся postMessage и отправляется в iframe с тетрадкой. Что в итоге? Поняв, что ему нужно сохранить тетрадку, он выполняет это действие и, в свою очередь, шлёт ответный postMessage об исполнении запроса. Когда iframe видит, что ему пришло нечто извне, он парсит полученную строку.

Redux-Saga видит, что экшен прилетел и сделал postMessage в iframe. Когда мы нажимаем кнопку «Проверить задание», в Redux Store отсылается соответствующее событие: «Так и так, мы пошли проверяться». Тем временем наш студент видит индикатор загрузки на кнопке «Проверить задание» и понимает, что тренажёр не завис, а «думает». Теперь она ждёт, пока iframe даст ответ. На сервере задание проверяется — правильное решение или нет, если допущены ошибки, то какие, и т. И только когда обратно приходит postMessage о том, что сохранение выполнено, Redux-Saga продолжает работу и посылает запрос к бэкенду. А уже оттуда фронтендный скрипт подтягивает её в интерфейс урока. д., — и эта информация аккуратно складируется в Redux Store.

Вот какая схема вышла в итоге:

(1) Жмём кнопку «Проверить задание» (Check) → (2) Шлём экшен CHECK_NOTEBOOK_REQUEST → (3) Ловим экшен проверки → (2) Шлём экшен SAVE_NOTEBOOK_REQUEST → (3) Ловим экшен и шлём postMessage в iframe с событием save-notebook → (4) Принимаем message → (5) Тетрадка сохраняется → (4) Получаем event от Jupyter API, что тетрадка сохранилась, и шлём postMessage notebook-saved → (1) Принимаем событие → (2) Шлём экшен SAVE_NOTEBOOK_SUCCESS → (3) Ловим экшен и шлём запрос на проверку тетрадки → (6) → (7) Проверяем, что эта тетрадка есть в базе → (8) → (7) Идём за кодом тетрадки → (5) Возвращаем код → (7) Запускаем проверку кода → (9) → (7) Получаем результат проверки → (6) → (3) Шлём экшен CHECK_NOTEBOOK_SUCCESS → (2) Складываем ответ проверки в стор → (1) Отрисовываем результат

Разберёмся, как всё это устроено в разрезе кода.

У нас на фронтенде есть trainer_type_jupyter.jsx — скрипт страницы, где отрисовывается наша тетрадка.

<div className="trainer__right-column"> src={notebookLink} /> ) : ( <Spin size="l" mix="trainer__jupiter-spin" /> )}
</div>

После нажатия кнопки «Проверить задание» происходит вызов метода handleCheckTasks.

handleCheckTasks = () => { const {checkNotebook, lesson} = this.props; checkNotebook({id: lesson.id, iframe: this.iframeRef}); };

Фактически handleCheckTasks служит для вызова Redux-экшена с переданными параметрами.

export const checkNotebook = getAsyncActionsFactory(CHECK_NOTEBOOK).request;

Это обычный экшен, предназначенный для Redux-Saga и асинхронных методов. Здесь getAsyncActionsFactory генерирует три actions:

// utils/store-helpers/async.js

export function getAsyncActionsFactory(type) { const ASYNC_CONSTANTS = getAsyncConstants(type); return { request: payload => ({type: ASYNC_CONSTANTS.REQUEST, payload}), error: (response, request) => ({type: ASYNC_CONSTANTS.ERROR, response, request}), success: (response, request) => ({type: ASYNC_CONSTANTS.SUCCESS, response, request}), }
}

Соответственно, getAsyncConstants генерирует три константы вида *_REQUEST, *_SUCCESS и *_ERROR.

Теперь посмотрим, как всё это хозяйство обработает наша Redux-Saga:

// trainer.saga.js

function* watchCheckNotebook() { const watcher = createAsyncActionSagaWatcher({ type: CHECK_NOTEBOOK, apiMethod: Api.checkNotebook, preprocessRequestGenerator: function* ({id, iframe}) { yield put(trainerActions.saveNotebook({iframe})); yield take(getAsyncConstants(SAVE_NOTEBOOK).SUCCESS); return {id}; }, successHandlerGenerator: function* ({response}) { const {completed_tests: completedTests} = response; for (let id of completedTests) { yield put(trainerActions.setTaskSolved(id)); } }, errorHandlerGenerator: function* ({response: error}) { yield put(appActions.setNetworkError(error)); } }); yield watcher();
}

Магия? Да ничего экстраординарного. Как видно, createAsyncActionSagaWatcher просто создаёт вотчер, который умеет предобрабатывать попадающие в экшен данные, делать запрос по определённому URL, диспатчить экшен *_REQUEST и по успешному ответу от сервера диспатчить *_SUCCESS и *_ERROR. Кроме того, естественно, на каждый вариант внутри вотчера предусмотрены обработчики.

И конечно, iframe на сервер отправлять не нужно, поэтому отдаём только id. Вы наверняка заметили, что в предобработчике данных мы вызываем другую Redux-Saga, ждём, пока он завершится с SUCCESS, и только тогда продолжаем работу.

Внимательнее посмотрим на функцию saveNotebook:

function* saveNotebook({payload: {iframe}}) { iframe.contentWindow.postMessage(JSON.stringify({ type: 'save-notebook' }), '*'); yield;
}

Мы дошли до самого важного механизма во взаимодействии iframe с фронтендом — postMessage. Приведённый фрагмент кода отправляет экшен с типом save-notebook, который обрабатывается внутри iframe.

Выглядят эти плагины примерно так: Я уже упоминал о том, что нам понадобилось написать плагин к Jupyter Notebook, который подгружался бы внутри тетрадки.

define([ 'base/js/namespace', 'base/js/events'
], function( Jupyter, events
) {...});

Для создания таких расширений приходится иметь дело с API самого Jupyter Notebook. К сожалению, внятной документации по нему нет. Зато доступны исходники, в них-то я и вникал. Хорошо хоть, что код там читаемый.

Прежде всего подпишемся на все сообщения, которые получаем: Плагин надо научить общаться с родительским окном во фронтенде урока: как-никак рассинхрон между ними — причина возникновения бага с проверкой заданий.

window.addEventListener('message', actionListener);

Теперь обеспечим их обработку:

function actionListener({data: eventString}) { let event = ''; try { event = JSON.parse(eventString); } catch(e) { return; } switch (event.type) { case 'save-notebook': Jupyter.actions.call('jupyter-notebook:save-notebook'); Break; ... default: break; } }

Все события, не подходящие нам по формату, смело игнорируем.

Остаётся только отправить обратно сообщение о том, что тетрадка сохранилась: Мы видим, что нам прилетает событие save-notebook, и вызываем экшен сохранения тетрадки.

events.on('notebook_saved.Notebook', actionDispatcher); function actionDispatcher(event) { switch (event.type) { case 'select': const selectedCell = Jupyter.notebook.get_selected_cell(); dispatchEvent({ type: event.type, data: {taskId: getCellTaskId(selectedCell)} }); return; case 'notebook_saved': default: dispatchEvent({type: event.type}); } } function dispatchEvent(event) { return window.parent.postMessage( typeof event === 'string' ? event : JSON.stringify(event), '*' ); }

Иначе говоря, просто отправляем {type: ‘notebook_saved’} наверх. Это значит, что тетрадка сохранилась.

Вернёмся к нашему компоненту:

//trainer_type_jupyter.jsx

componentDidMount() { const {getNotebookLink, lesson} = this.props; getNotebookLink({id: lesson.id}); window.addEventListener('message', this.handleWindowMessage); }

При маунте компонента мы запрашиваем у сервера ссылку на тетрадку и подписываемся на все экшены, которые могут нам прилететь:

handleWindowMessage = ({data: eventString}) => { const {activeTaskId, history, match: {params}, setNotebookSaved, tasks} = this.props; let event = null; try { event = JSON.parse(eventString); } catch(e) { return; } const {type, data} = event; switch (type) { case 'app_initialized': this.selectTaskCell({taskId: activeTaskId}) return; case 'notebook_saved': setNotebookSaved(); return; case 'select': { const taskId = data && data.taskId; if (!taskId) { return } const task = tasks.find(({id}) => taskId === id); if (task && task.status === TASK_STATUSES.DISABLED) { this.selectTaskCell({taskId: null}) return; } history.push(reversePath(urls.trainerTask, {...params, taskId})); return; } default: break; } };

Тут-то и вызывается диспатч экшена setNotebookSaved, который и позволит Redux-Saga продолжить работу и сохранить тетрадку.

Глюки выбора

С багом сохранения тетрадки мы совладали. И сразу переключились на новую проблему. Нужно было научиться блокировать задачи (таски), до которых студент ещё не дошёл. Иначе говоря, требовалось синхронизировать навигацию между нашим интерактивным тренажёром и тетрадкой Jupyter Notebook: внутри одного урока у нас была одна сидящая в iframe тетрадка с несколькими тасками, переходы между которыми надо было согласовывать с изменениями интерфейса урока в целом. Например, чтобы по клику на второй таск в интерфейсе урока в тетрадке происходило переключение на ячейку, соответствующую второму таску. И наоборот: если в фрейме Jupyter Notebook ты выбираешь ячейку, привязанную к третьему заданию, то URL в адресной строке браузера должен сразу смениться и, соответственно, в интерфейсе урока должен отобразиться сопроводительный текст с теорией именно по третьему заданию.

Дело в том, что наша программа обучения рассчитана на последовательное прохождение уроков и заданий. Была и задача посложнее. А в нашем случае каждая ячейка — это отдельное задание. Между тем по умолчанию в юпитеровской тетрадке пользователю ничто не мешает открыть любую ячейку. От риска нелинейного прохождения урока нужно было избавиться. Выходило так, что можно решить первый и третий таск, а второй пропустить.

Только нам пришлось дополнительно углубиться в API Jupyter Notebook, конкретнее — в то, что умеет делать сам юпитеровский объект. Основой решения послужил всё тот же postMessage. В самом общем виде он следующий. И придумать механизм проверки того, к какому таску привязана ячейка. У них могут быть метаданные. В структуре тетрадки ячейки идут последовательно одна за другой. Кроме того, с помощью тегирования ячеек можно определять, должны ли они пока быть заблокированы у ученика. В метаданных предусмотрено поле «Теги», и теги — это как раз идентификаторы тасков внутри урока. Если недоступен, мы переключаемся на предыдущую активную ячейку. В итоге в соответствии с нынешней моделью работы тренажёра, ткнув в ячейку, мы запускаем отправку postMessage из iframe в наш фронтенд, который, в свою очередь, ходит в Redux Store и проверяет исходя из свойств таска, доступен ли он нам сейчас.

Правда, это породило пусть некритичный, но баг: ты пытаешься нажать на ячейку с недоступным заданием, и она быстро «моргает»: видно, что она на мгновение активировалась, но тут же была заблокирована. Так мы добились того, что в тетрадке нельзя выбрать ячейку, которая не должна быть доступна по таймлайну обучения. Пока мы эту шероховатость не устранили, проходить уроки она не мешает, но в фоновом режиме мы продолжаем думать, как бы с ней справиться (кстати, есть мысли?).

Снова обратимся к trainer_type_jupyter.jsx — сфокусируемся на app_initialized и select. Чуть-чуть о том, как мы модифицировали для решения задачи свой фронтенд.

Например, выбрать текущую ячейку в зависимости от выбранного таска. С app_initialized всё элементарно: тетрадка загрузилась, и мы хотим что-то предпринять. Плагин описан так, чтобы можно было передать taskId и переключиться на первую ячейку, этому taskId соответствующую.

А именно:

// trainer_type_jupyter.jsx

selectTaskCell = ({taskId}) => { const {selectCell} = this.props; if (!this.iframeRef) { return; } selectCell({iframe: this.iframeRef, taskId}); };

// trainer.actions.js

export const selectCell = ({iframe, taskId}) => ({ type: SELECT_CELL, iframe, taskId
});

// trainer.saga.js

function* selectCell({iframe, taskId}) { iframe.contentWindow.postMessage(JSON.stringify({ type: 'select-cell', data: {taskId} }), '*'); yield;
} function* watchSelectCell() { yield takeEvery(SELECT_CELL, selectCell);
}

// custom.js (Jupyter plugin)

function getCellTaskId(cell) { const notebook = Jupyter.notebook; while (cell) { const tags = cell.metadata.tags; const taskId = tags && tags[0]; if (taskId) { return taskId; } cell = notebook.get_prev_cell(cell); } return null; } function selectCell({taskId}) { const notebook = Jupyter.notebook; const selectedCell = notebook.get_selected_cell(); if (!taskId) { selectedCell.unselect(); return; } if (selectedCell && selectedCell.selected && getCellTaskId(selectedCell) === taskId) { return; } const index = notebook.get_cells() .findIndex(cell => getCellTaskId(cell) === taskId); if (index < 0) { return; } notebook.select(index); const cell = notebook.get_cell(index); cell.element[0].scrollIntoView({ behavior: 'smooth', block: 'start' }); } function actionListener({data: eventString}) { ... case 'select-cell': selectCell(event.data); break;

Теперь можно переключать ячейки и узнавать от iframe, что ячейка была переключена.

Осталось только сделать обратное — при выборе в интерфейсе другого таска переключать ячейку. При переключении ячейки мы меняем URL и попадаем в другой таск. Легко:

componentDidUpdate({match: {params: {prevTaskId}}) { const {match: {params: {taskId}}} = this.props; if (taskId !== prevTaskId) { this.selectTaskCell({taskId});

Отдельный котёл для перфекционистов

Круто было бы просто похвастаться тем, какие мы молодцы. Решение в сухом остатке эффективное, хотя выглядит слегка сумбурным: если резюмировать, у нас есть метод, который обрабатывает любое сообщение, приходящее извне (в нашем случае из iframe). Но в построенной нами же самими системе есть штуки, которые лично мне, да и коллегам, не очень нравятся.

Нет единого изолированного механизма для работы между iframe и нашим фронтенд-компонентом, который отрисовывает Jupyter Notebook в интерфейсе урока и работает с нашими тасками. • Нет гибкости во взаимодействии элементов: всякий раз, когда мы захотим добавить новую функциональность, нам придётся менять плагин, чтобы он поддерживал как старый, так и новый формат общения. Причём в случае не только с юпитеровской тетрадкой, но и с любым iframe в тренажёрах. Глобально — есть желание сделать более гибкую систему, чтобы в дальнейшем было легко добавлять новые экшены, события и обрабатывать их. Так что мы смотрим в сторону того, чтобы передавать код плагина через postMessage и ивейлить (eval) его внутри плагина.

Общение с iframe производится как из Redux-Saga, так и из компонента, что уж точно не оптимально. • Фрагменты кода, решающие проблемы, разбросаны по всему проекту.

Редактировать его слегка проблематично, тем более с соблюдением принципа обратной совместимости. • Сам iframe с отрисовкой Jupyter Notebook у нас сидит на другом сервисе. Например, если мы хотим изменить какую-то логику на фронтенде и в самой тетрадке, нам приходится выполнять двойную работу.

Взять хотя бы React. • Многое хотелось бы реализовывать проще. Вдобавок меня смущает привязка к самому React. У него уйма lifecycle-методов, и каждый из них нужно обрабатывать. Вообще, пересечение выбранных нами технологий накладывает ограничения: та же Redux-Saga ждёт от нас именно Redux-экшенов, а не postMessage. В идеале хотелось бы уметь работать с нашими iframe независимо от того, какой у тебя фронтенд-фреймворк.

Дилемма хрестоматийная: можно уйти в сторону красоты, но пожертвовать оптимальностью performance, или наоборот. Так что на достигнутом мы точно не будем останавливаться. Наилучшего решения мы пока не нашли.

Возможно, идеи возникнут у вас?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть