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

в 7:26, , рубрики: front-end, Блог компании Яндекс, интерфейсы, обучение, Программирование, разработка, Разработка веб-сайтов

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

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

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

Команда разработки у нас предельно компактная. На бэкенде вообще всего два человека, на фронтенде — четыре, считая меня, фулстека. Периодически к нам в усиление присоединяются ребята из Яндекс.Учебника. Работаем мы по 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, на бэкенде прописали логику её проверки.

Как в Яндекс.Практикуме побеждали рассинхрон на фронтенде: акробатический номер с Redux-Saga, postMessage и Jupyter - 2
Сама ученическая тетрадка (справа) — это просто 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. А уже оттуда фронтендный скрипт подтягивает её в интерфейс урока.

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

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

(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, или наоборот. Наилучшего решения мы пока не нашли.

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

Автор: Кристина Лавренюк

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js