Je m'appelle Artyom Nesmiyanov, je suis développeur full-stack chez Yandex. Pratique, je m'occupe principalement du frontend. Nous pensons qu'il est possible et nécessaire d'étudier avec plaisir la programmation, l'analyse de données et d'autres métiers numériques. Et commencez à apprendre et continuez. Tout développeur qui n'a pas renoncé à lui-même «continue» toujours. Nous aussi. Par conséquent, nous percevons les tâches professionnelles comme un format d'apprentissage. Et l'un des récents m'a aidé, moi et les gars, à mieux comprendre dans quelle direction développer notre pile frontale.

De qui et de quoi l'atelier est fait
Notre équipe de développement est extrêmement compacte. Il n'y a que deux personnes sur le backend, sur le front-end - quatre, compte tenu de moi, une pile complète. De temps en temps, des gars de Yandex.Tutorial nous rejoignent en renfort. Nous travaillons sur Scrum avec des sprints de deux semaines.
Notre frontend est basé sur React.js en collaboration avec Redux / Redux-Saga, nous utilisons Express pour communiquer avec le backend. La partie backend de la pile est en Python (plus précisément, Django), la base de données est PostgreSQL, et pour certaines tâches, Redis. En utilisant Redux, nous stockons des stockages d'informations, envoyons des actions qui sont traitées par Redux et Redux-Saga. Tous les effets secondaires, tels que les demandes de serveur, les appels à Yandex.Metrica et les redirections, sont traités uniquement dans Redux-Saga. Et toutes les modifications de données se produisent dans les réducteurs Redux.
Comment ne pas oublier un journal dans votre iframe
Désormais sur notre plateforme, la formation est ouverte dans trois métiers: développeur front-end, développeur web, analyste de données. Et nous scions activement des outils pour chaque cours.
Pour le cours de six mois «
Data Analyst », nous avons créé un simulateur interactif, où nous enseignons aux utilisateurs
comment utiliser le bloc-notes Jupyter . Il s'agit d'une coquille cool pour l'informatique interactive, qui est appréciée à juste titre par les scientifiques des données. Toutes les opérations dans l'environnement sont effectuées à l'intérieur du bloc-notes, mais d'une manière simple - un bloc-notes (comme je l'appellerai plus tard).
L'expérience vous invite, et nous en sommes sûrs: il est important que les tâches de formation soient proches de la réalité. Y compris en termes d'environnement de travail. Par conséquent, il était nécessaire de s'assurer qu'à l'intérieur de la leçon, tout le code pouvait être écrit, exécuté et vérifié directement dans le cahier.
Avec la mise en œuvre de base des difficultés ne se posent pas. Le bloc-notes lui-même a été installé dans un iframe séparé, la logique de vérification a été prescrite sur le backend.
Le cahier de l'élève lui-même (à droite) n'est qu'un iframe dont l'URL mène à un cahier spécifique dans JupyterHub.En première approximation, tout fonctionnait sans accroc, sans accroc. Cependant, lors des tests, des absurdités sont apparues. Par exemple, vous êtes assuré de conduire la bonne version du code dans un ordinateur portable, cependant, après avoir cliqué sur le bouton "Tâche de test", le serveur répond que la réponse est supposée incorrecte. Et pourquoi - un mystère.
Eh bien, ce qui se passe, nous nous sommes rendu compte le même jour quand nous avons trouvé un bug: il s'est avéré que la solution qui ne volait pas était la solution actuelle, la solution vient d'être introduite dans le formulaire Jupyter Notebook, mais la précédente avait déjà été effacée. L'ordinateur portable lui-même n'a pas eu le temps de survivre, et nous avons ralenti le backend pour qu'il vérifie la tâche qu'il contient. Ce qu'il ne pouvait bien sûr pas faire.
Nous avons dû nous débarrasser du rassinhron entre l'enregistrement du portable et l'envoi d'une demande au serveur pour le vérifier. Le hic, c'était qu'il fallait faire communiquer l'iframe du cahier avec la fenêtre parent, c'est-à -dire avec le frontend sur lequel la leçon dans son ensemble tournait. Bien sûr, il était impossible de transmettre directement un événement entre eux: ils vivent sur des domaines différents.
À la recherche d'une solution, j'ai découvert que Jupyter Notebook permet de connecter ses plugins. Il existe un objet Jupiter - un cahier - avec lequel vous pouvez opérer. Travailler avec elle implique des événements, y compris la préservation du cahier, ainsi que l'appel à l'action appropriée. Après avoir compris l'intérieur de Jupyter (je devais: il n'y a pas de documentation normale), les gars et moi l'avons fait - nous avons construit notre propre plug-in pour cela et, en utilisant le mécanisme postMessage, nous avons réalisé le travail coordonné des éléments à partir desquels la leçon de l'atelier a été assemblée.
Nous avons élaboré une solution de contournement en tenant compte du fait que notre pile inclut initialement le Redux-Saga déjà mentionné - pour le dire simplement, un middleware sur Redux, ce qui permet de travailler de manière plus flexible avec des effets secondaires. Par exemple, l'enregistrement d'un ordinateur portable est quelque chose comme cet effet secondaire. Nous envoyons quelque chose au backend, attendons quelque chose, obtenons quelque chose. Tout ce mouvement est traité dans Redux-Saga: il envoie les événements au frontend, lui dictant comment afficher quoi dans l'interface utilisateur.
Quel est le résultat? PostMessage est créé et envoyé à l'iframe avec un bloc-notes. Lorsqu'un iframe voit que quelque chose est venu de l'extérieur, il analyse la chaîne reçue. Conscient qu'il doit garder le cahier, il effectue cette action et, à son tour, envoie une réponse postMessage sur l'exécution de la demande.
Lorsque nous cliquons sur le bouton "Vérifier la tâche", l'événement correspondant est envoyé au magasin Redux: "Telle et telle, nous sommes allés être vérifiés." Redux-Saga voit l'action arriver et faire un postMessage dans un iframe. Maintenant, elle attend la réponse de l'iframe. En attendant, notre élève voit l'indicateur de téléchargement sur le bouton "Vérifier la tâche" et comprend que le simulateur ne se bloque pas, mais "réfléchit". Et seulement lorsque postMessage revient en disant que la sauvegarde est terminée, Redux-Saga continue de fonctionner et envoie une demande au backend. Sur le serveur, la tâche est vérifiée - la bonne solution ou non, si des erreurs sont commises, lesquelles, etc., et ces informations sont soigneusement stockées dans le magasin Redux. Et à partir de là , le script frontal le tire dans l'interface de la leçon.
Voici le schéma qui est finalement sorti:
(1) Nous appuyons sur le bouton "Vérifier la tâche" (Vérifier) ​​→ (2) Nous envoyons l'action CHECK_NOTEBOOK_REQUEST → (3) Nous envoyons l'action du contrôle → (2) Nous envoyons l'action SAVE_NOTEBOOK_REQUEST → (3) Nous capturons l'action et envoyons un message dans l'iframe → enregistrer l'événement (4) Recevoir le message → (5) Le bloc-notes est enregistré → (4) Recevoir l'événement de l'API Jupyter que le bloc-notes a été enregistré et envoyer postMessage enregistré sur le bloc-notes → (1) Recevoir l'événement → (2) Envoyer l'action SAVE_NOTEBOOK_SUCCESS → (3) Nous interceptons l'action et envoyer une demande pour vérifier le cahier → (6) → (7) Vérifier que ce cahier est dans la base de données → (8) → (7) Rechercher le code du cahier → (5) Renvoyer le code → (7) Lancer la vérification du code → (9 ) → (7) Nous obtenons une coupe tat contrôle → (6) → (3) nous envoyons une
action CHECK_NOTEBOOK_SUCCESS → (2) vers le
bas pour vérifier la
réponse face → (1) tirage au
résultatVoyons comment tout cela fonctionne dans le contexte du code.
Nous avons à l'avant trainer_type_jupyter.jsx - le script de la page où notre cahier est dessiné.
<div className="trainer__right-column"> {notebookLinkIsLoading ? ( <iframe className="trainer__jupiter-frame" ref={this.onIframeRef} src={notebookLink} /> ) : ( <Spin size="l" mix="trainer__jupiter-spin" /> )} </div>
Après avoir cliqué sur le bouton "Vérifier le travail", la méthode handleCheckTasks est appelée.
handleCheckTasks = () => { const {checkNotebook, lesson} = this.props; checkNotebook({id: lesson.id, iframe: this.iframeRef}); };
En fait, handleCheckTasks sert à appeler l'action Redux avec les paramètres passés.
export const checkNotebook = getAsyncActionsFactory(CHECK_NOTEBOOK).request;
Il s'agit d'une action courante conçue pour Redux-Saga et les méthodes asynchrones. Ici, getAsyncActionsFactory génère trois 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}), } }
En conséquence, getAsyncConstants génère trois constantes de la forme * _REQUEST, * _SUCCESS et * _ERROR.
Voyons maintenant comment notre Redux-Saga va gérer toute cette économie:
// 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 magie? Rien d'extraordinaire. Comme vous pouvez le voir, createAsyncActionSagaWatcher crée simplement un filigrane qui peut prétraiter les données entrant dans l'action, faire une demande à une URL spécifique, envoyer l'action * _REQUEST et envoyer * _SUCCESS et * _ERROR après une réponse réussie du serveur. De plus, bien entendu, pour chaque option, des gestionnaires sont fournis à l'intérieur de la montre.
Vous avez probablement remarqué que dans le préprocesseur de données, nous appelons un autre Redux-Saga, attendons qu'il se termine avec SUCCESS, puis continuez à travailler. Et bien sûr, les iframes n'ont pas besoin d'être envoyés au serveur, nous ne donnons donc que l'identifiant.
Examinez de plus près la fonction saveNotebook:
function* saveNotebook({payload: {iframe}}) { iframe.contentWindow.postMessage(JSON.stringify({ type: 'save-notebook' }), '*'); yield; }
Nous avons atteint le mécanisme le plus important dans l'interaction des iframes avec le frontend - postMessage. Le fragment de code donné envoie une action avec le type save-notebook, qui est traitée à l'intérieur de l'iframe.
J'ai déjà mentionné que nous devions écrire un plug-in pour le bloc-notes Jupyter, qui serait chargé à l'intérieur du bloc-notes. Ces plugins ressemblent à ceci:
define([ 'base/js/namespace', 'base/js/events' ], function( Jupyter, events ) {...});
Pour créer de telles extensions, vous devez traiter avec l'API Jupyter Notebook elle-même. Malheureusement, il n'y a pas de documentation claire à ce sujet. Mais les
codes sources sont disponibles et je les ai explorés. C'est bien que le code soit lisible là -bas.
Le plugin doit apprendre à communiquer avec la fenêtre parent dans le front de la leçon: après tout, la désynchronisation entre eux est la cause du bug avec la vérification des tâches. Tout d'abord, nous souscrivons à tous les messages que nous recevons:
window.addEventListener('message', actionListener);
Nous allons maintenant fournir leur traitement:
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; } }
Tous les événements qui ne correspondent pas à notre format sont hardiment ignorés.
Nous voyons que l'événement save-notebook nous arrive, et nous appelons l'action pour enregistrer le notebook. Il ne reste plus qu'à renvoyer un message que le carnet a été conservé:
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 d'autres termes, envoyez simplement {type: 'notebook_saved'}. Cela signifie que le carnet a été conservé.
Revenons Ă notre composante:
//trainer_type_jupyter.jsx
componentDidMount() { const {getNotebookLink, lesson} = this.props; getNotebookLink({id: lesson.id}); window.addEventListener('message', this.handleWindowMessage); }
Lors du montage du composant, nous demandons au serveur un lien vers le notebook et souscrivons Ă toutes les actions qui peuvent nous parvenir:
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; } };
C'est là que la répartition de l'action setNotebookSaved est appelée, ce qui permettra à Redux-Saga de continuer à travailler et à sauvegarder le bloc-notes.
Glitches de choix
Nous avons résolu le bogue de conservation des cahiers. Et immédiatement passé à un nouveau problème. Il fallait apprendre à bloquer des tâches (tâches), auxquelles l'élève n'avait pas encore atteint. En d'autres termes, il était nécessaire de synchroniser la navigation entre notre simulateur interactif et le cahier Jupyter: dans une leçon, nous avions un cahier avec plusieurs tâches assis dans l'iframe, dont les transitions devaient être coordonnées avec les changements dans l'interface de la leçon dans son ensemble. Par exemple, de sorte qu'en cliquant sur la deuxième tâche dans l'interface de la leçon dans le bloc-notes, le passage à la cellule correspondant à la deuxième tâche a lieu. Et vice versa: si dans le cadre Jupyter Notebook vous sélectionnez une cellule liée à la troisième tâche, alors l'URL dans la barre d'adresse du navigateur doit immédiatement changer et, en conséquence, le texte d'accompagnement avec la théorie de la troisième tâche doit être affiché dans l'interface de la leçon.
Il y avait une tâche plus difficile. Le fait est que notre programme de formation est conçu pour le passage cohérent des leçons et des travaux. Pendant ce temps, par défaut, dans le bloc-notes Jupiter, rien n'empêche l'utilisateur d'ouvrir une cellule. Et dans notre cas, chaque cellule est une tâche distincte. Il s'est avéré que vous pouvez résoudre les première et troisième tâches et ignorer la seconde. Le risque de passage non linéaire de la leçon devait être éliminé.
La solution était basée sur le même postMessage. Seulement, nous avons dû approfondir l'API Jupyter Notebook, plus précisément, ce que l'objet Jupiter lui-même peut faire. Et trouvez un mécanisme pour vérifier à quelle tâche la cellule est attachée. Dans sa forme la plus générale, elle est la suivante. Dans la structure du cahier, les cellules vont séquentiellement les unes après les autres. Ils peuvent avoir des métadonnées. Le champ "Balises" est fourni dans les métadonnées, et les balises ne sont que des identificateurs de tâches à l'intérieur de la leçon. De plus, en utilisant des cellules de marquage, vous pouvez déterminer si elles doivent être bloquées par l'étudiant jusqu'à présent. En conséquence, conformément au modèle actuel du simulateur, en cliquant sur la cellule, nous commençons à envoyer postMessage de l'iframe à notre frontend, qui, à son tour, va au magasin Redux et vérifie, en fonction des propriétés de la tâche, s'il est disponible pour nous maintenant. S'il n'est pas disponible, nous basculons vers la cellule active précédente.
Nous avons donc réalisé qu'il était impossible de sélectionner une cellule dans un cahier qui ne devrait pas être accessible par la chronologie de la formation. Certes, cela a donné lieu à un bug non critique, mais: vous essayez de cliquer sur une cellule avec une tâche inaccessible, et elle "clignote" rapidement: il est clair qu'elle a été activée pendant un moment, mais a été immédiatement bloquée. Bien que nous n'ayons pas éliminé cette rugosité, cela n'interfère pas avec la prise de leçons, mais en arrière-plan, nous continuons à penser comment y faire face (en passant, y a-t-il des pensées?).
Un peu sur la façon dont nous avons modifié notre interface pour résoudre le problème. Passons à nouveau à trainer_type_jupyter.jsx - nous allons nous concentrer sur app_initialized et sélectionner.
Avec app_initialized, tout est élémentaire: le bloc-notes est chargé et nous voulons faire quelque chose. Par exemple, sélectionnez la cellule actuelle en fonction de la tâche sélectionnée. Le plugin est décrit afin que vous puissiez passer taskId et basculer vers la première cellule correspondant à ce taskId.
Ă€ savoir:
// 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 (plugin 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;
Vous pouvez maintenant changer de cellule et apprendre de l'iframe que la cellule a été changée.
Lors du changement de cellule, nous changeons l'URL et tombons dans une autre tâche. Il ne reste plus qu'à faire le contraire - lors du choix d'une autre tâche dans l'interface, changez de cellule. Facile:
componentDidUpdate({match: {params: {prevTaskId}}) { const {match: {params: {taskId}}} = this.props; if (taskId !== prevTaskId) { this.selectTaskCell({taskId});
Chaudière séparée pour perfectionnistes
Ce serait cool de se vanter de notre bien-être. La solution en fin de compte est efficace, même si elle semble un peu désordonnée: pour résumer, nous avons une méthode qui traite tout message provenant de l'extérieur (dans notre cas, à partir d'un iframe). Mais dans le système que nous avons nous-mêmes construit, il y a des choses que moi et mes collègues n'aimons pas vraiment.
• Il n'y a pas de flexibilité dans l'interaction des éléments: chaque fois que nous voulons ajouter de nouvelles fonctionnalités, nous devrons changer le plugin afin qu'il supporte à la fois l'ancien et le nouveau format de communication. Il n'y a pas de mécanisme isolé unique pour travailler entre l'iframe et notre composant frontal, ce qui rend le bloc-notes Jupyter dans l'interface de la leçon et fonctionne avec nos tâches. Globalement - il existe un souhait de rendre un système plus flexible, de sorte qu'à l'avenir, il soit facile d'ajouter de nouvelles actions, de nouveaux événements et de les traiter. Et dans le cas non seulement du portable Jupiter, mais aussi de tout iframe dans les simulateurs. Nous cherchons donc à passer le code du plugin via postMessage et à le rendre (eval) à l'intérieur du plugin.
• Les fragments de code qui résolvent les problèmes sont dispersés tout au long du projet. La communication avec les iframes s'effectue à la fois depuis Redux-Saga et depuis le composant, ce qui n'est certainement pas optimal.
• Iframe lui-même avec le rendu Jupyter Notebook est assis sur un autre service. Le modifier est légèrement problématique, notamment dans le respect du principe de compatibilité descendante. Par exemple, si nous voulons changer une logique sur le frontend et dans le notebook lui-même, nous devons faire un double travail.
• Beaucoup aimeraient mettre en œuvre plus facilement. Prenez au moins React. Il a une tonne de méthodes de cycle de vie, et chacune d'elles doit être traitée. De plus, je suis confus par la liaison à React lui-même. Idéalement, j'aimerais pouvoir travailler avec nos iframes, quel que soit votre framework frontal. En général, l'intersection des technologies que nous avons choisies impose des limites: la même Redux-Saga attend de nous des actions Redux, pas postMessage.
Nous ne nous arrêterons donc certainement pas sur ce qui a été accompli. Un dilemme des manuels: vous pouvez aller du côté de la beauté, mais sacrifier l'optimalité de la performance, ou vice versa. Nous n'avons pas encore trouvé la meilleure solution.
Peut-être que des idées vous viennent?