Seperti di Yandex.Practicum, desync front-end menang: angka akrobatik dengan Redux-Saga, postMessage dan Jupyter

Nama saya Artyom Nesmiyanov, saya adalah pengembang penuh-tumpukan di Yandex.Practicum, saya terutama berurusan dengan frontend. Kami percaya bahwa adalah mungkin dan perlu untuk mempelajari pemrograman, analisis data, dan kerajinan digital lainnya dengan senang hati. Dan mulailah belajar, dan lanjutkan. Setiap pengembang yang tidak menyerah pada dirinya sendiri selalu "melanjutkan". Begitu juga kita. Oleh karena itu, kami menganggap tugas kerja sebagai format pembelajaran. Dan salah satu yang baru-baru ini membantu saya dan teman-teman untuk lebih memahami ke arah mana untuk mengembangkan tumpukan frontend kami.



Terbuat dari apa dan dari apa bengkel itu


Tim pengembangan kami sangat kompak. Hanya ada dua orang di backend, di front-end - empat, mengingat saya, setumpuk penuh. Dari waktu ke waktu, orang-orang dari Yandex.Tutorial bergabung dengan kami dalam penguatan. Kami mengerjakan Scrum dengan sprint dua minggu.

Frontend kami didasarkan pada React.js bersama dengan Redux / Redux-Saga, kami menggunakan Express untuk berkomunikasi dengan backend. Bagian backend stack adalah dalam Python (lebih tepatnya, Django), database adalah PostgreSQL, dan untuk beberapa tugas, Redis. Menggunakan Redux, kami menyimpan penyimpanan informasi, mengirim tindakan yang diproses oleh Redux dan Redux-Saga. Semua efek samping, seperti permintaan server, panggilan ke Yandex.Metrica dan arahan ulang, diproses hanya di Redux-Saga. Dan semua modifikasi data terjadi pada reduksi Redux.

Bagaimana tidak mengabaikan log di iframe Anda


Sekarang di platform kami, pelatihan terbuka dalam tiga profesi: pengembang front-end, pengembang web, analis data. Dan kami secara aktif menggergaji alat untuk setiap kursus.

Untuk kursus enam bulan " Analis Data ", kami membuat simulator interaktif, tempat kami mengajarkan pengguna cara menggunakan Jupyter Notebook . Ini adalah shell keren untuk komputasi interaktif, yang sangat disukai oleh para ilmuwan data. Semua operasi di lingkungan dilakukan di dalam notebook, tetapi dengan cara yang sederhana - notebook (seperti yang akan saya sebut nanti).

Pengalaman diminta, dan kami yakin: adalah penting bahwa tugas-tugas pelatihan mendekati kenyataan. Termasuk dalam hal lingkungan kerja. Oleh karena itu, perlu dipastikan bahwa di dalam pelajaran semua kode dapat ditulis, dijalankan, dan diperiksa tepat di buku catatan.

Tidak ada kesulitan dengan implementasi dasar. Notebook itu sendiri diselesaikan dalam iframe terpisah, logika untuk memeriksanya ditentukan di backend.


Buku catatan siswa itu sendiri (di sebelah kanan) hanyalah iframe yang URL-nya mengarah ke buku catatan khusus di JupyterHub.

Dalam perkiraan pertama, semuanya bekerja tanpa hambatan, tanpa hambatan. Namun, selama pengujian, absurditas keluar. Misalnya, Anda dijamin untuk mengarahkan versi kode yang benar ke dalam buku catatan, namun, setelah mengklik tombol "Tugas uji", server menjawab bahwa jawabannya seharusnya salah. Dan mengapa - sebuah misteri.

Nah, apa yang terjadi, kami menyadari pada hari yang sama ketika kami menemukan bug: ternyata solusi yang tidak terbang adalah yang sekarang, solusi hanya didorong ke dalam bentuk Notebook Jupyter, tetapi yang sebelumnya telah dihapus. Notebook itu sendiri tidak punya waktu untuk bertahan, dan kami memperlambat backend sehingga memeriksa tugas di dalamnya. Apa, tentu saja, dia tidak bisa lakukan.

Kami harus menyingkirkan rassinhron antara menyimpan notebook dan mengirim permintaan ke server untuk memeriksanya. Tangkapannya adalah bahwa itu perlu untuk membuat iframe dari notebook berkomunikasi dengan jendela induk, yaitu, dengan frontend di mana pelajaran secara keseluruhan berputar. Tentu saja, tidak mungkin untuk meneruskan acara apa pun di antara mereka secara langsung: mereka hidup di domain yang berbeda.

Mencari solusi, saya menemukan bahwa Jupyter Notebook memungkinkan plugin-nya dihubungkan. Ada objek Jupiter - notebook - yang dapat digunakan untuk beroperasi. Bekerja dengannya melibatkan berbagai peristiwa, termasuk pelestarian notebook, serta panggilan tindakan yang sesuai. Setelah mengetahui bagian dalam Jupyter (saya harus: tidak ada dokumentasi normal untuk itu), saya dan orang-orang melakukannya - kami membangun plug-in kami sendiri untuk itu dan, menggunakan mekanisme postMessage, mencapai pekerjaan terkoordinasi dari unsur-unsur dari mana pelajaran Lokakarya disusun.

Kami menyusun solusi dengan mempertimbangkan fakta bahwa tumpukan kami awalnya mencakup Redux-Saga yang telah disebutkan - sederhananya, middleware atas Redux, yang memungkinkan untuk bekerja lebih fleksibel dengan efek samping. Misalnya, menyimpan buku catatan adalah sesuatu seperti efek samping ini. Kami mengirim sesuatu ke backend, menunggu sesuatu, mendapatkan sesuatu. Semua gerakan ini diproses di dalam Redux-Saga: ia melempar acara ke frontend, mendiktekan kepadanya bagaimana menampilkan apa yang ada di UI.

Apa hasilnya? PostMessage dibuat dan dikirim ke iframe dengan buku catatan. Ketika iframe melihat sesuatu telah datang dari luar, ia mem-parsing string yang diterima. Menyadari bahwa ia perlu menyimpan notebook itu, ia melakukan tindakan ini dan, pada gilirannya, mengirim respons postMessage tentang eksekusi permintaan.

Ketika kami mengklik tombol "Tugas uji", acara yang sesuai dikirim ke Redux Store: "Dulu, kami pergi untuk diperiksa." Redux-Saga melihat aksi tiba dan melakukan postMessage dalam iframe. Sekarang dia sedang menunggu iframe untuk memberikan jawaban. Sementara itu, siswa kami melihat indikator unduhan pada tombol "Periksa tugas" dan memahami bahwa simulator tidak hang, tetapi "berpikir". Dan hanya ketika postMessage kembali mengatakan bahwa save sudah selesai, Redux-Saga terus bekerja dan mengirim permintaan ke backend. Di server, tugas diperiksa - solusi yang tepat atau tidak, jika kesalahan dibuat, lalu yang mana, dll., Dan informasi ini disimpan dengan rapi di Redux Store. Dan dari sana, skrip front-end menariknya ke antarmuka pelajaran.

Berikut adalah diagram yang keluar pada akhirnya:



(1) Kami menekan tombol "Periksa tugas" (Periksa) → (2) Kami mengirim tindakan CHECK_NOTEBOOK_REQUEST → (3) Kami mengirim tindakan cek → (2) Kami mengirim tindakan SAVE_NOTEBOOK_REQUEST → (3) Kami menangkap tindakan dan mengirim post Pesan di iframe → simpan acara (4) Terima pesan → (5) Notebook disimpan → (4) Terima acara dari Jupyter API bahwa notebook telah disimpan dan kirim postMessage disimpan notebook → (1) Terima acara → (2) Kirim tindakan SAVE_NOTEBOOK_SUCCESS → (3) Kami menangkap aksi dan kirim permintaan untuk memeriksa buku catatan → (6) → (7) Periksa apakah buku catatan ini ada di database → (8) → (7) Cari kode buku catatan → (5) Kembalikan kode → (7) Jalankan kode periksa → (9) ) → (7) Kami mendapat potongan tat cek → (6) → (3) kami kirimkan tindakan CHECK_NOTEBOOK_SUCCESS → (2) turun untuk memverifikasi respon sisi → (1) Gambarkan hasil

Mari kita lihat bagaimana semua ini bekerja dalam konteks kode.

Kami memiliki trainer_type_jupyter.jsx di ujung depan - skrip halaman tempat buku catatan kami dibuat.

<div className="trainer__right-column"> {notebookLinkIsLoading ? ( <iframe className="trainer__jupiter-frame" ref={this.onIframeRef} src={notebookLink} /> ) : ( <Spin size="l" mix="trainer__jupiter-spin" /> )} </div> 

Setelah mengklik tombol "Periksa pekerjaan", metode handleCheckTasks dipanggil.

 handleCheckTasks = () => { const {checkNotebook, lesson} = this.props; checkNotebook({id: lesson.id, iframe: this.iframeRef}); }; 

Bahkan, handleCheckTasks berfungsi untuk menjalankan aksi Redux dengan parameter yang diteruskan.

 export const checkNotebook = getAsyncActionsFactory(CHECK_NOTEBOOK).request; 

Ini adalah tindakan umum yang dirancang untuk Redux-Saga dan metode asinkron. Di sini getAsyncActionsFactory menghasilkan tiga tindakan:

// 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}), } } 

Dengan demikian, getAsyncConstants menghasilkan tiga konstanta dari bentuk * _REQUEST, * _SUCCESS dan * _ERROR.

Sekarang mari kita lihat bagaimana Redux-Saga kami akan menangani semua ekonomi ini:

// 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(); } 

Keajaiban? Tidak ada yang luar biasa. Seperti yang Anda lihat, createAsyncActionSagaWatcher cukup membuat watermarker yang dapat pra-proses data masuk ke dalam tindakan, membuat permintaan di URL tertentu, mengirimkan tindakan * _REQUEST, dan mengirimkan * _SUCCESS dan * _ERROR setelah respons yang berhasil dari server. Selain itu, tentu saja, untuk setiap opsi, penangan disediakan di dalam arloji.

Anda mungkin memperhatikan bahwa di preprocessor data kami memanggil Redux-Saga lain, tunggu sampai selesai dengan SUKSES, dan baru kemudian terus bekerja. Dan tentu saja, iframe tidak perlu dikirim ke server, jadi kami hanya memberikan id.

Lihatlah lebih dekat fungsi saveNotebook:

 function* saveNotebook({payload: {iframe}}) { iframe.contentWindow.postMessage(JSON.stringify({ type: 'save-notebook' }), '*'); yield; } 

Kami telah mencapai mekanisme paling penting dalam interaksi iframe dengan frontend - postMessage. Fragmen kode yang diberikan mengirimkan tindakan dengan tipe save-notebook, yang diproses di dalam iframe.

Saya sudah menyebutkan bahwa kami perlu menulis plug-in untuk Notebook Jupyter, yang akan dimuat di dalam notebook. Plugin ini terlihat seperti ini:

 define([ 'base/js/namespace', 'base/js/events' ], function( Jupyter, events ) {...}); 

Untuk membuat ekstensi seperti itu, Anda harus berurusan dengan Jupyter Notebook API itu sendiri. Sayangnya, tidak ada dokumentasi yang jelas tentang itu. Tetapi kode sumber tersedia, dan saya menyelidiki mereka. Bagus bahwa kodenya dapat dibaca di sana.

Plugin harus diajarkan untuk berkomunikasi dengan jendela induk di bagian depan pelajaran: setelah semua, desync di antara mereka adalah penyebab bug dengan verifikasi tugas. Pertama-tama, kami berlangganan semua pesan yang kami terima:

 window.addEventListener('message', actionListener); 

Sekarang kami akan menyediakan pemrosesan mereka:

 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; } } 

Semua acara yang tidak sesuai dengan format kami diabaikan dengan berani.

Kami melihat bahwa acara simpan-buku catatan tiba kepada kami, dan kami memanggil tindakan untuk menyimpan buku catatan. Tetap hanya untuk mengirim kembali pesan bahwa notebook telah disimpan:

 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), '*' ); } 

Dengan kata lain, cukup kirim {type: 'notebook_saved'} ke atas. Ini berarti notebook telah dilestarikan.

Mari kita kembali ke komponen kita:

//trainer_type_jupyter.jsx

 componentDidMount() { const {getNotebookLink, lesson} = this.props; getNotebookLink({id: lesson.id}); window.addEventListener('message', this.handleWindowMessage); } 

Saat memasang komponen, kami meminta server untuk tautan ke buku catatan dan berlangganan semua tindakan yang dapat terbang kepada kami:

 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; } }; 

Di sinilah pengiriman tindakan setNotebookSaved dipanggil, yang akan memungkinkan Redux-Saga untuk terus bekerja dan menyimpan notebook.

Gangguan pilihan


Kami mengatasi bug pelestarian notebook. Dan segera beralih ke masalah baru. Itu perlu untuk belajar memblokir tugas (tugas), yang belum tercapai siswa. Dengan kata lain, itu perlu untuk menyinkronkan navigasi antara simulator interaktif kami dan Notebook Jupyter: di dalam satu pelajaran, kami memiliki satu buku catatan dengan beberapa tugas yang duduk di iframe, transisi di antaranya harus dikoordinasikan dengan perubahan pada antarmuka pelajaran secara keseluruhan. Sebagai contoh, sehingga dengan mengklik tugas kedua di antarmuka pelajaran di buku catatan, beralih ke sel yang sesuai dengan tugas kedua terjadi. Dan sebaliknya: jika dalam bingkai Jupyter Notebook Anda memilih sel yang dikaitkan dengan tugas ketiga, maka URL di bilah alamat browser harus segera berubah dan, dengan demikian, teks yang menyertai teori untuk tugas ketiga harus ditampilkan dalam antarmuka pelajaran.

Ada tugas yang lebih sulit. Faktanya adalah bahwa program pelatihan kami dirancang untuk memberikan pelajaran dan tugas yang konsisten. Sementara itu, secara default, di notebook Jupiter, tidak ada yang mencegah pengguna membuka sel apa pun. Dan dalam kasus kami, setiap sel adalah tugas yang terpisah. Ternyata Anda bisa menyelesaikan tugas pertama dan ketiga, dan melewatkan yang kedua. Risiko bagian nonlinear dari pelajaran harus dihilangkan.

Solusinya didasarkan pada postMessage yang sama. Hanya kami yang harus mempelajari lebih jauh tentang Jupyter Notebook API, lebih khusus lagi, tentang apa yang dapat dilakukan oleh objek Jupiter. Dan datang dengan mekanisme untuk memeriksa tugas sel mana yang dilampirkan. Dalam bentuknya yang paling umum, adalah sebagai berikut. Dalam struktur notebook, sel-sel berjalan berurutan. Mereka mungkin memiliki metadata. Kolom "Tag" disediakan dalam metadata, dan tag hanyalah pengidentifikasi tugas di dalam pelajaran. Selain itu, menggunakan sel penandaan, Anda dapat menentukan apakah sel harus diblokir oleh siswa sejauh ini. Akibatnya, sesuai dengan model simulator saat ini, dengan mengklik sel, kami mulai mengirim postMessage dari iframe ke frontend kami, yang, pada gilirannya, pergi ke Toko Redux dan memeriksa, berdasarkan pada sifat-sifat tugas, apakah tersedia untuk kita sekarang. Jika tidak tersedia, kami beralih ke sel aktif sebelumnya.

Jadi kami telah mencapai bahwa tidak mungkin untuk memilih sel dalam buku catatan yang tidak boleh diakses oleh timeline pelatihan. Benar, ini memunculkan bug yang tidak kritis, tetapi: Anda mencoba mengklik sel dengan tugas yang tidak dapat diakses, dan dengan cepat "berkedip": jelas bahwa itu diaktifkan untuk sesaat, tetapi segera diblokir. Meskipun kami belum menghilangkan kekasaran ini, itu tidak mengganggu kelulusan pelajaran, tetapi di latar belakang kami terus berpikir bagaimana cara mengatasinya (omong-omong, apakah ada pemikiran?)

Sedikit tentang bagaimana kami memodifikasi antarmuka kami untuk menyelesaikan masalah. Mari kita kembali ke trainer_type_jupyter.jsx - kita akan fokus pada app_initialized dan pilih.

Dengan app_initialized, semuanya sederhana: notebook telah dimuat, dan kami ingin melakukan sesuatu. Misalnya, pilih sel saat ini tergantung pada tugas yang dipilih. Plugin ini dideskripsikan sehingga Anda dapat melewati taskId dan beralih ke sel pertama yang sesuai dengan taskId ini.

Yaitu:

// 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; 

Sekarang Anda dapat beralih sel dan belajar dari iframe bahwa sel telah diaktifkan.

Saat mengganti sel, kami mengubah URL dan masuk ke tugas lain. Tetap hanya melakukan yang sebaliknya - saat memilih tugas lain di antarmuka, alihkan sel. Mudah:

 componentDidUpdate({match: {params: {prevTaskId}}) { const {match: {params: {taskId}}} = this.props; if (taskId !== prevTaskId) { this.selectTaskCell({taskId}); 

Boiler terpisah untuk perfeksionis


Itu akan keren untuk hanya membual tentang seberapa baik kita. Solusi pada intinya efektif, meskipun terlihat sedikit berantakan: jika kita meringkas, kami memiliki metode yang memproses pesan yang datang dari luar (dalam kasus kami, dari iframe). Tetapi dalam sistem yang kami bangun sendiri, ada hal-hal yang saya, dan rekan, tidak sukai.

• Tidak ada fleksibilitas dalam interaksi elemen: setiap kali kita ingin menambahkan fungsionalitas baru, kita harus mengubah plugin untuk mendukung format komunikasi lama dan baru. Tidak ada mekanisme tunggal yang terisolasi untuk bekerja antara iframe dan komponen front-end kami, yang menjadikan Notebook Jupyter dalam antarmuka pelajaran dan bekerja dengan tugas-tugas kami. Secara global - ada keinginan untuk membuat sistem yang lebih fleksibel sehingga di masa depan mudah untuk menambahkan tindakan, peristiwa, dan proses baru. Dan dalam kasus tidak hanya notebook Jupiter, tetapi juga dengan iframe dalam simulator. Jadi kami ingin meneruskan kode plug-in melalui postMessage dan merendernya (eval) di dalam plug-in.

• Kode fragmen yang menyelesaikan masalah tersebar di seluruh proyek. Komunikasi dengan iframe dilakukan baik dari Redux-Saga dan dari komponen, yang tentunya tidak optimal.

• Iframe sendiri dengan rendering Notebook Jupyter duduk di layanan lain. Mengeditnya sedikit bermasalah, terutama sesuai dengan prinsip kompatibilitas ke belakang. Misalnya, jika kita ingin mengubah semacam logika di ujung depan dan di notebook itu sendiri, kita harus melakukan pekerjaan ganda.

• Banyak yang ingin menerapkan lebih mudah. Ambil setidaknya Bereaksi. Dia memiliki banyak metode siklus hidup, dan masing-masing dari mereka perlu diproses. Selain itu, saya bingung dengan ikatan untuk Bereaksi sendiri. Idealnya, saya ingin dapat bekerja dengan iframe kami, apa pun kerangka front-end Anda. Secara umum, persimpangan teknologi yang kami pilih memberlakukan batasan: Redux-Saga yang sama mengharapkan tindakan Redux dari kami, bukan postMessage.

Jadi kita pasti tidak akan berhenti pada apa yang telah dicapai. Dilema buku teks: Anda dapat pergi ke sisi keindahan, tetapi mengorbankan optimalitas kinerja, atau sebaliknya. Kami belum menemukan solusi terbaik.

Mungkin ide-ide muncul dengan Anda?

Source: https://habr.com/ru/post/id453876/


All Articles