Меня зовут Артём Несмиянов, я фулстек-разработчик в Яндекс.Практикуме, занимаюсь в основном фронтендом. Мы верим в то, что учиться программированию, дата-аналитике и другим цифровым ремёслам можно и нужно с удовольствием. И начинать учиться, и продолжать. Любой не махнувший на себя рукой разработчик — всегда «продолжающий». Мы тоже. Поэтому рабочие задачи воспринимаем в том числе как формат учёбы. И одна из недавних помогла мне и ребятам лучше понять, в какую сторону развивать наш фронтенд-стек.
Кем и из чего сделан Практикум
Команда разработки у нас предельно компактная. На бэкенде вообще всего два человека, на фронтенде — четыре, считая меня, фулстека. Периодически к нам в усиление присоединяются ребята из Яндекс.Учебника. Работаем мы по 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 с тетрадкой. Когда iframe видит, что ему пришло нечто извне, он парсит полученную строку. Поняв, что ему нужно сохранить тетрадку, он выполняет это действие и, в свою очередь, шлёт ответный postMessage об исполнении запроса.
Когда мы нажимаем кнопку «Проверить задание», в Redux Store отсылается соответствующее событие: «Так и так, мы пошли проверяться». Redux-Saga видит, что экшен прилетел и сделал postMessage в iframe. Теперь она ждёт, пока 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">
{notebookLinkIsLoading
? (
<iframe
className="trainer__jupiter-frame"
ref={this.onIframeRef}
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. Кроме того, естественно, на каждый вариант внутри вотчера предусмотрены обработчики.
Вы наверняка заметили, что в предобработчике данных мы вызываем другую Redux-Saga, ждём, пока он завершится с SUCCESS, и только тогда продолжаем работу. И конечно, iframe на сервер отправлять не нужно, поэтому отдаём только id.
Внимательнее посмотрим на функцию 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 в адресной строке браузера должен сразу смениться и, соответственно, в интерфейсе урока должен отобразиться сопроводительный текст с теорией именно по третьему заданию.
Была и задача посложнее. Дело в том, что наша программа обучения рассчитана на последовательное прохождение уроков и заданий. Между тем по умолчанию в юпитеровской тетрадке пользователю ничто не мешает открыть любую ячейку. А в нашем случае каждая ячейка — это отдельное задание. Выходило так, что можно решить первый и третий таск, а второй пропустить. От риска нелинейного прохождения урока нужно было избавиться.
Основой решения послужил всё тот же postMessage. Только нам пришлось дополнительно углубиться в API Jupyter Notebook, конкретнее — в то, что умеет делать сам юпитеровский объект. И придумать механизм проверки того, к какому таску привязана ячейка. В самом общем виде он следующий. В структуре тетрадки ячейки идут последовательно одна за другой. У них могут быть метаданные. В метаданных предусмотрено поле «Теги», и теги — это как раз идентификаторы тасков внутри урока. Кроме того, с помощью тегирования ячеек можно определять, должны ли они пока быть заблокированы у ученика. В итоге в соответствии с нынешней моделью работы тренажёра, ткнув в ячейку, мы запускаем отправку 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. У него уйма lifecycle-методов, и каждый из них нужно обрабатывать. Вдобавок меня смущает привязка к самому React. В идеале хотелось бы уметь работать с нашими iframe независимо от того, какой у тебя фронтенд-фреймворк. Вообще, пересечение выбранных нами технологий накладывает ограничения: та же Redux-Saga ждёт от нас именно Redux-экшенов, а не postMessage.
Так что на достигнутом мы точно не будем останавливаться. Дилемма хрестоматийная: можно уйти в сторону красоты, но пожертвовать оптимальностью performance, или наоборот. Наилучшего решения мы пока не нашли.
Возможно, идеи возникнут у вас?
Автор: Кристина Лавренюк