Mi nombre es Artyom Nesmiyanov, soy un desarrollador de pila completa en Yandex.Practicum, principalmente trato con la interfaz. Creemos que es posible y necesario estudiar programación, análisis de datos y otras manualidades digitales con placer. Y comienza a aprender y continúa. Cualquier desarrollador que no se haya rendido a sí mismo siempre está "continuando". Nosotros también Por lo tanto, percibimos las tareas laborales como un formato de aprendizaje. Y uno de los recientes nos ayudó a mí y a los muchachos a comprender mejor en qué dirección desarrollar nuestra pila de frontend.

De quién y de qué está hecho el taller
Nuestro equipo de desarrollo es extremadamente compacto. Solo hay dos personas en el backend, en el front-end: cuatro, considerándome, una pila completa. De vez en cuando, chicos de Yandex. Tutorial se unen a nosotros en refuerzo. Trabajamos en Scrum con sprints de dos semanas.
Nuestro frontend se basa en React.js junto con Redux / Redux-Saga, utilizamos Express para comunicarnos con el backend. La parte del backend de la pila está en Python (más precisamente, Django), la base de datos es PostgreSQL y, para algunas tareas, Redis. Usando Redux, almacenamos almacenamientos de información, enviamos acciones procesadas por Redux y Redux-Saga. Todos los efectos secundarios, como las solicitudes del servidor, las llamadas a Yandex.Metrica y las redirecciones, se procesan solo en Redux-Saga. Y todas las modificaciones de datos ocurren en los reductores Redux.
Cómo no pasar por alto un registro en su iframe
Ahora en nuestra plataforma, la capacitación está abierta en tres profesiones: desarrollador front-end, desarrollador web, analista de datos. Y estamos aserrando activamente herramientas para cada curso.
Para el curso de seis meses "
Analista de datos ", creamos un simulador interactivo, donde enseñamos a los usuarios
cómo usar el Jupyter Notebook . Este es un shell genial para la computación interactiva, que es amado por los científicos de datos. Todas las operaciones en el entorno se realizan dentro del cuaderno, pero de una manera simple: un cuaderno (como lo llamaré más adelante).
Experimente indicaciones, y estamos seguros: es importante que las tareas de capacitación sean casi reales. Incluido en términos del entorno laboral. Por lo tanto, era necesario asegurarse de que dentro de la lección se pudiera escribir, ejecutar y verificar todo el código directamente en el cuaderno.
Con la implementación básica de las dificultades no surgieron. Instaló el cuaderno en un iframe separado, prescribió la lógica de su verificación en el backend.
El cuaderno de estudiantes en sí (a la derecha) es solo un iframe cuya URL conduce a un cuaderno específico en JupyterHub.En una primera aproximación, todo funcionó sin problemas, sin problemas. Sin embargo, durante las pruebas, salieron a la luz los absurdos. Por ejemplo, tiene la garantía de conducir la versión correcta del código a una computadora portátil, sin embargo, después de hacer clic en el botón "Probar tarea", el servidor responde que la respuesta es supuestamente incorrecta. Y por qué, un misterio.
Bueno, lo que está sucediendo, nos dimos cuenta el mismo día en que encontramos un error: resultó que la solución que no estaba volando era la actual, la solución simplemente se introdujo en el formulario Jupyter Notebook, pero la anterior ya había sido borrada. El cuaderno en sí no tuvo tiempo de sobrevivir, y ralentizamos el backend para que revisara la tarea en él. Lo que, por supuesto, no podía hacer.
Tuvimos que deshacernos del rassinhron entre guardar el cuaderno y enviar una solicitud al servidor para verificarlo. El problema era que era necesario hacer que el marco flotante del cuaderno se comunicara con la ventana principal, es decir, con la interfaz en la que giraba la lección en su conjunto. Por supuesto, era imposible reenviar cualquier evento entre ellos directamente: viven en diferentes dominios.
Buscando una solución, descubrí que Jupyter Notebook permite conectar sus complementos. Hay un objeto Júpiter, un cuaderno, con el que puede operar. Trabajar con él implica eventos, incluida la preservación del cuaderno, así como la llamada a la acción adecuada. Habiendo descubierto el interior de Jupyter (tenía que hacerlo: no hay documentación normal para ello), los chicos y yo lo hicimos: construimos nuestro propio complemento y, utilizando el mecanismo postMessage, logramos el trabajo coordinado de los elementos a partir de los cuales se ensambló la lección del taller.
Resolvimos una solución teniendo en cuenta el hecho de que nuestra pila inicialmente incluye la Redux-Saga ya mencionada, para decirlo simplemente, middleware sobre Redux, lo que hace posible trabajar de manera más flexible con efectos secundarios. Por ejemplo, guardar una computadora portátil es algo así como este efecto secundario. Enviamos algo al backend, esperamos algo, conseguimos algo. Todo este movimiento se procesa dentro de Redux-Saga: arroja eventos a la interfaz, diciéndole cómo mostrar qué en la interfaz de usuario.
Cual es el resultado? PostMessage se crea y se envía al iframe con un cuaderno. Cuando un iframe ve que algo proviene del exterior, analiza la cadena recibida. Al darse cuenta de que necesita conservar el cuaderno, realiza esta acción y, a su vez, envía una respuesta postMessage sobre la ejecución de la solicitud.
Cuando hacemos clic en el botón "Verificar tarea", el evento correspondiente se envía a la tienda Redux: "De modo regular, fuimos a ser revisados". Redux-Saga ve llegar la acción y hacer postMessage en un iframe. Ahora está esperando que el iframe responda. Mientras tanto, nuestro estudiante ve el indicador de descarga en el botón "Verificar la tarea" y entiende que el simulador no se cuelga, sino que "piensa". Y solo cuando postMessage regresa diciendo que el guardado se ha completado, Redux-Saga continúa trabajando y envía una solicitud al backend. En el servidor, se verifica la tarea: la solución correcta o no, si se cometen errores, cuál, etc., y esta información se almacena de forma ordenada en Redux Store. Y a partir de ahí, el script front-end lo lleva a la interfaz de la lección.
Aquí está el diagrama que salió al final:
(1) Presionamos el botón "Verificar tarea" (Verificar) → (2) Enviamos la acción CHECK_NOTEBOOK_REQUEST → (3) Enviamos la acción del cheque → (2) Enviamos la acción SAVE_NOTEBOOK_REQUEST → (3) Capturamos la acción y enviamos postMessage en el iframe → guardar evento (4) Recibir el mensaje → (5) El cuaderno está guardado → (4) Recibir el evento de la API de Jupyter de que el cuaderno ha sido guardado y enviar postMessage guardado en el cuaderno → (1) Recibir el evento → (2) Enviar la acción SAVE_NOTEBOOK_SUCCESS → (3) Capturamos la acción y envíe una solicitud para verificar el cuaderno → (6) → (7) Verifique que este cuaderno esté en la base de datos → (8) → (7) Busque el código del cuaderno → (5) Devuelva el código → (7) Ejecute la verificación del código → (9 ) → (7) Obtenemos un corte cheque tat → (6) → (3) enviamos CHECK_NOTEBOOK_SUCCESS acción → (2) hacia abajo para verificar la respuesta cara → (1) resultado del sorteoVeamos cómo funciona todo esto en el contexto del código.
Tenemos en la parte delantera trainer_type_jupyter.jsx: el guión de la página donde se dibuja nuestro cuaderno.
<div className="trainer__right-column"> {notebookLinkIsLoading ? ( <iframe className="trainer__jupiter-frame" ref={this.onIframeRef} src={notebookLink} /> ) : ( <Spin size="l" mix="trainer__jupiter-spin" /> )} </div>
Después de hacer clic en el botón "Verificar trabajo", se llama al método handleCheckTasks.
handleCheckTasks = () => { const {checkNotebook, lesson} = this.props; checkNotebook({id: lesson.id, iframe: this.iframeRef}); };
De hecho, handleCheckTasks sirve para invocar la acción de Redux con los parámetros pasados.
export const checkNotebook = getAsyncActionsFactory(CHECK_NOTEBOOK).request;
Esta es una acción común diseñada para Redux-Saga y métodos asincrónicos. Aquí getAsyncActionsFactory genera tres acciones:
// 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}), } }
En consecuencia, getAsyncConstants genera tres constantes de la forma * _REQUEST, * _SUCCESS y * _ERROR.
Ahora veamos cómo nuestra Redux-Saga manejará toda esta economía:
// 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(); }
La magia? Nada extraordinario Como puede ver, createAsyncActionSagaWatcher simplemente crea un marcador de agua que puede preprocesar los datos que entran en la acción, hacer una solicitud en una URL específica, enviar la acción * _REQUEST y enviar * _SUCCESS y * _ERROR después de una respuesta exitosa del servidor. Además, por supuesto, para cada opción, se proporcionan controladores dentro del reloj.
Probablemente haya notado que en el preprocesador de datos llamamos a otra Redux-Saga, espere hasta que termine con ÉXITO, y solo entonces continúe trabajando. Y, por supuesto, los iframes no necesitan enviarse al servidor, por lo que solo proporcionamos la identificación.
Eche un vistazo más de cerca a la función saveNotebook:
function* saveNotebook({payload: {iframe}}) { iframe.contentWindow.postMessage(JSON.stringify({ type: 'save-notebook' }), '*'); yield; }
Hemos alcanzado el mecanismo más importante en la interacción de los iframes con la interfaz: postMessage. El fragmento de código proporcionado envía una acción con el tipo guardar cuaderno, que se procesa dentro del iframe.
Ya mencioné que necesitábamos escribir un complemento para el Jupyter Notebook, que se cargaría dentro del notebook. Estos complementos se parecen a esto:
define([ 'base/js/namespace', 'base/js/events' ], function( Jupyter, events ) {...});
Para crear tales extensiones, debe lidiar con la API de Jupyter Notebook. Desafortunadamente, no hay documentación clara al respecto. Pero los
códigos fuente están disponibles, y profundicé en ellos. Es bueno que el código sea legible allí.
Se debe enseñar al complemento a comunicarse con la ventana principal en la parte frontal de la lección: después de todo, la desincronización entre ellos es la causa del error con la verificación de la tarea. En primer lugar, nos suscribimos a todos los mensajes que recibimos:
window.addEventListener('message', actionListener);
Ahora proporcionaremos su procesamiento:
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; } }
Todos los eventos que no se ajustan a nuestro formato se ignoran audazmente.
Vemos que nos llega el evento guardar cuaderno y llamamos a la acción para guardar el cuaderno. Solo queda enviar un mensaje de que el cuaderno se ha conservado:
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), '*' ); }
En otras palabras, simplemente envíe {type: 'notebook_saved'} hacia arriba. Esto significa que el cuaderno ha sido preservado.
Volvamos a nuestro componente:
//trainer_type_jupyter.jsx
componentDidMount() { const {getNotebookLink, lesson} = this.props; getNotebookLink({id: lesson.id}); window.addEventListener('message', this.handleWindowMessage); }
Al montar el componente, le pedimos al servidor un enlace al cuaderno y suscribimos todas las acciones que pueden volar hacia nosotros:
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; } };
Aquí es donde se llama el despacho de acción setNotebookSaved, que permitirá a Redux-Saga continuar trabajando y guardar el cuaderno.
Glitches de elección
Sobrellevamos el error de preservación del cuaderno. E inmediatamente cambió a un nuevo problema. Era necesario aprender a bloquear tareas (tareas), a las que el estudiante aún no había llegado. En otras palabras, era necesario sincronizar la navegación entre nuestro simulador interactivo y el Jupyter Notebook: dentro de una lección, teníamos un cuaderno con varias tareas en el iframe, las transiciones entre las cuales tenían que coordinarse con los cambios en la interfaz de la lección en su conjunto. Por ejemplo, para que al hacer clic en la segunda tarea en la interfaz de la lección en el cuaderno, se realice el cambio a la celda correspondiente a la segunda tarea. Y viceversa: si en el marco de Jupyter Notebook selecciona una celda vinculada a la tercera tarea, la URL en la barra de direcciones del navegador debe cambiar inmediatamente y, en consecuencia, el texto que acompaña a la teoría de la tercera tarea debe mostrarse en la interfaz de la lección.
Había una tarea más difícil. El hecho es que nuestro programa de capacitación está diseñado para el paso constante de lecciones y tareas. Mientras tanto, por defecto, en el cuaderno de Júpiter, nada impide que el usuario abra ninguna celda. Y en nuestro caso, cada celda es una tarea separada. Resultó que puede resolver la primera y la tercera tarea, y omitir la segunda. El riesgo de paso no lineal de la lección tuvo que ser eliminado.
La solución se basó en el mismo postMessage. Solo que teníamos que profundizar en la API de Jupyter Notebook, más específicamente, en lo que el objeto Jupiter puede hacer. Y encuentre un mecanismo para verificar a qué tarea está conectada la celda. En su forma más general, es como sigue. En la estructura del cuaderno, las celdas van secuencialmente una tras otra. Pueden tener metadatos. El campo "Etiquetas" se proporciona en los metadatos, y las etiquetas son solo identificadores de tareas dentro de la lección. Además, al usar celdas de etiquetado, puede determinar si el estudiante debe bloquearlas hasta ahora. Como resultado, de acuerdo con el modelo actual del simulador, al hacer clic en la celda, comenzamos a enviar postMessage desde el iframe a nuestra interfaz, que, a su vez, va a la Tienda Redux y comprueba, en función de las propiedades de la tarea, si está disponible para nosotros ahora. Si no está disponible, cambiamos a la celda activa anterior.
Por lo tanto, hemos logrado que sea imposible seleccionar una celda en una computadora portátil a la que no se pueda acceder en la línea de tiempo de capacitación. Es cierto que esto dio lugar a un error no crítico, pero crítico: intenta hacer clic en una celda con una tarea inaccesible y rápidamente "parpadea": está claro que se activó por un momento, pero se bloqueó de inmediato. Si bien no hemos eliminado esta aspereza, no interfiere con la toma de lecciones, pero en el fondo seguimos pensando cómo lidiar con eso (por cierto, ¿hay alguna idea?).
Un poco sobre cómo modificamos nuestra interfaz para resolver el problema. Volvamos a trainer_type_jupyter.jsx nuevamente: nos centraremos en app_initialized y select.
Con app_initialized, todo es elemental: el portátil se ha cargado y queremos hacer algo. Por ejemplo, seleccione la celda actual según la tarea seleccionada. El complemento se describe para que pueda pasar taskId y cambiar a la primera celda correspondiente a este taskId.
A saber:
// 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 (complemento de 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;
Ahora puede cambiar celdas y aprender del iframe que la celda se ha cambiado.
Al cambiar la celda, cambiamos la URL y caemos en otra tarea. Solo queda hacer lo contrario: al elegir otra tarea en la interfaz, cambie la celda. Fácil:
componentDidUpdate({match: {params: {prevTaskId}}) { const {match: {params: {taskId}}} = this.props; if (taskId !== prevTaskId) { this.selectTaskCell({taskId});
Caldera separada para perfeccionistas.
Sería genial alardear de lo bien que estamos. La solución en la línea de fondo es efectiva, aunque parece un poco desordenada: para resumir, tenemos un método que procesa cualquier mensaje proveniente del exterior (en nuestro caso, de un iframe). Pero en el sistema que nosotros mismos construimos, hay cosas que a mí y a mis colegas no nos gustan realmente.
• No existe flexibilidad en la interacción de los elementos: cada vez que queramos agregar una nueva funcionalidad, tendremos que cambiar el complemento para que admita el formato de comunicación antiguo y el nuevo. No existe un mecanismo aislado único para trabajar entre el iframe y nuestro componente front-end, que representa el Jupyter Notebook en la interfaz de la lección y funciona con nuestras tareas. A nivel mundial, existe el deseo de crear un sistema más flexible para que en el futuro sea fácil agregar nuevas acciones, eventos y procesarlos. Y en el caso no solo del cuaderno Júpiter, sino también con cualquier iframe en los simuladores. Por lo tanto, estamos buscando pasar el código del complemento a través de postMessage y representarlo (eval) dentro del complemento.
• Los fragmentos de código que resuelven problemas están dispersos por todo el proyecto. La comunicación con iframes se realiza tanto desde Redux-Saga como desde el componente, lo que ciertamente no es óptimo.
• El iframe en sí mismo con la representación de Jupyter Notebook se encuentra en otro servicio. La edición es un poco problemática, especialmente en cumplimiento del principio de compatibilidad con versiones anteriores. Por ejemplo, si queremos cambiar algún tipo de lógica en el front-end y en el cuaderno mismo, tenemos que hacer doble trabajo.
• A muchos les gustaría implementarlo más fácilmente. Toma al menos Reaccionar. Tiene un montón de métodos de ciclo de vida, y cada uno de ellos necesita ser procesado. Además, estoy confundido por la unión a React en sí. Idealmente, me gustaría poder trabajar con nuestros iframes sin importar cuál sea su marco front-end. En general, la intersección de las tecnologías que hemos elegido impone limitaciones: la misma Redux-Saga espera acciones de Redux de nosotros, no postMessage.
Así que definitivamente no nos detendremos en lo que se ha logrado. Un dilema de los libros de texto: puedes ir al lado de la belleza, pero sacrificar la optimización del rendimiento, o viceversa. Todavía no hemos encontrado la mejor solución.
¿Quizás te surjan ideas?