Образовательный процесс важно делать интересным и по возможности интерактивным. Особенно, когда дело касается технологий — гораздо полезнее, когда есть возможность не просто написать какой-то код, а потом получить ответ от проверяющего, мол, молодец, всё хорошо, а на лету смотреть, всё ли у тебя работает, где косяки и как ты в целом справился.
В попытках сделать что-то подобное мы в свое время запустили в Яндексе MVP веб-тренажёра, в котором пользователь мог писать код, скрипты и всё остальное на разных вкладках, а по соседству у него всё это отображалось в качестве финального результата.
MVP показал себя хорошо, и мы вывели веб-тренажёр на уровень полноценного инструмента для проверки знаний наших студентов в Яндекс.Практикуме. Меня зовут Артем, и я расскажу, как мы делали тренажёр для обучения веб-разработке, как он работает и что умеет.
Со стороны кажется, что тут вообще всё просто — запихал весь пользовательский код в iframe, запостил его через postmessage, потом отрисовал любым доступным образом, и всё работает. Такой немного прокачанный онлайн-предпросмотр кода.
Но есть нюансы.
Как всё устроено
Мы на старте отметили возможную проблему: если разворачивать тренажёр на домене Яндекса (как сам Практикум, например), то появляется ненулевая вероятность, что пользователи окажутся чуть более любознательными. А именно — возьмут и закинут в тренажёр какой-нибудь код, который тренажёр с энтузиазмом примется обрабатывать. А код окажется мошенническим и уведет имеющуюся куку Яндекса, вставит её в какой-то сторонний сервис, после чего мошенник будет иметь доступ к личному кабинету пользователя в Яндексе и всем личным данным. Реализовать подобное довольно легко, если этот iframe находится в домене yandex.ru. Поэтому мы сделали отдельный домен на yandex.net специально под тренажёр и назвали его Фейнман (Feynman). В честь Ричарда, да.
В целом наш тренажёр хранит файлы, которые мы ему отправляем на бекенд, в формате plain text, json и base64 для изображений. Затем они преобразуются в реальные файлы и раздаются уже в виде статики, которую мы можем положить в iframe для отрисовки.
Но мы же тут не просто в подсветку синтаксиса играем, у нас тренажёр для проверки знаний. Поэтому нам надо этот код на лету тестировать и проверять, то есть каким-то образом вклиниваться в процесс iframe и смотреть, всё ли пользователь сделал правильно, скажем, как назвал переменную, или всё ли у него в порядке с div-ами.
И тут мы опять упираемся в домены. Пользовательский код, как я уже писал, кладется в тренажёр на домене Feynman, а проверяем мы его уже со стороны Яндекса, из домена praktikum.yandex.ru. Политика браузера same-origin как раз стоит на страже и не позволяет вам вмешиваться во внутренности iframe, если у вас домены разные.
Поэтому мы решили запихнуть iframe в iframe.
Получилась такая ситуация:
- Мы создаём iframe, который на самом деле первое время пустой.
- Он рисует какую-то пустую страничку.
- Мы с нашего фронта шлем postmessage со ссылкой на то, что нам отдал Feynman (оттуда, где он хостит статику).
- Первый iframe берет эту ссылку и подставляет её в src внутреннего iframe.
В итоге у нас первый iframe может владеть кодом и делать с внутренним iframe всё, что угодно. Де-факто тесты — это просто eval-функция, которой доступны: document, window и прочее, всё, что есть в iframe. Это дает нам возможность взять тест для задачи и прогнать его в окне данного iframe.
Не тестами едиными
Потом нам захотелось добавить каких-то полезных фич: терминал, консоль, возможность выводить пользовательские данные о том, что он записал в лог, и прочие радости. Само собой, сделали полноценный адаптивный режим, чтобы пользователь мог посмотреть, как результат будет выглядеть на смартфонах и планшетах.
Для этого была написана специальная библиотека, которая подгружает все необходимые стили и эмулирует responsive-режим. Также мы немного меняем наш исходный iframe и добавляем в него всё, что позволяет пользователю выводить на экран его console.log, и не только какие-то простые объекты, но и полноценные dom-деревья документа.
Вдобавок к этому мы научились прогонять предварительные тесты. Это полезно, потому что есть множество тестов, которые проверяют примерно одни и те же штуки — например, не переборщил ли пользователь с циклами в коде, не увлекся ли со вложенностью и подобное. Описывать это в каждом тесте отдельно не имеет особого смысла, поэтому мы написали тестовую библиотеку, в которой есть набор специальных методов для пре-тестирования, которые проверяют код. Если на этом этапе всё хорошо, то проверяется уже основной тест и решенные задачи, после чего пользователю показывается результат.
Для отдачи пользователю результата предварительного тестирования мы тоже используем postmessage — посылаем через него сообщения, нашлись ли какие-то ошибки или всё круто. Кстати, код студента на веб-тренажёре проверяется еще и через линтер es-lint с переводом на русский язык и всегда подсвечивает синтаксические ошибки.
Проблемы при проверках кода (и не только)
Если на странице были какие-то системные или браузерные уведомления, например, предлагалось что-то ввести, то часто при прогоне нашего теста пользователь продолжал видеть браузерные окна с уведомлениями и запросами на ввод данных. Нам надо было сделать вот так: когда пользователь просто запускает страничку со своим кодом, чтобы посмотреть, как всё работает, нужно, чтобы этот алерт тоже работал. А когда тест прогоняет этот код для проверки, нам уже не надо, чтобы алерты продолжали появляться у пользователя на экране. Мы по сути все эти алерты заменяли своими заглушками для тестов (мокали), переопределяя alert, prompt, confirm внутри window. Если этого не делать, можно было на выходе получить зацикливание или вообще пустой алерт, который ничего не делает.
Кстати, о бесконечных циклах. Основная проблема тут была в том, что пользователь мог заведомо взять и написать код, который благополучно уйдёт в бесконечный цикл (в браузере же всего один тред у javascript), и в итоге весь браузер шёл полежать.
Для борьбы с этим мы научились прежде всего отслеживать такие бесконечные циклы перед тем, как отдавать код на проверку. Чтобы сделать это, надо было каким-то образом переделать скрипт пользователя, мы пошли вот таким путем:
- Каждому циклу добавляем определенную функцию, которая считает количество вызовов.
- Если это количество вызовов превышает 100 000, то мы сразу кидаем exception, который тоже посылаем через postmessage обратно. Плюс мы на всякий случай проверяем и таймаут, если цикл работает более 10 секунд.
- Попутно отслеживаем, что, раз уж возник exception, то что-то тут не так, и сам тест уже не имеет смысла запускать — код зациклен.
Отдельно стоит отметить ситуацию со ссылками. Допустим, у пользователя внутри его кода могут быть какие-то ссылки, которые должны по клику открываться в новой вкладке, например, его портфолио или github-аккаунт. И нам не нужно было, чтобы такие ссылки открывались прямо внутри iframe — иначе вместо iframe у нас и откроется страница с его ссылкой. Надо открывать такие штуки в новой вкладке, через Tab. Обычно, чтобы открыть ссылку не внутри айфрейма, а в родительском фрейме, требуется просто указать target="_parent". Но в нашем случае нужно было добавить обработчик, который определяет, является ли ссылка внешней.
И для всех ссылок мы написали специальный обработчик: если видим, что ссылка внешняя, то отдаем postmessage наружу, обрываем сам обработчик ссылки (prevent default), и к нам обратно в наш фронт приходит postmessage. Мы видим, что у нас тут external link, и показываем уведомление — точно ли переходим на внешний сайт? И уже после этого открываем новые вкладки.
А ещё якоря, с ними всё было куда более как однозначно. Они просто не работали внутри iframe. Вообще. Поэтому мы в качестве небольшого хака подписались на события клика по любой ссылке — если на ней был якорь, мы делали scrollIntoView на конкретный элемент.
Все метаданные (если у пользователя на HTML-страничке была прописана фавиконка, например, или конкретный заголовок) мы тоже шлем через postmessage уже после того, как iframe загрузился. С помощью querySelector достаем два этих тега, через postmessage посылаем их обратно на наш фронт, а фронт уже сам вставляет все эти иконки, куда надо. Вроде бы мелочь, но у пользователя создается впечатление, что у него внутри браузера полноценный браузер.
Попытки обойти тренажёр
Наш веб-тренажёр, в отличие от тренажёров, которые мы делали для Python, SQL и прочего, использует для проверок именно фронт, а не бекенд. Поэтому, когда пользователь корректно завершает тесты, отправляется соответствующий POST-запрос на бекенд. В принципе, пользователь при должной сноровке может сделать то же самое и послать такой запрос вручную.
Тут палка о двух концах. С одной стороны, это круто, что человек в достаточной мере интересуется технологиями и базовыми хаками, чтобы такое проделать. С другой, это немного напоминает выстрел в ногу, у нас ведь тренажёр стоит не для того, чтобы формально получить от него «ОК, ты молодец вообще, всё сделал», а чтобы научиться нормально работать, замечать свои ошибки и исправлять их. В целом, это все равно как прийти в спортзал, посидеть 5 минут на скамье для жима лежа, а потом написать в фейсбучек «Сделал 3 подхода с сотней кило»: самомнение потешить получится, но на этом достижения и остановятся.
Собственно, поэтому мы и не уводим эту проверку на бекенд, это решило бы подобную проблему. Люди приходят учиться, чтобы получить реальную работу (может быть, и в самом Практикуме), а не виртуальные ачивки.
Мы постоянно улучшаем веб-тренажёр, пользуясь как собственным списком пожеланий, так и обратной связью от пользователей, так что будем продолжать вам рассказывать про его развитие. Сейчас он дорабатывается, учитывая потребности студентов с запросом на конкретные технологии, например, мы добавили работу с React и NodeJS. Веб-тренажёр на сегодня самый популярный из всех, за ним следует тренажёр для Python — по большей части это связано как с более низким порогом вхождения, так и с популярностью самих технологий. Кроме технической части внутри тренажёра ещё и множество механик для работы с интерактивной теорией (а её достаточно на всех наших курсах). Отдельного тренажёра нет только на специальности QA, там мы сделали специальный набор квизов + стендов, на которых и учатся тестировщики. Кстати, пара тестировщиков, которые сейчас помогают нам делать Практикум, — выпускники нашего курса QA.
Сложнее устроены тренажёры для C++ и тренажёр для машинного обучения, если вам будет интересно — постараемся рассказать о них в следующих постах.
Спасибо, что дочитали, если у вас есть какие-то вопросы по нашим тренажёрам или по Практикуму в целом — пишите, ответим.
Автор: Артем Несмиянов