我叫Artyom Nesmiyanov,我是Yandex.Practicum的全职开发人员,主要负责前端。 我们认为,有可能并且有必要愉快地学习编程,数据分析和其他数字工艺。 并开始学习,然后继续。 任何不屈服于自己的开发者总是“继续”。 我们也是。 因此,我们将工作任务视为一种学习形式。 最近的一个帮助我和他们更好地了解了开发前端堆栈的方向。

讲习班由谁组成
我们的开发团队非常紧凑。 后端只有两个人,前端只有两个人-考虑到我,这是一个完整的堆栈。 Yandex.Tutorial的人不时加入我们的行列。 我们在Scrum上进行了两周的冲刺。
我们的前端基于React.js和Redux / Redux-Saga,我们使用Express与后端进行通信。 堆栈的后端部分使用Python(更准确地说是Django),数据库是PostgreSQL,对于某些任务,使用Redis。 使用Redux,我们可以存储信息存储,发送Redux和Redux-Saga处理的操作。 仅在Redux-Saga中处理所有副作用,例如服务器请求,对Yandex.Metrica的调用和重定向。 并且所有数据修改都在Redux reducer中进行。
如何不忽略iframe中的日志
现在,在我们的平台上,培训在三个行业中进行:前端开发人员,Web开发人员,数据分析师。 我们正在积极地为每门课程配备工具。
在为期六个月的“
数据分析师 ”课程中,我们制作了一个交互式模拟器,在该课程中,我们教用户
如何使用Jupyter Notebook 。 这是交互式计算的一个很酷的外壳,数据科学家理所当然地喜欢它。 环境中的所有操作都在笔记本电脑内部执行,但以一种简单的方式进行-笔记本电脑(稍后将对其进行命名)。
经验提示,我们相信:培训任务必须接近实际,这一点很重要。 包括就工作环境而言。 因此,有必要确保在课程中所有代码都可以在笔记本中正确编写,运行和检查。
随着基本实施的困难没有出现。 笔记本本身放置在单独的iframe中,用于检查笔记本的逻辑在后端中规定。
学生笔记本本身(在右侧)只是一个iframe,其URL指向JupyterHub中的特定笔记本。初步近似,一切都顺利进行。 但是,在测试过程中,出现了一些荒谬之处。 例如,可以保证将正确版本的代码驱动到笔记本中,但是,在单击“测试任务”按钮后,服务器将响应认为答案是错误的。 而为什么-一个谜。
好吧,发生了什么事,我们在发现错误的同一天就意识到了:事实证明,并非有效的解决方案是当前的解决方案,该解决方案只是驱动到了Jupyter Notebook表格中,而先前的解决方案已被删除。 笔记本电脑本身没有时间生存,我们降低了后端速度,以便它检查其中的任务。 当然,他做不到。
在保存笔记本和向服务器发送请求进行检查之间,我们不得不摆脱rassinhron的麻烦。 问题是,有必要使笔记本的iframe与父窗口(即整个课程在其上旋转的前端)进行通信。 当然,不可能直接在它们之间转发任何事件:它们生活在不同的域中。
在寻找解决方案时,我发现Jupyter Notebook允许连接其插件。 有一个木星对象-一个笔记本-您可以使用它进行操作。 使用它涉及事件,包括笔记本的保存以及适当操作的调用。 弄清楚了Jupyter的内部之后(我必须:没有常规文档),伙计们和我做了-我们为其构建了自己的插件,并使用postMessage机制实现了组装Workshop课程的元素的协调工作。
考虑到我们的堆栈最初包括已经提到的Redux-Saga的事实,我们制定了一种解决方法-简单地说,它是Redux之上的中间件,这使得可以更灵活地处理副作用。 例如,保存笔记本就是这种副作用。 我们发送一些东西到后端,等待一些东西,得到一些东西。 所有这些动作都在Redux-Saga内部进行处理:它向前端抛出事件,指示他如何在UI中显示内容。
结果如何? 将创建PostMessage并使用笔记本将其发送到iframe。 当iframe看到某物来自外部时,它将解析接收到的字符串。 他意识到自己需要保留笔记本,因此执行了此操作,然后发送了有关请求执行的响应postMessage。
当我们单击“检查任务”按钮时,相应的事件将发送到Redux Store:“如此,我们去检查了。” Redux-Saga看到动作到达,并在iframe中执行postMessage。 现在,她正在等待iframe给出答案。 同时,我们的学生看到“检查任务”按钮上的下载指示符,并了解模拟器未挂起,但“思考”。 并且只有当postMessage返回时说保存已完成时,Redux-Saga才继续工作并将请求发送到后端。 在服务器上检查任务-是否找到正确的解决方案,如果出错,则查找错误等,并将此信息整齐地存储在Redux Store中。 从那里,前端脚本将其拉入课程界面。
这是最后出现的图:
(1)按下“检查任务”按钮(Check)→(2)发送动作CHECK_NOTEBOOK_REQUEST→(3)发送动作检查→(2)发送动作SAVE_NOTEBOOK_REQUEST→(3)在iframe中捕获动作并发送postMessage→保存事件(4)接收消息→(5)保存笔记本→(4)从Jupyter API接收已保存笔记本的事件并发送postMessage笔记本已保存→(1)接收事件→(2)发送动作SAVE_NOTEBOOK_SUCCESS→(3)我们抓到动作并发送检查笔记本的请求→(6)→(7)检查该笔记本是否在数据库中→(8)→(7)输入笔记本代码→(5)返回代码→(7)运行代码检查→(9 )→(7)被割 TAT检查→(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生成三个动作:
// 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,等到成功完成为止,然后才可以继续工作。 当然,iframe不需要发送到服务器,因此我们只提供ID。
仔细看看saveNotebook函数:
function* saveNotebook({payload: {iframe}}) { iframe.contentWindow.postMessage(JSON.stringify({ type: 'save-notebook' }), '*'); yield; }
在iframe与前端的交互中,我们已经达到了最重要的机制-postMessage。 给定的代码片段将发送带有保存笔记本类型的操作,该操作将在iframe中进行处理。
我已经提到过,我们需要为Jupyter Notebook编写一个插件,该插件将被加载到笔记本中。 这些插件看起来像这样:
define([ 'base/js/namespace', 'base/js/events' ], function( Jupyter, events ) {...});
要创建此类扩展,您必须处理Jupyter Notebook API本身。 不幸的是,没有明确的文档。 但是
源代码可用,因此我深入研究了它们。 可以在其中读取代码是很好的。
必须在本课程的前端教导该插件与父窗口进行通信:毕竟,它们之间的不同步是导致任务验证错误的原因。 首先,我们订阅收到的所有消息:
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; } }
所有不符合我们格式的事件都会被大胆忽略。
我们看到保存笔记本事件到达了我们,我们调用该操作来保存笔记本。 它仅是发送回已保存笔记本的消息:
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应该立即更改,因此,第三个任务的理论附带文本应显示在课程界面中。
还有一个更困难的任务。 事实是,我们的培训计划旨在使课程和作业保持一致。 同时,默认情况下,在Jupiter笔记本中,没有什么可以阻止用户打开任何单元格。 在我们的案例中,每个单元都是一个单独的任务。 原来,您可以解决第一个和第三个任务,而跳过第二个。 必须消除课程非线性通过的风险。
该解决方案基于相同的postMessage。 只需要我们进一步深入研究Jupyter Notebook API,更具体地说,深入研究Jupiter对象本身可以做什么。 并提出一种机制来检查单元连接到的任务。 最一般的形式如下。 在笔记本电脑的结构中,单元依次依次移动。 他们可能有元数据。 元数据中提供了“标签”字段,标签只是课程中任务的标识符。 另外,使用标记单元格,您可以确定到目前为止是否应该将其阻止。 结果,根据模拟器的当前模型,通过单击单元格,我们开始将PostMessage从iframe发送到我们的前端,然后将其转至Redux Store并根据任务的属性检查它是否现在对我们可用。 如果不可用,我们将切换到上一个活动单元格。
因此,我们已经实现了不可能在笔记本中选择培训时间表无法访问的单元格。 没错,这引起了一个不严重的错误,但错误:您尝试单击任务无法访问的单元格,然后迅速“闪烁”:很明显它已被激活了一段时间,但立即被阻止。 尽管我们尚未消除这种粗糙感,但它不会干扰上课,但是在后台,我们继续思考如何处理(顺便说一下,有什么想法吗?)。
关于我们如何修改前端以解决该问题的一些知识。 让我们再次转到trainer_type_jupyter.jsx-我们将专注于app_initialized并进行选择。
使用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插件)
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并处理我们的任务。 在全球范围内-希望建立一个更加灵活的系统,以便将来可以轻松添加新动作,事件和处理它们。 而且,不仅是Jupiter笔记本,还包括模拟器中的任何iframe。 因此,我们希望通过postMessage传递插件代码,并在插件内部呈现(eval)。
•解决问题的代码片段分散在整个项目中。 与iframe的通信都是通过Redux-Saga和组件进行的,这当然不是最佳选择。
•具有Jupyter Notebook渲染的iframe本身位于另一服务上。 编辑它会出现一些问题,尤其是在遵循向后兼容原则的情况下。 例如,如果我们想更改前端和笔记本电脑本身的某种逻辑,则必须做双重工作。
•许多人希望实施起来更容易。 至少要有React。 他有大量的生命周期方法,每个方法都需要进行处理。 另外,我对与React本身的绑定感到困惑。 理想情况下,无论您的前端框架是什么,我都希望能够使用我们的iframe。 通常,我们选择的技术的交叉点会施加限制:相同的Redux-Saga期望Redux会从我们这里获取操作,而不是postMessage。
因此,我们绝对不会停止已经取得的成就。 教科书的困境:您可以站在美丽的一面,但会牺牲性能的最优性,反之亦然。 我们尚未找到最佳解决方案。
也许您想到了什么主意?