ابراهيم الخضور

الأعضاء
  • المساهمات

    22
  • تاريخ الانضمام

  • تاريخ آخر زيارة

السُّمعة بالموقع

2 Neutral
  1. عرض واجهة تسجيل الدخول في الحالات الملائمة لنعدل التطبيق بحيث لا تظهر واجهة تسجيل الدخول بشكل افتراضي: ستظهر الواجهة عندما ينقر المستخدم على الزر "Login": يمكن للمستخدم إغلاق الواجهة أيضًا بالنقر على الزر "Cancel". سنبدأ بنقل نموذج تسجيل الدخول إلى المكوِّن الخاص به: import React from 'react' const LoginForm = ({ handleSubmit, handleUsernameChange, handlePasswordChange, username, password }) => { return ( <div> <h2>Login</h2> <form onSubmit={handleSubmit}> <div> username <input value={username} onChange={handleUsernameChange} /> </div> <div> password <input type="password" value={password} onChange={handlePasswordChange} /> </div> <button type="submit">login</button> </form> </div> ) } export default LoginForm عُرّفت الحالة وكل الدوال المتعلقة بالنموذج خارج المكوِّن وتُمرر إليه كخصائص. لاحظ كيف أسندت الخصائص إلى المتغيرات بطريقة الإسناد بالتفكيك. ويعني ذلك أننا عوضًا عن كتابة الشيفرة التالية: pre widget const LoginForm = (props) => { return ( <div> <h2>Login</h2> <form onSubmit={props.handleSubmit}> <div> username <input value={props.username} onChange={props.handleChange} name="username" /> </div> // ... <button type="submit">login</button> </form> </div> ) } حيث نحصل فيها على خصائص الكائنprop باستعمال التابع prop.handleSubmit، سنسند الخصائص مباشرة إلى متغيراتها. إحدى الطرق السريعة في إضافة الوظيفة، هو تعديل الدالة loginForm العائدة للمكوِّن App كالتالي: const App = () => { const [loginVisible, setLoginVisible] = useState(false) // ... const loginForm = () => { const hideWhenVisible = { display: loginVisible ? 'none' : '' } const showWhenVisible = { display: loginVisible ? '' : 'none' } return ( <div> <div style={hideWhenVisible}> <button onClick={() => setLoginVisible(true)}>log in</button> </div> <div style={showWhenVisible}> <LoginForm username={username} password={password} handleUsernameChange={({ target }) => setUsername(target.value)} handlePasswordChange={({ target }) => setPassword(target.value)} handleSubmit={handleLogin} /> <button onClick={() => setLoginVisible(false)}>cancel</button> </div> </div> ) } // ... } تحتوي حالة المكوِّن App الآن على المتغير المنطقي loginVisible الذي سيقرر أتعرض واجهة تسجيل الدخول للمستخدم أم لا. تتبدل قيمة loginVisible باستعمال زرين، ولكل منهما معالج الحدث الخاص به والمعرّف مباشرة داخل المكون: <button onClick={() => setLoginVisible(true)}>log in</button> <button onClick={() => setLoginVisible(false)}>cancel</button> تُحدَّد إمكانية ظهور المكوِّن بتنسيقه ضمن السياق، حيث سيختفي المكوًن إن كانت قيمة الخاصية display هي (none). const hideWhenVisible = { display: loginVisible ? 'none' : '' } const showWhenVisible = { display: loginVisible ? '' : 'none' } <div style={hideWhenVisible}> // button </div> <div style={showWhenVisible}> // button </div> لاحظ أننا استخدمنا مجددًا العامل"؟" ثلاثي المعاملات. إن كان المتغير loginVisible "صحيحًا"، ستحمل قاعدة CSS للمكوِّن القيمة التالية: display: 'none'; وإن كان loginVisible "خاطئًا"، لن تحمل الخاصية display أية قيمة تتعلق بعرض المكوِّن. المكونات الأبناء (أو المصفوفة props.children) يُعتبر منطق الشيفرة التي تتحكم بظهور نموذج تسجيل الدخول مستقل بذاته، لهذا من الأجدى أن ننقلها إلى مكوّن جديد خاص بها. هدفنا حاليًا إضافة مكوِّن متبدّل (Togglable) يُستخدم بالطريقة التالية: <Togglable buttonLabel='login'> <LoginForm username={username} password={password} handleUsernameChange={({ target }) => setUsername(target.value)} handlePasswordChange={({ target }) => setPassword(target.value)} handleSubmit={handleLogin} /> </Togglable> تختلف طريقة استخدام المكوِّن قليلًا عن المكوٍّنات السابقة. فللمكوّن معرفات بداية ونهاية تحيط بمكوّن LoginForm. تصطلح React على تسمية المكوًّن LoginForm بالمكوِّن الإبن للمكوِّن Togglable. يمكننا إضافة عناصر React بين معرفي البداية والنهاية للمكوِّن Togglable كما في المثال التالي: <Togglable buttonLabel="reveal"> <p>this line is at start hidden</p> <p>also this is hidden</p> </Togglable> الشيفرة التالية هي شيفرة المكوِّن Togglable: import React, { useState } from 'react' const Togglable = (props) => { const [visible, setVisible] = useState(false) const hideWhenVisible = { display: visible ? 'none' : '' } const showWhenVisible = { display: visible ? '' : 'none' } const toggleVisibility = () => { setVisible(!visible) } return ( <div> <div style={hideWhenVisible}> <button onClick={toggleVisibility}>{props.buttonLabel}</button> </div> <div style={showWhenVisible}> {props.children} <button onClick={toggleVisibility}>cancel</button> </div> </div> ) } export default Togglable إن الجزء الجديد والمهم في الشيفرة هي الخاصية props.children، والتي يستخدم في الإشارة إلى المكوِّنات الأبناء لمكوِّن، والتي تمثل عناصر React الموجودة ضمن معرفي بداية ونهاية المكوّن. ستصيّر الآن المكونات الأبناء بالشيفرة ذاتها التي تصيّر المكون الأب: <div style={showWhenVisible}> {props.children} <button onClick={toggleVisibility}>cancel</button> </div> وعلى خلاف الخصائص الاعتيادية التي رأيناها سابقًا يضاف "الأبناء" تلقائيًا من قبل React وتكون موجودة دائمًا. لو عرّفنا مكوّنًا له معرف نهاية تلقائي "</" كالمكوِّن التالي: <Note key={note.id} note={note} toggleImportance={() => toggleImportanceOf(note.id)} /> ستشكل الخاصية props.children مصفوفة فارغة. يمكن إعادة استعمال المكوِّن togglable لإضافة خاصية العرض والإخفاء للنموذج الذي استخدمناه في إنشاء ملاحظة جديدة. قبل أن نفعل ذلك، لننقل شيفرة نموذج إنشاء الملاحظات الجديدة إلى مكوِّن مستقل: const NoteForm = ({ onSubmit, handleChange, value}) => { return ( <div> <h2>Create a new note</h2> <form onSubmit={onSubmit}> <input value={value} onChange={handleChange} /> <button type="submit">save</button> </form> </div> ) } سنعرّف تاليًا مكوِّن النموذج داخل المكوّن Togglable: <Togglable buttonLabel="new note"> <NoteForm onSubmit={addNote} value={newNote} handleChange={handleNoteChange} /> </Togglable> يمكنك إيجاد الشيفرة الكاملة لتطبيقنا الحالي ضمن الفرع part5-4 في المستودع المخصص للتطبيق على GitHub. حالة النماذج تتواجد حالة التطبيق في المكون الأعلى App. حيث ينص توثيق React على ما يلي بخصوص مكان تواجد الحالة: لو فكرنا قليلًا بحالة النماذج التي يضمها التطبيق، فلن يحتاج المكوّن App، على سبيل المثال، محتوى الملاحظة الجديدة قبل إنشائها. لذلك يمكننا بالمثل نقل حالة النماذج إلى مكوِّن خاص بها. سيتغيّر مكِّون الملاحظة إلى الشكل التالي: import React, {useState} from 'react' const NoteForm = ({ createNote }) => { const [newNote, setNewNote] = useState('') const handleChange = (event) => { setNewNote(event.target.value) } const addNote = (event) => { event.preventDefault() createNote({ content: newNote, important: Math.random() > 0.5, }) setNewNote('') } return ( <div> <h2>Create a new note</h2> <form onSubmit={addNote}> <input value={newNote} onChange={handleChange} /> <button type="submit">save</button> </form> </div> ) } export default NoteForm نُقلت صفة الحالة newNote ومعالج الحدث المسؤول عن تغييرها من المكوِّن App إلى المكوِّن المسؤول عن نموذج الملاحظات. وبقيت فقط الخاصية createNote وهي دالة يستدعيها النموذج عندما تُنشأ ملاحظة جديدة. سيغدو المكوّن أبسط بعد إزالة الحالة newNote ومعالج الحدث المرتبط بها. ستستقبل الدالة addNote التي تنشئ ملاحظة جديدة هذه الملاحظة كوسيط، وهذه الدالة هي الصفة الوحيدة التي أرسلناها إلى النموذج: const App = () => { // ... const addNote = (noteObject) => { noteService .create(noteObject) .then(returnedNote => { setNotes(notes.concat(returnedNote)) }) } // ... const noteForm = () => ( <Togglable buttonLabel='new note'> <NoteForm createNote={addNote} /> </Togglable> ) // ... } يمكن أن نكرر هذه العملية على نموذج تسجيل الدخول، لكننا سنترك ذلك للتمارين الإختيارية. يمكنك إيجاد شيفرة التطبيق في الفرع part5-5 على GitHub. إنشاء مراجع إلى المكوِّنات باستعمال ref جميع الإضافات التي أجريت على التطبيق جيدة، لكن هناك ناحية واحدة يمكن تحسينها. من المنطقي إخفاء نموذج إضافة الملاحظات بعد أن ننشئ الملاحظة الجديدة، لأن النموذج سيبقى ظاهرًا في الوضع الحالي. لكن هناك مشكلة صغيرة في ذلك، فمن يتحكم بعرض النماذج هو المتغير visible الموجود داخل المكوِّن Tooglable. فما هي الطريقة التي تمكننا من الوصول إليه، وهو خارج مكوّن النموذج؟ هناك طرق عديدة لإغلاق النموذج من المكوّن الأب، لكننا سنستعمل آلية المرجع ref الخاصة بالمكتبة React والتي تؤمن مرجعًا إلى المكوِّن. لنعدّل المكوّن App ليصبح كالتالي: import React, { useState, useRef } from 'react' const App = () => { // ... const noteFormRef = useRef() const noteForm = () => ( <Togglable buttonLabel='new note' ref={noteFormRef}> <NoteForm createNote={addNote} /> </Togglable> ) // ... } يستخدم الخطاف useRef لإنشاء مرجع إلى المتغير noteFormRef ويسند إلى المكوّن Togglable الذي يحتوي نموذج إنشاء ملاحظة. وبالتالي سيعمل المتغير noteFormRef كمرجع إلى المكوّن. يضمن الخطاف أن يبقى المرجع كما هو عند إعادة تصيير المكوِّن. عدّلنا أيضًا المكون Togglable ليصبح على الشكل التالي: import React, { useState, useImperativeHandle } from 'react' const Togglable = React.forwardRef((props, ref) => { const [visible, setVisible] = useState(false) const hideWhenVisible = { display: visible ? 'none' : '' } const showWhenVisible = { display: visible ? '' : 'none' } const toggleVisibility = () => { setVisible(!visible) } useImperativeHandle(ref, () => { return { toggleVisibility } }) return ( <div> <div style={hideWhenVisible}> <button onClick={toggleVisibility}>{props.buttonLabel}</button> </div> <div style={showWhenVisible}> {props.children} <button onClick={toggleVisibility}>cancel</button> </div> </div> ) }) export default Togglable تُغلَّف الدالة التي تنشئ المكوّن داخل استدعاء الدالة forwardRef، وبالتالي سيتمكن المكوّن من الوصول إلى المرجع الذي أسند إليه. يستخدم المكوِّن الخطاف useImperativeHandle ليجعل الدالة toggleVisibility متاحة خارج إطار المكوِّن. سنتمكن الآن من إخفاء النموذج بتنفيذ الأمر ()noteFormRef.current.toggleVisibility بعد أن تُنشأ الملاحظة الجديدة: const App = () => { // ... const addNote = (noteObject) => { noteFormRef.current.toggleVisibility() noteService .create(noteObject) .then(returnedNote => { setNotes(notes.concat(returnedNote)) }) } // ... } ولكي نلخص ما مضى، فإن الدالة useImperativeHandle هي خطاف يستخدم لتعريف دوال داخل المكوِّن يمكن استدعاؤها من خارجه. تنجح هذه الحيلة في تغيير حالة المكوِّن، لكنها مزعجة قليلًا. وقد كان بالإمكان إنجاز الوظيفة نفسها وبأسلوب أوضح مستخدمين طريقة المكوّنات المعتمدة على الأصناف التي اعتمدتها React القديمة. سنلقي نظرة على هذه الأصناف في القسم 7. إذًا فهي الحالة الوحيدة التي يكون فيها استخدام خطافات React أقل وضوحًا من مكوِّنات الأصناف. تستخدم المراجع في عدة حالات أخرى مختلفة غير الوصول إلى مكوِّنات React. يمكنك أن تجد شيفرة التطبيق بأكملها ضمن الفرع part5-6 في المستودع الخاص بالتطبيق على GitHub نقطة أخرى حول المكوّنات عندما نعرّف مكوّنًا في React كالتالي: const Togglable = () => ... // ... } ونستخدمه بالشكل: <div> <Togglable buttonLabel="1" ref={togglable1}> first </Togglable> <Togglable buttonLabel="2" ref={togglable2}> second </Togglable> <Togglable buttonLabel="3" ref={togglable3}> third </Togglable> </div> سنكون قد أنشأنا ثلاثة مكوّنات منفصلة ولكل منها حالته المستقلة: وتستخدم الصفة ref لإسناد مرجع لكل مكوّن ضمن المتغيرات togglable1 وtogglable2 وtogglable3. التمارين 5.5 - 5.10 5.5 واجهة أمامية لقائمة المدونات: الخطوة 5 غيّر نموذج إنشاء مدوّنة لكي يعرض عند الحاجة فقط. استخدم الأسلوب الذي اعتمدناه في بداية هذا الفصل. يمكنك إن أردت استعمال المكوّن Togglable الذي أنشأناه سابقًا. يجب أن لا يظهر النموذج بشكل افتراضي: يظهر النموذج بالنقر على الزر "new note": يختفي النموذج عند إنشاء مدونة جديدة. 5.6 واجهة أمامية لقائمة المدونات: الخطوة 6 افصل شيفرة نموذج إنشاء مدونة جديدة وضعها في مكوِّنها الخاص (إن لم تكن قد فعلت ذلك مسبقًا)، ثم انقل كل الحالات التي يتطلبها إنشاء مدونة جديدة إلى هذا المكوِّن. يجب أن يعمل المكوِّن بشكل مشابه للمكوِّن NoteForm الذي أنشأناه سابقًا في هذا الفصل. 5.7 واجهة أمامية لقائمة المدونات: الخطوة 7 * لنضف زرًا إلى كل مدونة ليتحكم بإظهار كل تفاصيل المدونة أو عدم إظهارها. حيث تظهر كل التفاصيل بالنقر على الزر وتختفي التفاصيل بالنقر على الزر ثانيةً. لا حاجة أن ينفذ الزر "like" أي شيء حاليًا. يستخدم التطبيق في الصورة السابقة بعض قواعد CSS لتحسين المظهر، ومن السهل القيام بذلك إن أدرجت قواعد التنسيق ضمن السياق، كما فعلنا في القسم 2. const Blog = ({ blog }) => { const blogStyle = { paddingTop: 10, paddingLeft: 2, border: 'solid', borderWidth: 1, marginBottom: 5 } return ( <div style={blogStyle}> <div> {blog.title} {blog.author} </div> // ... </div> )} ملاحظة: على الرغم من أن الوظيفة التي أضيفت هنا مطابقة تمامًا للوظيفة التي ينفذها بها المكوِّنToggleable، لا يمكنك استخدام هذا المكوِّن مباشرة لتحقيق المطلوب في هذا التمرين. سيكون الحل الأسهل بإضافة حالة إلى المنشور تتحكم بالنموذج الذي يعرضه. 5.8 واجهة أمامية لقائمة المدونات: الخطوة 8 * اضف وظيفةً لزر الاعجابات. يزداد عدد الإعجابات بإرسال طلب HTTP-PUT إلى عنوان المدونة الفريد في الواجهة الخلفية. وطالما أن العملية في الواجهة الخلفية ستستبدل المنشور كاملًا، عليك إرسال كل حقول المنشور ضمن جسم الطلب. فلو أردت إضافة إعجاب إلى المنشور التالي: { _id: "5a43fde2cbd20b12a2c34e91", user: { _id: "5a43e6b6c37f3d065eaaa581", username: "mluukkai", name: "Matti Luukkainen" }, likes: 0, author: "Joel Spolsky", title: "The Joel Test: 12 Steps to Better Code", url: "https://www.joelonsoftware.com/2000/08/09/the-joel-test-12-steps-to-better-code/" }, عليك إرسال طلب HTTP-PUT إلى العنوان api/blogs/5a43fde2cbd20b12a2c34e91/، يتضمن البيانات التالية: { user: "5a43e6b6c37f3d065eaaa581", likes: 1, author: "Joel Spolsky", title: "The Joel Test: 12 Steps to Better Code", url: "https://www.joelonsoftware.com/2000/08/09/the-joel-test-12-steps-to-better-code/" } تحذير من جديد: إن لاحظت أنك تستخدم await/async مع then فتأكد أنك سترتكب خطأً ما. استخدم أحد الأسلوبين وليس كلاهما في الوقت ذاته. 5.9 واجهة أمامية لقائمة المدونات: الخطوة 9 * عدّل التطبيق ليرتب المنشورات وفقًا لعدد الإعجابات التي تحملها. يمكن ترتيب المنشورات باستخدام تابع المصفوفات sort. 5.10 واجهة أمامية لقائمة المدونات: الخطوة 10 * أضف زرًا لحذف منشور. وأضف أيضًا وسيلة لحذف المنشورات في الواجهة الخلفية. قد يبدو تطبيقك مشابهًا للشكل التالي: يمكنك إظهار رسالة لتأكيد الحذف باستخدام الدالة window.confirm. أظهر زر حذف المنشور إذا كان المستخدم الحالي للتطبيق هو من أنشأ هذا المنشور. الحزمة PropTypes والخصائص النمطية يفترض المكوِّن Togglable أنه سيحصل على النص الذي سيظهر على الزر عبر الخاصية buttonLabel، فلو نسينا تحديد ذلك في المكوِّن: <Togglable> buttonLabel forgotten... </Togglable> سيعمل التطبيق، لكن سيصيّر المتصفح الزر الذي لا يحمل نصًا. لذلك قد نرغب أن نجعل وجود قيمة للنص الذي سيظهر كعنوان على الزر أمرًا إجباريًا عند استخدام المكوِّن Togglable. يمكننا تحديد الخصائص المتوقعة والضرورية (الخصائص النمطية) باستخدام الحزمة prop-types. لنثبت الحزمة إذًا npm install prop-types يمكننا تحدد الخاصية buttonLabel كخاصية إجبارية أو كخاصية نصية لازمة كالتالي: import PropTypes from 'prop-types' const Togglable = React.forwardRef((props, ref) => { // .. }) Togglable.propTypes = { buttonLabel: PropTypes.string.isRequired } ستعرض الطرفية رسالة الخطأ التالية إذا لم نحدد قيمة الخاصية: سيعمل التطبيق، فلا يمكن إجبارنا على تحديد الخصائص سوى تعريفات الخصائص النمطية PropTypes. لكن تذكر أن ترك رسائل غير مقروءة على طرفية المتصفح، هو أمر غير مهني على الإطلاق. لنعرِّف خصائص نمطية للمكوِّن LoginForm. import PropTypes from 'prop-types' const LoginForm = ({ handleSubmit, handleUsernameChange, handlePasswordChange, username, password }) => { // ... } LoginForm.propTypes = { handleSubmit: PropTypes.func.isRequired, handleUsernameChange: PropTypes.func.isRequired, handlePasswordChange: PropTypes.func.isRequired, username: PropTypes.string.isRequired, password: PropTypes.string.isRequired } إن كان نمط الخاصية الممررة خاطئًا، كأن نعرف مثلًا الخاصية handleSubmit كنص، سيتسبب ذلك برسالة التحذير التالية: المدقق ESlint هيئنا في القسم 3 مدقق تنسيق الشيفرة ESlint ليعمل مع الواجهة الخلفية. سنستخدمه الآن مع الواجهة الأمامية. يثبت البرنامج Create-react-app المدقق ESlint تلقائيًا في المشروع، وكل ما يبقى علينا هو كتابة تعليمات التهيئة المطلوبة داخل الملف eslintrc.js. ملاحظة: لا تنفذ الأمر eslint --init. سيثبت هذا الأمر آخر نسخة من ESlint والتي لن تتوافق مع ملف التهيئة الذي ينشئه createreactapp. سنبدأ تاليًا باختبار الواجهة الأمامية. ولكي نتجنب أخطاء التدقيق غير المرغوبة أو التي لا تتعلق بشيفرتنا، سنثبت الحزمة eslint-jest-plugin: npm add --save-dev eslint-plugin-jest لننشئ الملف eslintrc.js الذي يحتوي الشيفرة التالية: module.exports = { "env": { "browser": true, "es6": true, "jest/globals": true }, "extends": [ "eslint:recommended", "plugin:react/recommended" ], "parserOptions": { "ecmaFeatures": { "jsx": true }, "ecmaVersion": 2018, "sourceType": "module" }, "plugins": [ "react", "jest" ], "rules": { "indent": [ "error", 2 ], "linebreak-style": [ "error", "unix" ], "quotes": [ "error", "single" ], "semi": [ "error", "never" ], "eqeqeq": "error", "no-trailing-spaces": "error", "object-curly-spacing": [ "error", "always" ], "arrow-spacing": [ "error", { "before": true, "after": true } ], "no-console": 0, "react/prop-types": 0 }, "settings": { "react": { "version": "detect" } } } ملاحظة: إن كنت تستخدم ESlint كإضافة مع Visual Studio Code، قد تحتاج إلى إعدادات إضافية لفضاء العمل حتى يعمل. وإن كنت ترى الرسالة: فهذا دليل على أنك تحتاج إلى مزيد من تعليمات التهيئة. قد يؤدي إضافة الأمر "eslint.workingDirectories": [{ "mode": "auto" }] إلى الملف setting.json في فضاء العمل إلى حل المشكلة. يمكنك الاطلاع على المزيد حول الموضوع عبر الانترنت. لننشئ ملف تجاهل قواعد eslint ذو اللاحقة eslintignore. بحيث يحتوي الشيفرة التالية: node_modules build وهكذا لن تخضع المجلدات "build" و"node_modules" لعملية التدقيق. لننشئ أيضًا سكربت npm لتشغيل المدقق: { // ... { "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "server": "json-server -p3001 db.json", "eslint": "eslint ." }, // ... } سيسبب المكوِّن تحذيرًا مزعجًا: "لا يحمل تعريف المكوِّن اسمًا لعرضه" (Component definition is missing display name). وستكشف أداة تطوير React أيضًا أن المكوِّن لا يحمل اسمًا: لكن إصلاح ذلك أمر يسير. import React, { useState, useImperativeHandle } from 'react' import PropTypes from 'prop-types' const Togglable = React.forwardRef((props, ref) => { // ... }) Togglable.displayName = 'Togglable' export default Togglable يمكنك إيجاد شيفرة التطبيق بالكامل ضمن الفرع part5-7 في الستودع الخاص بالتطبيق على GitHub. التمرينان 5.11 - 5.12 5.11 واجهة أمامية لقائمة المدونات: الخطوة 11 عرّف خاصة نمطية propTypes لأحد مكوِّنات التطبيق. 5.12 واجهة أمامية لقائمة المدونات: الخطوة 12 أضف المدقق ESlint إلى التطبيق. أضف ما ترغب من قواعد التهيئة إلى المدقق، وأصلح كل الأخطاء الناتجة عنه. يثبت البرنامج create-react-app المدقق ESlint تلقائيًا في المشروع، وكل ما يبقى عليك هو كتابة تعليمات التهيئة المطلوبة داخل الملف eslintrc.js. ملاحظة: لا تنفذ الأمر eslint --init. سيثبت هذا الأمر آخر نسخة من ESlint والتي لن تتوافق مع ملف التهيئة الذي ينشئه create-react-app. ترجمة -وبتصرف- للفصل props.children, proptypes من سلسلة Deep Dive Into Modern Web Development
  2. لقد انصب اهتمامنا في القسمين السابقين على الواجهة الخلفية بشكل رئيسي، فلم تُدعم الواجهة الأمامية بوظائف إدارة المستخدمين التي أضفناها إلى الواجهة الخلفية في القسم 4 السابق (الذي يبدأ من درس مدخل إلى Node.js وExpress). تُظهر الواجهة الأمامية حاليًا الملاحظات الموجودة، وتعطي المستخدم امكانية تعديل أهمية الملاحظة. لم تعد هناك إمكانية لإضافة ملاحظات جديدة بعد التعديلات التي أجريناها على الواجهة الخلفية في القسم 4. حيث تتوقع الواجهة الخلفية إرفاق شهادة لتأكيد هوية المستخدم مع الملاحظة الجديدة. سنضيف جزءًا من وظيفة إدارة المستخدمين إلى الواجهة الأمامية. سنبدأ بواجهة تسجيل الدخول، وسنفترض عدم إمكانية إضافة مستخدمين جدد من الواجهة الأمامية. أُضيف نموذج تسجيل الدخول إلى أعلى الصفحة، وكذلك نُقل نموذج إضافة ملاحظة جديدة إلى أعلى قائمة الملاحظات سيبدو شكل المكوّن App كالتالي: const App = () => { const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) const [errorMessage, setErrorMessage] = useState(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') useEffect(() => { noteService .getAll().then(initialNotes => { setNotes(initialNotes) }) }, []) // ... const handleLogin = (event) => { event.preventDefault() console.log('logging in with', username, password) } return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> <form onSubmit={handleLogin}> <div> username <input type="text" value={username} name="Username" onChange={({ target }) => setUsername(target.value)} /> </div> <div> password <input type="password" value={password} name="Password" onChange={({ target }) => setPassword(target.value)} /> </div> <button type="submit">login</button> </form> // ... </div> ) } export default App يمكن أن تجد شيفرة التطبيق بوضعها الحالي في الفرع part5-1 على GitHub نتعامل مع نموذج تسجيل الدخول بالطريقة ذاتها التي تعاملنا فيها مع النماذج في القسم 2. تحتوي حالة التطبيق على حقول لكلمة السر واسم المستخدم لتخزين البيانات الموجودة في نموذج تسجيل الدخول. ولتلك الحقول معالجات أحداث تُزامن التغيرات في الحقول مع حالة المكون App. تعتمد معالجات الأحداث مبدأً بسيطًا، حيث يمرر كائن لها كوسيط ثم تفكك الحقل عن الكائن وتخزن قيمته في حالة التطبيق. ({ target }) => setUsername(target.value) لم نضف التابع handlelogin المسؤول عن التعامل مع البيانات في النموذج بعد. تجري عملية تسجيل الدخول بإرسال طلب HTTP POST إلى عنوان الموقع api/login/. لهذا سنضع الشيفرة المسؤولة عن هذا الطلب في وحدة مستقلة خاصة باسم login.js ضمن المجلد services. سنستخدم async/await بدلًا من الوعود للتعامل مع طلبات HTTP. import axios from 'axios' const baseUrl = '/api/login' const login = async credentials => { const response = await axios.post(baseUrl, credentials) return response.data } export default { login } يمكن إضافة التابع المسؤول عن تسجيل الدخول كالتالي: import loginService from './services/login' const App = () => { // ... const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [user, setUser] = useState(null) const handleLogin = async (event) => { event.preventDefault() try { const user = await loginService.login({ username, password, }) setUser(user) setUsername('') setPassword('') } catch (exception) { setErrorMessage('Wrong credentials') setTimeout(() => { setErrorMessage(null) }, 5000) } } // ... } إن نجح تسجيل الدخول، ستُفرَّغ حقول نموذج التسجيل، وتُحفظ استجابة الخادم (بما فيها الشهادة والتفاصيل) في الحقل user من حالة التطبيق. أما إن فشل تسجيل الدخول، أو نتج خطأ عن تنفيذ التابع loginService.login، سيتلقى المستخدم إشعارًا بذلك، ولن يتلقى المستخدم إشعارًا بنجاح العملية. لنعدّل التطبيق بحيث يظهر نموذج تسجيل الدخول فقط إذا لم يكن قد سجل المستخدم دخوله بعد. أي عندما تكون قيمة الحقل user لاشيء (Null). وكذلك لن يظهر نموذج إضافة ملاحظة جديدة إن لم يسجل المستخدم دخوله، أي يجب أن يحتوي الحقل user تفاصيل المستخدم. سنضيف دالتين مساعدتين إلى المكوِّن App لتوليد النموذجين: const App = () => { // ... const loginForm = () => ( <form onSubmit={handleLogin}> <div> username <input type="text" value={username} name="Username" onChange={({ target }) => setUsername(target.value)} /> </div> <div> password <input type="password" value={password} name="Password" onChange={({ target }) => setPassword(target.value)} /> </div> <button type="submit">login</button> </form> ) const noteForm = () => ( <form onSubmit={addNote}> <input value={newNote} onChange={handleNoteChange} /> <button type="submit">save</button> </form> ) return ( // ... ) } ثم سنصيّرهما شرطيًا: const App = () => { // ... const loginForm = () => ( // ... ) const noteForm = () => ( // ... ) return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> {user === null && loginForm()} {user !== null && noteForm()} <div> <button onClick={() => setShowAll(!showAll)}> show {showAll ? 'important' : 'all'} </button> </div> <ul> {notesToShow.map((note, i) => <Note key={i} note={note} toggleImportance={() => toggleImportanceOf(note.id)} /> )} </ul> <Footer /> </div> ) } سيبدو شكلها غريبًا قليلًا، إلا أنها حيلة في React تُستخدم لتصيير النماذج شرطيًا: { user === null && loginForm() } فإن كانت نتيجة تنفيذ العبارة الأولى (خطأ)، أو كانت قيمتها خاطئة، لن تُنفَّذ العبارة الثانية (العبارة التي تولد النموذج). كما يمكننا تنفيذ ذلك مباشرة باستخدام العامل الشرطي: return ( <div> <h1>Notes</h1> <Notification message={errorMessage}/> {user === null ? loginForm() : noteForm() } <h2>Notes</h2> // ... </div> ) إن كانت نتيجة الموازنة user === null صحيحة، سيُنفَّذ الأمر ()loginForm، وإلا سيُنفَّذ الأمر ()noteForm. سنجري تعديلًا أخيرًا يظهر فيه اسم المستخدم على الشاشة عندما يسجل دخوله: return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> {user === null ? loginForm() : <div> <p>{user.name} logged-in</p> {noteForm()} </div> } <h2>Notes</h2> // ... </div> ) لم نقدم الحل المثالي بعد، لكننا سنكتفي بما فعلناه حاليًا. لقد أصبح المكون الرئيسي ضخمًا، وهذه علامة على ضرورة إعادة كتابة شيفرة النماذج ضمن مكوناتها الخاصة. سنترك هذا الأمر لننفذه لاحقًا ضمن التمارين الاختيارية. ستجد شيفرة التطبيق ضمن الفرع part5-2 على GitHub. إنشاء ملاحظات جديدة تُحفظ الشهادة التي تعاد من الخادم بعد نجاح تسجيل الدخول ضمن الحقل user.token في حالة التطبيق. const handleLogin = async (event) => { event.preventDefault() try { const user = await loginService.login({ username, password, }) setUser(user) setUsername('') setPassword('') } catch (exception) { // ... } } لنصلح عملية إنشاء ملاحظة جديدة لتعمل مع الواجهة الخلفية، بإضافة شهادة المستخدم (بعد أن يسجل دخوله) إلى ترويسة تفويض طلب HTTP. ستصبح الوحدة noteService على النحو التالي: import axios from 'axios' const baseUrl = '/api/notes' let token = null const setToken = newToken => { token = `bearer ${newToken} `} const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } const create = async newObject => { const config = { headers: { Authorization: token }, } const response = await axios.post(baseUrl, newObject, config) return response.data } const update = (id, newObject) => { const request = axios.put(`${ baseUrl } /${id}`, newObject) return request.then(response => response.data) } export default { getAll, create, update, setToken } تحتوي الوحدة على المتغير الخاص token، الذي يمكن تغيير قيمته باستخدام الدالة setToken التي يصدّرها النموذج. تضع الدالة الشهادة في ترويسة التفويض باستعمال async/await. ولاحظ أن هذه الدالة ستُمرَّر كمعامل ثالث إلى التابع post العائد للمكتبة axios. ينبغي تغيير معالج الحدث المسؤول عن تسجيل الدخول ليستدعي التابع (noteService.setToken(user.token عند نجاح تسجيل الدخول: const handleLogin = async (event) => { event.preventDefault() try { const user = await loginService.login({ username, password, }) noteService.setToken(user.token) setUser(user) setUsername('') setPassword('') } catch (exception) { // ... } } وهكذا سنتمكن من إضافة ملاحظات جديدة. تخزين الشهادة في ذاكرة المتصفح لا تزال هناك ثغرة في التطبيق. فعندما يُعاد تصيير الصفحة ستختفي معلومات المستخدم. ستبطئ هذه الثغرة عملية التطوير. إذا علينا مثلًا أن نسجل الدخول في كل مرة نحاول فيها إنشاء ملاحظة جديدة. تُحل هذه المشكلة بسهولة إذا ما خزّنا تفاصيل عملية تسجيل الدخول ضمن الذاكرة المحلية للمتصفح، وهي قاعدة بيانات في المتصفح تخزن البيانات بطريقة مفتاح-قيمة، واستخدامها سهل للغاية. حيث ترتبط كل قيمة في هذه الذاكرة بمفتاح وتحفظ في قاعدة بيانات المتصفح باستخدام التابع setItem. window.localStorage.setItem('name', 'juha tauriainen') حيث يخزّن التابع في الشيفرة السابقة مثلًا، المعامل الثاني كقيمة للمفتاح name. يمكن الحصول على قيمة المفتاح باستخدام التابع getItem. window.localStorage.getItem('name') كما يمكن حذفه باستخدام التابع removeItem. ستبقى القيم في الذاكرة المحلية حتى لو أعدنا تصيير الصفحة، وهذه ذاكرة خاصة بالمَنشأ، أي أن كل تطبيق سيحتفظ بذاكرته الخاصة. سنوسع التطبيق لكي تُحفظ تفاصيل تسجيل الدخول في الذاكرة المحلية. إن القيم التي تحفظ في الذاكرة من النوع DOMstrings، لذلك لن نتمكن من حفظ كائن JavaScript كما هو. إذًا، يجب تحويل الكائن إلى تنسيق JSON أولًا باستخدام التابع JSON.stringify. وبالعكس لابد من تحويل المعلومات التي تُقرأ من الذاكرة إلى كائن JavaScript باستخدام التابع JSON.parse. ستصبح شيفرة تسجيل الدخول كالتالي: const handleLogin = async (event) => { event.preventDefault() try { const user = await loginService.login({ username, password, }) window.localStorage.setItem( 'loggedNoteappUser', JSON.stringify(user) ) noteService.setToken(user.token) setUser(user) setUsername('') setPassword('') } catch (exception) { // ... } } وهكذا سنحتفظ ببيانات تسجيل الدخول في الذاكرة المحلية والتي يمكن عرضها على الطرفية: سنتمكن أيضًا من تحري الذاكرة المحلية باستخدام أدوات التطوير. فإن كنت من مستخدمي Chrome، توجه إلى النافذة Application واختر Local storage (ستجد تفاصيل أكثر على موقع مطوري Google)، بينما لو كنت من مستخدمي Firefox توجه إلى النافذة Storage واختر Local Storage (ستجد تفاصيل أكثر على موقع مطوري Mozilla). بقي علينا أن نعدل التطبيق لكي يتحقق من وجود تفاصيل تسجيل الدخول ضمن الذاكرة المحلية. فإن وُجدت هذه التفاصيل ستُحفظ في حالة التطبيق وفي noteService أيضًا. أما الطريقة الصحيحة لتنفيذ الأمر هي استخدام خطاف التأثير، وهي آلية قدمناها لأول مرة في القسم 2، واستعملناها لإحضار الملاحظات من الخادم. نستطيع استخدام عدة خطافات تأثير، لذلك سننشئ واحدًا جديدًا ليتعامل مع أول تحميل للصفحة. const App = () => { const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) const [errorMessage, setErrorMessage] = useState(null) const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [user, setUser] = useState(null) useEffect(() => { noteService .getAll().then(initialNotes => { setNotes(initialNotes) }) }, []) useEffect(() => { const loggedUserJSON= window.localStorage.getItem('loggedNoteappUser') if (loggedUserJSON) { const user = JSON.parse(loggedUserJSON) setUser(user) noteService.setToken(user.token) } }, []) // ... } يضمن تمرير المصفوفة الفارغة إلى خطاف التأثير أن تنفيذه سيتم فقط عندما يُصير المكون للمرة الأولى. وهكذا سيبقى المستخدم في حالة تسجيل دخول دائمة. وهذا ما يجعلنا نفكر بإضافة وظيفة لتسجيل خروج المستخدم ومسح البيانات من الذاكرة المحلية. لكننا نفضل أن نترك ذلك للتمارين. يمكن تسجيل الخروج باستعمال الطرفية وهذا كافٍ حتى اللحظة. حيث يمكن تسجيل الخروج بتنفيذ الأمر: window.localStorage.removeItem('loggedNoteappUser') أو بالأمر التالي الذي يفرّغ الذاكرة المحليّة: window.localStorage.clear() يمكنك إيجاد الشيفرة الحالية للتطبيق ضمن الفرع part5-3 على GitHub. التمارين 5.1 - 5.4 سنطور في هذه التمارين واجهة أمامية لتطبيق قائمة المدونات. يمكنك الاستفادة من التطبيق المساعد الموجود على GitHub كأساس لحلك. يتوقع التمرين أن تعمل الواجهة الخلفية على المنفذ 3001. يكفي أن تسلم التمرين بشكله النهائي، كما يمكنك ترك تعليق بعد كل تمرين، لكن ذلك غير ضروري. تذكرك التمارين القليلة الأولى بكل ما تعلمته حتى الآن عن React. وستحمل أيضًا قدرًا من التحدي، وخاصة إن لم تكن واجهتك الخلفية مكتملة. قد يكون من الأفضل استخدام الواجهة الخلفية لنموذج الحل الذي اعتمدناه في القسم 4. تذكر كل الطرق التي تعلمناها في تنقيح التطبيقات أثناء كتابتك للشيفرة، وابق عينيك على طرفية التطوير دائمًا. تحذير: إن لاحظت أنك تستخدم await/async مع then، فتأكد أنك ستقع في الأخطاء بنسبة %99. استخدم أحد الأسلوبين وليس كلاهما. 5.1 الواجهة الأمامية لقائمة المدونات: الخطوة 1 انسخ التطبيق المساعد من GitHub بتنفيذ الأمر التالي: git clone https://github.com/fullstack-hy2020/bloglist-frontend أزل ملف تهيئة git من التطبيق الذي نسخته: cd bloglist-frontend // go to cloned repository rm -rf .git يمكنك تشغيل التطبيق بالطريقة الاعتيادية، لكن عليك أولًا تثبيت الاعتماديات: npm install npm start أضف وظيفة تسجيل الدخول إلى التطبيق. يجب أن تحفظ تفاصيل تسجيل الدخول الناجح في الحقل user من حالة التطبيق. لن يظهر سوى نموذج تسجيل الدخول إن لم يسجل المستخدم دخوله بعد. ستظهر قائمة المدونات بالإضافة إلى اسم المستخدم عندما ينجح في تسجيل دخوله. لا يجب عليك تخزين تفاصيل تسجيل الدخول ضمن الذاكرة المحلية للمتصفح بعد. ملاحظة: يمكن أن تساعدك الشيفرة التالية في تنفيذ التصيير الشرطي للصفحة عند تسجيل الدخول: if (user === null) { return ( <div> <h2>Log in to application</h2> <form> //... </form> </div> ) } return ( <div> <h2>blogs</h2> {blogs.map(blog => <Blog key={blog.id} blog={blog} /> )} </div> ) } الواجهة الأمامية لقائمة المدونات: الخطوة 2 اجعل تسجيل الدخول مستمرًا مستخدمًا الذاكرة المحلية للمتصفح، وأضف طريقة لتسجيل الخروج. تأكد من عدم تذكر المتصفح أية تفاصيل عن المستخدم بعد تسجيل الخروج. 5.3 الواجهة الأمامية لقائمة المدونات: الخطوة 3 وسع التطبيق بحيث يتمكن المستخدم من إضافة مدونات جديدة. 5.4 الواجهة الأمامية لقائمة المدونات: الخطوة 4 * أضف إشعارات إلى التطبيق في أعلى الصفحة، لإبلاغ المستخدم بحالة تسجيل الدخول إن كانت ناجحة أو فاشلة. على سبيل المثال، سيظهر إشعار كهذا عندما نضيف مدونة: وإشعار كهذا عند فشل تسجيل الدخول: يجب أن يظهر الإشعار لثوان عدة، وليس ضروريًا إضافة الألوان. ترجمة -وبتصرف- للفصل Login in frontend من سلسلة Deep Dive Into Modern Web Development
  3. على المستخدمين تسجيل دخولهم إلى التطبيق، ومن المفترض عندما يحدث ذلك، أن ترتبط معلوماتهم تلقائيًا بالملاحظات التي سينشئونها. لذا سنضيف آلية تحقق مبنية على الاستيثاق إلى الواجهة الخلفية. يمكن تقديم أساسيات التحقق المبني على الاستيثاق من خلال المخطط التتابعي التالي: يسجل المستخدم دخوله أولًا باستخدام نافذة تسجيل الدخول التي أضيفت إلى تطبيق React (سنفعل ذلك في القسم 5). سيدفع تسجيل الدخول شيفرة React إلى إرسال اسم المستخدم وكلمة المرور إلى العنوان api/login/ على الخادم عن طريق طلب HTTP-POST. إن كان اسم المستخدم وكلمة المرور صحيحين، سيوّلد الخادم مفتاح مشفَّر (token) يكون بمثابة شهادة تعرّف بطريقة ما المستخدم الذي سجل دخوله. يتميز المتفاح بأنه معلَّم بعلامة رقمية (يقال عليه توقيع رقمي أحيانًا) مما يجعل تزويره مستحيلًا باستخدام طرق التزوير المعروفة. يستجيب الخادم برمز حالة يشير إلى نجاح العملية ويعيد نسخة مفتاح مشفر مع الاستجابة. يحفظ المتصفح بهذا المفتاح في حالة تطبيق React مثلًا. عندما ينشئ المستخدم ملاحظة جديدة (أو عندما يقوم بأي أمر يتطلب التوثيق)، سترسل React نسخة من المفتاح مع الطلب إلى الخادم. يستخدم عندها الخادم هذا المفتاح للتحقق من المستخدم (العملية مشابهة تقريبًا لفتح باب المنزل بالمفتاح الخاص بصاحب المنزل). ملاحظة: سنطلق على هذا المفتاح المشفر (token) اسم «شهادة» مجازًا لأنه يشهد للمستخدم الحامل له بأنه صاحب الحساب الحقيقي المخول للوصول إلى حسابه وبياناته على الموقع. لنضف أولًا وظيفة تسجيل الدخول. علينا تثبيت المكتبة jsonwebtoken التي تسمح لنا بتوليد شهادات ويب JSON (أي JSON web tokens وتختصر إلى JWT). npm install jsonwebtoken --save نضع شيفرة تسجيل الدخول التالية في الملف login.js ضمن المجلد controllers: const jwt = require('jsonwebtoken') const bcrypt = require('bcrypt') const loginRouter = require('express').Router() const User = require('../models/user') loginRouter.post('/', async (request, response) => { const body = request.body const user = await User.findOne({ username: body.username }) const passwordCorrect = user === null ? false : await bcrypt.compare(body.password, user.passwordHash) if (!(user && passwordCorrect)) { return response.status(401).json({ error: 'invalid username or password' }) } const userForToken = { username: user.username, id: user._id, } const token = jwt.sign(userForToken, process.env.SECRET) response .status(200) .send({ token, username: user.username, name: user.name }) }) module.exports = loginRouter يبدأ عمل الشيفرة بالبحث عن المستخدم ضمن قاعدة البيانات من خلال اسم المستخدم المرفق مع الطلب. بعد ذلك يتحقق من كلمة المرور التي ترفق أيضًا مع الطلب. وبما أن كلمات المرور لا تخزن كما هي في قواعد البيانات بل على شكل رموز محسوبة اعتمادًا عليها، سيتحقق التابع bcrypt.compare من صحة كلمة المرور: await bcrypt.compare(body.password, user.passwordHash) إن لم يُعثَر على المستخدم، أو كانت كلمة المرور خاطئة، سيسيب الخادم للطلب برمز الحالة 401 (غير مخول بالعملية) وسيحدد سبب إخفاق الطلب في جسم الاستجابة. إن كانت كلمة المرور صحيحة، ستنشئ الشهادة باستخدام التابع jwt.sign. تحتوي الشهادة على اسم المستخدم ومعرف المستخدم بصيغة موقعة رقميًا. const userForToken = { username: user.username, id: user._id, } const token = jwt.sign(userForToken, process.env.SECRET) توّقع الشهادة رقميًا باستعمال قيمة نصية تمثل ""السر"" موجودة في متغير البيئة SECRET. يضمن هذا التوقيع عدم قدرة أي فريق لا يعرف كلمة "السر" من توليد شهادة صالحة. يجب أن توضع قيمة متغير البيئة في الملف ذو اللاحقة (env.). يستجيب الخادم لأي طلب صحيح بإرسال رمز الحالة 200 (مناسب). تعاد بعدها الشهادة الناتجة واسم المستخدم ضمن جسم الاستجابة. نضيف شيفرة تسجيل الدخول إلى التطبيق بإضافة متحكم المسار التالي إلى الملف app.js: const loginRouter = require('./controllers/login') //... app.use('/api/login', loginRouter) لنحاول تسجيل الدخول باستخدام VS Code REST-client: لن تنجح العملية وستطبع الرسالة التالية على الطرفية: (node:32911) UnhandledPromiseRejectionWarning: Error: secretOrPrivateKey must have a value at Object.module.exports [as sign] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/sign.js:101:20) at loginRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/login.js:26:21) (node:32911) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 2) أخفق الأمر (jwt.sign(userForToken, process.env.SECRET لأننا لم نضع قيمة "للسر" في متغير البيئة SECRET. سيعمل التطبيق بمجرد أن نقوم بذلك، وسيعيد تسجيل الدخول الناجح تفاصيل المستخدم والشهادة. سيولد اسم المستخدم الخاطئ أو كلمة المرور الخاطئة رسالة خطأ وسيعيد الخادم رمز الحالة المناسب. حصر تدوين الملاحظات بالمستخدمين المسجلين لنمنع إنشاء ملاحظات جديدة ما لم تكن الشهادة المرفقة مع الطلب صحيحة. بعدها ستحفظ الملاحظة في قائمة ملاحظات المستخدم الذي تعرّفه الشهادة. هناك طرق عديدة لإرسال الشهادة من المتصفح إلى الخادم. سنستخدم منها ترويسة التصريح (ِAuthorization). حيث توضح هذه الترويسة تخطيط الاستيثاق المستخدم. سيكون ذلك ضروريًا إن سمح الخادم بطرق متعددة للتحقق. حيث يوضح التخطيط للخادم الطريقة التي يفسر بها الشهادة المرفقة بالطلب. سيكون التخطيط Bearer مناسبًا لنا. وعمليًا لو كانت قيمة الشهادة على سبيل المثال (eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW) ستكون قيمة ترويسة التصريح: Bearer eyJhbGciOiJIUzI1NiIsInR5c2VybmFtZSI6Im1sdXVra2FpIiwiaW ستتغير شيفرة إنشاء ملاحظة جديدة على النحو التالي: const jwt = require('jsonwebtoken') // ... const getTokenFrom = request => { const authorization = request.get('authorization') if (authorization && authorization.toLowerCase().startsWith('bearer ')) { return authorization.substring(7) } return null} notesRouter.post('/', async (request, response) => { const body = request.body const token = getTokenFrom(request) const decodedToken = jwt.verify(token, process.env.SECRET) if (!token || !decodedToken.id) { return response.status(401).json({ error: 'token missing or invalid' }) } const user = await User.findById(decodedToken.id) const note = new Note({ content: body.content, important: body.important === undefined ? false : body.important, date: new Date(), user: user._id }) const savedNote = await note.save() user.notes = user.notes.concat(savedNote._id) await user.save() response.json(savedNote) }) تعزل الدالة المساعدة getTokenFrom الشهادة عن ترويسة التصريح، ويتحقق التابع jwt.verify من صلاحيتها. يفك التابع تشفيرالشهادة أيضًا أو يعيد الكائن الذي بُنيت على أساسه الشهادة. const decodedToken = jwt.verify(token, process.env.SECRET) يحمل الكائن الناتج عن الشهادة بعد فك شيفرته حقلي اسم المستخدم والمعرف الخاص به، ليخبر الخادم من أرسل الطلب. إن لم تكن هناك شهادة، أو لم يحمل الكائن الناتج عن الشهادة معرف المستخدم، سيعيد الخادم رمز الحالة 401 (غير مصرّح له)، وسيحدد سبب الخطأ في جسم الاستجابة. if (!token || !decodedToken.id) { return response.status(401).json({ error: 'token missing or invalid' }) } حالما يتم التحقق من هوية المستخدم، تتابع العملية التنفيذ بالشكل السابق. يمكن إنشاء ملاحظة جديدة باستخدام Postman إن أعطت ترويسة التصريح القيمة النصية الصحيحة وهي bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ، وستكون القيمة الأخرى هي الشهادة التي تعيدها عملية تسجيل الدخول. ستبدو العملية باستخدام Postman كالتالي: وباستعمال Visual Studio Code REST client كالتالي: معالجة الأخطاء يمكن لعمليات التحقق أن تسبب أخطاء من النوع JsonWebTokenError. فلو أزلنا بعض المحارف من الشهادة وحاولنا كتابة ملاحظة جديدة، هذا ما سيحدث: JsonWebTokenError: invalid signature at /Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:126:19 at getSecret (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:80:14) at Object.module.exports [as verify] (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/node_modules/jsonwebtoken/verify.js:84:10) at notesRouter.post (/Users/mluukkai/opetus/_2019fullstack-koodit/osa3/notes-backend/controllers/notes.js:40:30) هنالك أسباب عديدة لأخطاء فك التشفير. فقد تكون الشهادة خاطئة أو مزورة أو منتهية الصلاحية. إذًا سنوسع الأداة الوسطية لمعالجة الأخطاء لتأخذ بالحسبان أخطاء فك التشفير الممكنة: const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } const errorHandler = (error, request, response, next) => { if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } else if (error.name === 'ValidationError') { return response.status(400).json({ error: error.message }) } else if (error.name === 'JsonWebTokenError') { return response.status(401).json({ error: 'invalid token' }) } logger.error(error.message) next(error) } يمكنك إيجاد شيفرة التطبيق في الفرع part4-9 ضمن المستودع المخصص للتطبيق على GitHub. إن احتوى التطبيق على عدة واجهات تتطلب التحقق، يجب أن نضع أدوات التحقق JWT ضمن أداة وسطية مستقلة. ستساعدنا بعض المكتبات الموجودة مثل express-jwt في تحقيق ذلك. ملاحظة ختامية نظرًا للتغيرات الكثيرة التي أجريناها على الشيفرة فقد حدثت الكثير من الأخطاء التقليدية التي تحدث في كل المشاريع البرمجية، ولم ينجح الكثير من الاختبارات. ولأن هذا القسم من المنهاج مليء بالأفكار الجديدة، سنترك مهمة إصلاح الاختبارات إلى التمارين غير الإلزامية. يجب استخدام كلمات السر وأسماء المستخدمين وشهادات التحقق مع البروتوكول HTTPS. يمكننا استخدام HTTPS ضمن Node في تطبيقنا بدلًا من HTTP، لكن ذلك سيتطلب أوامر تهيئة أكثر. من ناحية أخرى، تتواجد نسخة الإنتاج من تطبيقنا على Heroku، وبذلك سيبقى التطبيق آمنًا. فكل المسارات بين المتصفح وخادم Heroku تستخدم HTTPS. سنضيف آلية تسجيل الدخول إلى الواجهة الأمامية في القسم التالي. التمارين 4.15 - 4.22 سنضيف أساسيات إدارة المستخدمين إلى تطبيق قائمة المدونات في التمارين التالية. إن الطريقة الأكثر أمانًا في الحل، هي متابعة المعلومات التي عرضناها في هذا الفصل والفصل السابق من القسم 4. كما يمكنك بالطبع استعمال مخيلتك. تحذير من جديد: إن لاحظت أنك تستخدم await/async مع then فتأكد أنك ستقع في الأخطاء بنسبة %99. استخدم أحد الأسلوبين وليس كلاهما. 4.15 التوسع في قائمة المدونات: الخطوة 3 أضف طريقة لتسجيل مستخدم جديد باستخدام طلب HTTP إلى العنوان api/users/. للمستخدمين اسم حقيقي واسم للدخول وكلمة مرور. لا تخزّن كلمة السر كنص واضح، بل استخدم المكتبة bcrypt بالطريقة التي استخدمناها في القسم4 -فصل (إدارة المستخدمين). ملاحظة: عانى بعض مستخدمي Windows أثناء العمل مع bcrypt. إن واجهتك المشاكل معها، ألغ تثبيتها كالتالي: npm uninstall bcrypt --save ثم ثبت المكتبة bcryptjs بدلًا عنها. أضف طريقة لعرض تفاصيل جميع المستخدمين من خلال طلب HTTP مناسب. يمكن أن تظهر قائمة المستخدمين بالشكل التالي: 4.16 التوسع في قائمة المدونات: الخطوة 4 * أضف ميزة للتطبيق تنفذ التقييدات التالية: اسم المستخدم وكلمة المرور إلزاميتان يجب أن يكون طول كل من كلمة المرور واسم المستخدم ثلاثة محارف على الأقل. يجب أن يكون اسم المستخدم فريدًا (غير مكرر). يجب أن تعيد العملية رمز الحالة المناسب مع رسالة خطأ عند محاولة إنشاء مستخدم جديد مخالف للتقييدات. ملاحظة: لا تختبر تقييد كلمة المرور باستعمال مقيمات Mongoose. لأن كلمة المرور التي تتلقاها الواجهة الخلفية وكلمة السر المرمزة التي تحفظ في قاعدة البيانات أمران مختلفان. يجب أن يقيم طول كلمة المرور باستخدام المتحكم كما فعلنا في القسم 3 قبل أن نستعمل مقيّمات Mongoose. أضف اختبارًا يتأكد من عدم إضافة مستخدم إذا كانت بياناته غير صحيحة. يجب أن تعيد العملية رمز حالة مناسب ورسالة خطأ. 4.17 التوسع في قائمة المدونات: الخطوة 5 وسٍّع المدونات بحيث تحتوي كل مدونة على معلومات عن المستخدم الذي أنشأها. عدل في أسلوب إضافة مدونة جديدة بحيث يُحدد أحد المستخدمين الموجودين في قاعدة البيانات كمنشئ لها (قد يكون أول شخص يظهر مثلًا). نفذ المهمة اعتمادًا على معلومات الفصل الثالث- القسم 4. لا يهم حاليًا إلى أي مستخدم قد نسبت المدونة، لأننا سنهتم بذلك في التمرين 4.19. عدّل طريقة عرض المدونات بحيث تظهر معلومات منشئ المدونة: عدل طريقة عرض قائمة المستخدمين بحيث تظهر المدونات التي أنشأها المستخدم: 4.18 التوسع في قائمة المدونات: الخطوة 6 أضف أسلوب توثيق يعتمد على الشهادات كما فعلنا في بداية هذا الفصل. 4.19 التوسع في قائمة المدونات: الخطوة 7 عدّل في طريقة إضافة مدونة جديدة لتُنفَّذ فقط في الحالة التي تُرسل فيها شهادة صحيحة عبر طلب HTTP-POST. ويعتبر عندها الشخص الذي تحدده الشهادة منشئ المدونة. 4.20 التوسع في قائمة المدونات: الخطوة 8 * راجع المثال الذي أوردناه في هذا الفصل عن استخلاص الشهادة من ترويسة التصريح باستخدام الدالة المساعدة getTokenFrom. إن أردت استخدام الأسلوب ذاته، أعد كتابة الشيفرة التي تأخذ الشهادة إلى أداة وسطية. يجب أن تأخذ الأداة الوسطية الشهادة من ترويسة التصريح وتضعها في الحقل token من كائن الطلب request. يعني هذا: إن صرّحت عن الأداة الوسطية في بداية الملف app.js وقبل كل المسارات كالتالي: app.use(middleware.tokenExtractor) ستتمكن المسارات من الوصول إلى الشهادة باستخدام الأمر request.token: blogsRouter.post('/', async (request, response) => { // .. const decodedToken = jwt.verify(request.token, process.env.SECRET) // .. }) وتذكر أن الأداة الوسطية هي دالة من ثلاث معاملات، تستدعى في نهاية تنفيذها المعامل الأخير next ليمرر التنفيذ إلى الأداة الوسطية التالية: const tokenExtractor = (request, response, next) => { // code that extracts the token next() } 4.21 التوسع في قائمة المدونات: الخطوة 9 * عدّل في عملية حذف مدونة بحيث تحذف من قبل منشئها فقط. وبالتالي ستنفذ عملية الحذف إن كانت الشهادة المرسلة عبر الطلب مطابقة لشهادة المستخدم الذي أنشأ المدونة. إن جرت محاولة حذف المدونة دون شهادة صحيحة أو من قبل المستخدم الخاطئ، على العملية أن تعيد رمز الحالة المناسب. لاحظ أنك لو أحضرت المدونة من قاعدة البيانات كالتالي: const blog = await Blog.findById(...) وأردت أن توازن بين معرف الكائن الذي أحضرته من قاعدة البيانات والقيمة النصية للمعرف، فلن تنجح عملية الموازنة العادية. ذلك أن الحقل blog.user ليس قيمة نصية بل كائن. لذلك لابد من تحويل قيمة المعرف في الكائن الذي أحضر من قاعدة البيانات إلى قيمة نصية أولًا. if ( blog.user.toString() === userid.toString() ) ... 4.22 التوسع في قائمة المدونات: الخطوة 10 * ستفشل اختبارات إضافة مدونة جديدة حالما تستخدم التوثيق المعتمد على الشهادات. أصلح الاختبارت، ثم اكتب اختبارًا جديدًا يتأكد من أن الخادم سيرد برسالة الخطأ 401 (غير مفوض) إن لم ترفق شهادة بالطلب. اطلع على بعض الأفكار المفيدة التي قد تساعدك في إصلاح الاختبارات. هكذا نكون قد وصلنا إلى التمرين الأخير في القسم، وحان الوقت لتسليم الحلول إلى GitHub، والإشارة إلى أنك سلمت التمارين المنتهية ضمن منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل Token authentication من سلسلة Deep Dive Into Modern Web Development
  4. سنحتاج إلى وسيلة للتحقق من المستخدم ومن الصلاحيات الممنوحة له في تطبيقنا. وينبغي أن تُحفظ بيانات المستخدمين في قاعدة البيانات وأن ترتبط بالمستخدم الذي أنشأها. فلن يُسمح بتعديل أو حذف الملاحظة إلا من قبل المستخدم الذي أنشأها. لنبدأ بإضافة معلومات عن المستخدمين إلى قاعدة البيانات. حيث ستظهر علاقة من الشكل "واحد إلى عدة" بين المستخدم User و الملاحظة Note: لو كنا نعمل مع قاعدة بيانات علاقيّة لكانت إضافة البيانات السابقة عملية مباشرة. حيث سيمتلك كلا الموردين جداول مستقلة في قاعدة البيانات وسيخزن معرّف المستخدم الذي أنشأ الملاحظة في جدول الملاحظات كمفتاح خارجي (foreign key). سيختلف الأمر قليلًا عند العمل مع قاعدة بيانات المستندات لوجود طرق عدة للقيام بذلك. يقتضي الحل الذي طبقناه في حالة الملاحظات بتخزين كل ملاحظة في تجمع للملاحظات notes collection ضمن قاعدة البيانات. فإن لم نشأ أن نغير التجمع، فمن المنطقي إنشاء تجمع جديد للمستخدمين على سبيل المثال. كما هي العادة في جميع قواعد بيانات المستندات يمكن استخدام معرّف الكائن في Mongo كمرجع للمستندات في التجمعات المختلفة. وهذا أمر مشابه لاستخدام المفتاح الغريب في قواعد البيانات العلاقية. لا تدعم قواعد البيانات التقليدية مثل Mongo الاستقصاء المشترك (joint query) المتاح في القواعد العلاقية، والذي يستخدم في تجميع البيانات من جداول عدة. لكن اعتبارًا من الإصدار 3.2، دعمت Mongo فكرة استقصاءات البحث المجمعة، والتي لن نتعامل معها في منهاجنا. إن احتجنا إلى وظيفة مشابهة للاستقصاء المجمع في تطبيقنا فسنضيفها إلى الشيفرة بتنفيذ استقصاءات متعددة. على الرغم من أن Mongoose تتكفل في بعض الحالات بموضوع ضم وتجميع البيانات بما يوحي بالاستقصاء المشترك، إلا أن ما تجريه في الواقع، هي استقصاءات متعددة في الخفاء. المراجع ما بين التجمعات لو استخدمنا قاعدة بيانات علاقية، فستحتوي الملاحظة على مفتاح مرجعي إلى المستخدم الذي أنشأها. وبالمثل يمكننا القيام بأمر مشابه في قواعد بيانات المستندات. لنفترض أن تجمع المستخدمين users سيضم مستخدمين اثنين: [ { username: 'mluukkai', _id: 123456, }, { username: 'hellas', _id: 141414, }, ]; يضم تجمع الملاحظات notes ثلاثة ملاحظات، لكل منها حقل خاص يرتبط مرجعيًا بمستخدم من تجمع المستخدمين: [ { content: 'HTML is easy', important: false, _id: 221212, user: 123456, }, { content: 'The most important operations of HTTP protocol are GET and POST', important: true, _id: 221255, user: 123456, }, { content: 'A proper dinosaur codes with Java', important: false, _id: 221244, user: 141414, }, ] لا تتطلب قاعدة بيانات المستند تخزين مفتاح خارجي في حقول الملاحظة، بل يمكن أن يُخزّن في تجمع المستخدمين كما يمكن أن يخزن في كلا التجمعين: [ { username: 'mluukkai', _id: 123456, notes: [221212, 221255], }, { username: 'hellas', _id: 141414, notes: [221244], }, ] طالما أن المستخدم قد ينشئ عدة ملاحظات، سنخزن معرفات هذه الملاحظات في مصفوفة ضمن الحقل notes في مورد المستخدم. تقدم قواعد بيانات المستندات أيضًا طريقة مختلفة جذريًا في تنظيم البيانات: فمن المجدي في بعض الحالات، أن نشعِّب مصفوفة الملاحظات التي أنشأها المستخدم بأكملها كجزء من المستند في تجمع المستخدمين: [ { username: 'mluukkai', _id: 123456, notes: [ { content: 'HTML is easy', important: false, }, { content: 'The most important operations of HTTP protocol are GET and POST', important: true, }, ], }, { username: 'hellas', _id: 141414, notes: [ { content: 'A proper dinosaur codes with Java', important: false, }, ], }, ] في هذا التخطيط لقاعدة البيانات، ستُشعّب الملاحظات تحت المستخدم، ولن تولّد قاعدة البيانات حينها أية معرفات لها. إن بنية وتخطيط قواعد بيانات المستندات ليس واضحة بذاتها كما هي الحال في القواعد العلاقيّة. فيجب أن يخدم التخطيط الذي سنستعمله حالات التطبيق جمعيها بأفضل شكل. وبالطبع فهذا القرار ليس بسيطًا على الإطلاق لأن حالات استخدام التطبيق ليست معروفة تمامًا في فترة التصميم. وللمفارقة، ستتطلب قواعد البيانات التي لا تمتلك تخطيطًا مثل Mongoose من المطور أن يتخذ قرارات جذرية جدًا حول تنظيم البيانات منذ البداية خلافًا للقواعد العلاقيّة. حيث تقدم القواعد العلاقية طرقًا أكثر أو أقل في تنظيم البيانات لعدة تطبيقات تخطيط Mongoose للمستخدمين لقد قررنا في حالتنا أن نخزن معرفات الملاحظات التي ينشئها مستخدم في مستند المستخدم. لذا سنعرف نموذجًا يمثل المستخدم ونضعه في الملف user.js ضمن المجلد models: const mongoose = require('mongoose') const userSchema = new mongoose.Schema({ username: String, name: String, passwordHash: String, notes: [ { type: mongoose.Schema.Types.ObjectId, ref: 'Note' } ], }) userSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v // the passwordHash should not be revealed delete returnedObject.passwordHash } }) const User = mongoose.model('User', userSchema) module.exports = User خُزّنت معرفات الملاحظات ضمن مستند المستخدم كمصفوفة معرفات Mongoose. وللتعريف الشكل التالي: { type: mongoose.Schema.Types.ObjectId, ref: 'Note' } إن نمط الحقل الذي يرتبط مرجعيًا بالملاحظات هو ObjectId. لن تكون Mongo قادرة على تميز هذا الحقل على أنه مرجع للملاحظات، لأن العبارة متعلقة ومعرّفة بالمكتبة Mongoose. لنوسع تخطيط الملاحظة المعرّف في الملف notre.js ضمن المجلد model لكي تضم معلومات على المستخدم الذي أنشأها: const noteSchema = new mongoose.Schema({ content: { type: String, required: true, minlength: 5 }, date: Date, important: Boolean, user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }}) وعلى نقيض قواعد البيانات العلاقيّة سنخزن المراجع في كلا المستندين. حيث سترتبط الملاحظة مرجعيًا بالمستخدم الذي أنشأها، كما يمتلك المستخدم مصفوفة من المراجع لكل الملاحظات التي أنشأها أيضًا. تسجيل مستخدمين جدد لنضف مسارًا لإنشاء مستخدمين جدد. يمتلك المستخدم اسمًا واسمًا آخر لتسجيل الدخول (username) ومعلومة أخرى تدعى كلمة السر المرمّزة passwordHash. هذه الكلمة المرمزة هي نتاج دالة الترميز وحيدة الاتجاه عندما نطبقها على كلمة السر. فمن غير الملائم أن تخزن كلمة السر كنص عادي في قاعدة البيانات. لنثبت الحزمة bcrypt التي ستتولى أمر توليد كلمات السر المرمزة: npm install bcrypt --save سننشئ مستخدمين جدد بما يتوافق مع توجيهات RESTful التي ناقشناها في القسم 3، وذلك بإرسال طلب HTTP POST إلى عنوان الموقع users. لنعرف متحكمًا بالمسار يتعامل مع المستخدمين ولنضعه في ملف جديد باسم users.js ضمن المجلد controllers. سنجعله قابلًا للاستخدام بإدراجه في الملف app.js، حيث سيعالج الطلبات المرسلة إلى العنوان api/users/: const usersRouter = require('./controllers/users') // ... app.use('/api/users', usersRouter) يحتوي الملف الذي يعرّف المتحكم بالمسار الشيفرة التالية: const bcrypt = require('bcrypt') const usersRouter = require('express').Router() const User = require('../models/user') usersRouter.post('/', async (request, response) => { const body = request.body const saltRounds = 10 const passwordHash = await bcrypt.hash(body.password, saltRounds) const user = new User({ username: body.username, name: body.name, passwordHash, }) const savedUser = await user.save() response.json(savedUser) }) module.exports = usersRouter لا تُخزَّن كلمة السر التي تُرسل ضمن الطلب في قاعدة البيانات، بل النسخة المعمّاة التي تولّدها الدالة bcrypt.hash. إن أساسيات تخزين كلمات المرور في قواعد البيانات موضوع خارج نطاق منهاجنا، ولن نناقش ما الذي يعنيه إسناد الرقم السحري 10 إلى المتغيّر saltRounds، لكن يمكن الاطلاع على معلومات أكثر عبر الإنترنت. لا تحتوي الشيفرة الحالية على معالجات أخطاء أو معالجات تقييم، لتتحقق من كون اسم المستخدم وكلمة المرور بالشكل الصحيح. يجب أن تُختبر الميزة الجديدة يدويًا بشكل مبدئي باستخدام أدوات مثل Postman. لكن سرعان ما سيغدو الأمر مربكًا وخاصة عندما نضيف الوظيفة التي تجبر المستخدم على إدخال اسم مستخدم فريد. لذلك فكتابة الاختبارات الآلية لن يحتاج جهدًا كبيرًا، وسيجعل تطوير التطبيق أكثر سهولة. قد يبدو اختبارنا المبدئي كالتالي: const bcrypt = require('bcrypt') const User = require('../models/user') //... describe('when there is initially one user in db', () => { beforeEach(async () => { await User.deleteMany({}) const passwordHash = await bcrypt.hash('sekret', 10) const user = new User({ username: 'root', passwordHash }) await user.save() }) test('creation succeeds with a fresh username', async () => { const usersAtStart = await helper.usersInDb() const newUser = { username: 'mluukkai', name: 'Matti Luukkainen', password: 'salainen', } await api .post('/api/users') .send(newUser) .expect(200) .expect('Content-Type', /application\/json/) const usersAtEnd = await helper.usersInDb() expect(usersAtEnd).toHaveLength(usersAtStart.length + 1) const usernames = usersAtEnd.map(u => u.username) expect(usernames).toContain(newUser.username) }) }) تستخدم الاختبارات الدالة المساعدة ()usersInDb التي أضفناها إلى الملف test_helper.js الموجود في المجلد tests. ستساعدنا الدالة على التحقق من حالة قاعدة البيانات عند إنشاء مستخدم جديد: const User = require('../models/user') // ... const usersInDb = async () => { const users = await User.find({}) return users.map(u => u.toJSON()) } module.exports = { initialNotes, nonExistingId, notesInDb, usersInDb, } تُضيف الكتلة beforeEach مستخدمًا له اسم المستخدم إلى قاعدة البيانات. يمكننا كتابة اختبار جديد يتحقق من عدم تسجيل اسم مستخدم جديد موجود أصلًا: describe('when there is initially one user in db', () => { // ... test('creation fails with proper statuscode and message if username already taken', async () => { const usersAtStart = await helper.usersInDb() const newUser = { username: 'root', name: 'Superuser', password: 'salainen', } const result = await api .post('/api/users') .send(newUser) .expect(400) .expect('Content-Type', /application\/json/) expect(result.body.error).toContain('`username` to be unique') const usersAtEnd = await helper.usersInDb() expect(usersAtEnd).toHaveLength(usersAtStart.length) }) }) لن ينجح الاختبار في هذه الحالة حاليًا. نتدرب من خلال الأمثلة السابقة على ما يسمى التطوير المقاد بالاختبارات test-driven development (TDD)، حيث تُكتب الاختبارات للوظائف الجديدة قبل إضافتها إلى التطبيق. لنقيّم تفرّد اسم المستخدم بمساعدة مقيمات المكتبة Mongoose. لا تمتلك المكتبة كما أشرنا في التمرين 3.19 على مقيمات مدمجة معها للتحقق من وحدانية القيمة لحقل محدد. لكن يمكننا أن نجد حلًا جاهزًا في حزمة npm mongoose-unique-validator. لنثبت هذه الحزمة إذًا. npm install --save mongoose-unique-validator كما ينبغي أن نجري التعديلات التالية على الملف user.js الموجود ضمن المجلد models: const mongoose = require('mongoose') const uniqueValidator = require('mongoose-unique-validator') const userSchema = new mongoose.Schema({ username: { type: String, unique: true }, name: String, passwordHash: String, notes: [ { type: mongoose.Schema.Types.ObjectId, ref: 'Note' } ], }) userSchema.plugin(uniqueValidator) // ... يمكننا إضافة العديد من الاختبارت لتقييم حالة المستخدم الجديد، كالتحقق من طول اسم المستخدم إن كان كافيًا، أو أنه يحوي على محارف غير مسموحة، أو أن كلمة السر قوية بما يكفي. لقد تركنا تلك الوظائف كتمرينات اختيارية. قبل أن نكمل طريقنا، سنضيف معالج مسار يعيد كل المستخدمين المسجلين في قاعدة البيانات: usersRouter.get('/', async (request, response) => { const users = await User.find({}) response.json(users) }) ستبدو القائمة كالتالي: يمكنك إيجاد شيفرة التطبيق بأكملها في الفرع part4-7 ضمن المستودع الخاص بالتطبيق على GitHub. إنشاء ملاحظة جديدة ينبغي أن نعدل شيفرة إنشاء ملاحظة جديدة لكي نربطها بالمستخدم الذي أنشأها. لنوسع الشيفرة بحيث تُرسل معلومات عن المستخدم الذي أنشأ الشيفرة ضمن الحقل userId من جسم الطلب: const User = require('../models/user') //... notesRouter.post('/', async (request, response, next) => { const body = request.body const user = await User.findById(body.userId) const note = new Note({ content: body.content, important: body.important === undefined ? false : body.important, date: new Date(), user: user._id }) const savedNote = await note.save() user.notes = user.notes.concat(savedNote._id) await user.save() response.json(savedNote) }) لن يؤثر التغيير الذي حصل على الكائن user في شيء. وسيُخزَّن معرف الملاحظة في الحقل notes. const user = await User.findById(body.userId) // ... user.notes = user.notes.concat(savedNote._id) await user.save() لنحاول إنشاء ملاحظة جديدة: تبدو العملية ناجحة. لنضف ملاحظة أخرى ثم نحضر بيانات كل المستخدمين باستخدام المسار المخصص لذلك: يمكن أن نرى أن المستخدم قد أنشأ ملاحظتين. كذلك الأمر يمكن رؤية معرفات المستخدمين الذين أنشأوا الملاحظات إذا ما أحضرنا كل الملاحظات باستخدام المسار المخصص لذلك. استخدام التابع Populate نريد لواجهتنا البرمجية أن تعمل بطريقة محددة بحيث تضع محتويات الملاحظات التي أنشأها المستخدم داخل الكائن user عند إرسال طلب HTTP GET، وليس فقط المعرفات الفريدة الخاصة بها. يمكن إنجاز ذلك في قواعد البيانات العلاقيّة باستخدام الاستقصاء المشترك. لا تدعم قواعد بيانات المستندات الاستقصاء المشترك بين التجمعات كما أشرنا سابقًا، لكن يمكن للمكتبة Mongoose أن تنفذ ببعض الأمور المشابهة. حيث تنجز المكتبة الاستقصاء المشترك بتنفيذها استقصاءات متعددة تختلف عن الاستقصاء المشترك في القواعد العلاقية. فلا تتأثر حالة هذه الأخيرة عندما نجري الاستقصاء المشترك، بينما لا يمكن أن نضمن ثبات الحالة بين التجمعين اللذين ضمهما الاستقصاء عند استخدام Mongoose. ويعني هذا أن حالة التجمعين قد تتغير أثناء الاستقصاء، إذا قمنا باستقصاء مشترك بين المستخدمين والملاحظات. تستخدم Mongoose التابع populate في ضم التجمعات. لنعدل المسار الذي يعيد كل المستخدمين أولًا: usersRouter.get('/', async (request, response) => { const users = await User.find({}).populate('notes') response.json(users) }) يظهر التابع populate في السلسة بعد أن ينفذ التابع find الاستقصاء. يحدد الوسيط الذي يُمرر إلى التابع أن معرفات كائنات الملاحظة في الحقل notes من المستند user ستُستبدل بمستند note المرتبط معه مرجعيًا. ستبدو النتيجة الآن كما نريد تقريبًا: يمكن استخدام معامل التابع populate لاختيار الحقول التي نريد إضافتها من المستندات المختلفة، ويتم ذلك باستخدام تعبير Mong التالي: usersRouter.get('/', async (request, response) => { const users = await User .find({}).populate('notes', { content: 1, date: 1 }) response.json(users) }); ستكون النتيجة الآن كما نريد تمامًا: لننشر معلومات المستخدم ضمن الملاحظات بشكل ملائم: notesRouter.get('/', async (request, response) => { const notes = await Note .find({}).populate('user', { username: 1, name: 1 }) response.json(notes) }); وهكذا ستُضاف معلومات المستخدم في الحقل user من كائن الملاحظة. من المهم معرفة أن قاعدة البيانات لاتعلم أن المعرفات المخزنة في الحقل user من الملاحظات مرتبطة مرجعيًا مع تجمع المستخدمين. تعتمد وظيفة التابع populate على حقيقة أننا عرّفنا أنماطًا مرجعية في تخطيط Mongoose مزودة بالخيار ref: const noteSchema = new mongoose.Schema({ content: { type: String, required: true, minlength: 5 }, date: Date, important: Boolean, user: { type: mongoose.Schema.Types.ObjectId, ref: 'User' } }) يمكنك إيجاد شيفرة التطبيق بأكملها في الفرع part4-8 ضمن المستودع الخاص بالتطبيق على GitHub. ترجمة -وبتصرف- للفصل User Administration من سلسلة Deep Dive Into Modern Web Development
  5. سنبدأ الآن بكتابة الاختبارات الخاصة بالواجهة الخلفية. وطالما أن منطق شيفرة الواجهة الخلفية ليس بهذا التعقيد، فلن يكون هناك معنى لكتابة اختبارت لأجزاء الشيفرة. لكن الشيء الوحيد الذي يمكن أن نجري عليه اختبار الأجزاء، هو التابع toJSON الذي يستخدم لتنسيق الملاحظات. قد يكون مفيدًا في بعض الأحيان، أن نختبر الواجهة الخلفية بأن نحاكي قاعدة البيانات بدلًا من استخدام القاعدة الأصلية، ويمكن الاستعانة بالمكتبة mongo-mock في ذلك. وطالما أن تطبيقنا لا يزال سهلًا نوعًا ما، سنختبر التطبيق بالكامل عبر واجهته البرمجية REST، وبالتالي سنضمن اختباره مع قاعدة بياناته. يسمى هذا النوع من الاختبارات الذي نجريه على عدة مكونات كمجموعة متكاملة اسم اختبار التكامل (integration testing) بيئة الاختبار أشرنا في أحد الفصول السابقة إلى أن خادم الواجهة الخلفية سيكون في وضع الإنتاج production mode عندما يعمل على Heroku. وتقتضي تقاليد Node تعريف وضع التطبيق عند تنفيذه ضمن متحول البيئة NODE_ENV. نستعمل في تطبيقنا متحولات البيئة المعرفة ضمن الملف env. فقط إن لم يكن التطبيق في وضع الإنتاج. وقد جرت العادة على تعريف أوضاع التطبيق بشكل منفصل في حالتي الاختبار والإنتاج. عدلنا سكربت package.json لكي يأخذ متحول البيئة NODE_ENV القيمة test عندما نجري الاختبارات: { // ... "scripts": { "start": "NODE_ENV=production node index.js", "dev": "NODE_ENV=development nodemon index.js", "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", "deploy": "git push heroku master", "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", "logs:prod": "heroku logs --tail", "lint": "eslint .", "test": "NODE_ENV=test jest --verbose --runInBand" }, // ... } كما أضفنا الخيار runInBand إلى سكربت npm، حيث يمنع هذا الخيار مكتبة Jest من تنفيذ الاختبارات على التوازي. وسنناقش أهمية هذا الخيار عندما نبدأ الاختبارات مستخدمين قاعدة البيانات. حددنا وضع التطبيق في سكربت npm run dev ليكون في وضع التطوير development، بحيث يمكن استخدام المكتبة nodemon. بينما حددنا وضع التطبيق ليكون في وضع الإنتاج production في سكربت npm start والذي يحدد وضع التطبيق عند التشغيل الافتراضي. سنجد أن هناك مشكلة صغيرة في تعريفنا لوضع التطبيق بهذا الشكل، فلن يعمل التطبيق على Windows. ولحل هذه المشكلة علينا تثبيت حزمة cross-env بتنفيذ الأمر التالي: npm install cross-env يمكننا إنجاز العمل بشكل متوافق مع منصات التشغيل المختلفة باستخدام المكتبة cross-env، حيث سنعدل في سكربت npm ضمن الملف package.json ليصبح على الشكل: { // ... "scripts": { "start": "cross-env NODE_ENV=production node index.js", "dev": "cross-env NODE_ENV=development nodemon index.js", // ... "test": "cross-env NODE_ENV=test jest --verbose --runInBand", }, // ... } وهكذا سنصبح قادرين على تشغيل التطبيق بأوضاع مختلفة. حيث نستطيع مثلًا أن نستخدم قاعدة بيانات منفصلة لأغراض الاختبارات عندما نجريها. على الرغم من إمكانية إنشاء قاعدة بيانات للاختبارات على MongoDB Atlas، إلا أن ذلك لن يكون حلًا جيدًا، لأن العديد من المطورين سيعملون على نفس التطبيق وبالتالي قد تظهر المشاكل. فمن المفترض ألا نجري عدة اختبارات في الوقت ذاته مستخدمين قاعدة البيانات ذاتها. ومن الأفضل إنجاز الاختبارات باستخدام قاعدة بيانات مثبتة على جهاز المطور وتعمل عليه. وبالتالي سيكون الخيار الأنسب استخدام قاعدة بيانات منفصلة لكل اختبار نجريه، وسيكون هذا الأمر بسيطًا نوعًا ما عندما نستخدم إحدى الحاويتين running Mongo in-memory أو Docker. لن نعقد الأمور أكثر بل سنستمر باستخدام قاعدة البيانات على MongoDB Atlas. لنجري بعض التعديلات على الوحدة التي تعرف إعدادات التهيئة لتطبيقنا: require('dotenv').config() let PORT = process.env.PORT let MONGODB_URI = process.env.MONGODB_URI if (process.env.NODE_ENV === 'test') { MONGODB_URI = process.env.TEST_MONGODB_URI} module.exports = { MONGODB_URI, PORT } يضم الملف env. متحولات منفصلة لتعريف عناوين قواعد البيانات لأغراض الاختبار والتطوير: MONGODB_URI=mongodb+srv://fullstack:secred@cluster0-ostce.mongodb.net/note-app?retryWrites=true PORT=3001 TEST_MONGODB_URI=mongodb+srv://fullstack:secret@cluster0-ostce.mongodb.net/note-app-test?retryWrites=true تشابه الوحدة config التي أضفناها إلى التطبيق الحزمة node-config قليلًا. لكن إضافة إعداداتنا الخاصة إلى التطبيق له مبرراته طالما أن التطبيق بسيط وأنه مصمم خصيصًا لتعلم نقاط هامة. وهكذا نكون قد أجرينا كل التعديلات المطلوبة على تطبيقنا. يمكنك إيجاد شيفرة التطبيق الحالي بالكامل في الفرع part4-2 ضمن المخزن المخصص على GitHub. استخدام المكتبة supertest سنستخدم الحزمة supertest التي ستساعدنا على إجراء الاختبارات على واجهة تطبيقنا البرمجية، حيث سنثبت الحزمة كاعتمادية تطوير كالتالي: npm install --save-dev supertest لنكتب أول اختباراتنا في الملف note_api.test.js الذي سننشئه في المجلد tests: const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') const api = supertest(app) test('notes are returned as json', async () => { await api .get('/api/notes') .expect(200) .expect('Content-Type', /application\/json/) }) afterAll(() => { mongoose.connection.close() }) تُدرج شيفرة الاختبار تطبيق Express من الوحدة app.js وتغلفها داخل الدالة supertest لنحصل على كائن جديد يدعى superagent. يُسند هذا الكائن إلى المتغير api حيث يستخدمه التطبيق لإرسال طلبات HTTP إلى الواجهة الخلفية. يرسل تطبيقنا طلب HTTP-GET إلى عنوان الموقع api/notes ويتحقق من استجابة الخادم عندما يعيد رمز الحالة (200). كما يتحقق الاختبار من أن قيمة ترويسة "نوع-المحتوى" هي application/json، وبالتالي ستكون البيانات بالتنسيق الصحيح. ويضم التطبيق أيضًا بعض التفاصيل الأخرى التي سنناقشها بعد قليل. تُسبق الدالة السهمية التي تعرف الاختبار بالتعليمة async، وكذلك يُسبق التابع الذي يستدعي الكائن api بالتعليمة await. سنعود إلى هذا الموضوع بعد أن ننفذ عدة اختبارات أولًا. لا تُعر الموضوع اهتمامًا حاليًا، وعليك فقط أن تتأكد بأن الاختبار يجري بنجاح. فالعبارتين awiat/async في واقع الأمر مرتبطتان بعدم تزامن طلبات HTTP إلى الواجهة البرمجية حيث تستخدم Async/await لكتابة شيفرة غير متزامنة في التنفيذ، لكنها توحي بالتزامن. يجب إنهاء الاتصال الذي تستخدمه Mongoose بقاعدة البيانات بمجرد إنتهاء الاختبارات (هناك اختبار واحد حاليًا). تُنجز هذه المهمة باستخدام التابع afterAll: afterAll(() => { mongoose.connection.close() }) من الممكن أن تواجه التحذير التالي على الطرفية عندما تنفذ الاختبارات: إن حدث هذا، علينا اتباع التعليمات وإضافة الملف jest.config.js عند جذر المشروع وبداخله الشيفرة التالية: module.exports = { testEnvironment: 'node' } يجدر بك الانتباه إلى تفصيل صغير ومهم، فلقد وضعنا تطبيق Express ضمن الملف app.js في بداية هذا القسم، وبقي للملف index.js وظيفة تشغيل التطبيق على المنفذ المحدد باستخدام كائن http المدمج في Node: const app = require('./app') // the actual Express app const http = require('http') const config = require('./utils/config') const logger = require('./utils/logger') const server = http.createServer(app) server.listen(config.PORT, () => { logger.info(`Server running on port ${config.PORT}`) }) تستخدم الاختبارات التي نجريها تطبيق express المعرّف في الملف app.js: const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') const api = supertest(app) // ... سنجد في توثيق supertest مايلي: بمعنًى آخر: ستهتم المكتبة بتشغيل التطبيق الذي تختبره على المنفذ الذي يستخدمه داخليًا. لنكتب عدة اختبارات جديدة: test('there are two notes', async () => { const response = await api.get('/api/notes') expect(response.body).toHaveLength(2) }) test('the first note is about HTTP methods', async () => { const response = await api.get('/api/notes') expect(response.body[0].content).toBe('HTML is easy') }) يخزن كلا الاختبارين الاستجابة ضمن المتغير response، وعلى خلاف الاختبار السابق الذي يستخدم توابع المكتبة supertest للتحقق من رمز الحالة الذي يعيده الخادم، سيتحقق الاختباران من البيانات المخزنة ضمن الخاصية response.body. حيث سيتحققا من تنسيق ومحتوى البيانات في الاستجابة باستخدام التابع expect العائد للمكتبة Jest. ستظهر لك الآن فائدة استخدام await/async. فعادة ما كنا نستخدم دوال الاستدعاء للوصول إلى البيانات التي تعيدها الوعود، لكن بهذه الطريقة ستغدو الأمور مريحةً أكثر: const res = await api.get('/api/notes') //http سيكون تنفيذ الشيفرة هنا بعد اكتمال طلب //res تُحفظ نتيجة تنفيذ الطلب في المتغير expect(res.body).toHaveLength(2) ستحجب الأداة الوسيطية المسؤولة عن إظهار معلومات طلب HTTP، المعلومات التي سيظهرها الاختبار، لذا سنعدل الأداة الوسطية logger بحيث لا تطبع أية معلومات على الطرفية أثناء الاختبار: const info = (...params) => { if (process.env.NODE_ENV !== 'test') { console.log(...params) }} const error = (...params) => { console.error(...params) } module.exports = { info, error } إعادة ضبط قاعدة البيانات قبل الاختبارات تبدو الاختبارات حتى هذه اللحظة سهلة وناجحة. لكنها اختبارت سيئة كونها تعتمد على حالة قاعدة البيانات. ولكي نجعل اختباراتنا أكثر واقعية، علينا إعادة ضبط قاعدة البيانات وتوليد بيانات الاختبار المطلوب بطريقة مضبوطة قبل أن نبدأ الاختبار. تستخدم اختباراتنا الدالة afterAll من المكتبة Jest لإنهاء الاتصال مع قاعدة البيانات بعد أن ينتهي تنفيذ الاختبار. لكن هناك العديد من الدوال التي تقدمها Jest والتي يمكنها تنفيذ العديد من المهام قبل أن يُنفذ أي اختبار أو قبل كل مرة يُنفذ فيها الاختبار. سنعيد ضبط قاعدة البيانات قبل كل اختبار مستخدمين الدالة beforeEach: const mongoose = require('mongoose') const supertest = require('supertest') const app = require('../app') const api = supertest(app) const Note = require('../models/note') const initialNotes = [ { content: 'HTML is easy', date: new Date(), important: false, }, { content: 'Browser can execute only Javascript', date: new Date(), important: true, }, ] beforeEach(async () => { await Note.deleteMany({}) let noteObject = new Note(initialNotes[0]) await noteObject.save() noteObject = new Note(initialNotes[1]) await noteObject.save() }) تُمحى في البداية البيانات الموجودة في قاعدة البيانات، ثم تُحفظ الملاحظتين المُخزنتين في المصفوفة initialNotes فيها. نضمن عند قيامنا بذلك، بقاء حالة قاعدة البيانات كما هي قبل كل اختبار. سنجري بعض التعديلات على الاختبارين الأخيرين: test('all notes are returned', async () => { const response = await api.get('/api/notes') expect(response.body).toHaveLength(initialNotes.length)}) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) expect(contents).toContain( 'Browser can execute only Javascript' ) }) انتبه جيدًا للتعليمة expect في الاختبار الأخير. ينشئ الأمر (response.body.map(r => r.content مصفوفة تضم محتوى كل ملاحظة تعيدها الواجهة البرمجية. بينما يتحقق التابع toContain من وجود الملاحظة التي تُمرر إليه كوسيط، ضمن قائمة الملاحظات التي تعيدها الواجهة البرمجية. إجراء الاختبارات واحدًا تلو الآخر يُنفٍّذ الأمر npm test كل اختبارات التطبيق. لكن من الأفضل عند كتابة الاختبارات أن ننفذ كل اختبار أو اختبارين معًا. تقدم Jest طرقًا عدة لإنجاز الأمر. من هذه الطرق استخدام التابع only، لكنها ليست فعالة عندما يُكتب الاختبار ليُنفَّذ عبر ملفات عدة. أما الطريقة الأفضل فهي تحديد الاختبارات التي نريد تنفيذها كمعاملات للأمر npm test. فالأمر التالي على سبيل المثال ينفذ فقط الاختبارات الموجودة في الملف note_api.test.js ضمن المجلد tests: npm test -- tests/note_api.test.js نستخدم الخيار t- لتنفيذ اختبار يحمل اسمًا محددًا: npm test -- -t 'a specific note is within the returned notes' يمكن للمعامل الذي نمرره، أن يشير إلى اسم الاختبار أو إلى كتلة الوصف describe block. كما يمكن أن يحمل المعامل جزءًا من الاسم فقط. سينفذ الأمر التالي،على سبيل المثال، كل الاختبارات التي تحتوي أسماؤها على الكلمة notes: npm test -- -t 'notes' ملاحظة: يمكن أن يبقى اتصال Mongoose مفتوحًا عندما ننفذ اختبارًا واحدًا، إن لم تكن هناك اختبارات قيد التنفيذ تستعمل الاتصال. قد تكون المشكلة أن supertest ستجهز الاتصال، لكن Jest لا تنفذ شيفرة الدالة afterAll. التعليمتين async/await قبل أن نستأنف كتابة المزيد من الاختبارات، لنلق نظرة على التعليميتن await وasync. قُدٍّمت التعليمتان لأول مرة في الإصدار ES7، حيث مكّنتا من استخدام "الدوال غير المتزامنة التي تعيد وعدًا" بطريقة تبدو وكأنها متزامنة. وكمثال على ما ذكرناه، سيبدو إحضار الملاحظات من قاعدة البيانات باستخدام الوعود كالتالي: Note.find({}).then(notes => { console.log('operation returned the following notes', notes) }) يعيد التابع ()Note.find وعدًا يمكن الوصول إلى نتيجته بالتصريح عن دالة استدعاء كوسيط للتابع then. وسنجد كل الشيفرة التي يجب تنفيذها حالما تنتهي العملية ضمن دالة الاستدعاء. فلو أردنا أن نجري عدة استدعاءات غير متزامنة للدالة بشكل متسلسل، ستسوء الأمور. لأن الطلبات غير المتزامنة يجب أن تتم ضمن دوال استدعاء، وهذا ما سيعقد الشيفرة وقد يقود إلى ما يسمى جحيم الاستدعاءات (callbak hell). يمكن أن نبقي الأمور تحت السيطرة نوعًا ما باستخدام سلاسل الوعود، وبالتالي نتحاشى "جحيم الاستدعاءات" بإنشاء سلسلة واضحة من التوابع then. ولقد صادفنا ذلك مراتٍ عدة في منهاجنا. لتوضيح ذلك، سنقدم مثالًا افتراضيًا عن دالة تحضر جميع الملاحظات وتحذف الأولى: Note.find({}) .then(notes => { return notes[0].remove() }) .then(response => { console.log('the first note is removed') // المزيد من الشيفرة هنا }) لا بأس باستخدام سلسلة التابع then، لكن هناك طريقة أفضل. تؤمن التوابع المولٍّدة (genertaor functions) التي قُدّمت في الإصدار ES6، طريقة ذكية في كتابة الشيفرة غير المتزامنة بطريقة تجعلها "وكأنها متزامنة". لكنها طريقة غريبة ولم تستخدم بشكل واسع. تقدم التعليمتان await/async اللتان ظهرتا للوجود في الإصدار ES7، الوظائف نفسها التي تقدمها المولدات، لكن بطريقة واضحة ومنهجية ومفهومة بالنسبة لكل مستخدمي JavaScript. يمكن إحضار الملاحظات من قاعدة البيانات باستخدام await كالتالي: const notes = await Note.find({}) console.log('operation returned the following notes', notes) تبدو الشيفرة وكأنها متزامنة. يتوقف التنفيذ مؤقتًا عند العبارة ({})const notes= await Note.find حتى يتحقق الوعد، ثم ينتقل التنفيذ بعدها إلى السطر التالي. تُسند نتيجة العملية التي تعيد الوعد بعد متابعة التنفيذ إلى المتغير notes. يمكن أن ننفذ المثال الذي طرحناه سابقًا (والذي يحمل قليلًا من التعقيد) مستخدمين await كالتالي: const notes = await Note.find({}) const response = await notes[0].remove() console.log('the first note is removed') لقد غدا الأمر أسهل وأوضح من طريقة سلسلة then بفضل القاعدة الجديدة. لكن عليك الانتباه إلى بعض التفاصيل المهمة عند استخدام async/await. فيجب على await أن تعيد وعدًا إذا أردت تطبيقها على عملية غير متزامنة. وطبعًا لن يكون الأمر صعبًا، طالما أن الدوال النظامية غير المتزامنة التي تستخدم الاستدعاءات يمكنها تدبر أمر الوعود. كما لا يمكن استعمال await في أي مكان ضمن شيفرة JavaScript، بل فقط ضمن دالة غير متزامنة. أي حتى يعمل المثال السابق لا بد من استعمال دوال غير متزامنة. لنركز الآن على السطر الأول من الدالة السهمية: const main = async () => { const notes = await Note.find({}) console.log('operation returned the following notes', notes) const response = await notes[0].remove() console.log('the first note is removed') } main() تصرح الشيفرة بوضوح أن الدالة السهمية المُسندة إلى main غير متزامنة، ثم تُستدعى الدالة بعد ذلك باستخدام ()main. استخدام async/await في الواجهة الخلفية سنغيّر شيفرة الواجهة الخلفية لتستخدم await وasync. يكفي أن نغير معالج المسار في أي دالة إلى دالة غير متزامنة حتى نستخدم التعليمة await، طالما أن كل العمليات غير المتزامنة ستجري داخل دالة. سنغير المسار الذي يستخدم لإحضار الملاحظات جميعها على النحو التالي: notesRouter.get('/', async (request, response) => { const notes = await Note.find({}) response.json(notes) }) يمكنك التحقق من نجاح الأمر باختبار الشيفرة ضمن المتصفح وكذلك بتنفيذ الاختبارات التي كتبناها سابقًا. يمكنك إيجاد شيفرة التطبيق الحالي بأكملها في الفرع part4-3 ضمن المستودع الخاص بالتطبيق على GitHub. اختبارت أكثر وإعادة كتابة الواجهة الخلفية يبقى دائمًا خطر التدهور (regression) قائمًا عند إعادة كتابة الشيفرة. ونقصد بذلك احتمال توقف التطبيق عن تنفيذ الوظيفة المطلوبة. إذُا سنعيد كتابة ما تبقى من عمليات بكتابة اختبار لكل مسار من مسارات الواجهة البرمجية. سنبدأ بعملية إضافة ملاحظة جديدة، حيث سنكتب اختبارًا ينشئ ملاحظة جديدة ويتحقق أن عدد الملاحظات الذي ستعيده الواجهة البرمجية سيزداد، وأن الملاحظة الجديدة موجودة بالفعل ضمن قائمة الملاحظات. test('a valid note can be added', async () => { const newNote = { content: 'async/await simplifies making async calls', important: true, } await api .post('/api/notes') .send(newNote) .expect(200) .expect('Content-Type', /application\/json/) const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) expect(response.body).toHaveLength(initialNotes.length + 1) expect(contents).toContain( 'async/await simplifies making async calls' ) }) سيُنفًّذ الاختبار بنجاح كما خططنا وتوقعنا. لنتكب اختبارًا آخر يتحقق من عدم تخزين أية ملاحظة في قاعدة البيانات ما لم يكن فيها محتوًى. test('note without content is not added', async () => { const newNote = { important: true } await api .post('/api/notes') .send(newNote) .expect(400) const response = await api.get('/api/notes') expect(response.body).toHaveLength(initialNotes.length) }) يتحقق كلا الاختبارين من حالة الملاحظة المخزنة في قاعدة البيانات، وذلك بإحضار كل الملاحظات في التطبيق. const response = await api.get('/api/notes') سننفذ بنفس خطوات التحقق السابقة في الاختبارات القادمة، لذا يفضل نقل شيفرة الاختبارات إلى الدوال المساعدة. ولهذا سنضيف دالة تضم الشيفرة السابقة إلى ملف جديد يدعى test_helper.js ونضعه في المجلد tests: const Note = require('../models/note') const initialNotes = [ { content: 'HTML is easy', date: new Date(), important: false }, { content: 'Browser can execute only Javascript', date: new Date(), important: true } ] const nonExistingId = async () => { const note = new Note({ content: 'willremovethissoon', date: new Date() }) await note.save() await note.remove() return note._id.toString() } const notesInDb = async () => { const notes = await Note.find({}) return notes.map(note => note.toJSON()) } module.exports = { initialNotes, nonExistingId, notesInDb } تعرف الوحدة الدالة notesIdDb التي تتحقق من الملاحظات المخزنة في قاعدة البيانات. كما تضم الوحدة المصفوفة initialNotes التي تخزن الحالة الأساسية لقاعدة البيانات.كما تعرف الوحدة الدالة nonExistingId التي يمكن استخدامها لاحقًا في إنشاء كائن مُعرِّف قاعدة اليانات (ID Object)، وهو كائن لا يتبع لأي ملاحظة مخزنة في القاعدة. سنتمكن الآن من استخدام وحدة مساعدة في الاختبارات التي ستصبح على النحو: const supertest = require('supertest') const mongoose = require('mongoose') const helper = require('./test_helper')const app = require('../app') const api = supertest(app) const Note = require('../models/note') beforeEach(async () => { await Note.deleteMany({}) let noteObject = new Note(helper.initialNotes[0]) await noteObject.save() noteObject = new Note(helper.initialNotes[1]) await noteObject.save() }) test('notes are returned as json', async () => { await api .get('/api/notes') .expect(200) .expect('Content-Type', /application\/json/) }) test('all notes are returned', async () => { const response = await api.get('/api/notes') expect(response.body).toHaveLength(helper.initialNotes.length)}) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) expect(contents).toContain( 'Browser can execute only Javascript' ) }) test('a valid note can be added ', async () => { const newNote = { content: 'async/await simplifies making async calls', important: true, } await api .post('/api/notes') .send(newNote) .expect(200) .expect('Content-Type', /application\/json/) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) const contents = notesAtEnd.map(n => n.content) expect(contents).toContain( 'async/await simplifies making async calls' ) }) test('note without content is not added', async () => { const newNote = { important: true } await api .post('/api/notes') .send(newNote) .expect(400) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length)}) afterAll(() => { mongoose.connection.close() }) ستعمل الشيفرة التي تعتمد على الوعود وستنجح الاختبارات التي سننفذها، وبالتالي أصبحنا مستعدين لاستخدام العبارة async/await. سنغير في الشيفرة المسؤولة عن إنشاء ملاحظة جديدة، وانتبه إلى أن تعريف معالج المسار سيُسبق بالكلمة async: notesRouter.post('/', async (request, response, next) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) const savedNote = await note.save() response.json(savedNote) }) لاتزال هناك هفوة صغيرة في شيفرتنا، فلم نكتب أي شيء للتعامل مع الأخطاء. كيف سنعالج الأمر إذًا؟ معالجة الأخطاء عند استعمال async/await إن حدث خطأ في معالجة الطلب POST، سنواجهة حالة الخطأ المألوفة التالية: أي سينتهي بنا المطاف أمام حالة رفض للوعد لم يهيئ التطبيق لالتقاطه، ولن تكون هناك أية استجابة للطلب. إن الطريقة المفضلة للتعامل مع الاستثناءات عندما نستعمل async/await هي الطريقة القديمة المألوفة try/catch: notesRouter.post('/', async (request, response, next) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) try { const savedNote = await note.save() response.json(savedNote) } catch(exception) { next(exception) }}) تستدعي الكتلة catch ببساطة الدالة next التي تمرر معالجة الطلب إلى الوحدة الوسطية التي تعالج الأخطاء. ستنجح كل الاختبارات أيضًا عند إجراء التعديلات السابقة. سنكتب تاليًا اختبارات لإحضار وحذف ملاحظة واحدة: test('a specific note can be viewed', async () => { const notesAtStart = await helper.notesInDb() const noteToView = notesAtStart[0] const resultNote = await api .get(`/api/notes/${noteToView.id}`) .expect(200) .expect('Content-Type', /application\/json/) const processedNoteToView = JSON.parse(JSON.stringify(noteToView)) expect(resultNote.body).toEqual(processedNoteToView) }) test('a note can be deleted', async () => { const notesAtStart = await helper.notesInDb() const noteToDelete = notesAtStart[0] await api .delete(`/api/notes/${noteToDelete.id}`) .expect(204) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength( helper.initialNotes.length - 1 ) const contents = notesAtEnd.map(r => r.content) expect(contents).not.toContain(noteToDelete.content) }) نحصل في الاختبار الأول على كائن الملاحظة بعد أن تخضع الاستجابة إلى عملية تقسيم ومعالجة خاصة ببيانات JSON. ستحول عملية المعالجة قيمة الخاصية date لكائن الملاحظة من كائن Date إلى نص. ولهذا، لن نتمكن مباشرة من الموازنة بين resultNote.body وnoteToView. بدلًا من ذلك علينا أن نُخضع noteToView إلى نفس عملية التقسيم والمعالجة كما يفعل الخادم مع كائن الملاحظة. لكلا الاختبارين الهيكل ذاته. ففي مرحلة إعادة الضبط (التصفير) سيحضر الاختبارين الملاحظة من قاعدة البيانات. يستدعي الاختباران بعد ذلك العملية الرئيسية التي يجري اختبارها. وأخيرًا يتحقق الاختباران من أن نتيجة العملية كما هو متوقع أو لا. سينجح الاختباران وبالتالي سنتمكن من إعادة كتابة شيفرة المسارات المختبرة بأسلوب: notesRouter.get('/:id', async (request, response, next) => { try{ const note = await Note.findById(request.params.id) if (note) { response.json(note) } else { response.status(404).end() } } catch(exception) { next(exception) } }) notesRouter.delete('/:id', async (request, response, next) => { try { await Note.findByIdAndRemove(request.params.id) response.status(204).end() } catch (exception) { next(exception) } }) يمكنك إيجاد شيفرة التطبيق الحالي بأكملها في الفرع part4-4 ضمن المستودع الخاص بالتطبيق على GitHub. الاستغناء عن الكتلة try/catch سترتب العبارة async/await الشيفرة قليلًا، لكن الثمن هو الحاجة إلى استخدام الكتلة catch لالتقاط الاستثناءات. تشترك معالجات المسار بالبنية نفسها: try { // do the async operations here } catch(exception) { next(exception) } قد يتسائل أحدنا عن إمكانية إعادة كتابة الشيفرة بالشكل الذي نستغني فيه عن كتلة catch. والحل بالطبع موجود في المكتبة express-async-errors. لثبتها إذًا. npm install express-async-errors --save إن استخدام المكتبة سهل للغاية. حيث ستدرج المكتبة ضمن الملف app.js في المجلد src كالتالي: const config = require('./utils/config') const express = require('express') require('express-async-errors') const app = express() const cors = require('cors') const notesRouter = require('./controllers/notes') const middleware = require('./utils/middleware') const logger = require('./utils/logger') const mongoose = require('mongoose') // ... module.exports = app ستلغي هذه المكتبة كتل try/catch كليًا. فلو أخذنا على سبيل المثال المسار الذي يحذف ملاحظة: notesRouter.delete('/:id', async (request, response, next) => { try { await Note.findByIdAndRemove(request.params.id) response.status(204).end() } catch (exception) { next(exception) } }) سيتحول إلى الشكل: notesRouter.delete('/:id', async (request, response) => { await Note.findByIdAndRemove(request.params.id) response.status(204).end() }) لم يعد علينا استدعاء next(exception)‎ بعد الآن، حيث تعالج المكتبة كل ما هو مطلوب في الخفاء. وإن حدث استثناء في مسار async سيُمرر تلقائيًا إلى الأداة الوسطية لمعالجة الأخطاء. ستصبح المسارات الأخرى على النحو: notesRouter.post('/', async (request, response) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) const savedNote = await note.save() response.json(savedNote) }) notesRouter.get('/:id', async (request, response) => { const note = await Note.findById(request.params.id) if (note) { response.json(note) } else { response.status(404).end() } }) ستجد شيفرة التطبيق في الفرع part4-5 ضمن المستودع المخصص على GitHub. تحسين الدالة beforeEach لنعد إلى كتابة الاختبارات ولنحاول أن نلقي نظرةً أقرب إلى الدالة beforeEach التي تضبط الاختبارات: beforeEach(async () => { await Note.deleteMany({}) let noteObject = new Note(helper.initialNotes[0]) await noteObject.save() noteObject = new Note(helper.initialNotes[1]) await noteObject.save() }) تحفظ الدالة أول ملاحظتين من المصفوفة helper.initialNotes ضمن قاعدة البيانات عبر عمليتين منفصلتين. لابأس بذلك، لكن هناك طريقة أفضل لتخزين عدة كائنات في قاعدة البيانات: beforeEach(async () => { await Note.deleteMany({}) console.log('cleared') helper.initialNotes.forEach(async (note) => { let noteObject = new Note(note) await noteObject.save() console.log('saved') }) console.log('done') }) test('notes are returned as json', async () => { console.log('entered test') // ... } لقد خزَّنا الملاحظات الموجودة في المصفوفة ضمن قاعدة البيانات باستعمال حلقة forEach. لن يعمل التطبيق كما أردنا تمامًا، لذلك كان علينا أن نطبع بعض السجلات على الطرفية لتساعدنا على فهم المشكلة. فأظهرت الطرفية ما يلي: cleared done entered test saved saved على الرغم من استخدام async/await، لم يعمل الحل المقترح بالطريقة المتوقعة. بل بدأ الاختبار قبل أن يُعاد ضبط قاعدة البيانات. تكمن المشكلة في أن كل تكرار للحلقة forEach يولد عملية غير متزامنة خاصة به، ولن تنتظر الحلقة forEach كل تكرار لكي يُنهي تنفيذ عمليته. بمعنى آخر، لم توضع التعليمات await الموجودة داخل حلقة forEach في الدالة beforeEach بل في دوال منفصلة لن تنتظرها beforeEach حتى يكتمل تنفيذها. طالما ستُنفَّذ الاختبارات مباشرة بعد الانتهاء من تنفيذ beforeEach، إذًا ستُنفذ قبل إعادة ضبط قاعدة البيانات. إحدى الطرق المتبعة في معالجة الموضوع هي انتظار العمليات غير المتزامنة حتى تنتهي باستعمال التابع Promise.all: beforeEach(async () => { await Note.deleteMany({}) const noteObjects = helper.initialNotes .map(note => new Note(note)) const promiseArray = noteObjects.map(note => note.save()) await Promise.all(promiseArray) }) إن الأسلوب المتبع متقدم على الرغم من مظهره المختصر. نلاحظ كيف يُسند المتغير noteObjects إلى مصفوفة من كائنات Mongoose التي أنشئت باستخدام الدالة البانية Note من أجل كل ملاحظة موجودة في المصفوفة helper.initialNotes. يُنشئ السطر التالي من الشيفرة مصفوفة جديدة تتضمن الوعود التي أُنشئت بدورها باستدعاء التابع save من أجل كل عنصر من عناصر المصفوفة noteOjects. بمعنًى آخر، تمثل المصفوفة مصفوفةً لتخزين كل العناصر ضمن قاعدة البيانات. يستخدم التابع Promise.all لتحويل مصفوفة من الوعود إلى وعد وحيد يتحقق بمجرد تحقق كل الوعود في المصفوفة التي تمرر إلى التابع. ينتظر الأمر (await Promise.all(promiseArray في السطر الأخير من الشيفرة كل وعد سيخزن ملاحظة في قاعدة البيانات حتى يتحقق، ويعني هذا أن قاعدة البيانات قد أعيد ضبطها. ينفذ التابع Promise.all الوعود التي تمرر إليه بالتوازي. وبالتالي إن أردنا تنفيذ الوعود بترتيب معين ستظهر المشاكل. يمكن أن تنفذ العمليات في حالات كهذه ضمن كتلة for…of التي تضمن ترتيبًا محددًا للتنفيذ: beforeEach(async () => { await Note.deleteMany({}) for (let note of helper.initialNotes) { let noteObject = new Note(note) await noteObject.save() } }) قد تقود الطبيعة غير المتزامنة للغة JavaScript إلى سلوك مفاجئ، ولهذا يجب الانتباه والحرص عند استخدام async/await. وعلى الرغم من أن استعمالها يسهل التعامل مع الوعود، لكن يجدر بنا فهم الآلية التي تعمل بها الوعود بشكل جيد. التمارين 4.8 - 4.12 ملاحظة: نستخدم في التمارين تابع المطابقة toContain في أماكن عدة لنتأكد من وجود عنصر معين في مصفوفة. يستخدم التابع السابق العامل (===) في الموازنة والمطابقة بين العناصر، وهذا غير ملائم في العديد من الحالات بما فيها الكشف عن تطابق كائنين. بينما يعتبر تابع المطابقة toContainEqual ملائمًا في معظم الحالات للمقارنة بين الكائنات ضمن المصفوفات. لا يتحقق نموذج الحل من الكائنات في المصفوفات باستخدام توابع المطابقة، لذا فاستخدام هذا الأسلوب ليس ملزمًا في حل التمارين. تحذير: إن وجدت نفسك قد استخدمت async/await مع then في نفس الشيفرة فهذا دليل على ارتكابك خطأً ما. استخدم أحد الأسلوبين وليس كلاهما. 4.8 اختبارات على قائمة المدونات: الخطوة 1 استخدم الحزمة supertest لكتابة اختبار يرسل طلب HTTP GET إلى العنوان api/blogs/. وتحقق أن تطبيق قائمة المدونات سيعيد العدد الصحيح من منشورات المدونات بصيغة JSON. حالما ينتهي الاختبار، أعد كتابة معالج المسار مستخدمًا async/await بدلًا من الوعود. لاحظ أنه عليك إجراء نفس التغيرات التي أجريناها سابقًا على شيفرتك كتحديد بيئة الاختبار، لتتمكن من كتابة اختبارات يتعامل كل منها مع قاعدة بيانات خاصة به. ملاحظة: قد تواجه التحذير التالي عندما تنفذ الاختبار: إن حدث ذلك، اتبع التعليمات وانشئ ملفًا جديدًا باسم jest.config.js عند جذر المشروع بحيث يحتوي الشيفرة التالية: module.exports = { testEnvironment: 'node' } ملاحظة: يفضل عند كتابة الاختبارات أن لا تنفذها دفعة واحدة. اختبر فقط ذلك الذي تعمل عليه. 4.9 اختبارات على قائمة المدونات: الخطوة 2 * اكتب اختبارًا يتحقق من أن اسم المعرِّف الفريد لكل منشور في المدونة هو id. ستسمي قاعدة البيانات هذه الخاصية بالاسم id_ بشكل افتراضي. يمكن تحقيق ذلك بسهولة عند استخدام تابع المطابقة toBeDefined العائد للمكتبة. أجري التعديلات المناسبة على الشيفرة حتي يُنفذ الاختبار بنجاح. قد يكون التابع toJSON الذي قدمناه في القسم3، المكان الأنسب لتعريف المعامل id. 4.10 اختبارات على قائمة المدونات: الخطوة 3 اكتب اختبارًا تتحقق فيه أن طلب HTTP POST إلى العنوان api/blogs/، سينشئ بنجاح منشورًا جديدًا. تحقق على الأقل أن العدد الكلي للمنشورات في المدونة قد ازداد بمقدار 1. كما يمكنك التحقق أيضًا، أن محتوى المنشور قد حُفظ بالشكل الصحيح في قاعدة البيانات. حالما ينجح الاختبار، أعد كتابة الشيفرة مستخدمًا async/await بدلًا من الوعود. 4.11 اختبارت على قائمة المدونات: الخطوة 4 * اكتب اختبارًا تتحقق فيه من وجود الخاصية likes في الطلب، واجلعها 0 إن لم تكن موجودة. لا تختبر بقية خصائص المدونات التي أنشئت. عدل في الشيفرة حتى تنجز الاختبار بنجاح. 4.12 اختبارات على قائمة المدونات: الخطوة 5 * اكتب اختبارًا متعلقًا بإنشاء مدونة جديدة من خلال الوجهة api/blogs/. حيث يتحقق من وجود الخاصيتين title وurl ضمن بيانات الطلب. فإن لم يعثر عليهما ستستجيب الواجهة الخلفية برمز الحالة 400 (طلب سيء). إعادة كتابة الاختبارت لم تكتمل تغطيتنا بعد لموضوع الاختبارات. فلم نختبر طلبات مثل GET /api/notes/:id وDELETE /api/notes/:id عندما ترسل بمعرف غير صالح. كما تحتاج عملية تنظيم وتجميع الاختبارات إلى بعض التحسينات، حيث كتبت جميعها في المستوى الأعلى نفسه ضمن ملف الاختبار. قد تتحسن قابلية قراءة الاختبارات عندما نجمع الاختبارات المترابطة باستخدام كتل الوصف (descripe blocks): const supertest = require('supertest') const mongoose = require('mongoose') const helper = require('./test_helper') const app = require('../app') const api = supertest(app) const Note = require('../models/note') beforeEach(async () => { await Note.deleteMany({}) const noteObjects = helper.initialNotes .map(note => new Note(note)) const promiseArray = noteObjects.map(note => note.save()) await Promise.all(promiseArray) }) describe('when there is initially some notes saved', () => { test('notes are returned as json', async () => { await api .get('/api/notes') .expect(200) .expect('Content-Type', /application\/json/) }) test('all notes are returned', async () => { const response = await api.get('/api/notes') expect(response.body).toHaveLength(helper.initialNotes.length) }) test('a specific note is within the returned notes', async () => { const response = await api.get('/api/notes') const contents = response.body.map(r => r.content) expect(contents).toContain( 'Browser can execute only Javascript' ) }) }) describe('viewing a specific note', () => { test('succeeds with a valid id', async () => { const notesAtStart = await helper.notesInDb() const noteToView = notesAtStart[0] const resultNote = await api .get(`/api/notes/${noteToView.id}`) .expect(200) .expect('Content-Type', /application\/json/) const processedNoteToView = JSON.parse(JSON.stringify(noteToView)) expect(resultNote.body).toEqual(processedNoteToView) }) test('fails with statuscode 404 if note does not exist', async () => { const validNonexistingId = await helper.nonExistingId() console.log(validNonexistingId) await api .get(`/api/notes/${validNonexistingId}`) .expect(404) }) test('fails with statuscode 400 id is invalid', async () => { const invalidId = '5a3d5da59070081a82a3445' await api .get(`/api/notes/${invalidId}`) .expect(400) }) }) describe('addition of a new note', () => { test('succeeds with valid data', async () => { const newNote = { content: 'async/await simplifies making async calls', important: true, } await api .post('/api/notes') .send(newNote) .expect(200) .expect('Content-Type', /application\/json/) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length + 1) const contents = notesAtEnd.map(n => n.content) expect(contents).toContain( 'async/await simplifies making async calls' ) }) test('fails with status code 400 if data invaild', async () => { const newNote = { important: true } await api .post('/api/notes') .send(newNote) .expect(400) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength(helper.initialNotes.length) }) }) describe('deletion of a note', () => { test('succeeds with status code 204 if id is valid', async () => { const notesAtStart = await helper.notesInDb() const noteToDelete = notesAtStart[0] await api .delete(`/api/notes/${noteToDelete.id}`) .expect(204) const notesAtEnd = await helper.notesInDb() expect(notesAtEnd).toHaveLength( helper.initialNotes.length - 1 ) const contents = notesAtEnd.map(r => r.content) expect(contents).not.toContain(noteToDelete.content) }) }) afterAll(() => { mongoose.connection.close() }) جُمّعت مخرجات الاختبارات وفقًا لكتل الوصف: هنالك متسع للتحسينات أيضًا، لكن لابد من المضي قُدمًا. إن هذه الطريقة في الاختبار التي تعتمد على إجراء طلبات HTTP وتحري قواعد البيانات باستخدام Mongoose، ليست الطريقة الوحيدة وليست الأفضل في إنجاز اختبارات التكامل على مستوى الواجهة البرمجية لتطبيقات الخوادم. في الواقع لا توجد طريقة مثلى معتمدة عالميًا لكتابة الاختبارات، لأنها تعتمد على طبيعة التطبيق المختبر والموارد المتاحة. يمكنك إيجاد شيفرة التطبيق الحالي بأكملها في الفرع part4-6 ضمن المستودع الخاص بالتطبيق على GitHub. التمارين 4.13 - 4.14 4.13 التوسع في قائمة المدونات: خطوة 1 أضف إلى التطبيق وظيفة حذف منشور واحد. استخدم العبارة async/await. إجعل عملك متوافقًا مع REST عندما تعرّف الواجهة البرمجية لطلبات HTTP. أضف، إن أردت، أية اختبارات للتأكد من عمل الوظيفة، أو استخدم Postman أو أية أداة أخرى. 4.14 التوسع في قائمة المدونات: خطوة 2 أضف إلى التطبيق وظيفة تعديل منشور واحد. استخدم العبارة async/await. أهم ما يتطلبه التطبيق هو تحديث عدد الإعجابات للمنشور. يمكنك إضافة هذه الوظيفة كما حدثنا الملاحظات في القسم 3. أضف، إن أردت، أية اختبارات للتأكد من عمل الوظيفة، أو استخدم Postman أو أية أداة أخرى. ترجمة -وبتصرف- للفصل testing the backend من سلسلة Deep Dive Into Modern Web Development
  6. سنعود للعمل على نسخة الواجهة الخلفية لتطبيق الملاحظات الذي بدأناه في القسم 3 من سلسلة full_stack_101. الهيكل العام للمشروع قبل أن ننتقل لموضوع الاختبارات، سنجري بعض التعديلات على هيكلية مشروعنا لنواكب أفضل المعايير في كتابة تطبيقات Node.js. سينتج عن هذه التعديلات الهيكل التالي لمجلد المشروع: ├── index.js ├── app.js ├── build │ └── ... ├── controllers │ └── notes.js ├── models │ └── note.js ├── package-lock.json ├── package.json ├── utils │ ├── config.js │ ├── logger.js │ └── middleware.js استخدمنا حتى هذه اللحظة الأمرين console.log وconsole.error في طباعة بيانات الشيفرة التي تهمنا على الطرفية. لكن من الأفضل فصل الشيفرات التي تتعلق بأمور الطباعة في وحدة مستقلة خاصة بها سنسميها logger.js، وسنضعها في المجلد utils: const info = (...params) => { console.log(...params) } const error = (...params) => { console.error(...params) } module.exports = { info, error } كما نلاحظ، تحتوي الوحدة على دالتين الأولى info تتولى أمر طباعة الرسائل العادية، والأخرى error تتولى طباعة رسائل الخطأ. لوضع شيفرة الطباعة في وحدة منفصلة مزايا عدة، فلو أردنا مثلًا طباعة السجلات أو الرسائل إلى ملف أو إلى خدمة خارجية لإدارة السجلات مثل graylog أو papertrail، لن يكون علينا سوى تعديل الشيفرة في ملف واحد. سيصبح ملف تشغيل التطبيق index.js على الشكل التالي: const app = require('./app') // the actual Express application const http = require('http') const config = require('./utils/config') const logger = require('./utils/logger') const server = http.createServer(app) server.listen(config.PORT, () => { logger.info(`Server running on port ${config.PORT}`) }) تنحصر وظيفة الملف index.js الآن، بإدراج التطبيق الفعلي من الملف app.js وتشغيله. ستقوم الدالة info بطباعة عبارة على الطرفية تدل على أن التطبيق يعمل. كذلك سننقل الشيفرة التي تتعامل مع متغيرات البيئة إلى وحدة مستقلة اسمها config.js ونضعها في المجلد utils: require('dotenv').config() const PORT = process.env.PORT const MONGODB_URI = process.env.MONGODB_URI module.exports = { MONGODB_URI, PORT } يمكن لأي جزء من التطبيق الوصول إلى متغيرات البيئة بإدراج الوحدة السابقة: const config = require('./utils/config') logger.info(`Server running on port ${config.PORT}`) نقلنا كذلك الشيفرة التي تتعامل مع المسارات إلى وحدة خاصة. وطالما أن مصطلح "متحكمات" يستخدم للإشارة إلى هذه المسارات، سننشئ مجلدًا باسم controller وسنضع فيه الوحدة notes.js التي ستحتوي شيفرة المسارات التي تتعلق بالملاحظات. سيكون محتوى هذه الوحدة كالتالي: const notesRouter = require('express').Router() const Note = require('../models/note') notesRouter.get('/', (request, response) => { Note.find({}).then(notes => { response.json(notes) }) }) notesRouter.get('/:id', (request, response, next) => { Note.findById(request.params.id) .then(note => { if (note) { response.json(note) } else { response.status(404).end() } }) .catch(error => next(error)) }) notesRouter.post('/', (request, response, next) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date() }) note.save() .then(savedNote => { response.json(savedNote) }) .catch(error => next(error)) }) notesRouter.delete('/:id', (request, response, next) => { Note.findByIdAndRemove(request.params.id) .then(() => { response.status(204).end() }) .catch(error => next(error)) }) notesRouter.put('/:id', (request, response, next) => { const body = request.body const note = { content: body.content, important: body.important, } Note.findByIdAndUpdate(request.params.id, note, { new: true }) .then(updatedNote => { response.json(updatedNote) }) .catch(error => next(error)) }) module.exports = notesRouter هذه الشيفرة هي محتوى الملف index.js القديم إجمالًا ما عدا بعض التغييرات المهمة. حيث أنشأنا كائن متحكم بالمسار في بداية الملف: const notesRouter = require('express').Router() //... module.exports = notesRouter لقد أدرجت الوحدة متحكمًا بالمسار ليكون متاحًا للاستخدام ضمنها. وهكذا تُستدعى المسارات التي نحتاجها كخصائص لكائن التحكم بالمسار بشكل مماثل لما يفعله الكائن الذي يتحكم بكامل التطبيق. ويجب الانتباه إلى الطريقة التي اختصرنا فيها عنوان المسار. فلقد عرفنا في النسخة السابقة مسارًا على الشكل: app.delete('/api/notes/:id', (request, response) => { بينما أصبح التعريف في النسخة الجديدة كالتالي: notesRouter.delete('/:id', (request, response) => { ما هو إذًا بالتحديد كائن التحكم بالمسار؟ يزودنا توثيق المكتبة express بالتعريف التالي: يمثل المتحكم بالمسار في واقع الأمر أداة وسطية يمكن استخدامها لتعريف العمليات المتعلقة بالمسارات في مكان واحد، وهو عادة وحدة منفصلة خاصة. يستخدم الملف app.js -الذي ينشئ التطبيق الوظيفي المطلوب- هذا المتحكم بالطريقة التالية: const notesRouter = require('./controllers/notes') app.use('/api/notes', notesRouter) يُستخدم المتحكم بالمسار الذي عرفناه في الشيفرة السابقة، إذا كان الطلب موجهًا إلى الموقع الذي يبدأ عنوانه بالصيغة "/api/notes". ولهذا على المتحكم notesRouter أن يعرّف المسارت بعناوين نسبية فقط على شكل مسار فارغ "/" أو أن يذكر المعامل فقط "id:/". سيبدو الملف app.js بعد إجراء التعديلات على النحو التالي: const config = require('./utils/config') const express = require('express') const app = express() const cors = require('cors') const notesRouter = require('./controllers/notes') const middleware = require('./utils/middleware') const logger = require('./utils/logger') const mongoose = require('mongoose') logger.info('connecting to', config.MONGODB_URI) mongoose.connect(config.MONGODB_URI, { useNewUrlParser: true, useUnifiedTopology: true }) .then(() => { logger.info('connected to MongoDB') }) .catch((error) => { logger.error('error connection to MongoDB:', error.message) }) app.use(cors()) app.use(express.static('build')) app.use(express.json()) app.use(middleware.requestLogger) app.use('/api/notes', notesRouter) app.use(middleware.unknownEndpoint) app.use(middleware.errorHandler) module.exports = app ستجد أن التطبيق قد استخدم العديد من الأدوات الوسطية ومنها الأداة notesRouter التي ارتبطت بالمسار "api/notes/". نقلنا أيضًا الأداة الوسطية الخاصة التي أنشأناها في القسم السابق باسم middleware.js إلى المجلد utils بعد إجراء القليل من التعديلات: const logger = require('./logger') const requestLogger = (request, response, next) => { logger.info('Method:', request.method) logger.info('Path: ', request.path) logger.info('Body: ', request.body) logger.info('---') next() } const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } const errorHandler = (error, request, response, next) => { logger.error(error.message) if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } else if (error.name === 'ValidationError') { return response.status(400).json({ error: error.message }) } next(error) } module.exports = { requestLogger, unknownEndpoint, errorHandler } أُوكلت مهمة الاتصال مع قاعدة البيانات إلى الوحدة الرئيسية app.js. بينما تتحمل الوحدة note.js الموجودة في المجلد models مسؤولية تعريف تخطيطات Mongoose للملاحظات. const mongoose = require('mongoose') mongoose.set('useFindAndModify', false) const noteSchema = new mongoose.Schema({ content: { type: String, required: true, minlength: 5 }, date: { type: Date, required: true, }, important: Boolean, }) noteSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v } }) module.exports = mongoose.model('Note', noteSchema) باختصار، سيبدو هيكل المشروع بشكله الجديد كالتالي: ├── index.js ├── app.js ├── build │ └── ... ├── controllers │ └── notes.js ├── models │ └── note.js ├── package-lock.json ├── package.json ├── utils │ ├── config.js │ ├── logger.js │ └── middleware.js لا نهتم كثيرًا في التطبيقات الصغيرة بهيكلية مشروع التطبيق، لكن بمجرد أن يبدأ التطبيق بالنمو، لابد من تأسيس هيكل بشكل أو بآخر، وتوزيع العمل على وحدات منفصلة. بهذا يغدو تطوير التطبيق أسهل. لا توجد معايير أو تفاهمات خاصة بين المطورين على تسمية مجلدات المشروع عند بناء تطبيقات express. بالمقابل تحتاج بعض المكتبات مثل Ruby on Rails هيكلًا خاصًا. يقدم الهيكل الذي بنيناه لتطبيقنا السابق، واحدًا من أفضل الأمثلة لبناء التطبيقات التي قد تصادفها على الانترنت. ستجد التطبيق بشكله الكامل في الفرع part4-1 على GitHub. إن نسخت المشروع لتحتفظ به لنفسك، نفذ الأمر npm install، قبل أن تشغل التطبيق بالأمر npm run. التمارين 4.1 - 4.2 سنبني خلال تمارين هذا القسم تطبيقًا لإنشاء قائمة مدونات تسمح للمستخدم أن يحفظ معلومات عن مدونات وجدها مهمة خلال تصفحه للإنترنت. سيقوم التطبيق بحفظ اسم المؤلف وعنوان المدونة وعنوان موقعها وعدد التقييمات الإيجابية للمدونة من قبل مستخدمي التطبيق. 4.1 قائمة بالمدونات: الخطوة 1 لنتخيل أنك تلقيت بريدًا إلكترونيًا له جسم التطبيق التالي: const http = require('http') const express = require('express') const app = express() const cors = require('cors') const mongoose = require('mongoose') const blogSchema = new mongoose.Schema({ title: String, author: String, url: String, likes: Number }) const Blog = mongoose.model('Blog', blogSchema) const mongoUrl = 'mongodb://localhost/bloglist' mongoose.connect(mongoUrl, { useNewUrlParser: true, useUnifiedTopology: true }) app.use(cors()) app.use(express.json()) app.get('/api/blogs', (request, response) => { Blog .find({}) .then(blogs => { response.json(blogs) }) }) app.post('/api/blogs', (request, response) => { const blog = new Blog(request.body) blog .save() .then(result => { response.status(201).json(result) }) }) const PORT = 3003 app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) حول التطبيق إلى مشروع npm قابل للعمل. هيئ التطبيق ليُنفّذ بمساعدة nodemon. يمكنك أن تنشئ قاعدة بيانات جديدة على MongoDB Atlas، أو استخدم قاعدة البيانات التي أنشأناها في تمرينات القسم السابق. تأكد من إمكانية إضافة مدونات إلى القائمة باستخدام Postman أو VS Code REST client وتأكد كذلك أن التطبيق سيعيد المدونات التي أضيفت إلى الجهة التي طلبتها. 4.2 قائمة بالمدونات: الخطوة 2 قسم التطبيق إلى وحدات منفصلة كما فعلنا سابقًا في هذا القسم. ملاحظة: نفذ التقسيم بخطوات صغيرة خطوة تلو الأخرى، وتأكد أن التطبيق سيعمل بعد كل تغيير. إن حاولت أن تختصر الطريق وتقوم بعدة خطوات في آن واحد سيفشل شيء ما في تطبيقك فهذا السلوك خاضع لقانون مورفي. وستستغرق وقتًا في إيجاد حل لمشكلتك أكثر من الوقت الذي ستستغرقه في تنفيذ التقسيم على خطوات منهجية وصغيرة. حاول أن توضح ما فعلت من خلال التعليقات وخاصة عندما تنجح الخطوة التي نفذتها، سيساعدك هذا في التراجع إلى النقطة التي عَمل فيها التطبيق بشكل جيد. اختبار تطبيقات Node لقد أهملنا كليًا ناحية مهمة من نواحي تطوير البرمجيات وهي الاختبارات المؤتمتة. سنبدأ رحلتنا في مجال الاختبارات، باختبارات الأجزاء unit test. إن منطق التطبيق الذي نعمل عليه بسيط فلن يكون موضوع اختبارات الأجزاء مهمًا لهذه الدرجة. مع ذلك لننشئ ملفًا جديدًا باسم for_testing.js في المجلد utils ولنعرف فيه دالتين نستخدمهما لاختبار بعض نواحي كتابة الشيفرة: const palindrome = (string) => { return string .split('') .reverse() .join('') } const average = (array) => { const reducer = (sum, item) => { return sum + item } return array.reduce(reducer, 0) / array.length } module.exports = { palindrome, average, } هناك الكثير من المكتبات أو أدوات الاختبار التي تُستخدم مع JavaScript. سنستخدم في منهاجنا المكتبة Jest التي طورت واستخدمت من قبل Facebook. وتشابه هذه المكتبة مكتبة Mocha الزعيمة السابقة لأدوات اختبار JavaScript. ومن البدائل المطروحة أيضًا مكتبة ava التي اكتسبت شعبية كبيرة في بعض النطاقات. سيكون اختيار Jest أمرًا طبيعيًا في منهاجنا، فهي تعمل بشكل جيد عند إجراء الاختبارات على الواجهة الخلفية، وتقدم أداء رائعًا عند إجراء اختبارات على تطبيقات React. طالما أن الاختبارات ستنفذ فقط في مرحلة التطوير، سنثبت Jest كاعتمادية للتطوير: npm install --save-dev jest لنعرف سكربت npm باسم test لتنفيذ الاختبارات باستخدام Jest وإنشاء تقرير عن التنفيذ باستخدام الأسلوب verbose: { //... "scripts": { "start": "node index.js", "dev": "nodemon index.js", "build:ui": "rm -rf build && cd ../../../2/luento/notes && npm run build && cp -r build ../../../3/luento/notes-backend", "deploy": "git push heroku master", "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && git push && npm run deploy", "logs:prod": "heroku logs --tail", "lint": "eslint .", "test": "jest --verbose" }, //... } تحتاج المكتبة Jest سكربت لنحدد فيه أن بيئة التنفيذ هي Node. ويتم ذلك بإضافة السطر التالي في نهاية الملف package.json: { //... "jest": { "testEnvironment": "node" } } أو بدلًا عن ذلك، تبحث Jest عن ملف تهيئة باسم jest.config.js، حيث تُعرّف داخله بيئة التنفيذ كالتالي: module.exports = { testEnvironment: 'node', }; سننشئ مجلدًا مستقلًا للاختبارات يدعى tests ونضع فيه الملف palindrome.test.js الذي يحتوي الشيفرة التالية: const palindrome = require('../utils/for_testing').palindrome test('palindrome of a', () => { const result = palindrome('a') expect(result).toBe('a') }) test('palindrome of react', () => { const result = palindrome('react') expect(result).toBe('tcaer') }) test('palindrome of releveler', () => { const result = palindrome('releveler') expect(result).toBe('releveler') }) ستعترض قواعد تهيئة المدقق ESLint الذي أضفناها إلى المشروع في الفصل السابق على الأمرين test و expect في ملف الاختبار، ذلك أن قواعد التهيئة لا تسمح بوجود متغيرات شاملة Globals. لنتخلص من ذلك بإضافة القيمة jest": true" إلى الخاصية env في الملف eslintrc.js: module.exports = { "env": { "commonjs": true "es6": true, "node": true, "jest": true, }, "extends": "eslint:recommended", "rules": { // ... }, }; يدرج الملف الاختبار في السطر الأول الدالة التي سنختبرها، ويسندها إلى متغير يدعى palindrome: const palindrome = require('../utils/for_testing').palindrome تُعرّف الحالات المختبرة بشكل فردي باستخدام الدالة test. تقبل الدالة معاملين، الأول سلسلة نصية تصف الاختبار والثاني دالة تُعرِّف طريقة تنفيذ اختبار الحالة. تبدو طريقة اختبار الحالة الثانية في شيفرة ملف الاختبار palindrome.js كالتالي: () => { const result = palindrome('react') expect(result).toBe('tcaer') } ننفذ أولًا الشيفرة التي سنختبرها، حيث نولد سلسلة معكوسة للنص "react". ثم نتحقق من النتيجة باستخدام الدالة expect. تغلف الدالة expect القيمة الناتجة على شكل كائن يقبل الكثير من توابع المطابقة التي يمكن استخدامها للتحقق من صحة النتيجة. يمكن استخدام تابع المطابقة toBe كون عملية المقارنة في حالتنا بين سلسلتين نصيتين. كما هو متوقع ستنجح جميع الاختبارات: تتوقع المكتبة Jest أن تحتوي أسماء ملفات الاختبارات العبارة "test." وسنلتزم في منهاجنا بهذا التوجيه وسنجعل لاحقة كل ملفات الاختبار على الشكل "test.js." تتمتع Jest بإظهارها رسائل خطأ ممتازة، سنجعل الاختبار يفشل لعرض ذلك: test('palindrom of react', () => { const result = palindrome('react') expect(result).toBe('tkaer') }) عند تنفيذ الاختبار السابق ستظهر رسالة الخطأ التالية: لنضف الآن عدة اختبارات إلى الدالة average في الملف average.test.js الموجود في المجلد tests. const average = require('../utils/for_testing').average describe('average', () => { test('of one value is the value itself', () => { expect(average([1])).toBe(1) }) test('of many is calculated right', () => { expect(average([1, 2, 3, 4, 5, 6])).toBe(3.5) }) test('of empty array is zero', () => { expect(average([])).toBe(0) }) }) سنلاحظ أن الدالة لن تعمل بالشكل الصحيح مع المصفوفة الفارغة، لأن ناتج القسمة على 0 في JavaScript سيعطي القيمة NaN. تصحيح المشكلة السابقة أمر بسيط جدًا: const average = array => { const reducer = (sum, item) => { return sum + item } return array.length === 0 ? 0 : array.reduce(reducer, 0) / array.length } ستعيد الدالة القيمة 0 إذا كان طول المصفوفة 0. ونستخدم في بقية الحالات التابع reduce لحساب المتوسط. يجب الانتباه إلى عدة أمور في الاختبارات التي أجريناها. فلقد أسمينا الكتلة النصية التي تصف الاختبار "average": describe('average', () => { // tests }) تستخدم الكتل التي تصف الاختبارات في تجميع الاختبارات ضمن مجموعات. كما تسمى نتيجة الاختبار في Jest باسم الكتلة أيضًا. وتبرز أهمية الكتل describe عندما نحاول تشغيل بعض الإعدادات المشتركة أو إنهاء العمليات المتعلقة بمجموعة من الاختبارات. وأخيرًا يجدر الانتباه إلى الطريقة المختصرة التي كتبت فيها شيفرة الاختبارات، بحيث لم نسند القيمة الناتجة عن تنفيذ الدالة إلى أي متغير: test('of empty array is zero', () => { expect(average([])).toBe(0) }) التمارين 4.3 - 4.7 لننشئ مجموعة من الدوال المساعدة التي ستُخصص للتعامل مع قائمة المدونات. ضع هذه الدوال في ملف يدعى list_helper.js ضمن مجلد اسمه utils. اكتب الاختبارات في ملف يحمل اسمًا مناسبًا وضعه في المجلد tests. 4.3 الدوال المساعدة واختبارات الأجزاء: الخطوة 1 عرّف في البداية دالةً باسم dummy تستقبل مصفوفة من منشورات مدونة كمعامل وتعيد دائمًا القيمة 1. سيبدو محتوى الملف list_helper.js حتى هذه اللحظة كالتالي: const dummy = (blogs) => { // ... } module.exports = { dummy } تحقق أن معلومات التهيئة لاختبارك ستعمل مع الاختبار التالي: const listHelper = require('../utils/list_helper') test('dummy returns one', () => { const blogs = [] const result = listHelper.dummy(blogs) expect(result).toBe(1) }) 4.4 الدوال المساعدة واختبارات الأجزاء: الخطوة 2 عرف دالةً جديدةً تدعى totalLikes تستقبل قائمة بمنشورات مدونة كمعامل وتعيد مجموع الإعجابات في كل منشورات هذه المدونة. اكتب اختبارًا مناسبًا للدالة. يفضل أن تضع الاختبار ضمن كتلة describe، كي تُجمّع التقارير المتولدة عن الاختبارات بشكل واضح. يمكن أن تُعرّف عناصر دخل الدالة على النحو التالي: describe('total likes', () => { const listWithOneBlog = [ { _id: '5a422aa71b54a676234d17f8', title: 'Go To Statement Considered Harmful', author: 'Edsger W. Dijkstra', url: 'http://www.u.arizona.edu/~rubinson/copyright_violations/Go_To_Considered_Harmful.html', likes: 5, __v: 0 } ] test('when list has only one blog, equals the likes of that', () => { const result = listHelper.totalLikes(listWithOneBlog) expect(result).toBe(5) }) }) إن وجدت أن إدخال قائمة المدونات يتطلب الكثير من الجهد، استخدم القائمة الجاهزة التي وضعناها على GitHub. ستواجهة المشاكل عندما ستكتب الاختبارات، لذلك تذكر استخدام وسائل التنقيح التي تعلمناها في القسم 3. ويمكنك دائمًا الطباعة على الطرفية مستخدمًا الأمر console.log حتى عند تنفيذ الاختبارات. كما يمكنك أيضًا استخدام المنقحات وستجد الكثير من مواقع الانترنت التي تعطيك إرشادات لاستخدامها. ملاحظة: يفضل في حال أخفقت بعض الاختبارات، أن تشغلها فقط عندما تحاول إصلاح المشكلة. كما يمكنك أن تنفذ اختبارًا واحدًا مستخدمًا التابع only. هناك أسلوب آخر لتنفيذ اختبار محدد بكتابة اسم الكتلة (اسم الاختبار) عند تنفيذ الاختبار مع الصفة t-. npm test -- -t 'when list has only one blog, equals the likes of that' 4.5 الدوال المساعدة واختبارات الأجزاء: الخطوة 3 * عرّف دالة جديدة باسم favoriteBlog تستقبل قائمة بالمدونات كمعامل. تكتشف الدالة المدونة التي تحمل أكبر عدد من الإعجابات. يكفي أن تعيد الدالة مدونة واحدة إن كان هناك أكثر من واحدة. يمكن أن تعيد الدالة قيمة لها الصيغة التالية: { title: "Canonical string reduction", author: "Edsger W. Dijkstra", likes: 12 } ملاحظة: عندما توازن بين الكائنات، استخدم التابع toEqual فهو على الأغلب ما تحتاجه تمامًا، ذلك أن التابع toBe سيحاول أن يتحقق من أن القيمتين متطابقتين بالإضافة إلى امتلاكهما نفس الخصائص. اكتب الاختبار ضمن كتلة describe. كرر ذلك في التمارين الباقية أيضًا. 4.6 الدوال المساعدة واختبارات الأجزاء: الخطوة 4 * يحمل هذا التمرين والتمرين الذي يليه قدرًا من التحدي. وانتبه أن إكمال هذين التمرينين ليس ملزمًا لتتابع تقدمك في مفردات المنهاج. لذلك يفضل العودة إليهما بعد إنهائك مادة هذا القسم بالكامل. يُنجَز هذا التمرين بلا استخدام أية مكتبات إضافية. لكنه في المقابل فرصة مواتية لتعلم استخدام المكتبة Lodash. عرّف دالة اسمها mostBlogs تستقبل مصفوفة من المدونات كمعامل. تعيد الدالة اسم المؤلف الذي لديه العدد الأكبر من المدونات، كما تعيد عدد هذه المدونات: { author: "Robert C. Martin", blogs: 3 } إن وجد أكثر من مؤلف يحقق المطلوب، يكفي أن تعرض أحدهم. 4.7 الدوال المساعدة واختبارات الأجزاء: الخطوة 5 * عرّف دالة باسم mostLikes تستقبل مصفوفة من المدونات كمعامل. تعيد الدالة المؤلف الذي حاز على أكبر عدد من الإعجابات، كما تعيد العدد الكلي لتلك الإعجابات: { author: "Edsger W. Dijkstra", likes: 17 } إن وجد أكثر من مؤلف يحقق المطلوب، يكفي أن تعرض أحدهم. ترجمة -وبتصرف- للفصل Structure of backend application, introduction to testing من سلسلة Deep Dive Into Modern Web Development
  7. علينا في الواقع، أن نطبق مجموعة من القيود على البيانات التي ستخزن في قاعدة بيانات التطبيق. فلا ينبغي أن نسمح بوجود ملاحظات مفقودة المحتوى أو لا تملك الخاصية content لسبب ما. يجري تفقد صلاحية الملاحظة من خلال معالج مسار: app.post('/api/notes', (request, response) => { const body = request.body if (body.content === undefined) { return response.status(400).json({ error: 'content missing' }) } // ... }) إن لم تمتلك الملاحظة الخاصية content، سيستجيب الخادم برمز الحالة 400 (طلب خاطئ). يمكننا أن نستخدم طريقة أفضل في تقييم تنسيق البيانات قبل تخزينها في قاعدة البيانات وهي الوظيفة validation التي تتيحها مكتبة Mongoose. حيث سنعرف معايير تقييم محددة لكل حقل من حقول مخطط قاعدة البيانات: const noteSchema = new mongoose.Schema({ content: { type: String, minlength: 5, required: true }, date: { type: Date, required: true }, important: Boolean }) تحدد القواعد السابقة أن يكون طول المحتوى 5 محارف على الأقل، وأن المحتوى أمر إجباري required:true وبالتالي يجب أن لا يكون مفقودّا، وكذلك التاريخ. لم نضف تقييدات على حقل الأهمية، فلم يتغير تعريفه في المخطط. لاحظ على سبيل المثال أن المقيّم minlength يأتي مدمجًا وجاهزًا للاستخدام مع المكتبة mongoose، لكنها أيضًا تقدم وظيفة المُقيِّمات الخاصة التي تمكننا من إنشاء مُقيِّمات بالطريقة التي نحتاجها إن لم تحقق المقّيمات المدمجة ما نريد. إن حاولنا أن نخزن كائنًا في قاعدة البيانات يخالف أحد التقييدات التي فرضناها عليه، سترمي العملية استثناءً. لنغيّر معالج إنشاء ملاحظة جديدة لكي يمرر أي استثناء محتمل إلى الأداة الوسطية لمعالجة الأخطاء: app.post('/api/notes', (request, response, next) => { const body = request.body const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) note.save() .then(savedNote => { response.json(savedNote.toJSON()) }) .catch(error => next(error))}) لنوسّع معالج الخطأ ليتعامل مع أخطاء التقييم. const errorHandler = (error, request, response, next) => { console.error(error.message) if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } else if (error.name === 'ValidationError') { return response.status(400).json({ error: error.message }) } next(error) } عندما يُخفق تقييم الكائن، ستعيد Mongoose رسالة الخطأ التالية: سلاسل الوعود تحوّل معظم معالجات المسار البيانات القادمة مع الاستجابة إلى الصيغة الصحيحة باستخدام التابع toJSON. فعندما ننشئ ملاحظة جديدة، يُستدعى هذا التابع ليعالج الكائن الذي مرر إلى التابع then: app.post('/api/notes', (request, response, next) => { // ... note.save() .then(savedNote => { response.json(savedNote.toJSON()) }) .catch(error => next(error)) }) يمكن أن نحصل على نفس النتيجة وبطريقة أكثر وضوحًا باستخدام سلاسل الوعود: app.post('/api/notes', (request, response, next) => { // ... note .save() .then(savedNote => { return savedNote.toJSON() }) .then(savedAndFormattedNote => { response.json(savedAndFormattedNote) }) .catch(error => next(error)) }) يستقبل أول تابع then الكائن savedNote الذي تعيده Mongoose ثم ينسقه، ويعيد نتيجة هذه العملية. وكما ناقشنا سابقًا يعيد تابع then الذي يليه وعدًا أيضًا بحيث يمكننا الوصول إلى الملاحظة المنسقة بالتصريح عن دالة استدعاء جديدة داخل التابع then الأخير. كما يمكننا توضيح الشيفرة أكثر باستخدام الصيغة المختصرة للدالة السهمية: app.post('/api/notes', (request, response, next) => { // ... note .save() .then(savedNote => savedNote.toJSON()) .then(savedAndFormattedNote => { response.json(savedAndFormattedNote) }) .catch(error => next(error)) }) لم تحقق سلسلة الوعود في هذا المثال الكثير من الفائدة. لكن الفائدة الحقيقة ستظهر عند تنفيذ عدة عمليات غير متزامنة على التسلسل. لن نخوض في هذا الموضوع كثيرًا، بل سنتركه إلى القسم التالي من المنهاج، حيث سنطلع على الصيغة async/await في JavaScript والتي تسهل كتابة العمليات المتسلسلة غير المتزامنة. إنجاز نسخة الإنتاج من الواجهة الخلفية المرتبطة بقاعدة بيانات ينبغي أن يعمل التطبيق كما هو على Heroku. ولا ينبغي أن ننشئ نسخة إنتاج جديدة للواجهة الأمامية نتيجة للتغيرات التي أجريناها عليها. ونشير أيضًا إلى أن استخدام متغيرات البيئة التي عرفناها باستخدام dotenv غير ممكن عندما تكون الواجهة الخلفية في وضع الإنتاج (على Heroku). لقد وضعنا متغيرات البيئة المستخدمة في مرحلة التطوير في الملف env.، لكن ينبغي ضبط متغيرات البيئة التي تعرف عنوان موقع قاعدة البيانات في وضع الإنتاج باستخدام الأمر heroku config:set $ heroku config:set MONGODB_URI=mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true ملاحظة: إن أعطى تنفيذ الأمر خطأً، ضع قيمة MONGO_URI ضمن إشارتي تنصيص مفردتين (' '). $ heroku config:set MONGODB_URI='mongodb+srv://fullstack:secretpasswordhere@cluster0-ostce.mongodb.net/note-app?retryWrites=true' ينبغي أن يعمل التطبيق الآن. لكن في بعض الأحيان لا تجري الأمور كما هو مخطط لها. لذلك استفد من سجلات heroku عند تنفيذ الشيفرة. توضح الصورة التالية ما أظهره Heroku عند فشل التطبيق بعد إجراء التغييرات: لسبب ما، ظهر عنوان قاعدة البيانات ككائن غير معرّف. لكن سجلات Heroku قد كشفت أن الخطأ هو أننا أسندنا عنوان موقع قاعدة البيانات إلى متغير البيئة MONGO_URL بينما تتوقع الشيفرة أن يكون العنوان قد أسند إلى متغير البيئة MONGODB_URI. ستجد شيفرة التطبيق بوضعه الحالي في الفرع part3-5 ضمن المستودع الخاص بالقسم على GitHub التمارين 3.19 - 3.21 3.19 دليل هاتف بقاعدة بيانات: الخطوة 7 ضع مقيّمات لتحديد صلاحية المُدخَلات إلى تطبيق دليل الهاتف، لتضمن أن المُدخَل الجديد يحمل اسمًا فريدًا غير مكرر. لن تسمح الواجهة الأمامية الآن أن يدخل المستخدم أسماء مكررة، لكن حاول أن تقوم بذلك مباشرة بوجود Postman أو VS Code REST client. لا تقدم Mongoose مُقيِّمات مدمجة لهذا الغرض، عليك تثبيت حزمة mongoose-unique-validator باستخدام npm. إن حاول طلب HTTP-POST إضافة اسم موجود مسبقًا، على الخادم أن يجيب برمز الحالة المناسب، بالإضافة إلى رسالة خطأ. ملاحظة: سيسبب المعرف الفريد (unique-validator) تحذيرًا سيطبع على الطرفية. (node:49251) DeprecationWarning: collection.ensureIndex is deprecated. Use createIndexes instead. connected to MongoDB اقرأ توثيق Mongoose وجد طريقة للتخلص من هذا التحذير. 3.20 دليل هاتف بقاعدة بيانات: الخطوة 8 * وسع التقييد بحيث لا يقل طول الاسم المخزّن في قاعدة البيانات عن ثلاثة محارف وأن لا يقل طول رقم الهاتف عن 8 أرقام. دع الواجهة الأمامية تظهر رسالة خطأ عندما يحدث خطأ في التقييم. يمكنك أن تعالج الخطأ بإضافة كتلة catch كما يلي: personService .create({ ... }) .then(createdPerson => { // ... }) .catch(error => { // هذه طريقة للوصول إلى رسالة الخطأ console.log(error.response.data) }) يمكنك إظهار رسالة الخطأ الافتراضية التي تعيدها Mongoose علمًا أن قراءتها ليست يسيرة: ملاحظة: أثناء عمليات التحديث لن تعمل مقّيمات Mongoose بشكل افتراضي. اطلع على التوثيق لتتعلم كيفية تمكينها. 3.21 إنجاز نسخة الإنتاج من الواجهة الخلفية المرتبطة بقاعدة بيانات أنشئ نسخة كاملة (full stack) من التطبيق بإنجاز نسخة إنتاج عن الواجهة الأمامية ونسخها إلى مستودع الواجهة الخلفية. تحقق من أن كل شيء يعمل جيدًا باستخدام التطبيق بشكله الكامل على الخادم المحلي الذي عنوانه https://localhost:3001. انقل النسخة النهائية إلى خادم Heroku وتحقق أن كل شيء يعمل بشكل جيد. المدققات (Lints) قبل أن ننتقل إلى القسم التالي من المنهاج، سنلقي نظرة على أداة مهمة تدعى المدقق lint. وجاء في wikipedia عن المدقق ما يلي: يمكن للغات التي تترجم بشكل ساكن كلغة Java وبيئات التطوير مثل NetBeans أن تشير إلى الأخطاء في الشيفرة، حتى تلك الأخطاء التي تعتبر أكثر من أخطاء ترجمة. يمكن استعمال أدوات إضافية لتقوم بالتحليل الساكن مثل checkstyle لتوسيع إمكانيات بيئة التطوير بحيث تصبح قادرةً على الإشارة إلى مشاكل تتعلق حتى بالتنسيقات مثل إزاحة الكلمات ضمن الأسطر. في عالم JavaScript، تعتبر الأداة ESlint هي الرائدة في مجال التدقيق والتحليل الساكن. لنثبت ESlint كملف ارتباط تطوير في مشروع الواجهة الخلفية بتنفيذ الأمر: npm install eslint --save-dev يمكننا بعد ذلك تهيئة المدقق بصيغته الافتراضية بتنفيذ الأمر: node_modules/.bin/eslint --init سنجيب طبعُا عن الأسئلة التالية: ستخزّن إعدادات التهيئة في الملف eslinterc.js: module.exports = { 'env': { 'commonjs': true, 'es6': true, 'node': true }, 'extends': 'eslint:recommended', 'globals': { 'Atomics': 'readonly', 'SharedArrayBuffer': 'readonly' }, 'parserOptions': { 'ecmaVersion': 2018 }, 'rules': { 'indent': [ 'error', 4 ], 'linebreak-style': [ 'error', 'unix' ], 'quotes': [ 'error', 'single' ], 'semi': [ 'error', 'never' ] } } لنغيّر مباشرة القاعدة التي تنظم الإزاحة في السطر الواحد بحيث تكون بمقدار فراغين: "indent": [ "error", 2 ], يمكن تفتيش الملفات مثل index.js والتحقق من صلاحيتها باستخدام الأمر: node_modules/.bin/eslint index.js يفضل أن تنشئ سكربت npm منفصل للتدقيق: { // ... "scripts": { "start": "node index.js", "dev": "nodemon index.js", // ... "lint": "eslint ." }, // ... } سيتحقق الآن الأمر npm run lint من كل ملف في المشروع. كما سيتحقق من الملفات الموجودة في المجلد build، وهذا ما لا نريده. لذلك سنمنع ذلك بإنشاء ملف تجاهل لاحقته eslintignorr.‎ في جذر المشروع و نزوده بالمحتوى التالي: build عندها لن يتحقق ESlint من المجلد build أو محتوياته. سيشير ESlint إلى الكثير من النقاط في شيفرتك: لن نصلح أي شيء الآن. يمكن أن تستخدم طريقة أفضل من سطر الأوامر في تنفيذ التدقيق، وهي تهيئة إضافة للتدقيق eslint-plugin على محررك بحيث تنفذ عملية التدقيق بشكل مستمر. وبالتالي سترى الأخطاء التي ترتكب مباشرة أثناء تحريرك للشيفرة. يمكنك الاطلاع أكثر من خلال الانترنت على الكثير من المعلومات حول الإضافة Visual Studio ESlint plugin. Editor. ستضع الإضافة السابقة خطًا أحمر تحت أخطاء التنسيق: وهكذا سنرصد الأخطاء بشكل أسهل. للمدقق ESlint الكثير من القواعد سهلة الاستخدام والتي يمكن إضافتها في الملف eslintrc.js. لنضع الآن القاعدة eqeqeq التي تحذرنا إن وجدت في الشيفرة عامل الموازنة الثلاثي (===). تضاف القاعدة ضمن الحقل rules في ملف التهيئة: { // ... 'rules': { // ... 'eqeqeq': 'error', }, } وطالما أننا استخدمنا القواعد لنقم بتغييرات أخرى. لنمنع وجود المسافات الفارغة trailing spaces في آخر السطر البرمجي، ولنطلب أيضًا أن يكون هناك فراغ قبل وبعد الأقواس المعقوصة، كذلك وجود فراغ بشكل دائم بين معاملات الدالة السهمية. { // ... 'rules': { // ... 'eqeqeq': 'error', 'no-trailing-spaces': 'error', 'object-curly-spacing': [ 'error', 'always' ], 'arrow-spacing': [ 'error', { 'before': true, 'after': true } ] }, } وتقدم لنا الإعدادات الافتراضية التي اعتمدناها في البداية، الكثير من القواعد التي ينصح بها المدقق، وتُطبق هذه القواعد بكتابة الأمر: 'extends': 'eslint:recommended', تتضمن هذه القواعد تحذيرات تتعلق بأمر الطباعة على الطرفية console.log. يمكن تعطيل أي قاعدة بجعل قيمتها تساوي 0 في ملف التهيئة. لنلغ القاعدة no-console على سبيل المثال: { // ... 'rules': { // ... 'eqeqeq': 'error', 'no-trailing-spaces': 'error', 'object-curly-spacing': [ 'error', 'always' ], 'arrow-spacing': [ 'error', { 'before': true, 'after': true } ], 'no-console': 0 }, } ملاحظة: يفضل، إن أجريت أية تغييرات على الملف ‎.eslintrc.js، أن تشغل المدقق من سطر الأوامر لتتحقق من أن التعليمات في ملف التهيئة مكتوبة بالشكل الصحيح. فإن كانت هناك أية مشاكل في ملف التهيئة، ستتصرف إضافة المدقق بشكل غير مفهوم. تحدد الكثير من الشركات معايير لكتابة الشيفرة وتفرضها على منظمة W3C عبر ملفات تهيئة ESlint. إذًا ليس عليك إعادة اختراع العجلة في كل مرة، ومن الأفضل لك اعتماد ملف تهيئة جاهز أنجزته جهة ما. تعتمد الكثير من المشاريع حاليًا على دليل تنسيق JavaScript الذي قدمته Airbnb، وذلك باستخدامها ملف تهيئة ESlint الذي تعتمده Airbnb. ستجد شيفرة التطبيق كاملًا في الفرع part3-6 ضمن المستودع الخاص بالقسم على GitHub التمرين 3.22 3.22 تهيئة المدقق أضف ESlint إلى تطبيقك وأصلح كل المشاكل. وهكذا نصل إلى آخر تمرينات هذا القسم، وحان الوقت لتسليم حلول التمارين إلى GitHub. لا تنس أن تشير إلى التمارين التي تسلمها أنها مكتملة ضمن منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل Validation and ESlint من سلسلة Deep Dive Into Modern Web Development
  8. قبل أن ننتقل إلى الموضوع الرئيسي في هذا الفصل، سنلقي نظرة على بعض الطرق المتبعة في تنقيح تطبيقات Node. تنقيح تطبيقات Node إن تنقيح التطبيقات المبنية باستخدام Node أصعب قليلًا من تنقيح شيفرة JavaScript التي تنفذ على المتصفح. لكن مع ذلك تبقى فكرة الطباعة على الطرفية وأسلوب المحاولة والخطأ طريقة فعالة في حل المشاكل. قد تجد من المطورين من يعتقد أن استخدام أساليب أكثر تطورًا هو أمر ضروري، لكن تذكر أن نخبة مطوري البرمجيات مفتوحة المصدر في العالم يستخدمون تلك الطريقة. برنامج Visual Studio Code ستجد أن المنقح المدمج ضمن هذا البرنامج ذو فائدة كبيرة في بعض الحالات. يمكنك أن تشغل البرنامج على وضع التنقيح كالتالي: ملاحظة: يمكن أن تجد التعليمة Run بدلًا من Debug في الإصدار الأحدث من Visual Studio Code. وقد يكون عليك أيضًا أن تهيئ ملف launch.json لتبدأ التنقيح. يمكن تنفيذ ذلك باختيار الأمر ...Add Configuration من القائمة المنسدلة الموجودة بجانب زر التشغيل الأخضر وفوق قائمة VARIABLES، ثم اختيار الأمر Run "npm start" in a debug terminal. يمكنك إيجاد المزيد من الإرشادات بالاطلاع على توثيق التنقيح لبرنامج Visual Studio Code. تذكر أن لا تشغل البرنامج ضمن أكثر من طرفية لأن ذلك سيحجز المنفذ مسبقًا ولن تتمكن من العمل. تعرض لقطة الشاشة التالية الطرفية وقد أوقفنا التنفيذ مؤقتًا في منتصف عملية حفظ الملاحظة الجديدة: توقَّف التنفيذ عندما وصلنا إلى نقطة التوقف التي وضعناها في السطر 63. يمكنك أن ترى في الطرفية قيمة المتغير note. وفي أعلى يسار النافذة ستجد بعض التفاصيل المتعلقة بحالة التطبيق. تُستخدم الأسهم في الأعلى للتحكم بترتيب عملية التنقيح. أدوات تطوير Chrome من الممكن أن نستخدم أدوات تطوير Chrome لتنقيح تطبيقات Node، وذلك بتشغيل التطبيق مستخدمين الأمر التالي: node --inspect index.js ستدخل إلى المنقح بالضغط على الأيقونة الخضراء (شعار Node) التي تظهر على طرفية تطوير Chrome: يعمل المنقح بنفس الطريقة التي يعمل بها عند تنقيح تطبيقات React. يمكنك استخدام النافذة Sources لزرع نقاط توقف في الشيفرة لإيقاف التنفيذ بشكل مؤقت عندها. ستظهر جميع الرسائل التي يطبعها الأمر console.log في النافذة Console من المنقح. كما يمكنك التحري عن قيم المتغيرات وتنفيذ شيفرة JavaScript إن أردت. تحقق من كل شيء قد يبدو لك تنقيح تطبيقات التطوير الشامل (واجهة خلفية وأمامية) محيّرًا في البداية. وقريبًا سيتواصل التطبيق مع قاعدة بيانات. وبالتالي سيزداد احتمال ظهور الأخطاء في أجزاء عدة من الشيفرة. عندما يتوقف التطبيق عن العمل، يجب علينا أولًا تصور المكان الذي قد تظهر فيه المشكلة. وعادة تظهر المشاكل في الأماكن غير المتوقعة وقد يستغرق إيجادها دقائق أو ساعات أو حتى أيامًا. مفتاح الحل هو تنظيم البحث. فالمشكلة قد تتواجد في أي مكان، لذلك تحقق من كل شيء واستبعد احتمالات المشكلة واحدًا تلو الآخر. ستساعدك الطباعة على شاشة الطرفية وكذلك برنامج Postman والمنقحات الأخرى، كما ستلعب الخبرة دورًا هامًا أيضًا. لا تتابع تطوير التطبيق إن لم تعثر على مصدر الخطأ، فهذه أسوأ استراتيجية. لأن ذلك سيسبب أخطاء أكثر وسيكون التنقيح أصعب. اتبع سياسة شركة Toyota لإنتاج الأنظمة (توقف وأصلح) فهي بالفعل سياسة مجدية جدًا في حالتنا. قاعدة البيانات MongoDB سنحتاج قطعًا إلى قاعدة بيانات لحفظ الملاحظات بشكل دائم. تتعامل معظم مناهج جامعة هلسينكي مع قواعد البيانات العِلاقيّة (Relational Databases)، لكننا سنتعامل في منهاجنا مع قاعدة البيانات MongoDB وهي من نمط قواعد البيانات المستقلة. تختلف قواعد البيانات المستقلة (أو التي تأتي على شكل مستندات منفصلة) عن العِلاقيّة في كيفية تنظيم البيانات ولغة الاستعلام التي تدعمها. وعادة ما تصنف قواعدة البيانات المستقلة تحت مظلة NoSQL أي التي لا تستخدم لغة الاستعلام SQL. اطلع على الفصلين collections و documents من دليل استخدام MongoDB لتتعلم أساسيات تخزين البيانات في قواعد البيانات المستقلة. يمكنك أن تُثبّت وتُشغّل MongoDB على حاسوبك. كما يمكنك الاستفادة من المواقع التي تقدم خدمات MongoDB على الإنترنت. سنتعامل في منهاجنا مع مزود الخدمة MongoDB Atlas. حالما تنشئ حسابًا على الموقع وتسجل الدخول، سينصحك الموقع بإنشاء عنقود: سنختار المزود AWS والمنطقة Frankfurt ثم ننشئ العنقود: انتظر حتى يكتمل العنقود ويصبح جاهزًا. قد يستغرق ذلك 10 دقائق. ملاحظة: لا تتابع قبل أن يصبح العنقود جاهزًا. سنستخدم نافذة database access لإنشاء معلومات التوثيق اللازمة لدخول قاعدة البيانات. وانتبه إلى أنها معلومات توثيق مختلفة عن تلك التي تستخدمها عند تسجيل الدخول، وسيستخدمها تطبيقك عندما يتصل بقاعدة البيانات. لنمنح المستخدم إمكانية القراءة والكتابة إلى قاعدة البيانات: ملاحظة: أبلغَ بعض المستخدمين عن عدم القدرة على الوصول إلى قاعدة البيانات بمعلومات التوثيق التي وضعوها بعد إنشاء القاعدة مباشرة. تريّث، فقد يستغرق الأمر دقائق حتى تفعّل هذه المعلومات. سنعرّف بعد ذلك عناوين IP التي يسمح لها بدخول قاعدة البيانات. ولتسهيل الأمر، سنسمح بالوصول إلى القاعدة من أي عنوان IP: أخيرًا أصبحنا جاهزين للاتصال بقاعدة البيانات، إبدأ بالنقر على connect: اختر Connect your application: سيظهر لك عنوان موقع MongoDB، وهو عنوان قاعدة البيانات التي أنشأناها والتي تزود مكتبة عميل MongoDB التي سنضيفها إلى تطبيقنا بالبيانات. يبدو العنوان كالتالي: mongodb+srv://fullstack:<PASSWORD>@cluster0-ostce.mongodb.net/test?retryWrites=true نحن الآن جاهزين لاستخدام قاعدة البيانات. يمكننا استخدام قاعدة البيانات مباشرة عبر شيفرة JavaScript بمساعدة المكتبة official MongoDb Node.js driver، لكن استخدامها مربك قليلًا. لذلك سنستخدم بدلًا منها مكتبة Mongoose التي تؤمن واجهة برمجية عالية المستوى. يمكن توصيف Mongoose بأنها رابط (Mapper) لكائنات من النوع document واختصارًا (ODM). وبالتالي سيكون حفظ كائنات JavaScript كمستندات Mongo مباشرًا باستخدام هذه المكتبة. لنثبت الآن Mongoose كالتالي: npm install mongoose --save لن نكتب أية شيفرات تتعامل مع Mongo في الواجهة الخلفية حاليًا، بل سننشئ تطبيقًا تدريبيًا ونضع في مجلده الجذري الملف mongo.js: const mongoose = require('mongoose') if (process.argv.length < 3) { console.log('Please provide the password as an argument: node mongo.js <password>') process.exit(1) } const password = process.argv[2] const url = `mongodb+srv://fullstack:${password}@cluster0-ostce.mongodb.net/test?retryWrites=true` mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) const note = new Note({ content: 'HTML is Easy', date: new Date(), important: true, }) note.save().then(result => { console.log('note saved!') mongoose.connection.close() }) ملاحظة: قد يختلف عنوان موقع MongoDB عن العنوان الذي عرضناه سابقًا بناء على المنطقة التي اخترتها عند إنشائك العنقود. تأكد من استخدامك العنوان الصحيح الذي حصلت عليه من MongoDB Atlas. ستفترض الشيفرة بأنها ستمرر كلمة المرور الموجودة ضمن معلومات التوثيق كمعامل لسطر أوامر. حيث يمكننا الوصول إلى معامل سطر الأوامر كالتالي: const password = process.argv[2] عندما تنفذ الشيفرة باستخدام الأمر node mongo.js password ستضيف Mongo ملفًا جديدًا إلى قاعدة البيانات. ملاحظة: استخدم كلمة السر التي اخترتها عند إنشاء قاعدة البيانات وليست كلمة سر الدخول إلى MongoDB Atlas. وانتبه أيضًا إلى الرموز الخاصة التي قد تضعها في كلمة مرورك فستحتاج عندها إلى تشفير الرموز في كلمة المرور عند كتابة عنوان الموقع. يمكنك الاطلاع على حالة قاعدة البيانات على MongoDB Atlas من Collections في النافذة Overview: وكما هو واضح، أضيف مستند يطابق الملاحظة إلى المجموعة notes في قاعدة البيانات التجريبية. توصي توثيقات Mongo بإعطاء أسماء منطقية لقواعد البيانات. ويمكننا تغيير اسم قاعدة البيانات من عنوان الموقع للقاعدة: سندمّر الآن قاعدة البيانات التجريبية بتغيير اسم قاعدة البيانات التي يشير إليها مؤسس الاتصال (connection string) إلى note-app بمجرد تعديل عنوان موقع القاعدة: mongodb+srv://fullstack:<PASSWORD>@cluster0-ostce.mongodb.net/note-app?retryWrites=true سننفذ الشيفرة الآن: لقد خُزِّنت الآن البيانات في القاعدة الصحيحة. يمكن باستخدام create database أن ننشئ قواعد بيانات مباشرة على موقع الويب MongoDB Atlas. لكن لا حاجة لذلك طالما أن الموقع ينشئ تلقائيًا قاعدة بيانات جديدة عندما يحاول التطبيق أن يتصل مع قاعدة بيانات غير موجودة. تخطيط قاعدة البيانات بعد تأسيس الاتصال مع قاعدة البيانات، سنعرف تخطيطًا (Schema) للملاحظة ونموذجًا (Model) مطابقًا للتخطيط: const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) عرّفنا أولًا تخطيطًا للملاحظة وأسندناه إلى المتغيّر noteSchema. يخبر التخطيط Mongosse كيف سيُخزّن الكائن الذي يمثل الملاحظة في قاعدة البيانات ثم نعرّف نموذجًا باسم Note يقبل معاملين، الأول هو اسم النموذج المفرد. حيث يختلف الاسم المفرد عن اسم المجموعة بأن الأخير يحمل صيغة الجمع ويكتب بأحرف صغيرة "notes" فهذا عرف تتبعه Mongoose وتقوم به تلقائيًا بينما يشير التخطيط إلى الملاحظات بالاسم المفرد. المعامل الثاني كما هو واضح هو تخطيط الملاحظة. لا تملك قواعد البيانات المستقلة مثل Mongo أية تخطيطات (schemaless). ويعني ذلك أن قاعدة البيانات لا تهتم ببنية البيانات المخزنة فيها، حيث يمكن تخزين ملفات بتخطيطات مختلفة تمامًا ضمن نفس المجموعة. لكن الفكرة وراء منح Mongoose البيانات المخزنة في قاعدة البيانات تخطيطًا على مستوى التطبيق، هي تحديد شكل المستندات المخزنة في مجموعة ما. إنشاء وحفظ الكائنات ينشئ التطبيق في الشيفرة التالية كائن ملاحظة جديد بمساعدة النموذج Note: const note = new Note({ content: 'HTML is Easy', date: new Date(), important: false, }) تدعى النماذج "دوال البناء". فهي التي تنشئ الكائنات الجديدة في JavaScript بناء على المعاملات التي تملكها. وطالما أن الكائنات ستبنى باستخدام الدوال البانية للنماذج، ستمتلك كل خصائص النموذج بما فيها توابع حفظ الكائن في قاعدة البيانات. وتحفظ الكائنات في قواعد البيانات باستخدام التابع save الذي يُزوّد بمعالج حدث ضمن التابع then: note.save().then(result => { console.log('note saved!') mongoose.connection.close() }) بعدما يُحفظ الكائن في قاعدة البيانات، يُستدعَى معالج الحدث المعرّف ضمن التابع then. حيث يغلق معالج الحدث قناة الاتصال مع قاعدة البيانات باستخدام الأمر ()mongoose.connection.close. إن لم يُغلق الاتصال، فلن ينتهي البرنامج من تنفيذ العملية. تُخزّن نتيجة عملية الحفظ في المعامل result لمعالج الحدث، لكنها غير مهمة كثيرًا، خاصة أننا خزّنا كائنًا مفردًا في قاعدة البيانات. يمكنك طباعة الكائن على الطرفية إن أردت التمعن فيه أثناء عمل التطبيق أو أثناء تنقيحه. لنخزّن عدة ملاحظات أخرى في قاعدة البيانات. لكن علينا أولًا تعديل الشيفرة ثم إعادة تنفيذها. ملاحظة: لسوء الحظ، فإن توثيق mongoose ليس مستقرًا جدًا. لقد استخدمت الاستدعاءات في بعض الأجزاء عند عرض الأمثلة بينما استخدمت الأجزاء الأخرى أساليب أخرى. لذلك لا ننصحك بنسخ ولصق الشيفرة مباشرة من تلك التوثيقات. لا تخلط الوعود مع استدعاءات المدرسة التقليدية في نفس الشيفرة فهذا أمر غير محبذ. إحضار كائنات من قاعدة البيانات حَوِّل الشيفرة السابقة التي استخدمناها لإنشاء ملاحظة جديدة إلى تعليقات واستخدم الشيفرة التالية بدلًا منها: Note.find({}).then(result => { result.forEach(note => { console.log(note) }) mongoose.connection.close() }) عندما تُنفّذ الشيفرة سيطبع التطبيق كل الملاحظات الموجودة ضمن قاعدة البيانات: نحصل على الكائنات من قاعدة البيانات مستعملين التابع find العائد للنموذج Note. يقبل التابع السابق معاملًا على هيئة كائن يحتوي على معايير البحث. وطالما أن المعامل في الشيفرة السابقة كائن فارغ ({})، فسنحصل على جميع الملاحظات المخزنة في المجموعة notes. تخضع معايير البحث إلى قواعد الاستعلام في Mongo. ويمكننا تحديد البحث ليشمل مثلًا الملاحظات الهامة فقط على النحو التالي: Note.find({ important: true }).then(result => { // ... }) التمرين 3.12 3.12 قاعدة بيانات بسطر أوامر أنشئ قاعدة بيانات سحابية باستخدام MongoDB لتطبيق دليل الهاتف وذلك على موقع الويب MongoDb Atlas. أنشئ الملف mongo.js في مجلد المشروع، حيث تضيف الشيفرة في الملف مُدخلات إلى دليل الهاتف، وتشكيل قائمة بكل المُدخلات الموجودة في الدليل. ملاحظة: لا تضع كلمة المرور في الملف الذي سترفعه إلى GitHub. يجب أن يعمل التطبيق على النحو التالي: تمرير ثلاثة معاملات إلى سطر الأوامر عند تشغيل التطبيق، على أن تكون كلمة السر هي المعامل الأول كما في المثال التالي: node mongo.js yourpassword Anna 040-1234556 سيطبع التطبيق النتيجة التالية: added Anna number 040-1234556 to phonebook يُخزَّن المُدخل الجديد ضمن قاعدة البيانات. وانتبه إلى وضع الاسم الذي يحتوي على فراغات ضمن قوسي تنصيص مزدوجين: node mongo.js yourpassword "Arto Vihavainen" 045-1232456 إذا شغلت التطبيق بمعامل واحد فقط هو كلمة السر كما يلي: node mongo.js yourpassword على التطبيق عندها أن يعرض كل المُدخَلات في دليل الهاتف: phonebook: Anna 040-1234556 Arto Vihavainen 045-1232456 Ada Lovelace 040-1231236 يمكنك الحصول على معاملات سطر الأوامر من المتغيّر process.argv. ملاحظة: لا تغلق الاتصال في المكان غير المناسب. فلن تعمل على سبيل المثال الشيفرة التالية: Person .find({}) .then(persons=> { // ... }) mongoose.connection.close() سيُنفَّذ الأمر ()mongoose.connection.closeمباشرة بعد أن تبدأ العملية Person.find. أي ستغلق قاعدة البيانات مباشرة قبل أن تنتهي العملية السابقة وتستدعى دالة معالج الحدث. لذلك فالمكان الصحيح لإغلاق قاعدة البيانات سيكون في نهاية معالج الحدث: Person .find({}) .then(persons=> { // ... mongoose.connection.close() } ) ملاحظة: إذا سميت النموذج person، ستسمي mongoose المجموعة المقابلة people. ربط الواجهة الخلفية مع قاعدة البيانات لقد تعلمنا ما يكفي لبدء استخدام Mongo في تطبيقنا. لننسخ ونلصق القيم التي عرفناها في التطبيق التجريبي، ضمن الملف index.js: const mongoose = require('mongoose') // DO NOT SAVE YOUR PASSWORD TO GITHUB!! const url = 'mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true' mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) const Note = mongoose.model('Note', noteSchema) لنغير معالج حدث إحضار كل الملاحظات إلى الشكل التالي: app.get('/api/notes', (request, response) => { Note.find({}).then(notes => { response.json(notes) }) }) يمكن أن نتحقق من عمل الواجهة الخلفية بعرض كل الملاحظات على المتصفح: يعمل التطبيق بشكل ممتاز الآن. تفترض الواجهة الأمامية أن لكل كائن معرفّا فريدًا id في الحقل id. وانتبه إلى أننا لا نريد إعادة حقل إصدار mongo إلى الواجهة الأمامية (v__). إحدى الطرق لنتحكم بصيغة الكائن الذي سنعيده، هي تعديل تخطيط الكائن باستخدام التابع toJSON والذي سنستعمله في كل النماذج التي تُنشأ اعتمادًا على هذا التخطيط. سيكون التعديل على النحو: noteSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v } }) حتى لو بدت الخاصية (id__) لكائن Mongoose كسلسلة نصية فهي في الواقع كائن. لذلك يحولها التابع toJSON إلى سلسلة نصية حتى نتأكد أنها آمنة. إن لم نفعل ذلك ستظهر المشاكل أمامنا بمجرد أن نبدأ كتابة الاختبارات. ستجيب الواجهة الخلفية على طلب HTTP بقائمة من الكائنات التي أعيدت صياغتها باستخدام التابع toJSON: app.get('/api/notes', (request, response) => { Note.find({}).then(notes => { response.json(notes) }) }) أسندت مصفوفة الكائنات التي أعادتها Mongo إلى المتغير notes. وعندما ترسل الاستجابة بصيغة JSON، يستدعى التابع toJSON تلقائيًا من أجل كل كائن من المصفوفة باستخدام التابع JSON.stringify. تهيئة قاعدة البيانات في وحدة خاصة بها قبل أن نعيد كتابة بقية تطبيق الواجهة الخلفية، لنضع الشيفرة الخاصة بالتعامل مع Mongoose في وحدة مستقلة خاصة بها. لذلك سننشئ مجلدًا جديدًا للوحدة يدعى models ونضيف إليه ملفًا باسم note.js: const mongoose = require('mongoose') const url = process.env.MONGODB_URI console.log('connecting to', url) mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) .then(result => { console.log('connected to MongoDB') }) .catch((error) => { console.log('error connecting to MongoDB:', error.message) }) const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) noteSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v } }) module.exports = mongoose.model('Note', noteSchema) يختلف تعريف الوحدات في Node قليلًا عن الطريقة التي عرفنا فيها وحدات ES6 في القسم 2. فتُعرّف الواجهة العامة للوحدة بإسناد قيمة للمتغيّر module.exports. سنسند له إذًا النموذج Note. لن يتمكن مستخدم الوحدة من الوصول أو رؤية الأشياء المعرفة داخلها، كالمتغيرات وmonogose وعنوان موقع القاعدة url. يجري إدراج الوحدة بإضافة السطر التالي إلى الملف index.js: const Note = require('./models/note') وهكذا سيُسند المتغيّر Note إلى نفس الكائن الذي تعرّفه الوحدة. تغيّرت قليلًا الطريقة التي نجري بها الاتصال مع قاعدة البيانات: const url = process.env.MONGODB_URI console.log('connecting to', url) mongoose.connect(url, { useNewUrlParser: true, useUnifiedTopology: true }) .then(result => { console.log('connected to MongoDB') }) .catch((error) => { console.log('error connecting to MongoDB:', error.message) }) const noteSchema = new mongoose.Schema({ content: String, date: Date, important: Boolean, }) noteSchema.set('toJSON', { transform: (document, returnedObject) => { returnedObject.id = returnedObject._id.toString() delete returnedObject._id delete returnedObject.__v } }) module.exports = mongoose.model('Note', noteSchema) لا تكتب عنوان الموقع لقاعدة البيانات بشكل مسبق في الشيفرة فهذه فكرة سيئة. بل مرر العنوان إلى التطبيق عبر متغيّر البيئة MONGODB_URI. تقدم الطريقة التي اتبعناها في تأسيس الاتصال دالتين للتعامل مع حالتي نجاح الاتصال وفشله. حيث تطبع كلتا الدالتين رسائل إلى الطرفية لوصف حالة الاتصال. ستجد طرقًا عديدة لتعريف قيمة متغيّر البيئة، إحداها أن تعرّفه عندما تشغل التطبيق: MONGODB_URI=address_here npm run dev الطريقة الأخرى الأكثر تعقيدًا هي استخدام المكتبة dotenv التي يمكنك تثبيتها بتنفيذ الأمر: npm install dotenv --save عليك إنشاء ملف لاحقته env. عند جذر المشروع، ومن ثم تعرف متغيرات البيئة داخله. سيبدو الملف بالشكل التالي: MONGODB_URI='mongodb+srv://fullstack:sekred@cluster0-ostce.mongodb.net/note-app?retryWrites=true' PORT=3001 لاحظ أننا حددنا رقم المنفذ بشكل مسبق ضمن متغير البيئة PORT. ملاحظة تَجاهل الملف ذو اللاحقة "env."، لأننا لا نريد أن ننشر معلومات التوثيق الخاصة بنا في العلن. نستخدم متغيرات البيئة التي عرّفناها في الملف env. بكتابة العبارة ()require('dotenv').config، ثم يمكنك بعدها الإشارة إليهم في الشيفرة بالطريقة المعهودة process.env.MONGODB_URI. لنغيّر الملف index.js على النحو التالي: require('dotenv').config()const express = require('express') const app = express() const Note = require('./models/note') // .. const PORT = process.env.PORTapp.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) من المهم إدراج المكتبة dotenv قبل إدراج الوحدة note، لكي نضمن أن متحولات البيئة التي عرّفت داخلها ستكون متاحة للاستخدام ضمن كامل الشيفرة، وذلك قبل إدراج بقية الوحدات. استخدام قاعدة البيانات مع معالجات المسار لنغيّر بقية الوظائف في تطبيق الواجهة الخلفية ليتعامل مع قواعد البيانات. تضاف ملاحظة جديدة كالتالي: app.post('/api/notes', (request, response) => { const body = request.body if (body.content === undefined) { return response.status(400).json({ error: 'content missing' }) } const note = new Note({ content: body.content, important: body.important || false, date: new Date(), }) note.save().then(savedNote => { response.json(savedNote) }) }) تنشئ الدالة البانية للنموذج Note الكائنات التي تمثل الملاحظات. ثم ترسل الاستجابة داخل دالة استدعاء التابع save. يضمن هذا أن الاستجابة لن تُعاد إلى المرسل إن لم تنجح العملية. وسنناقش بعد قليل آلية التعامل مع الأخطاء. يحمل معامل دالة الاستدعاء savedNote الملاحظة الجديدة التي أنشئت. وتذكر أن البيانات التي أعيدت في الاستجابة قد أعيد تنسيقها باستعمال التابع toJSON. response.json(savedNote) تغيرت طريقة إحضار الملاحظات المفردة لتصبح على النحو: app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id).then(note => { response.json(note) }) }) التحقق من تكامل أداء الواجهتين الخلفية والأمامية من الجيد اختبار التطبيق الذي يعمل على الواجهة الخلفية عندما نضيف إليه وظائف جديدة، يمكن أن نستعمل في الاختبار برنامج Postman أو VS Code REST client أو المتصفح الذي تستخدمه. لننشئ ملاحظة جديدة ونخزّنها في قاعدة البيانات: بعد أن نتأكد من أن كل شيء يعمل على ما يرام في الواجهة الخلفية، نختبر تكامل الواجهة الأمامية مع الخلفية. فمن غير الكافي إطلاقًا اختبار الأشياء ضمن الواجهة الأمامية فقط. ربما عليك أن تدرس تكامل الوظائف بين الواجهتين وظيفة تلو الأخرى. فيمكننا أولًا إضافة الشيفرة التي تحضر كل البيانات من قاعدة البيانات ونختبرها على طرفية الواجهة الخلفية ضمن المتصفح. بعدها نتأكد أن الواجهة الأمامية تعمل جيدًا مع الشيفرة الجديدة للواجهة الخلفية. عندما يجري كل شيء بشكل جيد ننتقل إلى الوظيفة التالية. علينا تفقد الحالة الراهنة لقاعدة البيانات بمجرد بدأنا العمل معها. يمكننا القيام بذلك على سبيل المثال، عبر لوحة التحكم في MongoDB Atlas. كما ستفيدك أثناء التطوير بعض برامج Node الصغيرة، كالبرنامج mongo.js الذي كتبناه في هذا الفصل. ستجد شيفرة التطبيق بشكله الحالي في المستودع part3-4 على موقع GitHub. التمارين 3.13 - 3.14 يمثل التمرينين التاليين تطبيقًا مباشرًا وسهلًا لما تعلمناه. لكن إن لم تتكامل الواجهتين معًا أثناء العمل، فستكمن الأهمية في كيفية إيجاد ومعالجة الأخطاء التي سببت ذلك. 3.13 دليل هاتف بقاعدة بيانات: الخطوة 1 غيِّر طريقة إحضار جميع المُدخَلات لكي يتم ذلك من قاعدة بيانات. تأكد أن الواجهة الأمامية ستعمل بعد إجراء هذه التغييرات. سنتكتب شيفرة التعامل مع قاعدة البيانات MongoDB في وحدة خاصة بها خلال التمارين القادمة، كما فعلنا سابقًا في هذا الفصل (تهيئة قاعدة البيانات في وحدة خاصة بها). 3.14 دليل هاتف بقاعدة بيانات: الخطوة 2 غيّر في شيفرة الواجهة الخلفية بحيث تحفظ الأرقام في قاعدة البيانات، وتحقق أن الواجهة الأمامية ستعمل بشكل جيد بعد التغييرات في الواجهة الخلفية. يمكنك في هذه المرحلة أن تجعل المستخدمين يدخلون كل ما يشاؤون في دليل الهاتف. ولاحظ أن دليل الهاتف قد يحوي الاسم نفسه مكررًا مرات عدة. معالجة الأخطاء لو حاولنا الوصول إلى موقع ملاحظة بمعرّف id غير موجود. فستكون الإجابة Null (لا شيء). لنغير ذلك بحيث يستجيب الخادم على هذا الطلب برمز الحالة 404 (غير موجود). سنضيف أيضًا كتلة catch لتتعامل مع الحالات التي يُرفض فيها الوعد الذي يعيده التابع findById: app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id) .then(note => { if (note) { response.json(note) } else { response.status(404).end() } }) .catch(error => { console.log(error) response.status(500).end() }) }) إن لم يُعثر على تطابق في قاعدة البيانات، ستكون قيمة الكائن note هي null، وبالتالي ستنفذ كتلة else. سينتج عن ذلك استجابة برمز الحالة 404 (غير موجود). وأخيرًا، إن رفض الوعد الذي يعيده التابع findById، ستكون الاستجابة برمز الحالة 500 (خطأ داخلي في الخادم). ستعرض لك طرفية التطوير معلومات مفصلة أكثر عن الخطأ. بالإضافة إلى الخطأ الناتج عن عدم وجود ملاحظة، ستواجه حالة أخرى يتوجب عليك معالجتها. تتلخص هذه الحالة بمحاولة إحضار ملاحظة بمعرّف id من نوع خاطئ لا يطابق تنسيق Mongo للمعرفات IDs. فلو ارتكبنا خطأً كهذا، سنرى الرسالة التالية: Method: GET Path: /api/notes/someInvalidId Body: {} --- { CastError: Cast to ObjectId failed for value "someInvalidId" at path "_id" at CastError (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/error/cast.js:27:11) at ObjectId.cast (/Users/mluukkai/opetus/_fullstack/osa3-muisiinpanot/node_modules/mongoose/lib/schema/objectid.js:158:13) ... إن إعطاء معرّف id بصيغة خاطئة، سيدفع التابع findById لإشهار خطأ يسبب رفضًا للوعد. كنتيجة لذلك ستُستدعى الدالة الموجودة في الكتلة catch. لنغيّر طريقة الاستجابة قليلًا في تلك الكتلة: app.get('/api/notes/:id', (request, response) => { Note.findById(request.params.id) .then(note => { if (note) { response.json(note) } else { response.status(404).end() } }) .catch(error => { console.log(error) response.status(400).send({ error: 'malformatted id' }) }) }) إن لم يكن تنسيق المعرف id صحيحًا، ستنتهي العملية بتنفيذ معالج الخطأ الموجود في catch. إن الاستجابة الملائمة لهذا الخطأ هو رمز الحالة 400 (طلب خاطئ) 400 Bad Request، لأن هذه الحالة تطابق تمامًا الوصف التالي: أضفنا أيضًا بعض البيانات إلى الاستجابة لنلقي الضوء على سبب الخطأ. يفضل دائمًا عند التعامل مع الوعود إضافة معالجات للأخطاء والاستثناءات، لأن إهمال ذلك سيدفعك لمواجهة أخطاء غريبة. ومن الجيد أيضًا طباعة الكائن الذي سبب الاستثناء على طرفية التطوير: .catch(error => { console.log(error) response.status(400).send({ error: 'malformatted id' }) }) قد يُستدعى معالج الخطأ التي زرعته، نتيجة لخطأ مختلف تمامًا عن الخطأ الذي تريد اعتراضه. فإن طبعت الخطأ على الطرفية ستوفر على نفسك عناء جلسات التنقيح المحبطة. وعلاوة على ذلك تزودك معظم الخدمات الحديثة بوسيلة ما لطباعة عمليات النظام، بحيث يمكنك الاطلاع عليها متى أردت والخادم Heroku مثال مهم عليها. وطالما أنك تعمل على الواجهة الخلفية فابق نظرك على الطرفية التي تظهر لك خرج العملية حتى لو عملت على شاشة صغيرة، سيلفت وقوع أي خطأ انتباهك. تحويل معالجات الأخطاء إلى أداة وسطية لقد كتبنا شيفرة ملاحقة الأخطاء ضمن بقية أجزاء الشيفرة، ويبدو الأمر معقولًا أحيانًا، لكن من الأفضل إضافة معالجات الأخطاء في مكان واحد. سيكون هذا الأمر مفيدًا إذا أردنا لاحقًا أن نقدم تقريرًا عن الأخطاء إلى منظومة تتبع أخطاء خارجية مثل Sentry. لنغيّر معالج المسار api/notes/:id/، بحيث يمرر الخطأ إلى الدالة next كمعامل، وتمثل هذه الدالة بدورها المعامل الثالث لمعالج المسار: app.get('/api/notes/:id', (request, response, next) => { Note.findById(request.params.id) .then(note => { if (note) { response.json(note) } else { response.status(404).end() } }) .catch(error => next(error))}) عندما تستدعى next دون معامل، سينتقل التنفيذ بكل بساطة إل المسار أو الأداة الوسطية التالية. بينما لو امتلكت هذه الدالة معاملًا فسيتابع التنفيذ إلى الأداة الوسطية لمعالجة الخطأ. معالجات أخطاء المكتبة Express هي أدوات وسطية تعرّف على شكل دالة تقبل أربع معاملات. سيبدو معالج الخطأ بالشكل التالي: const errorHandler = (error, request, response, next) => { console.error(error.message) if (error.name === 'CastError') { return response.status(400).send({ error: 'malformatted id' }) } next(error) } app.use(errorHandler) يتحقق المعالج من طبيعة الخطأ. فإن كان الاستثناء هو خطأ تحويل نوع (CastError)، سنتأكد أن مصدر الخطأ هو كائن id بتنسيق مخالف لقواعد Mongo. وعندها سيرسل معالج الخطأ استجابته إلى المتصفح عبر كائن الاستجابة response الذي يمرر كمعامل للمعالج. أما في بقية الاستثناءات، فسيمرر المعالج الخطأ إلى معالج الخطأ الافتراضي في express. تسلسل استخدام الأدوات الوسطية تنفذ الأدوات الوسطية بنفس تسلسل استخدامها في express، أي بنفس تسلسل ظهور الأمر app.use. لذلك ينبغي الانتباه أثناء تعريفها. سيكون التسلسل الصحيح للاستخدام كالتالي: app.use(express.static('build')) app.use(express.json()) app.use(logger) app.post('/api/notes', (request, response) => { const body = request.body // ... }) const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } // handler of requests with unknown endpoint app.use(unknownEndpoint) const errorHandler = (error, request, response, next) => { // ... } // handler of requests with result to errors app.use(errorHandler) يجب أن تضع الأداة الوسطية json-parser بين الأدوات الوسطية التي تعرّف أولًا. فلو كان الترتيب كالتالي: app.use(logger) // request.body is undefined! app.post('/api/notes', (request, response) => { // request.body is undefined! const body = request.body // ... }) app.use(express.json()) لن تكون البيانات المرسلة عبر طلب HTTP بصيغة JSON متاحة للاستخدام عبر الأداة الوسطية للولوج أو عبر معالج المسار POST، لأن الخاصية request.body ستكون غير محددة undefined في هذه المرحلة. ومن المهم جدًا أن تُعرّف الأداة الوسطية التي تعالج مشكلة المسارات غير المدعومة ضمن التعريفات الأخيرة، تمامًا قبل معالج الأخطاء. سيسبب الترتيب التالي على سبيل المثال مشكلة: const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } // handler of requests with unknown endpoint app.use(unknownEndpoint) app.get('/api/notes', (request, response) => { // ... }) لقد ظهر معالج النهاية غير المحددة (unknown endpoint) قبل معالج طلبات HTTP. وطالما أن معالج النهايات غير المحددة سيستجيب إلى كافة الطلبات بالرمز 404 unknown endpoint، فلن يُستدعى بعدها أي مسار أو أداة وسطية. ويبقى الاستثناء الوحيد هو معالج الخطأ الذي يجب أن يأتي أخيرًا بعد معالج النهايات غير المحددة. خيارات أخرى سنضيف الآن بعض الوظائف التي لم ندرجها بعد، بما فيها الحذف وتحديث ملاحظة مفردة. وأسهل الطرق لحذف ملاحظة من قاعدة البيانات هي استخدام التابع findByIdAndRemove: app.delete('/api/notes/:id', (request, response, next) => { Note.findByIdAndRemove(request.params.id) .then(result => { response.status(204).end() }) .catch(error => next(error)) }) سيستجيب الخادم في كلتا حالتي نجاح عملية الحذف برمز الحالة 202 (لا يوجد محتوى). ونقصد بالحالتين، حذف ملاحظة موجودة فعلًا، أو حذف ملاحظة غير موجودة. يمكن أن نستخدم المعامل result للتحقق من حذف المورد أم لا، وبالتالي سنتمكن من تحديد أي من حالتي النجاح قد أعيدت إن كنا بحاجة ماسة لذلك. ستمرر كل الاستثناءات إلى معالج الأخطاء. يمكن بسهولة تغيير أهمية الملاحظة باستخدام التابع findByIdAndUpdate: app.put('/api/notes/:id', (request, response, next) => { const body = request.body const note = { content: body.content, important: body.important, } Note.findByIdAndUpdate(request.params.id, note, { new: true }) .then(updatedNote => { response.json(updatedNote) }) .catch(error => next(error)) }) تسمح الشيفرة السابقة أيضًا بتعديل محتوى الملاحظة لكنها لا تدعم تغيير تاريخ الإنشاء. ولاحظ كيف يتلقى التابع findByIdAndUpdate معاملًا على هيئة كائن JavaScript نظامي، وليس كائن ملاحظة أنشئ بواسطة الدالة البانية للنموذج Note. يبقى لدينا تفصيل مهم يتعلق بالتابع findByIdAndUpdate. فمعامل معالج الحدث upDateNote سيستقبل الملف الأصلي للملاحظة بلا تعديلات. لذلك وضعنا المعامل الاختياري {new: true}، الذي يسبب استدعاء معالج الحدث بالنسخة المعدَّلة من الملاحظة بدلًا من الأصلية. بعد اختبار الواجهة الخلفية مباشرة مستخدمين Postman أو VS Code REST client، يمكننا التحقق من أنها تعمل بشكل صحيح. وكذلك التحقق من أن الواجهة الأمامية تتكامل مع الخلفية التي تستخدم قاعدة البيانات. لكن عندما نحاول تغيير أهمية ملاحظة، تستظهر على الطرفية رسالة الخطأ التالية: استخدم google للبحث عن سبب الخطأ وسيقودك إلى إرشادات لتصحيحه. واتباعًا للاقتراح الموجود في توثيق Mongoose، أضفنا السطر التالي للملف note.js: const mongoose = require('mongoose') mongoose.set('useFindAndModify', false) // ... module.exports = mongoose.model('Note', noteSchema) ستجد شيفرة التطبيق بشكله الحالي في المستودع part3-5 على موقع GitHub. التمارين 3.15 - 3.18 3.15 دليل هاتف بقاعدة بيانات: الخطوة 3 عدّل شيفرة الواجهة الخلفية لتحذف المُدخَلات مباشرة من قاعدة البيانات. تحقق أن الواجهة الأمامية تعمل بشكل صحيح بعد التعديلات. 3.16 دليل هاتف بقاعدة بيانات: الخطوة 4 حوّل معالج الخطأ في التطبيق إلى أداة وسطية جديدة لمعالجة الأخطاء. 3.17 دليل هاتف بقاعدة بيانات: الخطوة 5 * إذا حاول المستخدم إنشاء مُدخَل جديد إلى الدليل، وكان اسم الشخص موجودًا مسبقًا، ستحاول الواجهة الأمامية تحديث رقم الهاتف للمُدخَل الموجود بإرسال طلب HTTP-PUT إلى عنوان المُدخَل. عدّل الواجهة الخلفية لتدعم هذا الفعل، ثم تأكد أن الواجهة الأمامية ستعمل بعد التعديل. 3.18 دليل هاتف بقاعدة بيانات: الخطوة 6 * عدّل معالج المسار api/persons/:id ومعالج المسار api/persons/info ليستخدما قاعدة البيانات، ثم تحقق من أنهما يعملان جيدًا مستخدمًا المتصفح وPostman وVS Code REST client. سيبدو لك الأمر عند التحقق من مُدخَل فردي إلى دليل الهاتف كما في الشكل التالي: ترجمة -وبتصرف- للفصل saving data to MongoDB من سلسلة Deep Dive Into Modern Web Development
  9. سنربط في الفقرات التالية تطبيق الواجهة الأمامية الذي أنشأناه في القسم السابق من هذه السلسلة مع تطبيق الواجهة الخلفية. رأينا في القسم السابق، أن الواجهة الأمامية قادرة على الوصول إلى قائمة بكل الملاحظات من خادم JSON الذي لعب دور الواجهة الخلفية وذلك بطلب العنوان http://localhost:3001/notes. لكن عنوان الموقع قد تغير قليلًا الآن، وستجد الملاحظات على العنوان http://localhost:3001/api/notes. إذًا، سنغيّر الصفة baseUrl في الملف src/services/notes.js كالتالي: import axios from 'axios' const baseUrl = 'http://localhost:3001/api/notes' const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } // ... export default { getAll, create, update } لم يعمل الطلب GET إلى العنوان http://localhost:3001/api/notes لسبب ما. مالذي يحدث؟ يمكننا التحقق بالولوج إلى الواجهة الخلفية عبر المتصفح باستخدام Postman بلا أدنى مشكلة. سياسة الجذر المشترك ومفهوم CORS تأتي التسمية CORS من العبارة Cross-Origin Resource Sharing وتعني هذه العبارة "مشاركة الموارد ذات الجذور المختلطة". ووفقًا لموقع Wikipedia: تظهر هذه المشكلة في حالتنا بالصورة التالية: لا يمكن لشيفرة JavaScript التي نكتبها لتطبيق الواجهة الأمامية أن يعمل افتراضيًا مع خادم حتى يشتركا بالجذر نفسه. حيث يعمل تطبيق الخادم الذي أنجزناه سابقًا على المنفذ 3001، ويعمل تطبيق الواجهة الأمامية على المنفذ 3000. فلا يملكان جذرًا مشتركًا. وتذكر أن هذه السياسة لاتنطبق على React و Node فقط، بل هي سياسة عالمية لتشغيل تطبيقات الويب. يمكننا تخطي ذلك في تطبيقاتنا باستعمال الأداة الوسطية cors التي تقدمها Node. ثَبّت cors باستخدام الأمر التالي: npm install cors --save أدرج الأداة واستعملها على النحو: const cors = require('cors') app.use(cors()) وهكذا سترى أن تطبيق الواجهة الأمامية سيعمل، لكننا لم نضف حتى الآن وظيفة تغيير أهمية الملاحظة إلى الواجهة الخلفية. يمكنك الاطلاع على معلومات أكثر عن الأداة CORS من خلال Mozilla's page. تطبيقات للإنترنت طالما تأكدنا أن التطبيق بشقيه أصبح جاهزًا للعمل، سننقله إلى الإنترنت. سنستعين بالخادم Heroku لتنفيذ ذلك. إن لم تستخدم Heroku من قبل، ستجد تعليمات الاستخدام في توثيق Heroku أو بالبحث عبر الإنترنت. أضف الملف Procfile إلى جذر المشروع لتخبر Heroku كيف سيُشغّل التطبيق. web: npm start غيّر تعريف المنفذ الذي نستخدمه في تطبيقنا أسفل الملف index.js ليصبح كالتالي: const PORT = process.env.PORT || 3001 app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) وهكذا فإما أن سنستخدم المنفذ الذي عرّفناه كمتغيير بيئة أو المنفذ 3001 إن لم يُعرّف منفذ من خلال متغير البيئة. يُهيِّئ Heroku منفذ التطبيق بناء على قيمة منفذ متغير البيئة. أنشئ مستودع git في جذر المشروع وأضف الملف ذو اللاحقة gitignore. وفيه المعلومات التالية: node_modules أنشئ تطبيق Heroku بتنفيذ الأمر heroku create، حمل شيفرتك إلى المستودع ثم انقله إلى Heroku بتنفيذ الأمر git push heroku master. إن جرى كل شيء على مايرام، سيعمل التطبيق: إن لم يعمل التطبيق، تحقق من المشكلة بقراءة سجلات Heroku، وذلك بتنفيذ الأمر heroku logs. من المفيد في البداية أن تراقب باستمرار ما يظهره Heroku من سجلات. وأفضل وسيلة للمراقبة تنفيذ الأمر heroku log -t الذي يطبع سجلاته على الطرفية، عندما تحصل مشكلة ما على الخادم. إن كنت ستنشر تطبيقك من مستودع git ولم تكن الشيفرة موجودة في الفرع الرئيسي (إي إن كنت ستعدل مستوع الملاحظات من الدرس السابق)، عليك استخدام الأمر git push heroku HEAD:master. وإن فعلت ذلك مسبقًا، ربما ستحتاج إلى تنفيذ هذا الأمر git push heroku HEAD:master --force. تتكامل الواجهة الخلفية مع الأمامية على Heroku أيضًا. يمكنك التحقق من ذلك بتغيير عنوان الواجهة الخلفية الموجود ضمن شيفرة الواجهة الأمامية، ليصبح نفس عنوان الواجهة الخلفية على Heroku بدلًا من http://localhost:3001 السؤال التالي هو: كيف سننشر تطبيق الواجهة الأمامية على الإنترنت؟ لدينا العديد من الخيارات. بناء نسخة الإنتاج من الواجهة الأمامية لقد أنشأنا حتى هذه اللحظة تطبيقات React في وضعية التطوير (development mode)، حيث يهيأ التطبيق لإعطاء رسائل خطأ واضحة، وتُصيّر الشيفرة مباشرة عند حدوث أية تغييرات وهكذا. لكن عندما يغدو التطبيق جاهزًا للنشر لابد من إنشاء نسخة إنتاج (production build) أو نسخة التطبيق المتمثلة للإنتاج. ننشئ نسخة الإنتاج من التطبيقات التي بنيت باستخدام create-react-app بتنفيذ الأمر npm run build. لننفذ الأمر السابق عند جذر مشروع الواجهة الأمامية. ينشئ تنفيذ الأمر السابق مجلدًا يدعى build (يحوي ملف HTML الوحيد للتطبيق ويدعى index.html) وفي داخله مجلد آخر يدعى static ستوَلَّد فيه نسخة مصغرة عن شيفرة JavaScript للتطبيق. وعلى الرغم من وجود عدة ملفات JavaScript في تطبيقنا، إلا أنها ستُجمّع ضمن ملف مصغّر واحد. وفي واقع الأمر، سيَضم هذا الملف كل محتويات ملفات الارتباط الخاصة بالتطبيق أيضًا. لن يكون فهم أو قراءة محتويات هذا الملف يسيرًا كما ترى: !function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];c<i.length;c++)f=i[c],o[f]&&s.push(o[f][0]),o[f]=0;for(n in l)Object.prototype.hasOwnProperty.call(l,n)&&(e[n]=l[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,a||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,i=1;i<t.length;i++){var l=t[i];0!==o[l]&&(n=!1)}n&&(u.splice(r--,1),e=f(f.s=t[0]))}return e}var n={},o={2:0},u=[];function f(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,f),t.l=!0,t.exports}f.m=e,f.c=n,f.d=function(e,r,t){f.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},f.r=function(e){"undefined"!==typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}) تقديم الملفات ذات المحتوى الثابت من الواجهة الخلفية إن أحد الطرق المتبعة في نشر الواجهة الأمامية هو بناء نسخة إنتاج ووضعها في جذر المستودع الذي يحتوي الواجهة الخلفية. ومن ثم نهيئ الواجهة الخلفية لتعرض الصفحة الرئيسية للواجهة الأمامية (build/index.html). نبدأ العملية بنقل نسخة الإنتاج إلى جذر الواجهة الخلفية. استخدم الأمر التالي في إجراء عملية النسخ إن كنت تستخدم أحد نظامي التشغيل Mac أو Linux cp -r build ../../../osa3/notes-backend واستخدم في النظام windows إحدى التعليميتن copy أو xcopy أو استخدم ببساطة النسخ و اللصق. سيبدو مجلد الواجهة الخلفية كالتالي: سنستخدم أداة وسطية مدمجة مع المكتبة express تعرض الملفات ذات المحتوى الثابت التي تحضرها من الخادم مثل الملف index.html وملفات JavaScript وغيرها، تدعى هذه الأداة static. فعندما نضيف العبارة التالية إلى شيفرة الواجهة الخلفية: app.use(express.static('build')) ستتحقق Express من محتويات المجلد build عندما تتلقى أية طلبات HTTP-GET. فإن وجدت ملفًا مطابقًا للملف المطلوب ستعيده. وهكذا ستظهر الواجهة الأمامية المبنية باستخدام React، عندما يستجيب الخادم إلى طلبات GET إلى العنوان www.serversaddress.com/index.html أو إلى العنوان www.serversaddress.com. بينما تتعامل الواجهة الخلفية مع الطلب GET إلى العنوان www.serversaddress.com/api/notes. في حالتنا هذه سنجد أن للواجهتين العنوان نفسه، لذلك نستطيع أن نعطي للمتغير baseUrl عنوان موقع نسبي، وذلك كي لانذكر القسم الأول من العنوان والمتعلق بالخادم. import axios from 'axios' const baseUrl = '/api/notes' const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) // ... } بعد إجراء تلك التغييرات، علينا أن ننشئ نسخة إنتاج ونضعها في جذر مستودع الواجهة الخلفية. وبالتالي سنصبح قادرين على استخدام الواجهة الأمامية بطلب عنوان الواجهة الخلفية http://localhost:3001. وهكذا سيعمل تطبيقنا تمامًا كتطبيق الصفحة الواحدة النموذجي الذي درسناه في القسم 0. فعندما نطلب عنوان الواجهة الخلفية http://localhost:3001 سيعيد الخادم الملف index.html من المجلد build. ستجد محتوى الملف (بشكل مختصر) كالتالي: <head> <meta charset="utf-8"/> <title>React App</title> <link href="/static/css/main.f9a47af2.chunk.css" rel="stylesheet"> </head> <body> <div id="root"></div> <script src="/static/js/1.578f4ea1.chunk.js"></script> <script src="/static/js/main.104ca08d.chunk.js"></script> </body> </html> يتضمن الملف تعليمات لإحضار ملف CSS الذي يعرف تنسيقات عناصر التطبيق ومعرّفي شيفرة (script tag) يوجهان المتصفح إلى إحضار شيفرة JavaScript التي يحتاجها التطبيق، وهي الشيفرة الفعلية لتطبيق React. تحضر شيفرة React الملاحظات من العنوان http://localhost:3001/api/notes، وتصيّرها على الشاشة. يمكنك أن تتابع تفاصيل الاتصال بين المتصفح والخادم من نافذة Network ضمن طرفية التطوير: بعد أن نتأكد من عمل نسخة الإنتاج على الخادم المحلي، انقل المجلد build الذي يحوي الواجهة الأمامية إلى مستودع الواجهة الخلفية، ثم انقل الشيفرة كلها إلى خادم Heroku مجددًا. سيعمل التطبيق بشكل جيد، ماعدا الجزئية التي تتعلق بتغيير أهمية الملاحظات. حتى اللحظة يحفظ تطبيقنا الملاحظات ضمن متغيّر، وبالتالي إذا ما حدث أمر ما وتوقف التطبيق عن العمل ستختفي تلك الملاحظات. إذًا لايزال التطبيق بحاجة إلى قاعدة بيانات. سنشرح تاليًا عددًا من النقاط قبل أن ننتقل إلى قاعدة البيانات. نشر الواجهة الخلفية بطريقة انسيابية سنكتب سكربت npm بسيط لإنشاء نسخة إنتاج من الواجهة الأمامية بأقل جهد ممكن. ضع الشيفرة التالية في ملف package.json الموجود في مستودع الواجهة الخلفية: { "scripts": { //... "build:ui": "rm -rf build && cd ../../osa2/materiaali/notes-new && npm run build --prod && cp -r build ../../../osa3/notes-backend/", "deploy": "git push heroku master", "deploy:full": "npm run build:ui && git add . && git commit -m uibuild && npm run deploy", "logs:prod": "heroku logs --tail" } } عندما ينفذ npm الجزء (build:ui) من السكريبت السابقة بالأمر npm run build:ui، سيبني نسخة إنتاج من الواجهة الأمامية ضمن مستودع الواجهة الخلفية. بينما سينشر الأمر npm run deploy النسخة الحالية للواجهة الخلفية على الخادم Heroku. وأخيرًا يدمج الأمر npm run deploy:full التطبيقين معًا ويزودهما بأوامر git تساعد في تحديث مستودع الواجهة الخلفية. يبقى هناك جزء من سكريبت npm يُنفَّذ بالأمر npm run logs:prod، ويُستخدَم لطباعة سجلات Heroku أثناء التنفيذ. انتبه: تعتمد المسارات المكتوبة في الجزء build:ui من السكربت على موقع المستودعات في منظومة الملفات. تُنفَّذ npm سكريبت في نظام windows باستخدام cmd.exe لأن واجهة النظام (shell) الافتراضية لاتدعم أوامر أو واجهة bash. ولتنفيذ الأوامر السابقة استبدل واجهة النظام بالواجهة bash باستخدام محرر Git الافتراضي في windows كالتالي: npm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe" الخادم الوكيل لن تعمل الواجهة الأمامية بعد التغييرات التي أجريناها في وضعية التطوير (عندما تُشغَّل باستخدامnpm start)، لأن الاتصال مع الواجهة الخلفية لن يعمل. وذلك لتغيّر عنوان الواجهة الخلفية إلى عنوان نسبي: const baseUrl = '/api/notes' لقد كان عنوان الواجهة الأمامية في مرحلة التطوير localhost:3000، وبالتالي ستُرسل الطلبات الآن إلى الواجهة الخلفية على العنوان الخاطئ localhost:3000/api/notes. وطبعًا الواجهة الخلفية موجودة فعلًا على العنوان localhost:3001. سيكون الحل بسيطًا عندما ننشئ المشروع باستخدام create-react-app. أضف التصريحات التالية إلى الملف package.json الموجود في مستودع الواجهة الأمامية: { "dependencies": { // ... }, "scripts": { // ... }, "proxy": "http://localhost:3001" } بعد إعادة تشغيل التطبيق، ستعمل بيئة تطوير React كخادم وكيل. فلو أرسلت شيفرة React طلبًا إلى خادم على العنوان http://localhost:3000، ولم يكن تطبيق React هو من يدير هذا الخادم (أي في الحالة التي لا تحضر فيها الطلبات ملفات CSS أو JavaScript الخاصة بالتطبيق)، سيعاد توجيه الطلب إلى الخادم الذي عنوانه http://localhost:3001. وهكذا ستعمل الواجهة الأمامية بشكل جيد في وضعي التطوير والإنتاج. إن سلبية هذه المقاربة هي التعقيد الذي تظهره في نشر الواجهة الأمامية على الإنترنت. فنشر نسخة جديدة سيتطلب إنشاء نسخة إنتاج جديدة ونقلها إلى مستودع الواجهة الخلفية. وسيصعّب ذلك إنشاء خطوط نشر آلية. وتعرّف خطوط النشر بأنها طريقة مؤتمتة وقابلة للتحكم لنقل الشيفرة من حاسوب المطور إلى بيئة الإنتاج مرورًا باختبارات مختلفة بالإضافة إلى التحقق من الجودة. يمكن إنجاز ذلك بطرق عدة، منها وضع الواجهتين الخلفية والأمامية في نفس المستودع. لكننا لن نناقش ذلك الآن. في بعض الحالات من المعقول أن ننشر الواجهة الأمامية على شكل تطبيق مستقل وهذا أمر بسيط ويطبق مباشرة إن كتب التطبيق باستخدام create-react-app. ستجد الشيفرة الحالية للواجهة الخلفية في الفرع part3-3 على Github. بينما ستجد التعديلات على الواجهة الأمامية في الفرع part3-1 في مستوودع الواجهة الأمامية. التمارين 3.9 - 3.11 لايتطلب إنجاز التمارين التالية العديد من الأسطر البرمجية. لكنها ستحمل شيئًا من التحدي، لأنها تتطلب فهمًا دقيقًا لما يحدث وأين يحدث، بالإضافة إلى تهيئة التطبيق كما يجب. 3.11 دليل هاتف للواجهة الخلفية: الخطوة 9 اجعل الواجهة الخلفية تتكامل مع الأمامية في نفس التمرين من القسم السابق. لا تضف وظيغة تغيير الأرقام حاليًا، بل سنقوم بذلك لاحقًا. ربما ستغيّر قليلًا في الواجهة الأمامية، على الأقل عناوين مواقع الواجهة الخلفية. وتذكر أن تبقي طرفية التطوير مفتوحة في متصفحك. تحقق مما يحدث إن فشل أي طلب HTTP من خلال النافذة Network، وابق نظرك دائما على طرفية تطوير الواجهة الخلفية في نفس الوقت. إن لم تتمكن من إنجاز التمرين السابق، ستفيدك طباعة بيانات الطلب أوجسم الطلب على الطرفية بزرع تعليمة الطباعة في دالة معالج الحدث المسؤولة عن طلبات POST. 3.10 دليل هاتف للواجهة الخلفية: الخطوة 10 انشر الواجهة الخلفية على الإنترنت (على Heroku مثلًا). ملاحظة: إن لم تستطع لسبب ما تثبيت Heroku على حاسوبك، استخدم الأمر npx heroku-cli. اختبر الواجهة الخلفية التي نشرتها من خلال المتصفح بمساعدة Postman أو VS Code REST client للتأكد من أنها تعمل. نصيحة للاحتراف: عندما تنشر تطبيقك على Heroku، يفضل -على الأقل في البداية- أن تبقي نظرك على ما يطبعه تطبيق Heroku دائمًا، وذلك بتنفيذ الأمر heroku logs -t. تمثل الصورة التالية مايطبعه heroku عندما لا يستطيع إيجاد ملف الارتباط express. والسبب أن الخيار save-- لم يحدد عندما ثُبتت المكتبة express. وبالتالي لم تحفظ معلومات ملف الارتباط ضمن الملف package.json. أحد الأخطاء الأخرى، هو أن التطبيق لم يهيأ لاستعمال المنفذ الذي عُرِّف في متغير البيئة PORT: أنشئ ملفًا باسم README.md في جذر مستودعك، وأضف إليه رابطًا لتطبيقك على الإنترنت. 3.11 دليل هاتف للواجهة الخلفية: الخطوة 11 أنشئ نسخة إنتاج من الواجهة الأمامية، وانشرها على الإنترنت بالأسلوب الذي ناقشناه في هذا الفصل. ملاحظة: تأكد من أن المجلد build ليس ضمن قائمة الملفات التي يهملها git، وهو الملف الذي لاحقته gitignore. تأكد أيضًا أن الواجهة الأمامية لازالت تعمل. ترجمة -وبتصرف- للفصل Deploying App to Internet من سلسلة Deep Dive Into Modern Web Development
  10. full_stack_101

    سنوجّه اهتمامنا في هذا القسم إلى التعامل مع الواجهة الخلفية وآلية تطوير التطبيق بإضافة وظائف جديدة تنفذ هذه المرة من قبل الخادم. سنبني تلك الوظائف باستخدام NodeJS وهي بيئة تشغيل JavaScript مبنية على محرك JavaScript من تصميم Google يدعى Chrome V8. كُتبَت المادة العلمية للمنهاج باستخدام Node.js 10.18.0، وتأكد من أن النسخة المثبتة على جهازك هي على الأقل مطابقة للإصدار السابق، ويمكنك استخدام الأمر node -v للتحقق من الإصدار المثبت لديك. أشرنا سابقًا في مقال أساسيات جافاسكربت اللازمة للعمل مع React أن المتصفحات لا تدعم كامل الميزات الأحدث للغة JavaScript مباشرةً، لذلك من الضروري نقل الشيفرة التي تعمل على المتصفح إلى إصدار أقدم باستخدام babel مثلًا. لكن الأمر سيختلف تمامًا مع JavaScript التي تُنفّذ في الواجهة الخلفية، ذلك أن الإصدار الأحدث من Node.js سيدعم الغالبية العظمى من الميزات الجديدة للغة، فلا حاجة عندها للنقل. سنضيف شيفرة تتعامل مع تطبيق الملاحظات الذي تعرفنا عليه سابقًا في مقالات سابقة من هذه السلسلة لكن تنفيذها سيكون في الواجهة الخلفية. لكن علينا أولًا تعلم الأساسيات من خلال كتابة التطبيق التقليدي "Hello world". ملاحظة: لن تكون جميع التطبيقات والتمارين في هذا القسم تطبيقات React، ولن نستخدم create-react-app في تهيئة المشاريع التي تضم التطبيقات. لقد تعرفنا سابقًا في مقال إحضار البيانات من الخادم في تطبيقات React على مدير الحزم npm وهو أداة لإدارة حزم JavaScript تعود أصلًا إلى بيئة Node.js. انتقل إلى مجلد مناسب وأنشئ قالبًا لتطبيقنا مستخدمًا الأمر npm init. أجب عن الأسئلة التي تولدها الأداة، وستكون النتيجة إنشاء الملف package.json ضمن جذر المشروع يضم معلومات عنه. { "name": "backend", "version": "0.0.1", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "Matti Luukkainen", "license": "MIT" } من هذه المعلومات بالطبع -ستكون قد أدخلتها عند إجابتك عن الأسئلة- اسم المشروع ونقطة انطلاق التطبيق والتي هي الملف index.js في تطبيقنا. لنجري بعض التعديلات على محتوى الكائن scripts في الملف: { // ... "scripts": { "start": "node index.js", "test": "echo \"Error: no test specified\" && exit 1" }, // ... } لننشئ الإصدار الأول لتطبيقنا بإضافة ملف باسم index.js إلى المجلد الجذري الذي أنشأنا فيه المشروع. اكتب الأمر التالي في الملف: console.log('hello world') يمكن تشغيل التطبيق من node بكتابة التالي في سطر الأوامر: node index.js كما يمكن تشغيله كسكريبت (npm script): npm start ستعمل سكريبت npm لأننا عرفناها ضمن الكائن script في ملف package.json: { // ... "scripts": { "start": "node index.js", "test": "echo \"Error: no test specified\" && exit 1" }, // ... } سيُنفِّذ npm المشروع عند استدعاء الملف index.js من سطر الأوامر. يعتبر تشغيل التطبيق كسكريبت npm أمرًا اختياريًا. يعرّف ملف package.json افتراضيًا سكريبت npm أخرى تدعى npm test. وطالما أن المشروع لا يضم حتى الآن مكتبة للاختبارات، سينفذ npm الأمر التالي: echo "Error: no test specified" && exit 1 خادم ويب بسيط لنحوّل تطبيقنا إلى خادم ويب: const http = require('http') const app = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('Hello World') }) const PORT = 3001 app.listen(PORT) console.log(`Server running on port ${PORT}`) ستطبع الطرفية الرسالة التالية بمجرد تشغيل التطبيق: Server running on port 3001 سنشغل تطبيقنا المتواضع بطلب عنوان الموقع http://localhost:3001 من المتصفح: سيعمل الخادم كما سبق بغض النظر عن بقية أقسام العنوان، حتى أن كتابة عنوان الموقع بالشكل http://localhost:3001/foo/bar يعطي النتيجة نفسها. ملاحظة: إن كان المنفذ 3001 محجوزًا من قبل تطبيق آخر، سينتج عن تشغيل الخادم رسالة الخطأ التالية: ➜ hello npm start > hello@1.0.0 start /Users/mluukkai/opetus/_2019fullstack-code/part3/hello > node index.js Server running on port 3001 events.js:167 throw er; // Unhandled 'error' event ^ Error: listen EADDRINUSE :::3001 at Server.setupListenHandle [as _listen2] (net.js:1330:14) at listenInCluster (net.js:1378:12) في هذه الحالة ستكون أمام خياران: إما أن تغلق التطبيق الذي يَشغُل المنفذ 3001، أو اختيار منفذ آخر. لنتأمل الآن السطر الأول من شيفرة تطبيق الخادم: const http = require('http') يُدرج التطبيق وحدة خادم الويب web server المدمجة ضمن Node. لقد تعلمنا إدراج الوحدات في شيفرة الواجهة الأمامية لكن بعبارة مختلفة قليلًا: import http from 'http' تُستعمل حاليًا وحدات ES6 ضمن شيفرة الواجهة الأمامية. وتذكّر أنّ تعريف الوحدات يكون باستعمال التعليمة export واستخدامها ضمن الشيفرة باستعمال التعليمة import. تَستعمل Node.js ما يسمى CommonJS (وحدات JavaScript المشتركة). ذلك أن بيئتها تطلبت وجود الوحدات قبل أن تدعمها JavaScript بوقت طويل. بدأت Node.js بالدعم التجريبي لوحدات ES6 مؤخّرًا فقط. لن نجد فرقًا تقريبًا بين وحدات ES6 ووحدات CommonJS، على الأقل ضمن حدود منهاجنا. ستبدو القطعة التالية من الشيفرة على النحو التالي: const app = http.createServer((request, response) => { response.writeHead(200, { 'Content-Type': 'text/plain' }) response.end('Hello World') }) تستخدم الشيفرة التابع createServer الموجود ضمن الوحدة http لإنشاء خادم ويب جديد. تعرّف الشيفرة بعد ذلك معالج حدث ضمن الخادم، يُستدعى كلما ورد طلب HTTP إلى العنوان http://localhost:3001. يستجيب الخادم برمز الحالة (200) معيدًا ترويسة "نوع المحتوى" على أنها text/plain (نص أو فارغ)، وكذلك محتوى صفحة الويب التي ستُعرض، وهذا المحتوى هو العبارة "Hello World". تهيئ أسطر الشيفرة الأخيرة خادم http الذي أُسند إلى المتغيّر App لينصت إلى طلبات HTTP القادمة إلى المنفذ 3001: const PORT = 3001 app.listen(PORT) console.log(`Server running on port ${PORT}`) إنّ الغاية الأساسية من استخدام خادم الواجهة الخلفية في منهاجنا، هو تقديم بيانات خام بصيغة JSON إلى الواجهة الأمامية. ولهذا السبب سنعدّل الخادم (تطبيق الخادم) ليعيد قائمة من الملاحظات المكتوبة مسبقًا بصيغة JSON: const http = require('http') let notes = [ { id: 1, content: "HTML is easy", date: "2019-05-30T17:30:31.098Z", important: true }, { id: 2, content: "Browser can execute only Javascript", date: "2019-05-30T18:39:34.091Z", important: false }, { id: 3, content: "GET and POST are the most important methods of HTTP protocol", date: "2019-05-30T19:20:14.298Z", important: true } ] const app = http.createServer((request, response) => { response.writeHead(200, { 'Content-Type': 'application/json' }) response.end(JSON.stringify(notes)) }) const PORT = 3001 app.listen(PORT) console.log(`Server running on port ${PORT}`) لنعد تشغيل الخادم ولنحدث المتصفح. ملاحظة: يمكنك إغلاق الخادم بالضغط على ctrl+c من طرفية سطر أوامر Node.js. تُعلِم القيمة (application/JSON) الموجودة في ترويسة "نوع المحتوى" متلقي البيانات أنها بصيغة JSON.تُحوَّل المصفوفة notes إلى JSON باستخدام التابع ()JSON.stringify. ستظهر المعلومات على المتصفح تمامًا كما ظهرت في مقال إحضار البيانات من الخادم في تطبيقات React عندما استخدمنا خادم JSON. مكتبة Express يمكن كما رأينا كتابة شيفرة الخادم مباشرة باستخدام الوحدة http المدمجة ضمن Node.js.لكن الأمر سيغدو مربكًا عندما يزداد حجم التطبيق. طوّرت العديد من المكتبات لتسهّل تطوير تطبيقات الواجهة الخلفية باستخدام Node، وذلك بتقديم واجهة أكثر ملائمة للعمل بالموازنة مع وحدة http المدمجة. تعتبر المكتبة express حتى الآن الأكثر شعبية لتحقيق المطلوب. لنضع express موضع التنفيذ بتعريفها كملف اعتمادية dependency وذلك بتنفيذ الأمر: npm install express --save يُضاف ملف الاعتمادية أيضًا إلى الملف package.json: { // ... "dependencies": { "express": "^4.17.1" } } تُثبت الشيفرة المصدرية لملف الاعتمادية ضمن المجلد node_modules الموجود في المجلد الجذري للمشروع. يمكنك إيجاد عدد كبير من ملفات الاعتمادية بالإضافة إلى express ضمن هذا المجلد. في الواقع سيضم المجلد السابق ملفات اعتمادية express وملفات اعتمادية متعلقة بملفات اعتمادية express وهكذا. ندعو هذا الترتيب بملفات الاعتمادية الانتقالية transitive dependencies للمشروع. ثُبِّتت في مشروعنا المكتبة express 4.17.1. لكن ما الذي تعنيه إشارة (^) أمام رقم الإصدار في ملف package.json؟ "express": "^4.17.1" يستخدم npm ما يسمى بآلية الإصدار الدلالية semantic versioning، وتعني الدلالة (^) أنه إذا حُدِّثت ملفات اعتمادية المشروع فإن إصدار express سيبقى 4.17.1. يمكن أن يتغير رقم الدفعة ضمن الإصدار (الرقم الأخير) أو رقم الإصدار الثانوي (الرقم الأوسط) لكن رقم الإصدار الرئيسي (الرقم الأول) يجب أن يبقى كما هو. نستخدم الأمر التالي لتحديث ملفات اعتمادية المشروع: npm update يمكننا تثبيت أحدث ملفات اعتمادية معرفة في ملف package.json إذا أردنا أن نعمل على مشروعنا في حاسوب آخر باستخدام الأمر: npm install وبشكل عام عند تحديث ملف اعتمادية، يدل عدم تغير رقم الإصدار الرئيسي أن الإصدار الأحدث سيبقى متوافقًا مع الإصدار الأقدم دون الحاجة لتغييرات في الشيفرة. بينما تغير رقم الإصدار الرئيسي سيدل أن الإصدار الأحدث قد يحوي تغييرات قد تمنع التطبيق من العمل. فقد لا يعمل تطبيقنا إن كان إصدار المكتبة express فيه 4.17.7 مثلًا وتم تحديثها إلى الإصدار 5.0.0 (الذي قد نراه مستقبلًا)، بينما سيعمل مع الإصدار 4.99.1. استخدام express في تطوير صفحات الويب لنعد إلى تطبيقنا ونجري بعض التعديلات عليه: const express = require('express') const app = express() let notes = [ ... ] app.get('/', (req, res) => { res.send('<h1>Hello World!</h1>') }) app.get('/api/notes', (req, res) => { res.json(notes) }) const PORT = 3001 app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) سنعيد تشغيل التطبيق حتى نحصل على النسخة الجديدة منه. طبعًا لن نلاحظ تغييرًا كليًا فلقد أدرجنا express على شكل دالة ستنشئ تطبيق express يُخزّن ضمن المتغيّر App: const express = require('express') const app = express() وبعدها عرّفنا مسارين للتطبيق، يعرّف الأول معالج حدث يتعامل مع طلبات HTTP-GET الموجهة إلى المجلد الجذري للتطبيق (' / '): app.get('/', (request, response) => { response.send('<h1>Hello World!</h1>') }) حيث تقبل دالة المعالج معاملين الأول request ويحوي كل المعلومات عن طلب HTTP، ويستخدم الثاني response لتحديد آلية الاستجابة للطلب. يستجيب الخادم إلى الطلب في شيفرتنا باستخدام التابع send العائد للكائن response. حيث يرسل الخادم عند الاستجابة العبارة النصية <h1>Hello World!</h1>التي مُرّرت كمعامل للتابع send. وطالما أن القيمة المعادة نصية ستَسند express القيمة text/html إلى ترويسة "نوع المحتوى" ويعاد رمز الحالة 200. يمكننا التحقق من ذلك من خلال النافذة Network في طرفية تطوير المتصفح: بالنسبة للمسار الثاني فإنه يعرّف معالج حدث يتعامل مع طلبات HTTP-GET الموجهة إلى موقع وجود الملاحظات، نهاية المسار notes: app.get('/api/notes', (request, response) => { response.json(notes) }) يستجيب الخادم إلى الطلب باستخدام التابع json العائد للكائن response. حيث تُرسل مصفوفة الملاحظات التي مُرّرت للتابع بصيغة JSON النصية. وكذلك ستتكفل express بإسناد القيمة application/json إلى ترويسة "نوع المحتوى". سنلقي تاليًا نظرة سريعة على البيانات التي أرسلت بصيغة JSON. كان علينا سابقًا تحويل البيانات إلى نصوص مكتوبة بصيغة JSON باستخدام التابع JSON.stringify: response.end(JSON.stringify(notes)) لا حاجة لذلك عند استخدام express، فهي تقوم بذلك تلقائيًا. وتجدر الإشارة هنا أنه لا فائدة من كون JSON مجرد نص، بل يجب أن يكون كائن JavaScript مثل notes الذي أُسند إليه. ستشرح لك التجربة الموضحة في الشكل التالي هذه الفكرة: أُنجز الاختبار السابق باستخدام الوحدة التفاعلية node-repl. يمكنك تشغيل هذه الوحدة بكتابة node في سطر الأوامر. تفيدك هذه الوحدة بشكل خاص لتوضيح طريقة عمل الأوامر، وننصح بشدة أن تستخدمها أثناء كتابة الشيفرة. مكتبة nodemon رأينا سابقًا ضرورة إعادة تشغيل التطبيق عند تعديله حتى تظهر نتائج التعديلات. ونقوم بذلك عن طريق إغلاق التطبيق أولًا باستخدام ctrl+c ثم تشغيله من جديد. طبعًا فالأمر مربك بالموازنة مع طريقة عمل React التي تقوم بذلك تلقائيًا بمجرد تغيّر الشيفرة. إن حل هذه المشكلة يكمن في استخدام nodemon. سنثبت الآن nodemon كملف اعتمادية باستخدام الأمر: npm install --save-dev nodemon سيتغير أيضًا محتوى الملف package.json: { //... "dependencies": { "express": "^4.17.1", }, "devDependencies": { "nodemon": "^2.0.2" } } إن أخطأت في كتابة الأمر وظهر ملف اعتمادية nodemon تحت مسمى ملفات الاعتمادية "dependencies" بدلًا من ملفات اعتمادية التطوير"devDependencies"، أصلح الأمر يدويًا في ملف package.json. إن الإشارة لملف اعتمادية على أنه ملف اعتمادية تطوير هو للدلالة على الحاجة له أثناء تطوير التطبيق فقط، ليعيد على سبيل المثال تشغيل التطبيق كما تفعل nodemon. ولن تحتاجها لاحقًا عندما تشغل التطبيق على خادم الاستثمار الفعلي مثل Heroku. لتشغيل التطبيق مع nodemon اكتب الأمر التالي: node_modules/.bin/nodemon index.js سيسبب الآن أي تغيير في الشيفرة إعادة تشغيل الخادم تلقائيًا. وطبعًا لا فائدة من إعادة تشغيل الخادم إن لم نحدث المتصفح الذي يعرض الصفحة، لذلك علينا القيام بذلك يدويًا. فلا نمتلك حاليًا وسيلة لإعادة التحميل الدائم (hot reload) كما في React. يبدو الأمر السابق طويلًا، فلنعرف إذًا سكريبت npm خاصة بتشغيل التطبيق ضمن الملف package.json: { // .. "scripts": { "start": "node index.js", "dev": "nodemon index.js", "test": "echo \"Error: no test specified\" && exit 1" }, // .. } لا حاجة في هذه السكريبت لتحديد المسار التالي node_modules/.bin/nodemon للمكتبة nodemon، لأن npm يعرف أين سيبحث عنها. لنشغل الخادم بوضعية التطوير كما يلي: npm run dev على خلاف مخطوطتي start و test يجب إضافة التعليمة run إلى الأمر. العمل مع واجهة التطبيقات REST لنجعل تطبيقنا قادرًا على تأمين واجهة http متوافقة مع REST كما فعلنا مع خادم JSON. قدم روي فيلدينغ مفهوم نقل حالة العرض (REpresentational State Transfer) واختصارًا REST، عام 2000 في أطروحته للدكتوراه وهي عبارة عن أسلوب تصميمي لبناء تطبيقات ويب بمقاسات قابلة للتعديل. لن نغوص في تعريف REST، أو نهدر وقتنا في التفكير بالواجهات المتوافقة أو غير المتوافقة معها، بل سنعتمد مقاربة ضيقة تهتم فقط بكيفية فهم التوافق مع REST من منظور تطبيقات الويب، فمفهوم REST الأصلي ليس محدودًا بتطبيقات الويب. لقد أشرنا في القسم السابق أن REST تعتبر كل الأشياء الفردية -كالملاحظات في تطبيقنا- موردًا. ولكل مورد موقع محدد URL يمثل العنوان الفريد لهذا المورد. إن أحد الأعراف المتبعة في إنشاء عنوان فريد للمورد هو دمج نوع المورد مع المعرف الفريد له. فلو افترضنا أن الموقع الجذري للخدمة هو www.example.com/api، وأننا عرّفنا نوع المورد الذي يمثل الملاحظات على أنه note وأننا نحتاج إلى المورد note ذو المعرف 10، سيكون عنوان موقع المورد www.example.com/api/notes/10. وسيكون عنوان موقع مجموعة الملاحظات www.example.com/api/notes. يمكننا تنفيذ العديد من العمليات على الموارد، وتعرّف العملية التي نريد تنفيذها على مورد على أنها فعل HTTP: الموقع الفعل الوظيفة notes/10 GET إحضار مورد واحد notes GET إحضار كل موارد المجموعة notes POST إنشاء مورد جديد بناء على البيانات الموجودة في الطلب notes/10 DELETE حذف المورد المحدد notes/10 PUT استبدال كامل المورد المحدد بالبيانات الموجودة في الطلب notes/10 PATCH استبدال جزء من المورد المحدد بالبيانات الموجودة في الطلب table { width: 100%; } thead { vertical-align: middle; text-align: center;} td, th { border: 1px solid #dddddd; text-align: right; padding: 8px; text-align: inherit; } tr:nth-child(even) { background-color: #dddddd; } وهكذا نحدد الخطوط العامة لما تعنيه REST كواجهة نموذجية، أي أنها تعرّف أسلوبُا ثابتُا ومنسقًا للواجهات، تجعل الأنظمة المختلفة قادرة على العمل المشترك. إنّ المفهوم الذي اعتمدناه حول REST يصنف كمستوًى ثانٍ من التوافق وفق نموذج توافق ريتشاردسون. مع هذا فنحن إذ عّرفنا واجهة REST، لم نعرفها لتتوافق تمامًا مع المعايير التي قدمها روي فيلدينغ في أطروحته، وهذا حال الغالبية العظمى من الواجهات التي تدعي توافقها الكامل مع REST. يشار إلى نموذجنا الذي يقدم واجهة أساسية (إنشاء-قراءة-تحديث-حذف) (CRUD: Create-Read-Update-Delete) في العديد من المراجع (Richardson, Ruby: RESTful Web Services) على أنه تصميم موجه للعمل مع الموارد resource oriented architecture بدلًا من كونه متوافقًا مع REST. إحضار مورد واحد لنوسع تطبيقنا بحيث يقدم واجهة REST تتعامل مع الملاحظات بشكل فردي. لكن علينا أولًا إنشاء مسار لإحضار المورد. سنعتمد نموذج (نوع/معرف) في الوصول إلى موقع المورد (notes/10 مثلًا). يمكن تعريف معاملات المسار في express كما يلي: app.get('/api/notes/:id', (request, response) => { const id = request.params.id const note = notes.find(note => note.id === id) response.json(note) }) سيتعامل المسار (...,'app.get('/api/notes/:id مع طلبات HTTP-GET التي تأتي بالصيغة api/notes/SOMETHING حيث يشير SOMETHING إلى نص افتراضي. يمكن الوصول إلى المعامل id للمسار من خلال الكائن request: const id = request.params.id نستخدم التابع find الخاص بالمصفوفات للبحث عن الملاحظة من خلال معرفها الفريد id والذي يجب أن يتطابق مع قيمة المعامل، ثم تعاد الملاحظة إلى مرسل الطلب. لكن لو جربنا الشيفرة المكتوبة واستخدمنا المتصفح للوصول إلى العنوان http://localhost:3001/api/notes/1، فلن يعمل التطبيق كما هو متوقع، وستظهر صفحة فارغة. لن يفاجئني هذا كمطور اعتاد على المشاكل، لذا فقد حان وقت التنقيح. لنزرع الأمر console.log كما اعتدنا: app.get('/api/notes/:id', (request, response) => { const id = request.params.id console.log(id) const note = notes.find(note => note.id === id) console.log(note) response.json(note) }) بمجرد انتقال المتصفح إلى العنوان السابق ستُظهر الطرفية الرسالة التالية: لقد مُرِّر المعامل id إلى التطبيق، لكن التابع find لم يجد ما يتطابق معه. وللتعمق في تقصي مصدر الخطأ زرعنا الأمر console.log ضمن الدالة التي تُمرَّر كمعامل للتابع find. وكان علينا لتنفيذ ذلك إعادة كتابة الدالة السهمية بشكلها الموسع واستخدام عبارة return في نهايتها: app.get('/api/notes/:id', (request, response) => { const id = request.params.id const note = notes.find(note => { console.log(note.id, typeof note.id, id, typeof id, note.id === id) return note.id === id }) console.log(note) response.json(note) }) سيطبع لنا الأمر console.log (معرف الملاحظة، نوعه، المتغير id، نوعه، هل هناك تطابق). وعندما نتوجه مجددًا نحو عنوان الملاحظة عبر المتصفح، ستُطبع عبارة مختلفة على الطرفية مع كل استدعاء للدالة: 1 'number' '1' 'string' false 2 'number' '1' 'string' false 3 'number' '1' 'string' false يبدو أن سبب المشكلة قد توضّح الآن. إن المتغيّر id يضم قيمة نصية هي "1"، بينما يحمل معرف الملاحظة قيم صحيحة. حيث يعتبر عامل المساواة الثلاثي === في JavaScript وبشكل افتراضي أن القيم من أنواع مختلفة غير متساوية. لنصحح الخطأ بتحويل قيمة المتغير id إلى عدد: app.get('/api/notes/:id', (request, response) => { const id = Number(request.params.id) const note = notes.find(note => note.id === id) response.json(note) }) لقد تم الأمر! لاتزال هناك مشكلة أخرى في التطبيق. فلو بحثنا عن ملاحظة معّرفها غير موجود أصلًا، سيجيب الخادم كالتالي: يعيد الخادم رمز الحالة 200، وهذا يعني أن الاستجابة قد تمت بنجاح. وطبعًا لا توجد هناك بيانات لإعادتها طالما أن قيمة ترويسة "طول المحتوى" هي 0. يمكن التحقق من ذلك أيضًا عبر المتصفح. إن السبب الكامن خلف هذا السلوك، هو أن المتغير note سيأخذ القيمة undefined إن لم نحصل على تطابق. وبالتالي لابد من التعامل مع هذه المشكلة على الخادم الذي يجب أن يعيد رمز الحالة not found 404 بدلًا من 200. لنعدل الشيفرة كالتالي: app.get('/api/notes/:id', (request, response) => { const id = Number(request.params.id) const note = notes.find(note => note.id === id) if (note) { response.json(note) } else { response.status(404).end() } }) عندما لا تكون هناك بيانات لإعادتها نستخدم التابع status لإعداد حالة التطبيق، والتابع end للاستجابة على الطلب دون إرسال أية بيانات. تظهر لك العبارة الشرطية if أن كل كائنات JavaScript محققة (تعيد القيمة المنطقية "صحيح" في عمليات الموازنة)، بينما يعتبر الكائن undefiend خاطئ. سيعمل التطبيق الآن، وسيرسل رمز الحالة الصحيح إن لم يعثر على الملاحظة المطلوبة. لن يعرض التطبيق شيئًا على الصفحة كغيره من التطبيقات عندما تحاول الوصول إلى مورد غير موجود. وهذا في الواقع أمر طبيعي، فلا حاجة لعرض أي شيء على المتصفح طالما أن REST واجهة معدة للاستخدام برمجيًا، وسيكون رمز الحالة الذي يعيده التطبيق هو كل ما نحتاجه. حذف الموارد لنضف مسارًا لحذف مورد محدد. يتم ذلك من خلال الطلب HTTP-DELETE إلى موقع المورد: app.delete('/api/notes/:id', (request, response) => { const id = Number(request.params.id) notes = notes.filter(note => note.id !== id) response.status(204).end() }) إن نجحت عملية الحذف، أي أن المورد وُجد وحُذف، سيجيب التطبيق على الطلب برمز الحالة no content 204 دون إعادة أية بيانات. لا يوجد رمز حالة محدد لإعادته عند محاولة حذف مورد غير موجود، لذلك ولتبسيط الأمر سنعيد الرمز 204 في الحالتين. اختبار التطبيقات باستخدام Postman كيف نتأكد أن عملية حذف المورد قد تمت فعلًا؟ من السهل إجراء طلب HTTP-GET من خلال المتصفح والتأكد. لكن كتابة شيفرة اختبار ليست الطريقة الأنسب دائمًا. ستجد العديد من الأدوات الجاهزة لتسهيل الاختبارات على الواجهة الخلفية. فمنها على سبيل المثال curl وهو برنامج سطر أوامر أشرنا إليه في القسم السابق، لكننا سنستخدم هنا Postman للقيام بهذه المهمة. إذًا لنثبت Postman: من السهل جدًا استخدام Postman في حالتنا هذه، إذ يكفي أن تختار موقعا وطلب HTTP مناسب (DELETE في حالتنا). يستجيب خادم الواجهة الخلفية بشكل صحيح كما يبدو. فلو أرسلنا إلى الموقع http://localhost:3001/api/notes طلب HTTP-GET سنجد أن الملاحظة التي معرّفها 2 غير موجودة ضمن قائمة الملاحظات، فقد نجحت عملية الحذف. ستعود قائمة الملاحظات إلى وضعها الأصلي عند إعادة تشغيل التطبيق، لأن الملاحظات قد حُفِظت في ذاكرة الجهاز فقط. عميل REST في بيئة التطوير Visual Studio Code إن كنت تستخدم Visual Studio Code في تطوير التطبيقات يمكنك تثبيت الإضافة REST client واستعمالها بدلًا من Postman حيث ننشئ مجلدًا يدعى requests عند جذر التطبيق، ثم نحفظ كل طلبات REST client في ملف ينتهي باللاحقة rest. ونضعه في المجلد السابق. لننشئ الآن الملف getallnotes.rest ونعرّف فيه كل طلبات HTTP التي تحضر الملاحظات: بالنقر على النص Send Requests، سينفذ REST client طلبات HTTP وستظهر استجابة الخادم في نافذة المحرر. استقبال البيانات سنوسع التطبيق بحيث نغدو قادرين على إضافة ملاحظة جديدة إلى الخادم. تنفذ العملية باستخدام طلب HTTP-POST إلى العنوان http://localhost:3001/api/notes، حيث تُرسل المعلومات عن الملاحظة الجديدة بصيغة JSON ضمن جسم الطلب. وحتى نصل إلى معلومات الملاحظة بسهولة سنحتاج إلى مفسّر JSON الذي تقدمه express ويستخدم عبر تنفيذ الأمر (()app.use(express.json. لنستخدم مفسّر JSON ولنضف معالج حدث أوّلي للتعامل مع طلبات HTTP-POST: const express = require('express') const app = express() app.use(express.json()) //... app.post('/api/notes', (request, response) => { const note = request.body console.log(note) response.json(note) }) يمكن لدالة معالج الحدث الوصول إلى البيانات من خلال الخاصية body للكائن request. لكن من غير استخدام مفسر JSON ستكون هذه الخاصية غير معرّفة undefined. فوظيفة المفسر استخلاص بيانات JSON من الطلب وتحويلها إلى كائن JavaScript يرتبط مع الخاصية body للكائن requestوذلك قبل أن تُستدعى الدالة التي تتعامل مع مسار الطلب. حتى هذه اللحظة لا يفعل التطبيق شيئًا سوى استقبال البيانات وطباعتها على الطرفية ثم إعادة إرسالها كاستجابة للطلبات. قبل أن نضيف بقية الشيفرة إلى التطبيق، لنتأكد باستخدام Postman أن الخادم قد استقبل البيانات بالفعل. لكن يجب الانتباه إلى تعريف البيانات التي أرسلناها ضمن جسم الطلب بالإضافة إلى موقع المورد: يطبع البرنامج البيانات التي أرسلناها ضمن الطلب على الطرفية: ملاحظة: ابق الطرفية التي تُشغّل التطبيق مرئية بالنسبة لك طيلة فترة العمل مع الواجهة الخلفية. ستساعدنا Nodemon على إعادة تشغيل التطبيق عند حدوث أية تغييرات في الشيفرة. إذا ما دققت في ما تعرضه الطرفية، ستلاحظ مباشرة وجود أخطاء في التطبيق: من المفيد جدًا التحقق باستمرار من الطرفية لتتأكد من أن كل شيء يسير كما هو متوقع ضمن الواجهة الخلفية وفي مختلف الحالات، كما فعلنا عندما أرسلنا البيانات باستخدام الطلب HTTP-POST. ومن الطبيعي استخدام الأمر console.log في مرحلة التطوير لمراقبة الوضع. من الأسباب المحتملة لظهور الأخطاء هو الضبط الخاطئ لترويسة "نوع المحتوى" في الطلبات. فقد ترى الخطأ التالي مع Postman إذا لم تعرّف نوع جسم الطلب بشكل صحيح: حيث عُرّف نوع المحتوى على أنه text/plain (نص أو فارغ): ويبدو أن الخادم مهيأ لاستقبال مشروع فارغ فقط: لن يتمكن الخادم من تفسير البيانات بشكل صحيح دون وجود القيمة الصحيحة في الترويسة. ولن يخمن حتى صيغة البيانات نظرًا للكمية الهائلة من أنواع المحتوى التي قد تكونها البيانات. إن كنت قد ثبتت VS REST client ستجد أن الطلب HTTP-POST سيرسل بمساعدة REST client كما يلي: حيث أنشأنا الملف create_note.rest وصغنا الطلب كما يوصي توثيق البرنامج. يتمتع REST client بميزة على Postman بأن التعامل يدويًا مع الطلبات متاح في المجلد الجذري للمشروع، ويمكن توزيعها إلى أعضاء فريق التطوير. بينما وإن كان Postman سيسمح بحفظ الطلبات، لكن ستغدو الأمور فوضوية وخاصة إذا استُخدم في تطوير عدة مشاريع غير مترابطة. ملاحظات جانبية مهمة قد تحتاج عند تنقيح التطبيقات إلى معرفة أنواع الترويسات التي ترافق طلبات HTTP. يعتبر استخدام التابع get العائد للكائن request، إحدى الطرق التي تسمح بذلك. حيث يستخدم التابع للحصول على قيمة ترويسة محددة، كما يمتلك الكائن request الخاصية headers التي تحوي كل الترويسات الخاصة بطلب محدد. قد تحدث الأخطاء عند استخدام VS REST client لو أضفت سطرًا فارغًا بين السطر الأعلى والسطر الذي يعرّف ترويسات HTTP. حيث يفسر البرنامج ذلك بأن كل الترويسات قد تُركَت فارغة، وبالتالي لن يعلم خادم الواجهة الخلفية أن البيانات التي استقبلها بصيغة JSON. يمكنك أن ترصد ترويسة "نوع المحتوى" المفقودة إذا ما طبعت كل ترويسات الطلب على الطرفية باستخدام الأمر (console.log(request.headers. لنعد الآن إلى تطبيقنا، طالما تأكدنا أن الخادم قد استقبل البيانات الواردة إليه بالشكل الصحيح، وقد حان الوقت لإتمام معالجة الطلب: app.post('/api/notes', (request, response) => { const maxId = notes.length > 0 ? Math.max(...notes.map(n => n.id)) : 0 const note = request.body note.id = maxId + 1 notes = notes.concat(note) response.json(note) }) نحتاج إلى id فريد للملاحظة الجديدة وللقيام بذلك علينا أن نجد أكبر قيمة id تحملها قائمة الملاحظات ونسند قيمتها للمتغير maxId. هكذا سيكون id الملاحظة الجديدة هو maxId+1. في واقع الأمر، حتى لو استخدمنا هذا الأسلوب حاليًّا، فلا ننصح به، وسنتعلم أسلوبًا أفضل قريبًا. الشيء الآخر أن تطبيقنا بشكله الحالي يعاني مشكلة مفادها أن طلب HTTP-POST يمكن أن يُستخدَم لإضافة كائن له خصائص غير محددة. لذلك سنحسن التطبيق بأن لا نسمح لقيمة الخاصية content أن تكون فارغة، وسنعطي الخاصيتين important وdate قيمًا افتراضية، وسنهمل بقية الخصائص: const generateId = () => { const maxId = notes.length > 0 ? Math.max(...notes.map(n => n.id)) : 0 return maxId + 1 } app.post('/api/notes', (request, response) => { const body = request.body if (!body.content) { return response.status(400).json({ error: 'content missing' }) } const note = { content: body.content, important: body.important || false, date: new Date(), id: generateId(), } notes = notes.concat(note) response.json(note) }) وضعنا الشيفرة التي تولد المعرف id للملاحظة الجديدة ضمن الدالة generateId. فعندما يستقبل الخادم بيانات قيمة الخاصية content لها غير موجودة، سيجيب على الطلب برمز الحالة 400 bad request: if (!body.content) { return response.status(400).json({ error: 'content missing' }) } لاحظ أن استخدام return حيوي جدًا لسير العملية، فلولاها ستُنفّذ الشيفرة تباعًا وستُحفظ الملاحظة ذات الصياغة الخاطئة. وبالطبع ستحفظ الملاحظة التي تحمل فيها الخاصية content قيمة، بما تحويه من بيانات. ونذكّر أيضًا أننا تحدثنا عن ضرورة توليد زمن إنشاء الملاحظة من قبل الخادم وليس جهاز الواجهة الأمامية، لذلك ولّده الخادم. إن لم تحمل الخاصية important قيمة فسنعطيها القيمة الإفتراضية false. لاحظ الطريقة الغريبة التي استخدمناها: important: body.important || false, وتفسر الشيفرة السابقة كالتالي: إن حملت الخاصية important للبيانات المستقبلة قيمة ستعتمد العبارة السابقة هذه القيمة وإلا ستعطيها القيمة false. أمَّا عندما تكون قيمة الخاصية important هي false عندها ستعيد العبارة التالية body.important || false القيمة false بناء على قيمة الطرف الأيمن من العبارة. ستجد شيفرة التطبيق بشكله الحالي في المستودع part3-1 على موقع GitHub. يحوي المسار الرئيسي للمستودع كما ستلاحظ شيفرات للنسخ التي سنطورها لاحقًا، لكن النسخة الحالية في المسار part3-1. إن نسخت المشروع، فتأكد من تنفيذ الأمر npm install قبل أن تشغل التطبيق باستخدام إحدى التعليمتين npm start أو npm run dev. ملاحظة أخرى قبل الشروع بحل التمارين، سيبدو الشكل الحالي لدالة توليد المعرفات IDs كالتالي: const generateId = () => { const maxId = notes.length > 0 ? Math.max(...notes.map(n => n.id)) : 0 return maxId + 1 } يحتوي جسم الدالة سطرًا يبدو غريبًا نوعًا ما: Math.max(...notes.map(n => n.id)) ينشئ التابع (notes.map(n=>n.id مصفوفة جديدة تضم كل قيم المعرفات id للملاحظات. ثم يعيد التابع Math.max أعلى قيمة من القيم التي مُرّرت إليه. وطالما أن نتيجة تطبيق الأمر (notes.map(n=>n.ids ستكون مصفوفة، فمن الممكن تمريرها مباشرة كمعامل للتابع Math.max. وتذكّر أنه يمكن فصل المصفوفة إلى عناصرها باستخدام عامل النشر (…). التمارين 3.1 - 3.6 ملاحظة: من المفضل أن تنفذ تمارين هذا القسم ضمن مستودع خاص على git، وأن تضع الشيفرة المصدرية في جذر المستودع، وإلا ستواجه المشاكل عندما تصل للتمرين 3.10. ملاحظة: لا ننفذ حاليًا مشروعًا للواجهة الأمامية باستخدام React، ولم ننشئ المشروع باستخدام create-react-app. لذلك هيئ المشروع باستعمال الأمر npm init كما شرحنا في هذا الفصل. توصية مهمة: ابق نظرك دائمًا على الطرفية التي تُشغِّل تطبيقك عندما تطور التطبيقات للواجهة الخلفية. 3.1 دليل الهاتف للواجهة الخلفية: الخطوة 1 اكتب تطبيقًا باستخدام Node يعيد قائمة من مدخلات دليل هاتف مكتوبة مسبقًا وموجودة في الموقع http://localhost:3001/api/persons: لاحظ أنه ليس للمحرف '/' في المسار api/persons أي معنى خاص بل هو محرف كباقي المحارف في النص. يجب أن يُشّغل التطبيق باستخدام الأمر npm start. كما ينبغي على التطبيق أن يعمل كنتيجة لتنفيذ الأمر npm run dev وبالتالي سيكون قادرًا على إعادة تشغيل الخادم عند حفظ التغيرات التي قد تحدث في ملف الشيفرة المصدرية. 3.2 دليل الهاتف للواجهة الخلفية: الخطوة 2 أنشئ صفحة ويب عنوانها http://localhost:3001/info بحيث تبدو مشابهةً للصفحة التالية: ستظهر الصفحة الوقت الذي استقبل فيه الخادم الطلب، وعدد مدخلات دليل الهاتف في لحظة معالجة الطلب. 3.3 دليل الهاتف للواجهة الخلفية: الخطوة 3 أضف إمكانية إظهار معلومات مُدخل واحد من مُدخلات دليل الهاتف. يجب أن يكون موقع الحصول على بيانات الشخص الذي معرفه 5 هو http://localhost:3001/api/persons/5. إن لم يكن المُدخَل ذو المعرف المحدد موجودًا، على الخادم أن يستجيب معيدًا رمز الحالة المناسب. 3.4 دليل الهاتف للواجهة الخلفية: الخطوة 4 أضف إمكانية حذف مدخل واحد من مُدخلات دليل الهاتف مستعملًا الطلب HTTP-DELETE إلى عنوان المدخل الذي سيُحذَف ثم اختبر نجاح العملية مستخدمًا Postman أو Visual Studio Code REST client. 3.5 دليل الهاتف للواجهة الخلفية: الخطوة 5 أضف إمكانية إضافة مدخلات إلى دليل الهاتف مستعملًا الطلب HTTP-POST إلى عنوان مجموعة المدخلات http://localhost:3001/api/persons. استعمل الدالة Math.random لتوليد رقم المعرف id للمُدخل الجديد. واحرص أن يكون مجال توليد الأرقام العشوائية كبيرًا ليقلّ احتمال تكرار المعرف نفسه لمدخلين. 3.6 دليل الهاتف للواجهة الخلفية: الخطوة 6 أضف معالج أخطاء للتعامل مع ما قد يحدث عند إنشاء مدخل جديد. حيث لا يُسمح بنجاح العملية إذا كان: اسم الشخص أو رقمه غير موجودين الاسم موجود مسبقًا في الدليل. استجب للحالتين السابقتين برمز حالة مناسب، وأعد كذلك معلومات توضح سبب الخطأ كالتالي: { error: 'name must be unique' } فكرة عن أنواع طلبات HTTP يتحدث معيار HTTP عن خاصيتين متعلقتين بأنواع الطلبات هما الأمان safety والخمول idempotence. فيجب أن يكون طلب HTTP-GET آمنًا: يعني الأمان أن تنفيذ الطلب لن يسبب تأثيرات جانبية على الخادم. ونعني بالتأثيرات الجانبية أن حالة قاعدة البيانات لن تتغير كنتيجة للطلب وأن الاستجابة ستكون على شكل بيانات موجودة مسبقًا على الخادم. لا يمكن أن نضمن أمان الطلب GET وما ذكر مجرد توصية في معيار HTTP. لكن بالتزام مبادئ التوافق مع REST في واجهة تطبيقنا، ستكون طريقة استخدام GET آمنة دائمًا. كما عرّف معيار HTTP نمطًا للطلبات هو HEAD وعلى هذا الأخير أن يكون آمنًا أيضًا. يعمل HEAD تمامًا عمل GET، إلا أنه لا يعيد سوى رمز الحالة وترويسات الاستجابة. لن يعيد الخادم جسمًا للاستجابة عندما تستخدم HEAD. وعلى كل طلبات HTTP أن تكون خاملة ماعدا POST: ويعني هذا أن التأثيرات الجانبية التي يمكن أن يسببها طلب، يجب أن تبقى كما هي، بغض النظر عن عدد المرات التي يرسل فيها الطلب إلى الخادم. فلو أرسلنا الطلب HTTP-PUT إلى العنوان api/notes/10/ حاملًا البيانات {content:"no sideeffects!", important:true} فإن الاستجابة ستكون نفسها بغض النظر عن عدد المرات التي يُرسَل فيها هذا الطلب. وكما أن الأمان في GET لا يتحقق دومًا، كذلك الخمول. فكلاهما مجرد توصيات في معيارHTTP لا يمكن ضمانها معتمدين على نوع الطلب فقط. لكن بالتزام مبادئ التوافق مع REST في واجهة تطبيقنا، سنستخدم GET، HEAD PUT، DELELTE بطريقة تحقق خاصية الخمول. أما الطلب POST فلا يعتبر آمنًا ولا خاملًا. فلو أرسلنا 5 طلبات HTTP-POST إلى الموقع /api/notes، بحيث يضم جسم الطلب البيانات {content: "many same", important: true}، ستكون النتيجة 5 ملاحظات جديدة لها نفس المحتوى. البرمجيات الوسيطة Middleware يصنف مفسّر JSON الذي تقدمه express بأنه أداة وسطية. فالبرمجيات الوسيطة (Middleware) هي دوال تستخدم لمعالجة كائنات الطلبات والاستجابات. فمفسر JSON الذي تعرفنا عليه سابقًا في هذا الفصل سيأخذ بيانات خام من جسم كائن الطلب، ثم يحولها إلى كائن JavaScript ويسندها إلى الكائن request كقيمة للخاصية body. يمكن في الواقع استخدام عدة أدوات وسطية في نفس الوقت. ستنفذ البرمجيات الوسيطة بالتتالي وفق ترتيب استخدامها من قبل express. لنضف أدوات وسطية خاصة بنا لطباعة معلومات حول كل طلب أرسل إلى الخادم. الأداة الوسطية التي سنستعملها دالة تقبل ثلاث معاملات: const requestLogger = (request, response, next) => { console.log('Method:', request.method) console.log('Path: ', request.path) console.log('Body: ', request.body) console.log('---') next() } سنجد في نهاية الدالة، دالة أخرى هي ()next مررت كمعامل لدالة الأداة الوسطية وتستدعى في نهايتها. تنقل الدالة next التحكم إلى الأداة الوسطية التالية. تستخدم الأداة الوسطية كالتالي: app.use(requestLogger) تستدعى دوال البرمجيات الوسيطة بالتتالي وفق ترتيب استخدامها من قبل express عن طريق التابع use. وانتبه إلى أن مفسر JSON سيستخدم قبل الأداة الوسطية requestLogger، وإلا فلن يهيأ جسم الكائن request عندما تُنفّذ الدالة requestLogger. يجب أيضًا تعريف دوال البرمجيات الوسيطة قبل المسارات إن أردنا تنفيذها قبل أن يُستدعى معالج الحدث الخاص بالمسار. لكن قد تكون هناك حالات معاكسة نعرّف فيها الأداة الوسطية بعد تعريف المسار، وخاصة إن أردنا لدالة الأداة الوسطية أن تُنفّذ إن لم تُعرّف أية مسارات لمعالجة طلب HTTP. لنعرف الأداة الوسطية التالية في شيفرتنا بعد تعريف المسار، لالتقاط الطلبات الموجهة إلى مسارات غير موجودة. ستعيد دالة الأداة الوسطية في هذه الحالة رسالة خطأ بصيغة JSON: const unknownEndpoint = (request, response) => { response.status(404).send({ error: 'unknown endpoint' }) } app.use(unknownEndpoint) ستجد شيفرة التطبيق بشكله الحالي في المستودع part3-2 على موقع GitHub. التمارين 3.7 - 3.8 3.7 دليل هاتف للواجهة الخلفية: الخطوة 7 أضف الأداة الوسطية morgan إلى تطبيقك للمراقبة. اضبط الأداة لطباعة الرسائل على الطرفية وفقًا لنمط التهيئة tiny. لا يقدم لك توثيق Morgan كل ما تريده، وعليك قضاء بعض الوقت لتتعلم تهيئة الأداة بشكل صحيح. وهذا بالطبع حال التوثيقات جميعها، فلا بد من فك رموز هذه التوثيقات. ثبت Morgan كأي مكتبة أخرى باستخدام الأمر npm install. واستخدمها تمامًا كما نستخدم أية أداة وسطية بتنفيذ الأمر app.use. 3.8 دليل هاتف للواجهة الخلفية: الخطوة 8 * هيئ Morgan حتى تعرض لك أيضًا بيانات الطلبات HTTP-POST: قد يكون طباعة بيانات الطلبات خطرًا طالما أنها تحتوي على معلومات حساسة قد تخرق بعض قوانين الخصوصية لبعض البلدان مثل الاتحاد الأوروبي، أو قد تخرق معيار الأعمال. لا تهتم لهذه الناحية في تطبيقنا، لكن عندما تنطلق في حياتك المهنية، تجنب طباعة معلومات حساسة. يحمل التمرين بعض التحديات، علمًا أن الحل لا يتطلب كتابة الكثير من الشيفرة. يمكنك إنجاز التمرين بطرائق عدة، منها استخدام التقنيات التالية: إنشاء مفاتيح جديدة (new token) JSON.stringify ترجمة -وبتصرف- للفصل Node.js, Express من سلسلة Deep Dive Into Modern Web Development
  11. full_stack_101

    يبدو مظهر تطبيقنا الحالي متواضعًا. لقد طلبنا منك سابقًا في التمرين 0.2 أن تطلع على دورة تدريبة تتعلق بالتنسيقات المتتالية التي سنستخدمها حاليًا. لنلقي نظرة على الطريقة التي نضيف فيها تنسيقات لتغيير مظهر التطبيق قبل الانتقال إلى القسم التالي. هناك طرق كثيرة لتنفيذ ذلك، سنلقي عليها نظرة لاحقًا، لكن في البداية سنضيف شيفرة CSS إلى تطبيقنا بالطريقة التقليدية في ملف منفصل واحد دون استخدام معالجة تحضيرية CSS preprocessor (وهذا ليس صحيحًا تمامًا كما سنرى). سننشئ ملفًا باسم index.css ضمن المجلد src ثم نضيفه إلى التطبيق باستخدام تعليمة الإدراج import: import './index.css' لنضف بعض قواعد التنسيق إلى الملف: h1 { color: green; } تتألف قاعدة التنسيق من المحدد selector والتصريح declaration. يعرّف المحدد العنصر الذي تطبق عليه القاعدة. يطبق المحدد في المثال السابق على عنصر العنوان H1 في التطبيق. بينما ينص التصريح على تغيير خاصية اللون إلى الأخضر. يمكن لقاعدة واحدة من قواعد التنسيق أن تغير أي عدد من الخصائص، لنغير إذًا تنسيق الخط ليصبح مائلًا: h1 { color: green; font-style: italic; } هناك العديد من الطرق لاختيار العناصر التي نريد تنسيقها باستخدام أنواع مختلفة من محددات التنسيق. فإن أردنا مثًلا أن نغير تنسيق كل ملاحظة من الملاحظات، يمكننا اختيار المحدد li طالما أن الملاحظات ستعرض على شكل عنصر li: const Note = ({ note, toggleImportance }) => { const label = note.important ? 'make not important' : 'make important'; return ( <li> {note.content} <button onClick={toggleImportance}>{label}</button> </li> ) } لنضف القاعدة التالية إلى ملف التنسيق: li { color: grey; padding-top: 3px; font-size: 15px; } إن اختيار قواعد التنسيق لتستهدف العناصر سيسبب بعض المشاكل. فلو احتوى تطبيقنا على عناصر li أخرى ستطبق القاعدة عليها أيضًا. ولتطبيق التنسيق على الملاحظات حصرًا، من الأفضل استخدام محددات الأصناف class selectors. تعّرف محددات الأصناف في HTML القياسية كقيمة للصفة class: <li class="note">some text...</li> بينما في React، علينا أن نستخدم الصفة className بدلًا من الصفة class. لنجري الآن بعض التغييرات على المكوِّن Note: const Note = ({ note, toggleImportance }) => { const label = note.important ? 'make not important' : 'make important'; return ( <li className='note'> {note.content} <button onClick={toggleImportance}>{label}</button> </li> ) } لاحظ كيف عرفنا محدد الصنف ضمن الصفة classname: .note { color: grey; padding-top: 5px; font-size: 15px; } لن تتأثر الآن عناصر li الأخرى الآن بهذه القاعدة بل فقط العناصر التي تظهر الملاحظات. رسائل خطأ بمظهر أفضل استخدمنا سابقا التابع alert لإظهار رسالة خطأ عندما يحاول المستخدم تغيير أهمية ملاحظة محذوفة. لننشئ الآن مكوِّنًا خاصًا بإظهار رسائل الخطأ: const Notification = ({ message }) => { if (message === null) { return null } return ( <div className="error"> {message} </div> ) } لن يصيّر شيء على الشاشة إن كانت رسالة الخطأ فارغة، بينما ستصيّر الرسالة في بقية الحالات داخل عنصر div. لنضف قطعة حالة تدعى errorMessage إلى المكوِّن App. ونعطها رسالة خطأ ما كقيمة ابتدائية لنتمكن من اختبارها مباشرة: const App = () => { const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) const [errorMessage, setErrorMessage] = useState('some error happened...') // ... return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> <div> <button onClick={() => setShowAll(!showAll)}> show {showAll ? 'important' : 'all' } </button> </div> // ... </div> ) } لنضف قاعدة تنسيق جديدة باسم error تناسب رسالة الخطأ: .error { color: red; background: lightgrey; font-size: 20px; border-style: solid; border-radius: 5px; padding: 10px; margin-bottom: 10px; } سنضيف الآن آلية لظهور رسالة الخطأ، وذلك بتعديل شيفرة الدالة toggleImportanceOf على النحو التالي: const toggleImportanceOf = id => { const note = notes.find(n => n.id === id) const changedNote = { ...note, important: !note.important } noteService .update(changedNote).then(returnedNote => { setNotes(notes.map(note => note.id !== id ? note : returnedNote)) }) .catch(error => { setErrorMessage( `Note '${note.content}' was already removed from server` ) setTimeout(() => { setErrorMessage(null) }, 5000) setNotes(notes.filter(n => n.id !== id)) }) } عندما يقع الخطأ، نضيف وصفًا لهذا الخطأ ضمن قطعة الحالة errorMessage. وفي نفس اللحظة سيعمل مؤقت يعيد قيمة الحالة إلى null (لا شيء) بعد خمس ثوان. ستبدو النتيجة كالتالي: يمكن الحصول على شيفرة التطبيق بوضعه الحالي في الفرع part2-7 على github. التنسيق ضمن السياق تعطي React الإمكانية لتنسيق العناصر مباشرة ضمن سياق الشيفرة أو ما يدعى التنسيق ضمن السياق inline styles. والغاية من ذلك أن نزوّد كل مكوِّن في React بمجموعة من التنسيقات المعرفة داخل كائن JavaScript من خلال الصفة style. تعرّف التنسيقات بشكل مختلف قليلًا في JavaScript عما هي عليه في ملف CSS. فلو أردنا أن يظهر عنصر ما باللون الأخضر وأن يكون مائلًا حجمه 16 بيكسل، سنجد أن هذه القاعدة ستكتب في CSS بالشكل: { color: green; font-style: italic; font-size: 16px; } بينما في React كتنسيق مباشر في المكان ستكون كالتالي: { color: 'green', fontStyle: 'italic', fontSize: 16 } لاحظ كيف عرّفت كل خاصة تنسيق بشكل مستقل ضمن كائن JavaScript، وأن القيم العددية المقدّرة بالبيكسل يمكن تعريفها ببساطة كأعداد صحيحة. أحد الاختلافات الرئيسة بين الأسلوبين هو أن كتابة الخصائص بطريقة أسياخ الشواء (kebab case) المتبعة في CSS ستتغير إلى طريقة سنام الجمل (camelCase) في JavaScript. سنضيف أخيرًا كتلة سفلية لتطبيقنا بإنشاء مكوِّن جديد يدعى Footer وننسقه في مكانه كالتالي: const Footer = () => { const footerStyle = { color: 'green', fontStyle: 'italic', fontSize: 16 } return ( <div style={footerStyle}> <br /> <em>Note app, Department of Computer Science, University of Helsinki 2020</em> </div> ) } const App = () => { // ... return ( <div> <h1>Notes</h1> <Notification message={errorMessage} /> // ... <Footer /> </div> ) } يأتي التنسيق ضمن السياق بمحدودية معينة. فلا يمكن استخدام أصناف الحالات الزائفة pseudo-classes مباشرة على سبيل المثال. إنّ استخدام التنسيق ضمن السياق وبعض الطرق الأخرى في تعريف التنسيقات ضمن مكوِّنات React تتعارض مع الأعراف المتبعة سابقًا. فقد اعتبر فصل التنسيقات CSS عن المحتوى HTML وعن الوظائف JavaScript هو العمل المثالي. وكان الهدف من ذلك وفقًا لأفكار المدرسة التقليدية، هو كتابة كل منها في ملفه الخاص. تعارض فلسفة React تمامًا الفكرة السابقة من منطلق أن عملية الفصل في ملفات مختلفة لن تتماشى مع التطبيقات الأكبر. فتقسيم التطبيقات من وجهة نظر React يجب أن يكون على أساس المهام الوظيفية للمكوِّنات التي تمثل التطبيق ككل. فالمكوِّنات هي من يعطي التطبيق قدرته الوظيفية. حيث تستخدم المكوِّنات HTML لبناء المحتوى وتستخدم دوال JavaScript لتنفيذ وظائف التطبيق بالإضافة إلى تنسيق المكوِّنات. والهدف من كل ذلك بناء مكوِّن مستقل بحد ذاته يمكن إعادة استخدامه قدر الإمكان. يمكن الحصول على شيفرة التطبيق بوضعه الحالي في الفرع part2-8 على github. التمارين 2.19- 2.20 2.19 دليل الهاتف: الخطوة 11 استخدم المثال في فقرة رسائل خطأ بمظهر أفضل من هذا الفصل، كدليل يساعدك في إظهار تنبيه لعدة ثوان، بعد تنفيذ عملية بشكل ناجح (إضافة اسم أو رقم). 2.20 دليل الهاتف: الخطوة 12 * افتح تطبيقك ضمن متصفحين. لاحظ إن حذفت شخصًا في المتصفح الأول، ثم أردت بعدها بقليل تغيير رقم الشخص نفسه في المتصفح الثاني ستظهر لك رسالة الخطأ التالية: أصلح هذه المشكلة بالاستفادة من المثال في فقرة الوعود والأخطاء في القسم 2. عدّل تطبيقك بحيث تظهر رسالة خطأ للمستخدم تخبره بفشل العملية. وتذكر أن تظهر رسالتي النجاح والفشل بشكلين مختلفين. ملاحظة: يجب أن تظهر رسالة الخطأ حتى لو تمت معالجة الخطأ. وهكذا نصل إلى التمرين الأخير في هذا القسم، وقد حان الوقت لتسليم الحلول على GitHub. لا تنسى أن تشير إلى التمارين على أنها منجزة ضمن منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل Adding styles to react app من سلسلة Deep Dive Into Modern Web Development
  12. من الطبيعي عندما ننشئ ملاحظات جديدة في تطبيقنا، أن نحفظ هذه الملاحظات على الخادم. تَعتبِر حزمة خادم JSON نفسها على أنها واجهة REST أو RESTful كما ورد في توثيقها: لا يتطابق توصيف خادم JSON تمامًا مع تعريف واجهة التطبيقات REST، كما هي حال معظم الواجهات التي تدّعي بأنها متوافقة تمامًا مع REST. سنطلع أكثر على REST في القسم التالي من المنهاج، لكن من المهم أن نتعلم في هذه المرحلة بعض المعايير التوافقية المستخدمة من قبل خادم JSON والواجهة REST بشكل عام. وسنلقي نظرة خاصة على الاستخدام التوافقي للمسارات (Routes) وهي الروابط وطلبات HTTP وفق مفاهيم REST. واجهة التطبيقات REST نشير إلى الكائنات التي تمثل بيانات منفردة -كالملاحظات في تطبيقنا- باسم الموارد (resources) وفقًا لمصطلحات REST. ولكل مورد مكانه المحدد والوحيد الذي يرتبط به (URL الخاص به). وبناء على معيار عام يعتمده خادم JSON، سنتمكن من الحصول على ملاحظة واحدة من الملاحظات من المورد الموجود في الموقع notes/3. حيث يمثل الرقم 3 المعرِّف الفريد للمورد. ومن ناحية أخرى سيمثل موقع المورد notes تجمّع موارد (resource collection) يضم كل الملاحظات. تُحضَر الموارد من الخادم عن طريق الطلبات HTTP-GET. فالطلب HTTP-GET المرسل إلى الموقع notes/3 سيعيد الملاحظة التي معرِّفها 3. بينما لو أُرسل نفس الطلب إلى الموقع notes سيعيد لائحة بالملاحظات الموجودة. يُنشأ مورد جديد لتخزين ملاحظة بإرسال طلب HTTP-POST إلى الموقع notes، وفقًا لمعيار REST الذي يتوافق معه خادم JSON. ترسل البيانات إلى المورد الجديد "الملاحظة الجديدة" ضمن جسم الطلب. يفرض خادم JSON إرسال جميع البيانات بتنسيق JSON. ويقضي ذلك عمليًا أن تكون البيانات على شكل نص منسق بشكل صحيح وأن يضم الطلب ترويسة "نوع المحتوى" بداخلها القيمة application/json. إرسال البيانات إلى الخادم سنعدّل معالج الحدث المسؤول عن إنشاء ملاحظة جديدة كالتالي: addNote = event => { event.preventDefault() const noteObject = { content: newNote, date: new Date(), important: Math.random() < 0.5, } axios .post('http://localhost:3001/notes', noteObject) .then(response => { console.log(response) })} لقد أنشأنا كائنًا جديدًا يحتوي بيانات الملاحظة لكننا أغفلنا الخاصية id، فمن الأفضل أن يولد الخادم قيم المعرّفات الفريدة للموارد id. يُرسل الكائن بعد ذلك إلى الخادم باستخدام التابع post من المكتبة axios. ثم يطبع معالج الحدث المعرّف مسبقًا استجابة الخادم على الطرفية. ستعرض الطرفية البيانات التالية عند إنشاء ملاحظة جديدة: يخزَّن مورد الملاحظة الجديدة في الخاصية data لكائن الاستجابة الذي يعيده الخادم. من المفيد أحيانًا تحرّي طلبات HTTP في النافذة Network من طرفية تطوير المتصفح الخاص بك والتي استخدمناها كثيرًا في مقال أساسيات بناء تطبيقات الويب. باستخدام المفتش inspector سنتمكن من تحري الترويسات التي ترسل عبر طلبات post فيما لو كانت هي تمامًا ما نريده، أو أنّ القيم التي تحملها صحيحة. تسنِد axios تلقائيًا القيمة "application/json" إلى ترويسة "نوع-المحتوى"، طالما أن البيانات المرسلة عبر الطلب post هي كائن JavaScript. لم تصيّر الملاحظة الجديدة على الشاشة بعد، وذلك لأننا لم نحدث حالة المكوِّنApp بعد إنشائها، لذا سنصلح الأمر: addNote = event => { event.preventDefault() const noteObject = { content: newNote, date: new Date(), important: Math.random() > 0.5, } axios .post('http://localhost:3001/notes', noteObject) .then(response => { setNotes(notes.concat(response.data)) setNewNote('') }) } تضاف الملاحظة الجديدة القادمة من الخادم إلى قائمة الملاحظات في تطبيقنا باستخدام الدالة setNotes، وبعدها تصفّر الشيفرة نموذج إنشاء الملاحظات. وتذكر أحد التفاصيل المهمة عند استخدام التابع concat، بأنه لا يغيّر المصفوفة الأصلية وبالتالي لا يغيّر حالة المكوِّن بل ينشئ نسخة عن القائمة الأصلية. سيبدأ تأثير الخادم على سلوك تطبيقنا لحظة إعادته البيانات. وسنكون مباشرة أمام تحديات جديدة منها على سبيل المثال عدم التزامن في الاتصال. لهذا ستظهر الحاجة إلى استراتيجيات جديدة للتنقيح بالإضافة إلى الطباعة على الطرفية، وغيرها من الطرق التنقيح التي تزداد أهميتها مع الوقت. إضافة إلى ذلك لابد من فهمٍ كافٍ لمبادئ بيئة تشغيل JavaScript ومكوِّنات React. فلن يكفي التخمين فقط في حل المشاكل. ومن المفيد أغلب الأحيان التحقق من حالة الخادم من خلال المتصفح: حيث يمكننا التحقق من أنّ جميع البيانات التي أرسلناها قد استقبلها الخادم. سنتعلم في القسم التالي من المنهاج كيف نتعامل بمنطقنا الخاص مع الواجهة الخلفية، وسنلقي نظرة أقرب على أدوات مهمة مثل postman التي ستساعد في تنقيح تطبيقات الواجهة الخلفية. يكفينا حاليًا تحرّي حالة الخادم من خلال المتصفح. يمكن الحصول على شيفرة التطبيق بوضعه الحالي في الفرع part2-5 على github. تغيير مؤشر أهمية الملاحظة لنضف زرًا إلى كل ملاحظة بحيث نتمكن من تغيير حالتها بشكل مستمر، ستتغير الشيفرة في المكوِّن Note كما يلي: const Note = ({ note, toggleImportance }) => { const label = note.important ? 'make not important' : 'make important' return ( <li> {note.content} <button onClick={toggleImportance}>{label}</button> </li> ) } لقد أضفنا عنصر button إلى المكوِّن، وعيّنا الدالة toggleImportance كمعالج لحدث النقر على الزر، ومررناه إلى المكوِّن من خلال الخصائص. يعرّف المكوِّن App نسخة ابتدائية من معالج الحدث toggleImportanceOf، ثم يمرره إلى كل مكوِّن Note: const App = () => { const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) // ... const toggleImportanceOf = (id) => { console.log('importance of ' + id + ' needs to be toggled') } // ... return ( <div> <h1>Notes</h1> <div> <button onClick={() => setShowAll(!showAll)}> show {showAll ? 'important' : 'all' } </button> </div> <ul> {notesToShow.map((note, i) => <Note key={i} note={note} toggleImportance={() => toggleImportanceOf(note.id)} /> )} </ul> // ... </div> ) } لاحظ في الشيفرة السابقة كيف ستحصل كل ملاحظة على معالج حدث فريد خاص بها، ذلك أن قيمة id لكل ملاحظة هي قيمة فريدة. فلو افترضنا أن قيمة id لملاحظة هي 3، ستكون دالة معالج الحدث الخاصة بها والتي تعيدها الدالة (toggleImportance(note.id من الشكل: () => { console.log('importance of 3 needs to be toggled') } وتذكر هنا أننا استخدمنا أسلوبًا مشابهًا للغة Java في طباعة النص على الطرفية، وذلك بجمع السلاسل النصية: console.log('importance of ' + id + ' needs to be toggled') كما يمكنك استخدام القالب النصي الذي أضيف مع ES6، لكتابة سلسلة نصية مماثلة وبطريقة أجمل: console.log(`importance of ${id} needs to be toggled`) إذا وضعنا عبارة JavaScript بين قوسين معقوصين وقبلها رمز الدولار($) ستُنفَّذ هذه العبارة ضمن السلسلة النصية وتُطبَع قيمتها. وانتبه جيدًا إلى علامات التنصيص المستخدمة في القالب النصي (``` نستطيع التعديل على الملاحظة المخزنة على خادم JSON بطريقتين مختلفتين، وذلك بإرسال طلبات HTTP إلى الموقع الفريد للملاحظة. إذ بالإمكان أن نستبدل الملاحظة بالكامل من خلال الطلب HTTP-PUT، أو أن نغير بعض حقولها من خلال الطلب HTTP-PATCH. سيبدو الشكل النهائي لدالة معالج الحدث كالتالي: const toggleImportanceOf = id => { const url = `http://localhost:3001/notes/${id}` const note = notes.find(n => n.id === id) const changedNote = { ...note, important: !note.important } axios.put(url, changedNote).then(response => { setNotes(notes.map(note => note.id !== id ? note : response.data)) }) } يمتلئ جسم الدالة السابقة بالتفاصيل الهامة. حيث يعرّف السطر الأول الموقع الفريد لكل ملاحظة بناء على قيمة id. ويُستخدَم التابع find لإيجاد الملاحظة التي نرغب في تعديلها ثم تُسند قيمته إلى المتغير note. ثم ننشئ بعد ذلك كأئنًا جديدًا يمثل نسخة تطابق الملاحظة الأصلية باستثناء ما يتعلق بخاصية "الأهمية". قد يبدو لك إنشاء كائن جديد بأسلوب الكائن المنشور غريبًا بعض الشيء: const changedNote = { ...note, important: !note.important } تنشئ التعليمة {note...} كائنًا جديدًا يحمل نسخًا عن جميع خصائص (حقول) الكائن note. وعند وضع الخصائص ضمن قوسين معقوصين بعد نشر الكائن على الشكل التالي {note, important: true …}، ستأخذ الخاصة important للكائن الجديد القيمة true. لكن انتبه إلى مثالنا السابق بأننا أخذنا القيمة المعاكسة للخاصة important. هناك نقاط أخرى لابد من الإشارة إليها، فلماذا مثلًا أنشأنا نسخة عن الملاحظة التي نريد تعديلها إن كان بالإمكان تنفيذ ذلك كما يلي: const note = notes.find(n => n.id === id) note.important = !note.important axios.put(url, note).then(response => { // ... لا نفضل استخدام هذا الأسلوب لأن المتغير note يمثل مرجعًا إلى أحد عناصر المصفوفة notes في حالة المكوِّن، وتذكّر أن لا تغيّر حالة المكوِّن بشكل مباشر في تطبيقات React. كما لن يحمل الكائن الجديد الناتج عن تغير الملاحظة بهذه الطريقة أي تغيير فعلي وهذا ما يسمى النسخ السطحي. ويعني هذا أنّ قيم النسخة الجديدة هي نفسها قيم الكائن القديم. حتى لو كانت قيم الكائن القديم هي كائنات بحد ذاتها، ستشير نسخها الموجودة في الكائن الجديد إلى نفس الكائنات القديمة. بالعودة إلى تفاصيل دالة معالج حدث تغيير أهمية الملاحظة، ستُرسل أخيرًا الملاحظة الجديدة إلى الخادم على شكل طلب HTTP-PUT، وتُستبدل الملاحظة القديمة. تضبط دالة الاستدعاء (إعادة النداء) حالة الملاحظات في المكوِّن من جديد على قيم المصفوفة التي تضم جميع الملاحظات ماعدا تلك التي تم استبدالها بالنسخة الجديدة التي يعيدها الخادم: axios.put(url, changedNote).then(response => { setNotes(notes.map(note => note.id !== id ? note : response.data)) }) وينجز ذلك باستخدام التابع map: notes.map(note => note.id !== id ? note : response.data) يعيد التابع map مصفوفة جديدة عن طريق ربط كل عنصر من المصفوفة القديمة بعنصر من المصفوفة الجديدة. لكن إنشاء المصفوفة الجديدة في مثالنا كان شرطيًا، فإن تحقق الشرط التالي note.id !==id ننقل العنصر من المصفوفة القديمة إلى الجديدة، وإن لم يتحقق ستحل محلها الملاحظة الجديدة التي يعيدها الخادم. قد تبدو حيلة map هذه غريبةً قليلًا، لكنها تستحق التوقف عندها وفهمها لأننا سنستخدمها مرات عدة لاحقًا. نقل تعليمات الاتصال مع الواجهة الخلفية إلى وحدة منفصلة لقد أصبح المكوِّنApp مليئًا بالشيفرات بعد إضافة الاتصال مع الواجهة الخلفية. وانسجامًا مع مبدأ المسؤولية الفردية، وجدنا من الحكمة أن ننقل الشيفرات المتعلقة بالاتصال إلى وحدة منفصلة خاصة بها. لننشئ مجلدًا باسم services ضمن المجلد src وننشئ ضمنه الملف notes.js: import axios from 'axios' const baseUrl = 'http://localhost:3001/notes' const getAll = () => { return axios.get(baseUrl) } const create = newObject => { return axios.post(baseUrl, newObject) } const update = (id, newObject) => { return axios.put(`${baseUrl}/${id}`, newObject) } export default { getAll: getAll, create: create, update: update } تعيد الوحدة كائنًا من ثلاث دوال getAll وcreate و update، بالإضافة إلى خصائصه التي تتعامل مع الملاحظات. تعيد تلك الدوال وبشكل مباشر الوعود الناتجة عن تنفيذ توابع المكتبة axios والمتعلقة بالاتصال مع الخادم. يدرج المكوِّن App الوحدة الجديدة باستخدام التعليمة import: import noteService from './services/notes' const App = () => { يمكن استخدام الدوال مباشرة بإسنادها للمتغير noteService عند إدراج الوحدة كما يلي: const App = () => { // ... useEffect(() => { noteService .getAll() .then(response => { setNotes(response.data) }) }, []) const toggleImportanceOf = id => { const note = notes.find(n => n.id === id) const changedNote = { ...note, important: !note.important } noteService .update(id, changedNote) .then(response => { setNotes(notes.map(note => note.id !== id ? note : response.data)) }) } const addNote = (event) => { event.preventDefault() const noteObject = { content: newNote, date: new Date().toISOString(), important: Math.random() > 0.5 } noteService .create(noteObject) .then(response => { setNotes(notes.concat(response.data)) setNewNote('') }) } // ... } export default App سننقل التطبيق خطوة إضافية إلى الأمام بحيث يتلقى المكوِّن App كائنًا يحتوي على الاستجابة الكاملة للخادم على طلب HTTP في حال استخدم المكوِّن الدوال السابقة: noteService .getAll() .then(response => { setNotes(response.data) }) يستخدم المكوِّن App الخاصة response.data فقط من كائن الاستجابة. وبالتالي سيكون استخدام الوحدة مريحًا أكثر لو أمكننا أن نحصل على بيانات الاستجابة فقط بدلًا من الاستجابة كاملةً. ستبدو الشيفرة التي تنفذ ذلك على النحو: noteService .getAll() .then(initialNotes => { setNotes(initialNotes) }) يمكننا إنجاز ذلك بتغيير الشيفرة في الوحدة على النحو التالي: import axios from 'axios' const baseUrl = 'http://localhost:3001/notes' const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } const create = newObject => { const request = axios.post(baseUrl, newObject) return request.then(response => response.data) } const update = (id, newObject) => { const request = axios.put(`${baseUrl}/${id}`, newObject) return request.then(response => response.data) } export default { getAll: getAll, create: create, update: update } وهكذا فإن دوال وحدة الاتصال لن تعيد وعود توابع axios مباشرة، بل تسند الوعد إلى المتغيّر request ثم تستدعي التابع then الموافق: const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } بالنسبة للسطر الأخير من الشيفرة السابقة فهو شكل أكثر اختصارًا للشيفرة التالية: const getAll = () => { const request = axios.get(baseUrl) return request.then(response => { return response.data })} تعيد الدالة المعدّلة getAll وعدًا، وكذلك سيفعل التابع then الذي ينتج عن وعد ويعيد وعدًا. فبعد أن نعرّف معامل التابع then ليعيد مباشرة بيانات الاستجابة response.data نكون قد حصلنا على غايتنا من استخدام الدالة getAll. إن نجح طلب HTTP سيعيد الوعد البيانات التي يرسلها الخادم ضمن استجابته للطلب. سنحدّث الآن شيفرة المكوِّن App ليعمل مع التغييرات التي أجريناها على وحدة الاتصال. سنبدأ بإصلاح دوال الاستدعاء التي تُمرّر كمعاملات إلى توابع الكائن noteService حتى تتمكن من إعادة بيانات الاستجابة من الخادم مباشرة: const App = () => { // ... useEffect(() => { noteService .getAll() .then(initialNotes => { setNotes(initialNotes) }) }, []) const toggleImportanceOf = id => { const note = notes.find(n => n.id === id) const changedNote = { ...note, important: !note.important } noteService .update(id, changedNote) .then(returnedNote => { setNotes(notes.map(note => note.id !== id ? note : returnedNote)) }) } const addNote = (event) => { event.preventDefault() const noteObject = { content: newNote, date: new Date().toISOString(), important: Math.random() > 0.5 } noteService .create(noteObject) .then(returnedNote => { setNotes(notes.concat(returnedNote)) setNewNote('') }) } // ... } تبدو الأمور معقدة، وقد تزداد تعقيدًا عندما نحاول أن نتعمق في شرحها، لذلك حاول أن تطلع على هذا الموضوع أكثر. وبالطبع شرحت أكاديمية حسوب شرحًا وافيًا هذا الموقع في مقال، سلسلة الوعود في جافاسكربت وستجد شرحًا وافيًا ومطولًا عن الموضوع في المراجع الأجنبية مثلًا في كتاب "Async performance" وهو أحد كتب سلسلة You do not know JS. إنّ فكرة الوعود هي فكرة مركزية في تطوير تطبيقات JavaScript الحديثة، لذلك ننصحك بشدة أن تقضي وقتًا كافيًا في فهمها. كتابة شيفرة أوضح عند تعريف الكائنات المجرّدة تُصَدِّر الوحدة التي أنشأناها سابقًا والتي تعرّف خدمات تتعلق بالتعامل مع الملاحظات، كائنًا يمتلك الخصائص getAll وcreate وupdate والتي تسند إلى دوال مخصصة للتعامل مع الملاحظات. يبدو تعريف هذه الوحدة بالشكل التالي: import axios from 'axios' const baseUrl = 'http://localhost:3001/notes' const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } const create = newObject => { const request = axios.post(baseUrl, newObject) return request.then(response => response.data) } const update = (id, newObject) => { const request = axios.put(`${baseUrl}/${id}`, newObject) return request.then(response => response.data) } export default { getAll: getAll, create: create, update: update } تصدَّر الوحدة الكائن التالي: { getAll: getAll, create: create, update: update } تدعى العبارات التي تقع على يسار العمود في الشيفرة السابقة، مفاتيح الكائن. بينما تدعى العبارات التي تقع على اليمين بمتغيّرات الكائن والتي تُعرّف داخل الوحدة. وطالما أن المفاتيح والمتغيرات تحمل نفس التسمية، يمكننا صياغة الكائن بشكل أكثر إختصارًا: { getAll, create, update } وكنتيجة لذلك سيصبح تعريف الوحدة أبسط: import axios from 'axios' const baseUrl = 'http://localhost:3001/notes' const getAll = () => { const request = axios.get(baseUrl) return request.then(response => response.data) } const create = newObject => { const request = axios.post(baseUrl, newObject) return request.then(response => response.data) } const update = (id, newObject) => { const request = axios.put(`${baseUrl}/${id}`, newObject) return request.then(response => response.data) } export default { getAll, create, update } إنّ تعريف الكائن بهذه الصيغة المختصرة، يعني استخدامنا لميزة جديدة قدمتها JavaScript عند إطلاق ES6، والتي ستمكننا من تعريف الكائنات التي تستخدم المتغيّرات بطريقة مختصرة أكثر. ولتوضيح هذه الميّزة، دعونا نتأمل حالة معينة تسند فيها القيم التالية إلى متغيرات: const name = 'Leevi' const age = 0 لقد توجب علينا في النسخ القديمة من JavaScript أن نعرف الكائن على النحو التالي: const person = { name: name, age: age } وطالما أن الخصائص والمتغيرات في الكائن لها نفس التسمية، سيكون كافيًا كتابة التعريف باستخدام ES6 كالتالي: const person = { name, age } ستكون النتيجة نفسها في الطريقتين، فكلاهما يعرفان كائنًا له خاصية تدعى name تحمل القيمة "Leevi" وخاصية تدعى "age" وتحمل القيمة 0. الوعود والأخطاء لو سمح تطبيقنا للمستخدم أن يحذف ملاحظات، فقد يواجه حالة يريد فيها تغيير أهمية ملاحظة محذوفة سابقًا من النظام. لنحاكي هذه الحالة بأن نطلب من الدالة getAll أن تعيد ملاحظة مكتوبة مسبقًا لكنها عمليًا غير موجودة على الخادم: const getAll = () => { const request = axios.get(baseUrl) const nonExisting = { id: 10000, content: 'This note is not saved to server', date: '2019-05-30T17:30:31.098Z', important: true, } return request.then(response => response.data.concat(nonExisting)) } عندما نحاول تغيير أهمية تلك الملاحظة، ستظهر لنا رسالة خطأ على شاشة الطرفية مفادها أن الخادم قد استجاب لطلب HTTP برمز الحالة 404 (غير موجود). لذلك ينبغي على التطبيق أن يمنع وقوع هذا النوع من الأخطاء. فلن يعرف المستخدم أنّ هذا الخطأ قد وقع، إلا إذا صدف وكانت طرفية التطوير في متصفحه مفتوحة. الطريقة الوحيدة التي يمكن أن يُلاحظ فيها وقوع الخطأ في التطبيق هو عدم تغيّّر أهمية الملاحظة بعد النقر على الزر. لقد أشرنا سابقًا في القسم الثاني أن للوعد ثلاث حالات مختلفة. حيث يُرفض الوعد إذا فشل طلب HTTP. لم يعالج تطبيقنا الحالة التي يُرفض فيها الوعد. ويعالج ذلك بتزويد التابع then بدالة استدعاء أخرى، تُستدعى في مثل هذه الحالة. نستخدم عادة التابع catch لهذه الغاية ويعرّف كالتالي: axios .get('http://example.com/probably_will_fail') .then(response => { console.log('success!') }) .catch(error => { console.log('fail') }) عندما يخفق الطلب، يستدعى معالج الحدث المعرف ضمن التابع catch الذي يوضع عادة في آخر سلسلة التوابع التي تتعامل مع الوعد. فعندما يرسل التطبيق طلب HTTP، سينشأ في الواقع ما يسمى سلسلة الوعد كما في الشيفرة التالية: axios .put(`${baseUrl}/${id}`, newObject) .then(response => response.data) .then(changedNote => { // ... }) وهكذا يُستدعى التابع catch حالما يرمي أي تابع في سلسلة الوعد خطأ ويرفض الوعد: axios .put(`${baseUrl}/${id}`, newObject) .then(response => response.data) .then(changedNote => { // ... }) .catch(error => { console.log('fail') }) لنستفد من الميزة السابقة ولنعرّّف معالجًا للخطأ في المكوِّن App: const toggleImportanceOf = id => { const note = notes.find(n => n.id === id) const changedNote = { ...note, important: !note.important } noteService .update(id, changedNote).then(returnedNote => { setNotes(notes.map(note => note.id !== id ? note : returnedNote)) }) .catch(error => { alert( `the note '${note.content}' was already deleted from server` ) setNotes(notes.filter(n => n.id !== id)) })} يُعلِمُ التطبيق المستخدمَ بوجود خطأ من خلال النافذة المنبثقة alert، وتستبعد الملاحظة المحذوفة من حالة التطبيق. ويتم استبعاد الملاحظة المحذوفة مسبقًا من حالة التطبيق باستخدام تابع المصفوفات filter والذي يعيد مصفوفة جديدة تضم فقط القيم التي ستعيدها الدالة -التي تُمرّر إليه كمعامل- على أنها صحيحة. notes.filter(n => n.id !== id) قد لا يعتبر استخدام alert مناسبًا في تطبيقات React، لذلك سنتعلم لاحقًا طرقًا متقدمة أكثر في عرض الرسائل والتنبيهات. لا يعني ذلك أن لا نستخدم alert أبدًا فقد تكون مفيدة كنقطة انطلاق. يمكن الحصول على شيفرة التطبيق بوضعه الحالي في الفرع part2-6 على github. التمارين 2.15-2.18 2.15 دليل الهاتف: الخطوة 7 لنعد إلى تطبيق دليل الهاتف الذي بدأناه سابقًا. احفظ رقم الهاتف الذي ندخله في الخادم. 2.16 دليل الهاتف: الخطوة 8 انقل الشيفرات التي تؤمن الاتصال مع الواجهة الخلفية إلى وحدة خاصة بها، بالاستفادة من المثال الذي عرضناه سابقًا في هذا الجزء. 2.17 دليل الهاتف: الخطوة 9 امنح المستخدم إمكانية حذف المدخلات إلى دليل الهاتف. يمكن أن تنفذ ذلك باستخدام زر خاص يظهر بجوار كل اسم في الدليل. يمكنك الطلب من المستخدم تأكيد عملية الحذف باستخدام التابع window.confirm: يمكن حذف المورد المتعلق بشخص من الخادم باستخدام طلب HTTP-DELETE إلى موقع المورد. فلو أردت حذف الشخص الذي قيمة معرّفه id هي 2، نفذ ذلك باستخدام الطلب السابق إلى الموقع localhost:3001/persons/2. وتذكر أنه لا تُرسل أية بيانات مع هذا الطلب. يمكنك استخدام الطلب HTTP-DELETE مع توابع المكتبة axios بنفس الطريقة التي استخدمناها فيها. انتبه لا يمكن اختيار "delete" كاسم لمتغيّر، لأنها كلمة محجوزة في JavaScript. فالشيفرة التالية مثلًا غير صحيحة: // استخدم اسما آخر للمتغير const delete = (id) => { // ... } 2.18 دليل الهاتف: الخطوة 10 * أضف ميزة إلى تطبيقك تستبدل فيها الرقم القديم بالرقم الجديد إذا أضاف المستخدم رقمًا إلى شخص موجود مسبقًا. يفضل استخدام طلب HTTP-PUT لتنفيذ هذا الأمر. ليطلب تطبيقك من المستخدم أن يؤكد العملية إذا كانت معلومات الشخص موجودة مسبقًا. ترجمة -وبتصرف- للفصل Altering Data in Server من سلسلة Deep Dive Into Modern Web Development
  13. انصب تركيزنا حتى هذه اللحظة على كتابة الشيفرة للعمل مع الواجهة الأمامية (العمل من جهة المستخدم أو المتصفح)، وسنبدأ العمل على الواجهة الخلفية (جهة الخادم) عند الوصول إلى الفصل الثالث. مع ذلك سنخطو بداية خطواتنا بتعلم طريقة التواصل بين الشيفرة المكتوبة للمتصفح مع الخادم. سنستعمل عند تطويرنا التطبيقات أداة تدعى JSON Server لتأمين خادم افتراضي على جهازك. أنشئ ملفًا باسم db.json في المجلد الجذري للمشروع يحوي ما يلي: { "notes": [ { "id": 1, "content": "HTML is easy", "date": "2019-05-30T17:30:31.098Z", "important": true }, { "id": 2, "content": "Browser can execute only JavaScript", "date": "2019-05-30T18:39:34.091Z", "important": false }, { "id": 3, "content": "GET and POST are the most important methods of HTTP protocol", "date": "2019-05-30T19:20:14.298Z", "important": true } ] } يمكن تثبيت كامل بيئة JSON على جهازك بتنفيذ الأمر install -g json-server. ويتطلب ذلك امتلاك امتيازات مدير النظام والخبرة بالتأكيد. لا يتطلب الأمر عادة تثبيتًا كاملًا، فيمكنك تثبيت ما يلزم في المجلد الجذري للمشروع بتنفيذ الأمر npx json-server: npx json-server --port 3001 --watch db.json يعمل JSON-server افتراضيًا على المنفذ 3000، ونظرًا لاستخدامنا create-react-app الذي يعمل أيضًا عند نفس المنفذ، لابد من تحديد منفذ جديد للخادم مثل 3001. توجه إلى العنوان http://localhost:3001/notes عبر متصفحك، ستلاحظ كيف سيعرض خادم JSON البيانات التي كتبناها سابقًا في ملف JSON. ثبت ملحقًا (plugin) إلى متصفحك لإظهار البيانات المكتوبة بصيغة JSON مثل JSONView، إن لم يتمكن من عرضها بشكل صحيح. لاحقًا، ستكون المهمة حفظ البيانات على الخادم. طبعًا خادم JSON في حالتنا. حيث تحضر شيفرة React الملاحظات ثم تصيّرها على الشاشة. وكذلك سترسل أية ملاحظات جديدة إلى الخادم لتجعلها "محفورة" في الذاكرة. يخزن خادم JSON البيانات في الملف db.json. في الواقع العملي، ستُخزَّن البيانات ضمن قواعد البيانات، لكن سيبقى هذا الخادم أداة مفيدة في متناول اليد لتجريب العمل مع الواجهة الخلفية خلال مرحلة التطوير دون عناء برمجة أي شيء عليه. سنتعلم المزيد حول مبادئ تطوير التطبيقات للتعامل مع الواجهة الخلفية بتفاصيل أكثر في القسم 3. دور المتصفح كبيئة تشغيل تقتضي مهمتنا الأولى إحضار الملاحظات الموجودة إلى تطبيقنا من العنوان http://localhost:3001/notes. ولقد تعلمنا بالفعل طريقةً لإحضار البيانات من الخادم باستخدام JavaScript في القسم 0. حيث استخدمنا XMLHttpRequest في إحضار البيانات من الخادم وهو مايعرف بطلب HTTP باستخدام الكائن XHR. قُدّمت هذه التقنية عام 1999 وتدعمها حتى اللحظة جميع المتصفحات. لكن لا ينصح حاليًا باستخدام هذا الأسلوب، بل استخدام التابع fetch الذي تدعمه المتصفحات بشكل واسع. وتعتمد طريقة عمله على مايسمى وعودًا promises، بدلًا من الأسلوب المقاد بالأحداث والذي يستخدمه XHR. كتذكرة من القسم 0 (لا يجب استخدام XHR إلا إن كنت بحاجة ماسة لذلك) سنحضر البيانات باستخدام هذا الأسلوب كالتالي: const xhttp = new XMLHttpRequest() xhttp.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { const data = JSON.parse(this.responseText) // Data يعالج الاستجابة التي أسندت إلى المتغيّر } } xhttp.open('GET', '/data.json', true) xhttp.send() صرحنا في البداية عن معالج حدث للكائن xhttp الذي يمثل طلب HTML. يُستدعى هذا المعالج من قبل بيئة تشغيل JavaScript عندما تتغير حالة الكائن xhttp. ستُعالج طبعًا البيانات التي أُحضرت بشكل مناسب إذا تغيرت الحالة نتيجة لتلقي استجابة الخادم. ولهذا لا قيمة لأية شيفرة قد نضعها في معالج الحدث قبل إرسال الطلب إلى الخادم. لكن بالطبع سيتم تنفيذها في وقت لاحق. إذًا لا تُنفَّذ الشيفرة بشكل متزامن من الأعلى للأسفل، بل بشكل غير متزامن، وستستدعي JavaScript المعالج المصرح عنه سابقًا في مرحلة ما. تعبر البرمجة بالطريقة غير المتزامنة شائعة الاستخدام في Java، يوضح ذلك المثال التالي (انتبه إلى أن الشيفرة في المثال ليس شيفرة Java قابلة للتنفيذ): HTTPRequest request = new HTTPRequest(); String url = "https://fullstack-exampleapp.herokuapp.com/data.json"; List<Note> notes = request.get(url); notes.forEach(m => { System.out.println(m.content); }); ُتنفَّذ الشيفرة في Java سطرًا تلو الآخر وتتوقف بانتظار نتيجة طلب HTTP، أي إتمام تنفيذ الأمر ()request.get. تُخزَّن بعدها البيانات التي يرسلها الخادم -وهي في حالتنا هذه "الملاحظات"- ليتم التعامل معها بالطريقة المطلوبة. تتبع بيئة تشغيل JavaScript أو محرك JavaScript بالمقابل الأسلوب غير المتزامن. ويتطلب ذلك من حيث المبدأ ألا تعيق عمليات الدخل والخرج IO-operations عملية التنفيذ (مع بعض الاستثناءات). أي ينبغي أن تُستأنف عملية تنفيذ الشيفرة مباشرة بعد استدعاء دالة (الدخل/الخرج) دون انتظار الاستجابة. وعندما تكتمل العملية السابقة أو بالأحرى خلال مرحلة ما بعد اكتمالها، يستدعي محرك JavaScript معالج الحدث المعَّرف سابقًا. تعتبر محركات JavaScript حاليًا أحادية المسلك أو الخيط SingleThreaded، أي أنها لا تستطيع تنفيذ الشيفرات على التوازي. لذلك يفضل عمليًا اعتماد نموذج (دخل/خرج) لا يعيق تنفيذ الشيفرة، وإلا سيجمد المتصفح عند إحضار البيانات من الخادم على سبيل المثال. ومن التبعات الأخرى لاستخدام المسلك الأحادي للمحركات، هو توقف المتصفح عند تنفيذ شيفرات تستغرق وقتًا طويلًا خلال فترة التنفيذ. لو وضعنا الشيفرة التالية في أعلى التطبيق: setTimeout(() => { console.log('loop..') let i = 0 while (i < 50000000000) { i++ } console.log('end') }, 5000) سيعمل التطبيق بشكل طبيعي لمدة خمس ثوان (وهو مُعامل الدالة setTimeOut) قبل أن يدخل في تنفيذ الحلقة الطويلة. حيث سيتوقف المتصفح عن العمل ولن نتمكن حتى من إغلاق المتصفح (على الأقل لا يمكننا ذلك في Chrome). وليبقى المتصفح متجاوبًا بشكل مستمر مع أفعال المستخدم وبسرعة كافية، لابد من اعتماد منطق لا يسمح بحسابات طويلة عند كتابة الشيفرة. ستجد على شبكة الإنترنت العديد من المواد المتعلقة بهذا الموضوع، ونخص بالذكر منها الملاحظات المفتاحية التي قدمها فيليب روبرتس بعنوان What the heck is the event loop anyway والتي تمثل عرضًا واضحًا للموضوع. يمكن تنفيذ الشيفرة على التوازي في المتصفحات الحديثة باستخدام ما يسمى عمال الويب web workers. لكن تنفيذ حلقة الأحداث في كل نافذة على حدى سيبقى بأسلوب المسلك الأحادي npm (مدير الحزم في Node.js) لنعد إلى إحضار البيانات من الخادم. يمكن أن نستخدم -كما أشرنا سابقًا- الدالة fetch التي تعتمد على مفهوم الوعود. وتعتبر هذه الدالة من الأدوات المميزة والمعيارية التي تدعمها كل المتصفحات الحديثة. لكننا مع ذلك سنستخدم دوال المكتبة axios بدلًا منها للتواصل بين المتصفح والخادم. حيث تعمل دوال هذه المكتبة بشكل مشابه للدالة fetch لكن التعامل معها أسهل. والسبب المهم الآخر هو تعلم إضافة مكتبات خارجية والمعروفة باسم حزم npm ضمن مشاريع React. تُعرّف حاليًا كل مشاريع JavaScript باستخدام npm، وكذلك تطبيقات React المبنية باستخدام create-react-app. ويشير وجود الملف package.json في المجلد الجذري للمشروع على استخدام npm. { "name": "notes", "version": "0.1.0", "private": true, "dependencies": { "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.4.0", "@testing-library/user-event": "^7.2.1", "react": "^16.12.0", "react-dom": "^16.12.0", "react-scripts": "3.3.0" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject" }, "eslintConfig": { "extends": "react-app" }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] } } نشير هنا إلى الجزء الأهم من الملف package.json وهو ملفات الارتباط dependencies أو المكتبات الخارجية التي يعتمدها المشروع. سنبدأ الآن باستخدام axios. يمكننا تعريف هذه المكتبة مباشرة في الملف package.jaon، لكن من الأفضل أن نثبتها باستخدام الأمر: npm install axios --save ملاحظة: تنفذ جميع أوامر npm في المجلد الجذري للمشروع، وهو المكان الذي تجد فيه الملف package.jsons. بعد تثبيتها ستظهر axios ضمن ملفات الارتباط: { "dependencies": { "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.4.0", "@testing-library/user-event": "^7.2.1", "axios": "^0.19.2", "react": "^16.12.0", "react-dom": "^16.12.0", "react-scripts": "3.3.0" }, // ... } سُينزِّل أمر التثبيت السابق بالإضافة إلى المكتبة axios شيفرة المكتبة. وكغيرها من المكتبات ستجد شيفراتها ضمن المجلد nodemodules الموجود في المجلد الجذري للمشروع. يمكنك أن تلاحظ ان المجلد nodemodules يضم كميةً لا بأس بها من الأشياء المهمة. لنضف أمرًا آخر. ثبت json-server كملف ارتباط (وهذا فقط أثناء التطوير) باستخدام الأمر: npm install json-server --save-dev ثم عدّل قليلًا القسم Scripts من الملف package.json كما يلي: { // ... "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "server": "json-server -p3001 --watch db.json" }, } يمكننا الآن تشغيل خادم json -وبلا تعريف لأية مُعاملات جديدة- من المجلد الجذري للمشروع باستخدام الأمر: npm run server سنطلع أكثر على الأداة npm في القسم الثالث من منهاجنا. ملاحظة: يجب إيقاف الخادم الذي شغلناه باستخدام الأمر السابق قبل تشغيل خادم جديد وإلا ستنتظرك المشاكل: تخبرنا رسالة الخطأ المطبوعة باللون الآخر عن المشكلة التالية: لا يمكن الارتباط بالمنفذ 3001. اختر رجاء رقمًا آخر للمنفذ من خلال التعليمة port-- أو من خلال ملف التهيئة لخادم json. والسبب أن المنفذ 3001 قد شُغِل من قبل الخادم الذي أنشئ أولًا ولم يتم إيقافه، وبالتالي لم يستطع التطبيق الارتباط بهذا المنفذ. لاحظ أن هناك فرقًا بسيطًا بين أمري التثبيت التاليين: npm install axios --save npm install json-server --save-dev ثُبِّتت المكتبة axios كملف ارتباط (save--) للتطبيق، لأن تنفيذه يتطلب وجود هذه المكتبة. بالمقابل ثُبِّت خادم jason كملف ارتباط للتطوير (save-dev--) لأن البرنامج لا يحتاجه فعلًا حتى يُنفّذ، بل سيساعد فقط خلال عملية تطوير التطبيق. سنتحدث أكثر عن موضوع ملفات الارتباط في القسم التالي. مكتبة Axios والوعود نحن الآن مستعدين للعمل مع axios، وسنفترض أن خادم json يعمل على المنفذ 3001. ملاحظة: لتشغل كل من jason و create-react-ap معًا عليك أن تفتح نافذتين من الطرفية node.js، كل نافذة لبرنامج. يمكن إدراج المكتبة ضمن التطبيق بالطريقة التي أدرجت فيها React وهي استعمال العبارة import. أضف مايلي إلى الملف index.js: import axios from 'axios' const promise = axios.get('http://localhost:3001/notes') console.log(promise) const promise2 = axios.get('http://localhost:3001/foobar') console.log(promise2) من المفترض أن يطبع ما يلي على الشاشة: يعيد التابع get وعدًا. ستجد في التوثيق على موقع موسوعة حسوب حول موضوع الوعود مايلي: وبكلمات أخرى، هو كائن يمثل عملية غير متزامنة يمكن أن يأخذ إحدى الحالات الثلاث التالية: الوعد طور التنفيذ (pending): أي أن القيمة النهائية (والتي تمثلها إحدى الحالتين التاليتين) غير جاهزة حتى اللحظة. الوعد قد تحقق (fulfilled): إي أن العملية قد اكتملت والقيمة النهائية جاهزة. ويعني هذا عمومًا أن العملية قد نجحت. وقد يشار إلى هذه الحالة أحيانًا على أنها منجزة Resolved. الوعد قد رفض (rejected): أي أن خطأً قد منع تحديد القيمة النهائية. ويعني هذا عمومًا إخفاق العملية. تَحقَّق الوعد الأول في مثالنا السابق، ويمثل نجاح الطلب (axios.get(http://localhost:3001/notes، بينما رفض الوعد الثاني. وتُبلغنا الطرفية سبب الرفض بأنها تبدو كمحاولة لإرسال طلب HTTP-GET إلى عنوان غير موجود. إذا أردنا أن نعرف كيف وأين سنحصل على نتيجة العملية التي يمثلها الوعد، لابد من التصريح عن معالج حدث لهذا الوعد. ويتم ذلك باستخدام تابع axios يدعى then: const promise = axios.get('http://localhost:3001/notes') promise.then(response => { console.log(response) }) ستظهر على شاشة الطرفية المعلومات التالية: تستدعي بيئة تشغيل JavaScript الدالة المصرح عنها داخل then وتمرر إليها الكائن response كمُعامل. يحوي المُعامل response كل البيانات الأساسية المتعلقة باستجابة الخادم على طلب HTTP-GET، وهي البيانات المعادة ورمز الحالة والترويسات. لا داعٍ لإسناد كائن الوعد إلى متغير، بل تُستخدم عادة طريقة الاستدعاء المتسلسل للتوابع كالتالي: axios.get('http://localhost:3001/notes').then(response => { const notes = response.data console.log(notes) }) حيث تستخلص دالة الاستدعاء البيانات من الاستجابة التي تحصل عليها، ومن ثم تحفظها في متغير وتطبعها على شاشة الطرفية. ومن المفضل كتابة توابع الاستدعاء المتسلسل بحيث يقع كل تابع في سطر جديد وذلك لتسهيل قراءة الشيفرة: axios .get('http://localhost:3001/notes') .then(response => { const notes = response.data console.log(notes) }) يعيد الخادم البيانات المطلوبة على شكل سلسلة نصية طويلة غير منسقة بعد. ثم تحول axios البيانات إلى مصفوفة JavaScript لأن الخادم قد حدد نمط البيانات المرسلة على أنها application/json بنمط محارف utf-8 وذلك ضمن ترويسة نوع المحتوى content-type. وبهذا سنصبح قادرين أخيرًا على استخدام البيانات القادمة من الخادم. سنحاول الآن طلب الملاحظات من الخادم المحلي ثم تصييرها على شكل مكوِّن App. لكن تذكر أن هذه المقاربة ستعرضك لمشاكل عدة لأننا سنصيّر المكوِّن App عندما يستجيب الخادم فقط: import React from 'react' import ReactDOM from 'react-dom' import App from './App' import axios from 'axios' axios.get('http://localhost:3001/notes').then(response => { const notes = response.data ReactDOM.render( <App notes={notes} />, document.getElementById('root') ) }) يمكن أن نستخدم الطريقة السابقة في ظروف معينة، لكنها سبب للعديد من المشاكل. لنحاول بدلًا من ذلك أن نضع أمر إحضار البيانات داخل المكوِّنApp بدلًا من أن يكون جزءًا من دالة الاستجابة. لكن ما ليس واضحًا تمامًا هو المكان الذي سنضع فيه الأمر داخل المكوِّن. خطافات التأثير تعرفنا سابقًا على خطافات الحالة التي ظهرت مع الإصدار 16.8.0 من React، والتي تمنح دالة المكوِّن إمكانية تحديد حالته الراهنة. كما ظهرت في نفس النسخة خطافات التأثير effect hooks كميزة جديدة، وقد وثّقت كالتالي: إذًا فخطافات التأثير هو ما نحتاجه تمامًا لإحضار البيانات من الخادم. لنحذف أمر إحضار البيانات من ملف index.js، ولن نحتاج أيضًا إلى استخدام الخصائص لتمرير الملاحظات إلى المكوِّن، طالما أننا سنحضرها مباشرة من الخادم. وبالتالي سيبدو الملف index.js كالتالي: ReactDOM.render(<App />, document.getElementById('root')) سيتغير المكوِّن App ليصبح كالتالي: import React, { useState, useEffect } from 'react' import axios from 'axios' import Note from './components/Note' const App = () => { const [notes, setNotes] = useState([]) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) useEffect(() => { console.log('effect') axio.get('http://localhost:3001/notes').then(response => { console.log('promise fulfilled') setNotes(response.data) }) }, []) console.log('render', notes.length, 'notes') // ... } كما أضفنا عدة أوامر للطباعة على الطرفية بغية توضيح مسار العملية، وستظهر عند التنفيذ كالتالي: render 0 notes effect promise fulfilled render 3 notes تنفذ في البداية الشيفرة الموجودة داخل جسم دالة المكوِّن، وتصيَّر النتيجة للمرة الأولى. وعندها ستُطبع العبارة render 0 notes على الطرفية إشارة إلى أن البيانات لم تحضر بعد من الخادم. ستُنفَّذ الدالة أو (التأثير) التالي مباشرة بعد أول عملية تصيير: () => { console.log('effect') axios .get('http://localhost:3001/notes') .then(response => { console.log('promise fulfilled') setNotes(response.data) }) } يؤدي تنفيذ التأثير السابق إلى طباعة العبارة effect على الطرفية، ويهيئ الأمر axios.get لعملية إحضار البيانات من الخادم ويُعرِّف في نفس الوقت معالج الحدث التالي للعملية: response => { console.log('promise fulfilled') setNotes(response.data) }) بمجرد وصول البيانات من الخادم، تستدعي بيئة تشغيل JavaScript معالج الحدث المرتبط بالعملية والذي يطبع بدوره العبارة fulfilled على الطرفية، ويخزّن الملاحظات القادمة من الخادم ضمن حالة المكوِّن باستخدام الدالة (setNote(response.data. وكما هو الحال دائمًا ستسبب تحديث حالة التطبيق إعادة تصيير المكوِّن، وستظهر ثلاث ملاحظات على الطرفية ومن ثم ستصيّر من جديد على الشاشة. لنلقي في النهاية نظرة شاملة على خطاف التأثير الذي استخدمناه: useEffect(() => { console.log('effect') axios .get('http://localhost:3001/notes').then(response => { console.log('promise fulfilled') setNotes(response.data) }) }, []) لنُعِد كتابة الشيفرة بشكل مختلف قليلًا: const hook = () => { console.log('effect') axios .get('http://localhost:3001/notes') .then(response => { console.log('promise fulfilled') setNotes(response.data) }) } useEffect(hook, []) سنرى الآن بوضوح أن الدالة useEffect تأخذ في الواقع مُعاملين اثنين. الأول هو الدالة effect وتمثل التأثير ذاته وفقًا للتوثيق: يتم تنفيذ دالة التأثير افتراضيًا بعد أن يتم تصيير المكوِّن. لكننا نريد في حالتنا هذه أن ينفّذ فقط مع أول تصيير. أما المُعامل الثاني فيستخدم لتحديد كم مرة سيُنفَّذ التأثير الجانبي. فإذا وضعنا مصفوفة فارغة [ ] كقيمة له فإن التأثير سينفذ مرة واحدة عند أول تصيير للمكوِّن. يمكن أن نستخدم خطافات التأثير في أمور عدة بعد إحضار البيانات من الخادم، لكننا سنكتفي حاليًا بهذا الاستخدام. عليك أن تفكر مليًا بتسلسل الأحداث التي ناقشناها. أي جزء من الشيفرة نُفِّذ أولًا، وبأي ترتيب، وكم مرة، لأن فهم ترتيب الأحداث أمر حيوي جدًا. لاحظ أنه كان بالإمكان كتابة شيفرة دالة التأثير على النحو التالي: useEffect(() => { console.log('effect') const eventHandler = response => { console.log('promise fulfilled') setNotes(response.data) } const promise = axios.get('http://localhost:3001/notes') promise.then(eventHandler) }, []) أُسند مرجعٌ لدالة معالج الحدث إلى المتغيّرeventHandler. وخُزّن الوعد الذي يعيده التابع get في المتغيّر promise. يعرّف الاستدعاء (إعادة النداء) بجعل المتغير eventHandler كمُعامل للتابع then العائد إلى الوعد. فمن الضروري إسناد الدوال والوعود إلى متغيرات، ويفضل كتابة الأشياء بشكل مختصر: useEffect(() => { console.log('effect') axios .get('http://localhost:3001/notes') .then(response => { console.log('promise fulfilled') setNotes(response.data) }) }, []) تبقى لدينا مشكلة في التطبيق، فالملاحظات الجديدة التي ننشئها لن تخزن على الخادم. ستجد نسخة عن التطبيق حتى هذه المرحلة على github في الفرع part2-4. بيئة التشغيل الخاصة بالتطوير ستغدو تهيئة التطبيق بشكل كامل أصعب شيئًا فشيئًا. لنراجع إذًا ما الذي يحدث وأين سيحدث. تُنفَّذ شيفرة JavaScript التي توصّف التطبيق على المتصفح. حيث يحضر المتصفح هذه الشيفرة من خادم تطوير React. وهذا الأخير عبارة عن تطبيق يعمل مباشرة بعد تنفيذ الأمر npm start. يحول خادم التطوير dev-server شيفرة JavaScript إلى صيغة يفهمها المتصفح، كما يقوم بعدة أشياء أخرى منها تجميع شيفرة JavaScript من عدة ملفات في ملف واحد. سنطّلع على تفاصيل أكثر حول خادم التطوير في القسم 7. يحضر تطبيق React -الذي يعمل على المتصفح- البيانات بصيغة JSON من خادم JSON الذي يَشغُل المنفذ 3001 من جهازك. يحصل خادم JSON بدوره على البيانات المطلوبة من الملف db.json. تتواجد كل أقسام التطبيق حتى هذه المرحلة من مراحل التطوير ضمن آلة تطوير البرنامج Software Developer 's Machine. والتي تُعرف بالخادم المحلي. سيتغير الوضع حالما تنشر التطبيق على الإنترنت، وهذا ما سنراه في القسم 3. التمارين 2.11 -2.14 2.11 دليل الهاتف: الخطوة 6 خزن المعلومات الأولية للتطبيق في الملف db.json وضعه في المجلد الجذري للمشروع: { "persons":[ { "name": "Arto Hellas", "number": "040-123456", "id": 1 }, { "name": "Ada Lovelace", "number": "39-44-5323523", "id": 2 }, { "name": "Dan Abramov", "number": "12-43-234345", "id": 3 }, { "name": "Mary Poppendieck", "number": "39-23-6423122", "id": 4 } ] } شغل خادم JSON على المنفذ 3001 وتأكد من أن الخادم سيعيد إليك قائمة الأشخاص السابقة عند طلب العنوان http://localhost:3001/persons من المتصفح. إن تلقيت رسالة الخطأ التالية: events.js:182 throw er; // Unhandled 'error' event ^ Error: listen EADDRINUSE 0.0.0.0:3001 at Object._errnoException (util.js:1019:11) at _exceptionWithHostPort (util.js:1041:20) سيعني ذلك بأن المنفذ 3001 محجوز من قبل تطبيقٍ آخر كخادم JSON آخر. أغلق كل التطبيقات الأخرى أو غيّر رقم المنفذ. عدّل التطبيق ليحضر لك بيانات الأشخاص من الخادم باستخدام المكتبة axios. ثم أكمل عملية إحضار البيانات باستخدام خطاف التأثير. 2.12 معلومات عن البلدان: الخطوة 1 * تزودك الواجهة البرمجية https://restcountries.eu بمعلومات عن بلدان مختلفة بصيغة تفهمها آلة التطوير، والتي تدعى REST API. أنشئ تطبيقًا يمكننا من خلاله الاطلاع على معلوماتٍ من بعض الدول. من المفترض أن يحصل تطبيقك على البيانات من نقطة الاتصال all. واجهة المستخدم بسيطة جدًا، فالبحث عن الدولة يكون بكتابة اسمها في حقل البحث. وإن ظهرت العديد من النتائج المطابقة للبحث (أكثر من 10) تظهر رسالة تنبيه للمستخدم أن يحدد بحثه بشكل أدق. وإن ظهرت عدة نتائج للبحث، لكنها أقل من 10 ستظهر جميع النتائج على الشاشة أما إن كانت نتيجة البحث دولة واحدة، ستظهر بعض المعلومات الأولية عنها مع علمها واللغات المحكية فيها. ملاحظة: يكفي أن يعمل التطبيق لأغلبية بلدان العالم. لأن بعض الدول مثل السودان ستربك التطبيق كون كلمة السودان هي أيضًا جزء من اسم دولة أخرى هي جنوب السودان. لا تلق بالًا لهذا الموضوع الآن. تحذير: تنشئ الأداة create-react-app مستودع git محلي يحتوي المشروع، إلا إن كان في المجلد مستودع محلي سابق. من المرجح أنك لا تريد أن يغدو المشروع مستودعًا، لهذا نفذ الأمر التاليrm -rf .git في مسار المشروع. 2.13 معلومات عن البلدان: الخطوة 2 * هناك الكثير من التمارين التي ينبغي إنجازها، لا تبقى طويلًا في هذا التمرين! (معلّم على أنه غير أساسي) حسِّن التطبيق بحيث يعرض زرًا بجانب الدول التي تظهر كنتيجة للبحث، يعطينا بالنقر عليه المعلومات التي ذكرناها سابقًا عن هذه الدولة. يكفي هنا أيضًا أن يعمل التطبيق بالنسبة لأغلبية البلدان، تجاهل الحالة التي أشرنا إليها سابقًا. 2.14 معلومات عن البلدان: الخطوة 3 * هناك الكثير من التمارين التي ينبغي إنجازها لا تبقى طويلًا في هذا التمرين! (معلّم على أنه غير أساسي) أضف إلى المعلومات التي تظهر عندما تكون نتيجة البحث دولة واحدة فقط، معلومات عن حالة الطقس في العاصمة. ستجد الكثير من الواجهات التي تزودك بأحوال الطقس مثل https://weatherstack.com. ملاحظة: ستحتاج إلى مفتاح لأي واجهة برمجية لخدمات الطقس. لا تحتفظ بالمفتاح ضمن عنصر التحكم ولا تبقه كما هو ضمن الشيفرة المصدرية لتطبيقك. استخدم بدلًا من ذلك متغيرات البيئة لحفظ المفتاح. فلو افترضنا أن مفتاح الواجهة هو 0p53cr3t4p1k3yv4lu3، فعندما يُقلع التطبيق بالشكل التالي: REACT_APP_API_KEY='t0p53cr3t4p1k3yv4lu3' npm start // لملف إقلاع لينوكس وماكنتوش ($env:REACT_APP_API_KEY='t0p53cr3t4p1k3yv4lu3') -and (npm start) // powershell ويندوز set REACT_APP_API_KEY='t0p53cr3t4p1k3yv4lu3' && npm start // سطر أوامر ويندوز يمكنك الوصول إلى قيمة المفتاح من خلال الكائن process.env كالتالي: const api_key = process.env.REACT_APP_API_KEY //يمتلك التطبيق الآن قيمة المفتاح الذي وضعناه عند الإقلاع انتبه: إن أنشأت التطبيق باستخدام الأمر npx create-react-app وأردت أن تطلق اسمًا آخر على متغيّر البيئة يجب أن يبدأ الاسم بالعبارة _REACT_APP. كما يمكنك إنشاء ملف لاحقته env. في المجلد الجذري للمشروع وتضع فيه الشيفرة التالية: # .env REACT_APP_API_KEY=t0p53cr3t4p1k3yv4lu3 بدلًا من تعريف المتغير من خلال سطر الأوامر command line كل مرة. انتبه: عليك إعادة تشغيل الخادم من جديد حتى تُطبّق التغييرات. ترجمة -وبتصرف- للفصل Getting Data from Server](https://fullstackopen.com/en/part2/gettingdatafrom_server) من سلسلة Deep Dive Into Modern Web Development
  14. full_stack_101

    سنوسّع تطبيقنا وذلك بالسماح للمستخدمين أن يضيفوا ملاحظات جديدة. من الأفضل هنا أن نخزّن الملاحظات داخل حالة التطبيق App، ذلك إن أردنا أن تحدثّ الصفحة محتوياتها عند إضافة الملاحظة الجديدة. لندرج إذًا الدالة useState ونعرّف قطعًا للحالة تأخذ قيمها الابتدائية من القيم الابتدائية لمصفوفة الملاحظات التي يتم تمريرها كخصائص: import React, { useState } from 'react'import Note from './components/Note' const App = (props) => { const [notes, setNotes] = useState(props.notes) return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <Note key={note.id} note={note} /> )} </ul> </div> ) } export default App يستخدم المكوِّن الدالة useState لتهيئة قطع الحالة المخزنة في notes بمصفوفة الملاحظات التي تمرر كخصائص: const App = (props) => { const [notes, setNotes] = useState(props.notes) // ... } علينا استخدام مصفوفة فارغة لتهيئة الملاحظات إن أردنا أن نبدأ التطبيق بقائمة فارغة من الملاحظات. سنحذف أيضًا الخصائص props من تعريف الدالة طالما أنها لن تُستخدم: const App = () => { const [notes, setNotes] = useState([]) // ... } لنركز الآن على القيم الأولية التي تمرر كخصائص للدالة، حيث سنضيف نموذج HTML إلى المكوِّن الذي سيضيف الملاحظات الجديدة: const App = (props) => { const [notes, setNotes] = useState(props.notes) const addNote = (event) => { event.preventDefault() console.log('button clicked', event.target) } return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <Note key={note.id} note={note} /> )} </ul> <form onSubmit={addNote}> <input /> <button type="submit">save</button> </form> </div> ) } لقد أضفنا أيضًا الدالة addNote كمعالج حدث يُستدعى عندما يُسلّم (submit) النموذج عند النقر على زر التسليم. سنستخدم الطريقة التي ناقشناها في [القسم 1]() لتعريف معالج الحدث: const addNote = (event) => { event.preventDefault() console.log('button clicked', event.target) } يؤدي المعامل event دور الحدث الذي يستدعي دالة المعالج. يستدعي معالج الأحداث التابع ()event.preventDefaultr الذي يمنع حدوث التسليم الافتراضي للنموذج، والذي يسبب إعادة تحميل الصفحة إضافة إلى عدة أشياء أخرى. لنطبع الآن وجهة الحدث event.target على الطرفية: إنّ النموذج الذي عرّفناه في المكوًّن هو وجهة الحدث كما هو واضح، لكن كيف سنصل إلى البيانات الموجودة ضمن عنصر الإدخال input (وهو مربع النص في حالتنا) في النموذج؟ طرق كثيرة يمكن أن تفي بالغرض. سنلقي نظرة على أولها وهي استخدام المكوِّنات المقادة (controlled components). لنضف الآن قطعة حالة جديدة تدعى newNote لتخزّن ما يدخله المستخدم في العنصر input ضمن الصفة value: const App = (props) => { const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState( 'a new note...' ) const addNote = (event) => { event.preventDefault() console.log('button clicked', event.target) } return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <Note key={note.id} note={note} /> )} </ul> <form onSubmit={addNote}> <input value={newNote} /> <button type="submit">save</button> </form> </div> ) } سيُخزّن النص الموجود في مربع النص كقيمة ابتدائية للملاحظة الجديدة، لكن لو حاولنا إضافة أي نص فلن يسمح مربع النص بذلك. ستعرض الطرفية تنبيهًا قد يعطينا دليلًا على الخطأ الذي وقع: طالما أننا أسندنا قطعة من حالة المكوِّن App إلى الصفة value لعنصر الإدخال، فسيتحكم المكوِّن الآن بسلوك هذا العنصر. لذا لابد من تعريف معالج حدث جديد يزامن التغييرات التي تحدث في عنصر الإدخال مع حالة المكوِّن إن أردنا الكتابة ضمنه: const App = (props) => { const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState( 'a new note...' ) // ... const handleNoteChange = (event) => { console.log(event.target.value) setNewNote(event.target.value) } return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <Note key={note.id} note={note} /> )} </ul> <form onSubmit={addNote}> <input value={newNote} onChange={handleNoteChange} /> <button type="submit">save</button> </form> </div> ) } سنعرف الآن معالج حدث للخاصية onChange لعنصر إدخال النموذج: <input value={newNote} onChange={handleNoteChange} /> سيُستدعى الآن معالج الحدث عند حدوث أية تغييرات في عنصر الإدخال، وستتلقى دالة المعالج كائن الحدث كمعامل: const handleNoteChange = (event) => { console.log(event.target.value) setNewNote(event.target.value) } ترتبط الآن الخاصية target لكائن الحدث بعنصر الإدخال المُقاد (من قِبل المكوِّن)، وستشير القيمة event.target.value إلى محتوى عنصر الإدخال. ملاحظة: لا داعي لاستدعاء التابع ()event.preventDefault كما فعلنا سابقًا، لعدم وجود عمليات افتراضية عند حدوث تغيرات على عنصر الإدخال. يمكنك تتبع ما يجري عند استدعاء المعالج ضمن النافذة console في الطرفية: سترى كيف تتغير الحالة مباشرة داخل نافذة React Devtools، طبعًا إن كنت قد ثبتها سابقًا. ستعكس قطعة الحالة newNote في المكوِّن App التغيرات في قيمة عنصر الإدخال، إذًا يمكننا إكمال الدالة addNote التي تضيف ملاحظة جديدة: const addNote = (event) => { event.preventDefault() const noteObject = { content: newNote, date: new Date().toISOString(), important: Math.random() < 0.5, id: notes.length + 1, } setNotes(notes.concat(noteObject)) setNewNote('') } سننشئ أولًا كائنًا جديدًا يدعى noteObject يتلقى محتواه من قيمة الحالة newState. بينما يتولّد المعرِّف الفريد id وفقًا لعدد الملاحظات. سينجح هذا الأسلوب في تطبيقنا طالما أنه لن يستخدم لحذف الملاحظات. سنعطي الملاحظة احتمالًا بنسبة 50% لأن تعتبر مهمة بالاستفادة من الدالة ()Math.random وهي دالة تستخدم لتوليد أرقام عشوائية. نستخدم التابع concat الذي تعرفنا عليه سابقًا في إضافة الملاحظة الجديدة إلى قائمة الملاحظات: setNotes(notes.concat(noteObject)) لا يغير التابع المصفوفة الأصلية بل ينشئ مصفوفة جديدة وينسخ العناصر إليها ثم يضيف العنصر الجديد إلى نهايتها. وهذا أمر ضروري، إذ لا يجب علينا تغيير الحالة بشكل مباشر في React. سيقوم معالج الحدث أيضًا بتصفير قيمة عنصر الإدخال المقاد باستدعاء الدالة setNewNote المعرفة كالتالي: setNewNote('') ستجد شيفرة التطبيق الذي نعمل عليه في المسار part2-2 ضمن المخزن المخصص على github. انتقاء العناصر التي ستعرض سنضيف مهمة جديدة للتطبيق تقتضي إظهار الملاحظات الهامة فقط. ولنبدأ بإضافة قطعة حالة جديدة للمكوِّن App مهمتها تحديد الملاحظات التي يجب أن تعرض: const App = (props) => { const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) // ... } سنعدل المكوًن أيضًا لكي يخزن قائمة بكل الملاحظات التي ستعرض ضمن المتغيّر notesDirectShow. يعتمد اختيار عناصر القائمة على حالة المكوِّن: import React, { useState } from 'react' import Note from './components/Note' const App = (props) => { const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) // ... const notesToShow = showAll ? notes : notes.filter(note => note.important === true) return ( <div> <h1>Notes</h1> <ul> {notesToShow.map(note => <Note key={note.id} note={note} /> )} </ul> // ... </div> ) } يظهر تعريف المتغيّر notesDirectShow مختصرًا بالشكل التالي: const notesToShow = showAll ? notes : notes.filter(note => note.important === true) سترى في التعريف العامل الشرطي الذي ستراه أيضًا في عدة لغات برمجة أخرى. وسنشرح منطق هذا العامل من خلال المثال التالي: const result = condition ? val1 : val2 تعمل هذه الصيغة كالتالي: سيأخذ المتغير result القيمة val1 إذا تحقق الشرط condition وإلا سيأخذ القيمة val2. وبالعودة إلى الصيغة الموجودة في شيفرة التطبيق، سيأخذ المتغيّر notesToShow القيمة notes، وستظهر كل الملاحظات، إذا كان الشرط showAll محققًا. وإلا سيأخذ القيمة الأخرى، وستظهر الملاحظات الهامة فقط. لاحظ أن عملية الانتقاء قد تمت بمساعدة تابع الانتقاء في المصفوفات filter. notes.filter(note => note.important === true) ليس لعامل الموازنة (===) أهمية طالما أن القيمة note.important من النمط المنطقي. إذًا يمكننا ببساطة كتابة السطر البرمجي السابق بالشكل: notes.filter(note => note.important) إن المغزى من كتابة عامل الموازنة بالشكل(===) هو توضيح ناحية هامة بأن العامل (==) لا يؤدي دوره بالشكل المطلوب في كل الحالات ويجب عدم استخدامه في المقارنة حصرًا. اقرأ المزيد حول الموضوع لفهمٍ أعمق. يمكنك اختبار انتقاء الملاحظات بجعل القيمة الأولية false لقطعة الحالة showAll. سنقوم تاليًا بمنح المستخدم إمكانية تغيير قيمة الحالة showAll من واجهة المستخدم. وستظهر الشيفرة بعد التعديل كما يلي: import React, { useState } from 'react' import Note from './components/Note' const App = (props) => { const [notes, setNotes] = useState(props.notes) const [newNote, setNewNote] = useState('') const [showAll, setShowAll] = useState(true) // ... return ( <div> <h1>Notes</h1> <div> <button onClick={() => setShowAll(!showAll)}> show {showAll ? 'important' : 'all' } </button> </div> <ul> {notesToShow.map(note => <Note key={note.id} note={note} /> )} </ul> // ... </div> ) } سنتحكم بإخفاء أو إظهار الملاحظات باستخدام زر، وسيكون معالج الحدث الخاص به بسيطًا بحيث يمكن تعريفه مباشرة داخل الصفة onClick. يبدّل معالج الحدث قيمة showAll عند كل نقرة زر. () => setShowAll(!showAll) يتغير النص المكتوب على الزر حسب الحالة فمرة سيكون "important" ومرة "all": show {showAll ? 'important' : 'all'} ستجد شيفرة التطبيق الذي نعمل عليه في المسار part2-3 ضمن المخزن المخصص على github. التمارين 2.6-2.10 سنطور التطبيق الذي سنبدأه في التمرين الأول خلال التمارين اللاحقة. يفضل أن تسلم في مثل هذه الحالات النسخة النهائية من التطبيق. ويمكنك الإشارة إلى ذلك إن أردت من خلال التعليقات ضمن شيفرتك. تحذير: تنشئ الأداة create-react-app مستودع git محلي يحتوي المشروع، إلا إن كان في المجلد مستودع محلي سابق. من المرجح أنك لا تريد أن يغدو المشروع مستودعًا، لهذا نفذ الأمر التاليrm -rf .git في مسار المشروع. 2.6 دليل الهاتف: الخطوة 1 صمم دليل هاتف بسيط. سنعمل في هذا القسم على إضافة الأسماء فقط. اكتب الشيفرة المناسبة لإضافة شخص إلى دليلك. يمكنك استخدام الشيفرة التالية كنقطة انطلاق للمكوِّن App الخاص بتطبيقك: import React, { useState } from 'react' const App = () => { const [ persons, setPersons ] = useState([ { name: 'Arto Hellas' } ]) const [ newName, setNewName ] = useState('') return ( <div> <h2>Phonebook</h2> <form> <div> name: <input /> </div> <div> <button type="submit">add</button> </div> </form> <h2>Numbers</h2> ... </div> ) } export default App تستخدم قطعة الحالة newName في قيادة عنصر الإدخال في النموذج. من المفيد أحيانًا تصيير قطع الحالة أو المتغيرات لتظهر قيمها بشكل نصي لأغراض التنقيح. إذ يمكنك إضافة الشيفرة التالية مؤقتًا إلى العنصر الذي تصّيره: <div>debug: {newName}</div> لا تنس الاستفادة مما تعلمته عن تنقيح تطبيقات React في القسم 1، كما سيمنحك الموسِّع React developer tools قدرة كبيرة على تتبع التغيرات التي تحدث في حالة التطبيق. بعد إتمامك العمل ستبدو واجهة التطبيق مشابهة للواجهة التالية: لاحظ في الشكل السابق كيف يُستخدم الموسِّع الذي ذكرناه. تذكر: يمكنك أن تضع اسم الشخص كقيمة للخاصية key. تذكر أن تمنع التسليم الافتراضي للنموذج. 2.7 دليل الهاتف: الخطوة 2 امنع المستخدم من إدخال أسماء موجودة أصلًا في الدليل. استفد من توابع المصفوفات التي تقدمها JavaScript لإتمام المطلوب. وأظهر رسالة تحذير عندما يحاول المستخدم القيام بذلك باستخدام التابع alert. تلميح: يفضل استخدام القالب النصي إذا أردت أن تضع قيمة متغير ما ضمن سلسلة نصية. `${newName} is already added to phonebook` فإن كانت قيمة المتغير newName هي Arto Hellas سيعيد القالب النصي ما يلي: `Arto Hellas is already added to phonebook` كما يمكن استعمال إشارة (+) بكل بساطة: newName + ' is already added to phonebook' طبعًا ستبدو شيفرتك إحترافية أكثر عندما تستخدم القالب النصي. 2.8 دليل الهاتف: الخطوة 3 اجعل المستخدم قادرًا على إضافة رقم الهاتف أيضًا، لذا عليك إضافة عنصر إدخال آخر إلى النموذج، وطبعًا إضافة معالج حدث مناسب: <form> <div>name: <input /></div> <div>number: <input /></div> <div><button type="submit">add</button></div> </form> حتى هذه النقطة ستبدو واجهة التطبيق مشابه للصورة التالية: لاحظ كيف يقوم الموسٍّع بعمله! 2.9 دليل الهاتف: الخطوة 4 * أضف للتطبيق إمكانية البحث عن الأشخاص بالاسم: استخدم لذلك عنصر إدخال جديد وضعه خارج النموذج. توضح الصورة السابقة أنّ منطق الانتقاء غير حساس لحالة الأحرف أي البحث عن arto سيعطيك النتيجة Arto أيضًا إن وجدت. تذكر: أضف دائمًا بعض البيانات الجاهزة في تطبيقك كما فعلنا في الشيفرة التالية: const App = () => { const [persons, setPersons] = useState([ { name: 'Arto Hellas', number: '040-123456' }, { name: 'Ada Lovelace', number: '39-44-5323523' }, { name: 'Dan Abramov', number: '12-43-234345' }, { name: 'Mary Poppendieck', number: '39-23-6423122' } ]) // ... } سيوفر عليك ذلك عناء إدخال البيانات يدويًا كل مرة عند اختبار وظيفة جديدة. 2.10 دليل الهاتف: الخطوة 5 إن صممت تطبيقك ليستخدم مكوِّنًا واحدًا، أعد بناءه بوضع الأجزاء التي تراها مناسبة في مكوِّنات جديدة. ابق على حالة المكوِّن ومعالجات الأحداث ضمن المكوِّن الجذري App. يكفي أن تشكل ثلاثة مكوِّنات جديدة. يمكنك على سبيل المثال عزل عملية البحث في مكوِّن وكذلك النموذج الذي يُستخدم لإضافة الأشخاص وأيضًا عملية تصيير كل الأشخاص في الدليل أو عملية تصيير تفاصيل شخص واحد. يمكن أن يشبه المكوِّن الجذري بعد إعادة البناء الشكل التالي: const App = () => { // ... return ( <div> <h2>Phonebook</h2> <Filter ... /> <h3>Add a new</h3> <PersonForm ... /> <h3>Numbers</h3> <Persons ... /> </div> ) } سيصيّر المكوِّن الجذري عنوان التطبيق فقط، وتتولى المكوِّنات الجديدة بقية المهام. تذكر: قد تواجهك المشاكل في هذا التمرين إن لم تعرف المكوِّنات في المكان الصحيح، ولعلك ستستفيد الآن من مراجعة الفقرة لا تعرف مكوِّنًا داخل مكوِّن آخر من القسم السابق. ترجمة -وبتصرف- للفصل Forms من سلسلة Deep Dive Into Modern Web Development
  15. لنلقي نظرة سريعة على المواضيع التي بدت صعبة في النسخة السابقة من المنهاج قبل أن ننتقل إلى موضوع جديد. التعليمة console.log سيبدو ذلك -رغم المفارقة- صحيحًا على الرغم من أن الهاوي سيحتاج هذه التعليمة أو غيرها من طرق التنقيح أكثر مما يحتاج إليه المحترف. عندما يتوقف تطبيقك عن العمل، لا تحاول أن تخمن مصدر الخطأ فحسب، بل استخدم تعليمة log أو غيرها من وسائل التنقيح. تذكر لا تضم ما تريد إظهاره على الطرفية مستخدمًا إشارة الجمع (+) كأسلوب Java: console.log('props value is' + props) بل افصل الأشياء التي تريد طباعتها بفاصلة (,): console.log('props value is', props) إن استخدمت أسلوب Java فلن تحصل على النتيجة المطلوبة: props value is [Object object] بينما لو استخدمت الفاصلة لتمييز القيم التي تريد إظهارها على الطرفية فستُطبع بشكل قيم نصية منفصلة ومفهومة. لمعرفة المزيد، راجع بعض الأفكار التي تحدثنا عنها بشأن تنقيح تطبيقات React. نصيحة للاحتراف: استخدام مقاطع شيفرة Visual Studio يمكن بسهولة إنشاء مقاطع شيفرة باستخدام Visual studio، حيث تعتبر هذه المقاطع أسطرًا برمجية قابلة لإعادة الاستخدام. وتشابه في عملها أسطر sout في برنامج Netbeans. يمكنك إيجاد المزيد من الإرشادات حول إنشاء واستخدام المقاطع من خلال الإنترنت. كما يمكنك البحث عن مقاطع جاهزة على شكل إضافات لبرنامج Visual Studio مثل الإضافة xabikos.ReactSnippets. يعتبر المقطع الذي ينفذ الأمر ()console.log leg في الشيفرة التالية الأكثر أهمية حيث يوضح الأمرclog: { "console.log": { "prefix": "clog", "body": [ "console.log('$1')", ], "description": "Log output to console" } } مصفوفات JavaScript انطلاقًا من هذه النقطة سنستخدم توابعًا للتعامل مع المصفوفات في JavaScript وفق أسلوب البرمجة بالدوال مثل التوابع find وfilter وmap. تشابه هذه التوابع في مبدأ عملها "المجاري (Streams)" في Java 8 التي اعتُمدت خلال السنوات القليلة الماضية في مقررات قسم علوم الحاسب في جامعة هلسنكي. إن بدا لك غريبًا أسلوب البرمجة بالدوال مع المصفوفات، يمكنك متابعة مقاطع الفيديو الأجنبية التالية: Functional Programming Higher-order functions Map Reduce basics معالجات الأحداث: نظرة ثانية أثبت مفهوم معالجة الأحداث أنه صعب استنادًا إلى تقييمنا لمنهاج العام الفائت. لذا من الأجدى أن تلقي نظرة ثانية على هذا الموضوع الذي ناقشناه في الفصل السابق ضمن فقرة "عودة إلى معالجة الأحداث" إذا أردت إنعاش معلوماتك. كما برزت تساؤلات عدة حول تمرير معالجات الأحداث إلى المكوِّن الابن للمكوِّن الذي أنشأناه سابقًا App، لذلك يفضل مراجعة هذا الموضوع. تصيير مجموعات البيانات سنعمل الآن على الواجهة الأمامية للتطبيقات (frontend) أو ما يعرف بكتابة شيفرة التطبيق من جهة المتصفح. لنبدأ بتطبيق React مشابه للمثال النموذجي الذي رأيناه في مقال أساسيات بناء تطبيقات الويب: import React from 'react' import ReactDOM from 'react-dom' const notes = [ { id: 1, content: 'HTML is easy', date: '2019-05-30T17:30:31.098Z', important: true }, { id: 2, content: 'Browser can execute only Javascript', date: '2019-05-30T18:39:34.091Z', important: false }, { id: 3, content: 'GET and POST are the most important methods of HTTP protocol', date: '2019-05-30T19:20:14.298Z', important: true } ] const App = (props) => { const { notes } = props return ( <div> <h1>Notes</h1> <ul> <li>{notes[0].content}</li> <li>{notes[1].content}</li> <li>{notes[2].content}</li> </ul> </div> ) } ReactDOM.render( <App notes={notes} />, document.getElementById('root') ) تحتوي كل ملاحظة على قيمة نصية وعبارة زمنية وقيمة منطقية (Boolean) تحدد إذا ما كانت الملاحظة هامة أم لا وكذلك قيمة فريدة (unique value) لتمييز الملاحظة تدعى id. يعمل التطبيق السابق على مبدأ وجود ثلاث ملاحظات فقط في المصفوفة، وتصيّر كل ملاحظة باستخدام القيمة id مباشرة: <li>{notes[1].content}</li> لكن من الأفضل أن نظهر المعلومات المستخلصة من المصفوفة على شكل عنصر React باستخدام الدالة map: notes.map(note => <li>{note.content}</li>) ستكون النتيجة مصفوفة من العناصر li: [ <li>HTML is easy</li>, <li>Browser can execute only Javascript</li>, <li>GET and POST are the most important methods of HTTP protocol</li>, ] يمكن إضافتها إلى القائمة ul: const App = (props) => { const { notes } = props return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <li>{note.content}</li>)} </ul> </div> ) } انتبه لضرورة وضع شيفرة JavaScript -الموجودة ضمن قالب JSX- داخل أقواس معقوصة كي يتم تنفيذها. سنجعل الشيفرة أكثر وضوحًا بنشر تصريح الدالة السهمية ليمتد على عدة أسطر: const App = (props) => { const { notes } = props return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <li>{note.content}</li>)} </ul> </div> ) } الصفات المفتاحية key-attribute على الرغم من أن التطبيق سيعمل بشكل طبيعي، إلّا أن تحذيرًا مزعجًا يظهر على الطرفية: تشير التوجيهات في الصفحة المرتبطة برسالة الخطأ، ضرورة امتلاك عناصر القوائم التي أنشأها التابع map قيمًا مفتاحية فريدة وهي صفات تدعى key. لنضف إذا المفاتيح المناسبة: const App = (props) => { const { notes } = props return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <li key={note.id}>{note.content}</li> )} </ul> </div> ) } وهكذا ستختفي رسالة التحذير. تستخدم React الصفات المفتاحية لتقرر الآلية التي تحدّث فيها مظهر التطبيق عندما يعاد تصيير مكوِّن. يمكنك دومًا استخدام وثائق React لمعرفة المزيد. التابع Map إنّ فهمك لآلية عمل التابع map عند التعامل مع المصفوفات أمر حيوي سيلزمك دائمًا. يحتوي التطبيق على مصفوفة تدعى notes: const notes = [ { id: 1, content: 'HTML is easy', date: '2019-05-30T17:30:31.098Z', important: true }, { id: 2, content: 'Browser can execute only Javascript', date: '2019-05-30T18:39:34.091Z', important: false }, { id: 3, content: 'GET and POST are the most important methods of HTTP protocol', date: '2019-05-30T19:20:14.298Z', important: true } ] لنتوقف قليلًا ونرى كيف يعمل map. لنقل أن الشيفرة التالية قد أضيفت إلى نهاية الملف: const result = notes.map(note => note.id) console.log(result) سينشئ التابع map مصفوفة جديدة قيمها [1,2,3] انطلاقًا من عناصر المصفوفة الأصلية. ويستخدم دالة كمُعامل له. ولهذه الدالة الشكل التالي: note => note.id تمثل الشيفرة السابقة الشكل المختصر لدالة سهمية تعطى بشكلها الكامل على النحو: (note) => { return note.id } تقبل الدالة السابقة الكائن note كمُعامل، وتعيد قيمة الحقل id منه. لكن إن غيرنا الشيفرة لتصبح: const result = notes.map(note => note.content) ستكون النتيجة مصفوفة تضم محتوى الملاحظات. هذه الشيفرة قريبة جدًا من شيفرة React التي استخدمناها سابقًا: notes.map(note => <li key={note.id}>{note.content}</li> ) والتي تنشئ عناصر li تضم المحتوى النصي للملاحظات. وطالما أن الدالة مُرّرت كمعامل إلى التابع map لإظهار العناصر، يجب وضع قيم المتغيّرات داخل الأقواس المعقوصة. note => <li key={note.id}>{note.content}</li> حاول أن تعرف ما الذي سيحدث لو أهملت تلك الأقواس. سيزعجك استخدام الأقواس المعقوصة في البداية لكنك ستعتاد على ذلك وستساعدك React بإظهارها مباشرة نتائج شيفرتك. استخدام مصفوفة القرائن كمفاتيح: الأسلوب المعاكس كان بإمكاننا إخفاء رسالة الخطأ السابقة باستخدام مصفوفة القرائن (Array indexes) كمفاتيح، حيث نستخلص هذه القرائن بتمرير معامل آخر إلى دالة الاستدعاء التي استخدمناها كمعامل للتابع map: notes.map((note, i) => ...) عندما نستدعي الدالة بالشكل السابق ستأخذ i قيمة القرينة التي تشير إلى موقع الملاحظة في الكائن note، وبهذا الأسلوب سنعرّف طريقةً لإنشاء المطلوب دون أخطاء: <ul> {notes.map((note, i) => <li key={i}> {note.content} </li> )} </ul> على الرغم من ذلك لا نوصي باتباع هذا الأسلوب نظرًا للمشاكل غير المتوقعة التي يمكن أن تحدث حتى لو بدا أنّ التطبيق يعمل بشكل جيد. يمكنك الاطلاع أكثر بقراءة مقالات تتعلق بالموضوع. إعادة تكوين الوحدات لندعّم الشيفرة التي كتبناها قليلًا. طالما أن اهتمامنا ينحصر في الحقلnotes فقط من خصائص المكوِّن، لنستخلص قيمة الحقل المطلوب مباشرة بطريقة التفكيك: const App = ({ notes }) => { return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <li key={note.id}> {note.content} </li> )} </ul> </div> ) } وإن أردت تذكّر معلوماتك عن موضوع التفكيك راجع فقرة التفكيك في القسم السابق. سنعرف الآن مكوِّنًا جديدًا Note لعرض الملاحظة: const Note = ({ note }) => { return (<li>{note.content}</li> )} const App = ({ notes }) => { return ( <div> <h1>Notes</h1> <ul> {notes.map(note => <Note key={note.id} note={note} /> )} </ul> </div> ) } لاحظ أن الصفة المفتاحية ارتبطت الآن بالمكوِّن Note وليس بالعنصر li. يمكن كتابة تطبيق React بأكمله ضمن ملف واحد. لكن جرت العادة على كتابة كل مكوِّن في ملف خاص على شكل وحدة ES6، على الرغم من أن هذا الأسلوب ليس عمليًا جدًا. لقد استخدمنا الوحدات منذ البداية في شيفرتنا كما فعلنا في أول سطرين من الملف: import React from 'react' import ReactDOM from 'react-dom' فلقد أدرجنا وحدتين لاستخدامهما في الملف. حيث أسندت الوحدة react إلى متغيّر يدعى React وكذلك أسندت الوحدة react-dom إلى المتغيّر ReactDOM. لننقل الآن المكوِّن Note إلى وحدته الخاصة. توضع المكوِّنات عادة في مجلد يدعى components ضمن المجلد src الموجود في المجلد الرئيسي للتطبيق، ويفضل تسمية الملف باسم المكوِّن. دعونا ننفذ ما ذكرناه ونضع المكوِّن Note في ملف جديد يدعى Note.js كالتالي: import React from 'react' const Note = ({ note }) => { return ( <li>{note.content}</li> ) } export default Note لاحظ أننا أدرجنا React في السطر الأول لأن المكوِّن هو مكوِّن React. سيصدّر السطر الأخير الوحدة الجديدة التي عرفناها باسم Note لكي تستخدم في مكان آخر. سندرج الآن الوحدة الجديدة في الملف index.js: import React from 'react' import ReactDOM from 'react-dom' import Note from './components/Note' const App = ({ notes }) => { // ... } وهكذا يمكن استخدام الوحدة الجديدة بإسنادها إلى المتغيّر Note. وتذكر كتابة عنوان الوحدة بطريقة العنوان النسبي عند إدراجها. './components/Note' تشير النقطة (.) في بداية العنوان إلى المسار الحالي وبالتالي فإن الملف Note.js موجود ضمن المجلد الفرعي component والموجود ضمن المسار الحالي (المجلد الذي يحوي التطبيق). ملاحظة: يمكن إغفال كتابة اللاحقة js . لنصرح الآن عن المكوِّن App أيضًا في وحدة خاصة به. وطالما أنه المكوِّن الجذري سنضعه مباشرة في المجلد src: import React from 'react' import Note from './components/Note' const App = ({ notes }) => { return ( <div> <h1>Notes</h1> <ul> {notes.map((note) => <Note key={note.id} note={note} /> )} </ul> </div> ) } export default App ما بقي إذًا في الملف index.js هو التالي: import React from 'react' import ReactDOM from 'react-dom' import App from './App' const notes = [ // ... ] ReactDOM.render( <App notes={notes} />, document.getElementById('root') ) للوحدات استعمالات أخرى عديدة بالإضافة إلى التصريح عن المكوِّنات في ملفاتها الخاصة، سنعود إليها لاحقًا في المنهاج. ستجد شيفرة التطبيق الحالي على موقع GitHub. يحوي المسار الرئيسي للمخزن كما ستلاحظ شيفرات للنسخ التي سنطورها لاحقًا، لكن النسخة الحالية في المسار part2-1. قبل أن تشغّل التطبيق باستعمال الأمر npm start، نفذ أولًا الأمر npm install. عندما ينهار التطبيق سترى في بداياتك البرمجية أو حتى بعد 30 عامًا من كتابة الشيفرات أن التطبيق قد ينهار أحيانًا وبشكل مفاجئ. وسيكون الأمر أسوأ في اللغات التي يمكن كتابة تعليماتها بشكل ديناميكي أثناء التنفيذ مثل JavaScript. فلا يتاح عندها للمتصفح من التحقق مثلًا من نوع البيانات التي يحملها متغيّر أو التي تمرر لدالة. يظهر الشكل التالي مثالًا عن انهيار تطبيق React: سيكون مخرجك الوحيد من المأزق هو الطباعة على الطرفية. إنّ الشيفرة المسؤولة عن الانهيار في حالتنا هي: const Course = ({ course }) => ( <div> <Header course={course} /> </div> ) const App = () => { const course = { // ... } return ( <div> <Course course={course} /> </div> ) } سنقتفي أثر الخطأ بإضافة التعليمة console.log إلى الشيفرة. وطالما أن المكوِّن App سيصيّر أولًا فمن المفيد وضع أول تعليمة طباعة ضمنه: const App = () => { const course = { // ... } console.log('App works...') return ( // .. ) } عليك طبعًا النزول إلى الأسفل مرورًا بقائمة الأخطاء الطويلة حتى ترى نتيجة الطباعة. حالما تتأكد من أن الأمور جيدة حتى نقطة معينة، عليك نقل تعليمة الطباعة إلى نقطة أعمق. وتذكر أن التصريح عن مكوِّن بعبارة واحدة أو عن دالة دون أن تعيد قيمة، سيجعل الطباعة على الطرفية أصعب. const Course = ({ course }) => ( <div> <Header course={course} /> </div> ) لذا غيًر تصريح المكوِّن إلى شكله النموذجي حتى تستطيع الطباعة: const Course = ({ course }) => { console.log(course) return ( <div> <Header course={course} /> </div> ) } قد يكون السبب الأساسي للمشكلة في أحيانٍ كثيرة متعلقًا بالخصائص التي تظهر بأنواع مختلفة عن المتوقع أو التي تستدعى بأسماء مختلفة عن أسمائها الفعلية. وكنتيجة لذلك، ستخفق عملية التفكيك. ويدل على ذلك التخلص من الأخطاء بمجرد إزالة عملية التفكيك، حيث يمكن معرفة المحتوى الفعلي للخصائص. const Course = (props) => { console.log(props) const { course } = props return ( <div> <Header course={course} /> </div> ) } إن لم تُحل المشكلة، لا يمكنك عندها سوى اصطياد الأخطاء بزرع تعليمة الطباعة console.log في أماكن مختلفة من الشيفرة. أضيفت هذه الفقرة لمادة المنهاج بعد أن أخفقت الوحدة التي تمثل حلًا للسؤال التالي بشكل كامل (نظرًا لخطأ في نمط الخصائص) وتوجب علي البحث عن السبب باستخدام تعليمة الطباعة. التمارين 2.1 - 2.5 تسلّم التمارين عبر GitHub ومن ثم يشار إلى إكمالها ضمن منظومة تسليم الملفات. يمكنك أن تضع جميع التمارين في مخزن واحد أو استخدم مخازن مختلفة. إذا وضعت تمارين الأقسام المختلفة في مخزن واحد، احرص على تسمية المجلدات ضمنه بشكل مناسب. تُسلَّم تمارين كل قسم دفعة واحدة، فلا يمكنك تسليم أية تمارين أنجزتها مؤخرًا من قسم ما إذا كنت قد سلمت غيرها من القسم ذاته. يضم هذا القسم تمارين أكثر من القسمين السابقين، لا تسلم أيًا منها حتى تنجزها كلها. تحذير: سيجعل create_react_app المشروع مستودع git إلا إذا أنشأت مشروعك داخل مستودع موجود مسبقًا. لن تريد فعل ذلك على الأغلب، لذا نفذ الأمر rm -rf .git عند جذر مشروعك. 2.1 معلومات عن المنهاج: الخطوة 6 سننهي في هذا التمرين تصيير محتوى المنهاج الذي استعرضناه في التمارين 1.1-1.5. يمكنك البدء من الشيفرة الموجودة في نماذج الأجوبة. ستجد نماذج أجوبة القسم 1 في منظومة تسليم الملفات. انقر على my submissions في الأعلى ثم انقر على show الموجودة ضمن العمود solutions في السطر المخصص للقسم 1. انقر على الملف index.js للاطلاع على حل تمرين "معلومات عن المنهاج course info" تحت عبارة kurssitiedot التي تعني (معلومات عن المنهاج). تنبيه: إذا نسخت مشروعًا من مكان لآخر، سيتوجب عليك حذف المجلد node_modules وإعادة تثبيت ملفات الارتباط (Dependencies) مرة أخرى بتنفيذ الأمر npm install قبل أن تشغل التطبيق. لا يفضل أن تنسخ المحتوى الكامل لتطبيق أو أن تضيف المجلد node_modules إلى منظومة التحكم بالإصدار أو أن تقوم بكلا العملين. لنغيّر الآن المكوِّن App كما يلي: const App = () => { const course = { id: 1, name: 'Half Stack application development', parts: [ { name: 'Fundamentals of React', exercises: 10, id: 1 }, { name: 'Using props to pass data', exercises: 7, id: 2 }, { name: 'State of a component', exercises: 14, id: 3 } ] } return <Course course={course} /> } عّرف مكوِّنًا يدعى Course مسؤولًا عن تنسيق منهاج واحد. يمكن أن يتخذ التطبيق الهيكلية التالية: App Course Header Content Part Part ... سيضم المكوِّن Course كل المكوِّنات التي عرّفناها في القسم السابق والمسؤولة عن تصيير اسم المنهاج وأقسامه. ستبدو الصفحة بعد التصيير على الشكل التالي: ليس عليك جمع عدد التمارين بعد. لكن يجب عليك التأكد من أن التطبيق يعمل بشكل جيد بغض النظر عن عدد الأقسام في المنهاج. أي أنه سيعمل لو أزلت أو أضفت أقسامًا. واحرص على عدم ظهور أخطاء على الطرفية. 2.2 معلومات عن المنهاج: الخطوة 7 أظهر عدد التمارين الكلية في المنهاج. 2.3 معلومات عن المنهاج: الخطوة 8 * إن لم تجد طريقة لحل التمرين السابق، احسب العدد الكلي للتمارين باستخدام التابع reduce الذي يستعمل مع المصفوفات. نصيحة للاحتراف 1: إن بدت شيفرتك بالشكل التالي: const total = parts.reduce((s, p) => someMagicHere) ولم تعمل، يجدر بك استخدام تعليمة الطباعة على الطرفية، والتي تتطلب إعادة كتابة الدالة السهمية بشكلها الكامل: const total = parts.reduce((s, p) => { console.log('what is happening', s, p) return someMagicHere }) نصيحة للاحتراف 2: يمكنك استخدام إضافة للمحرر VS يمكنها تحويل الدالة السهمية المختصرة إلى الكاملة وبالعكس. 2.4: معلومات عن المنهاج: خطوة 9 وسّع التطبيق ليعمل مع أي عدد افتراضي من المناهج: const App = () => { const courses = [ { name: 'Half Stack application development', id: 1, parts: [ { name: 'Fundamentals of React', exercises: 10, id: 1 }, { name: 'Using props to pass data', exercises: 7, id: 2 }, { name: 'State of a component', exercises: 14, id: 3 }, { name: 'Redux', exercises: 11, id: 4 } ] }, { name: 'Node.js', id: 2, parts: [ { name: 'Routing', exercises: 3, id: 1 }, { name: 'Middlewares', exercises: 7, id: 2 } ] } ] return ( <div> // ... </div> ) } يمكن أن يبدو التطبيق بالشكل التالي: 2.5 وحدات منفصلة صرح عن المكوِّن Course في وحدة منفصلة يمكن إدراجها في ملف المكوِّن App. يمكن أن تضع كل المكوِّنات الفرعية للمنهاج ضمن نفس الوحدة. ترجمة -وبتصرف- للفصل Rendering a collection,modules من سلسلة Deep Dive Into Modern Web Development