اذهب إلى المحتوى

البحث في الموقع

المحتوى عن 'full_stack_101'.

  • ابحث بالكلمات المفتاحية

    أضف وسومًا وافصل بينها بفواصل ","
  • ابحث باسم الكاتب

نوع المحتوى


التصنيفات

  • الإدارة والقيادة
  • التخطيط وسير العمل
  • التمويل
  • فريق العمل
  • دراسة حالات
  • التعامل مع العملاء
  • التعهيد الخارجي
  • السلوك التنظيمي في المؤسسات
  • عالم الأعمال
  • التجارة والتجارة الإلكترونية
  • نصائح وإرشادات
  • مقالات ريادة أعمال عامة

التصنيفات

  • مقالات برمجة عامة
  • مقالات برمجة متقدمة
  • PHP
    • Laravel
    • ووردبريس
  • جافاسكربت
    • لغة TypeScript
    • Node.js
    • React
    • Vue.js
    • Angular
    • jQuery
    • Cordova
  • HTML
  • CSS
    • Sass
    • إطار عمل Bootstrap
  • SQL
  • لغة C#‎
    • ‎.NET
    • منصة Xamarin
  • لغة C++‎
  • لغة C
  • بايثون
    • Flask
    • Django
  • لغة روبي
    • إطار العمل Ruby on Rails
  • لغة Go
  • لغة جافا
  • لغة Kotlin
  • لغة Rust
  • برمجة أندرويد
  • لغة R
  • الذكاء الاصطناعي
  • صناعة الألعاب
  • سير العمل
    • Git
  • الأنظمة والأنظمة المدمجة

التصنيفات

  • تصميم تجربة المستخدم UX
  • تصميم واجهة المستخدم UI
  • الرسوميات
    • إنكسكيب
    • أدوبي إليستريتور
  • التصميم الجرافيكي
    • أدوبي فوتوشوب
    • أدوبي إن ديزاين
    • جيمب GIMP
    • كريتا Krita
  • التصميم ثلاثي الأبعاد
    • 3Ds Max
    • Blender
  • نصائح وإرشادات
  • مقالات تصميم عامة

التصنيفات

  • مقالات DevOps عامة
  • خوادم
    • الويب HTTP
    • البريد الإلكتروني
    • قواعد البيانات
    • DNS
    • Samba
  • الحوسبة السحابية
    • Docker
  • إدارة الإعدادات والنشر
    • Chef
    • Puppet
    • Ansible
  • لينكس
    • ريدهات (Red Hat)
  • خواديم ويندوز
  • FreeBSD
  • حماية
    • الجدران النارية
    • VPN
    • SSH
  • شبكات
    • سيسكو (Cisco)

التصنيفات

  • التسويق بالأداء
    • أدوات تحليل الزوار
  • تهيئة محركات البحث SEO
  • الشبكات الاجتماعية
  • التسويق بالبريد الالكتروني
  • التسويق الضمني
  • استسراع النمو
  • المبيعات
  • تجارب ونصائح
  • مبادئ علم التسويق

التصنيفات

  • مقالات عمل حر عامة
  • إدارة مالية
  • الإنتاجية
  • تجارب
  • مشاريع جانبية
  • التعامل مع العملاء
  • الحفاظ على الصحة
  • التسويق الذاتي
  • العمل الحر المهني
    • العمل بالترجمة
    • العمل كمساعد افتراضي
    • العمل بكتابة المحتوى

التصنيفات

  • الإنتاجية وسير العمل
    • مايكروسوفت أوفيس
    • ليبر أوفيس
    • جوجل درايف
    • شيربوينت
    • Evernote
    • Trello
  • تطبيقات الويب
    • ووردبريس
    • ماجنتو
    • بريستاشوب
    • أوبن كارت
    • دروبال
  • الترجمة بمساعدة الحاسوب
    • omegaT
    • memoQ
    • Trados
    • Memsource
  • برامج تخطيط موارد المؤسسات ERP
    • تطبيقات أودو odoo
  • أنظمة تشغيل الحواسيب والهواتف
    • ويندوز
    • لينكس
  • مقالات عامة

التصنيفات

  • آخر التحديثات

أسئلة وأجوبة

  • الأقسام
    • أسئلة البرمجة
    • أسئلة ريادة الأعمال
    • أسئلة العمل الحر
    • أسئلة التسويق والمبيعات
    • أسئلة التصميم
    • أسئلة DevOps
    • أسئلة البرامج والتطبيقات

التصنيفات

  • كتب ريادة الأعمال
  • كتب العمل الحر
  • كتب تسويق ومبيعات
  • كتب برمجة
  • كتب تصميم
  • كتب DevOps

ابحث في

ابحث عن


تاريخ الإنشاء

  • بداية

    نهاية


آخر تحديث

  • بداية

    نهاية


رشح النتائج حسب

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

  • بداية

    نهاية


المجموعة


النبذة الشخصية

  1. نلقي نظرةً في هذا المقال على الطرق المتبعة في نقل التغييرات التي تجري أثناء جلسة العمل إلى قاعدة البيانات العلاقية وتخزينها، إضافةً إلى ربط الجداول ببعضها من خلال المفاتيح الخارجية foreign keys. ملفات التهجير Migrations لنتابع في توسعة تطبيق الملاحظات الذي عملنا عليه في كل من مقال العمل مع قواعد بيانات علاقية باستخدام Sequelize والمقال السابق، إذ سننجز آليةً تمكّن بعض المستخدمين الذي يمتلكون ميزةً إدارية admin status من إيقاف نشاط مستخدمين آخرين ومنعهم من تسجيل الدخول أو إنشاء ملاحظات جديدة. نحتاج لإنجاز الأمر إلى حقل يأخذ قيمًا منطقيةً في جدول قاعدة البيانات يشير إلى المستخدمين ذوي الامتيازات الإدارية وأخرى تدل على حالة المستخدم إن كان نشاطه معلقًا أم لا. يمكننا العمل وفق الآلية السابقة بتعديل النموذج الذي يعرّف الجدول والاعتماد على مكتبة Sequelize في مزامنة التغييرات مع قاعدة البيانات من خلال أسطر الشيفرة التالية في الملف "models/index.js": const Note = require('./note') const User = require('./user') Note.belongsTo(User) User.hasMany(Note) Note.sync({ alter: true }) User.sync({ alter: true }) module.exports = { Note, User } ولكن يُعد هذا الأسلوب (تغيير النموذج كلما أردنا توسعة التطبيق) غير منطقي على المدى الطويل، لهذا سنزيل الأسطر التي تدعم مزامنة التغييرات ونستخدم أسلوبًا أكثر قوةً وهو ملفات التهجير migrations التي تقدمها Sequelize وغيرها من المكتبات. ملف التهجير migration عمليًا هو ملف جافاسكربت JavaScript وحيد يصف التعديلات التي تطرأ على قاعدة البيانات، وينشأ عن تغير وحيد أو مجموعة تغيرات معًا. تحتفظ Sequelize بسجلات عن التهجيرات التي أجريت، أي التغيرات التي طرأت وجرى نقلها ومزامنتها مع قاعدة البيانات، إذ تبقى Sequelize مطلّعةً على التغيرات التي طرأت على مخطط قاعدة البيانات ولم تُطبّق بعد عند إنشاء تهجير جديد، ويمكن بهذه الطريقة التحكم بطريقة تطبيق التغييرات من خلال شيفرة البرنامج المخزنة في منظومة التحكم بالإصدار version control. لننشئ أولًا ملف تهجير يهيئ قاعدة البيانات. إليك شيفرة هذا الملف: const { DataTypes } = require('sequelize') module.exports = { up: async ({ context: queryInterface }) => { await queryInterface.createTable('notes', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, content: { type: DataTypes.TEXT, allowNull: false }, important: { type: DataTypes.BOOLEAN }, date: { type: DataTypes.DATE }, }) await queryInterface.createTable('users', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, username: { type: DataTypes.STRING, unique: true, allowNull: false }, name: { type: DataTypes.STRING, allowNull: false }, }) await queryInterface.addColumn('notes', 'user_id', { type: DataTypes.INTEGER, allowNull: false, references: { model: 'users', key: 'id' }, }) }, down: async ({ context: queryInterface }) => { await queryInterface.dropTable('notes') await queryInterface.dropTable('users') }, } يعرِّف الملف الدالتين up و down؛ إذ تحدّد الأولى كيفية تعديل قاعدة البيانات عندما يجري تهجير؛ وتحدد الثانية آلية التراجع إن كان هناك مبررٌ لذلك. يتضمن ملف التهجير ثلاثة خيارات؛ إذ يُنشئ الخيار الأول الجدول notes؛ بينما ينشئ الثاني الجدول users؛ ويضيف الخيار الثالث مفتاحًا خارجيًا إلى الجدول notes يشير إلى مُنشئ الملاحظة، وتُحدَّد التغيرات في المخطط باستدعاء توابع الكائن queryInterface. و على نقيض النماذج، من الضروري أن تتذكر دائمًا كتابة أسماء الأعمدة والجداول بأسلوب الأفعى snake case عند تعريف ملف تهجير: await queryInterface.addColumn('notes', 'user_id', { type: DataTypes.INTEGER, allowNull: false, references: { model: 'users', key: 'id' }, }) أي ستكتب أسماء الجداول والأعمدة كما تظهر تمامًا في قاعدة البيانات، بينما تُكتب في النماذج بالأسلوب التقليدي للمكتبة Sequelize وذلك عبر استخدام أسلوب سنام الجمل camel Case. خزّن شيفرة ملف التهجير في ملف يحمل الاسم "migrations/20211209_00_initialize_notes_and_users.js". لا بُد أن تُسمى ملفات التهجير أبجديًا عند إنشائها كي تكون التغيرات القديمة قبل الجديدة، وقد تبدأ اسم الملف بتاريخ وتسلسل هذا الملف وهذه طريقةٌ جيدة. يمكننا تشغيل ملف التهجير انطلاقًا من سطر الأوامر باستخدام أداة سطر الأوامر الخاصة بالمكتبة Sequelize، لكننا قررنا تنفيذ التهجيرات يديويًا انطلاقًا من شيفرة البرنامج باستخدام المكتبة Umzug، لهذا سنثبت هذه المكتبة: npm install umzug لنعدّل الملف "util/db.js" الذي يتكفل بمعالجة الاتصال مع قاعدة البيانات: const Sequelize = require('sequelize') const { DATABASE_URL } = require('./config') const { Umzug, SequelizeStorage } = require('umzug') const sequelize = new Sequelize(DATABASE_URL, { dialectOptions: { ssl: { require: true, rejectUnauthorized: false } }, }); const runMigrations = async () => { const migrator = new Umzug({ migrations: { glob: 'migrations/*.js', }, storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }), context: sequelize.getQueryInterface(), logger: console, }) const migrations = await migrator.up() console.log('Migrations up to date', { files: migrations.map((mig) => mig.name), }) } const connectToDatabase = async () => { try { await sequelize.authenticate() // highlight-start await runMigrations() // highlight-end console.log('connected to the database') } catch (err) { console.log('failed to connect to the database') console.log(err) return process.exit(1) } return null } module.exports = { connectToDatabase, sequelize } تُنفّذ الدالة runMigrations ملف التهجير في كل مرة يؤسِّس فيها التطبيق اتصالًا مع قاعدة بيانات عند تشغيله، كما تراقب مكتبة Sequelize التهجيرات المُكتملة، فإذا لم يكن هناك تهجيرات جديدة، فلن ينفع تشغيل الدالة runMigrations في أي شيء. لنبدأ كل شيء من جديد، ونزيل كل الجداول الموجودة من التطبيق: username => drop table notes; username => drop table users; username => \d Did not find any relations. عند تشغيل التطبيق، تظهر رسالةً على الطرفية تحيطك علمًا بوضع التهجيرات: INSERT INTO "migrations" ("name") VALUES ($1) RETURNING "name"; Migrations up to date { files: [ '20211209_00_initialize_notes_and_users.js' ] } database connected إذا أعدنا تشغيل التطبيق، سيُظهر السجل أن التهجير لم يتكرر، وسيبدو مخطط قاعدة بيانات التطبيق على النحو التالي: username=> \d List of relations Schema | Name | Type | Owner --------+--------------+----------+---------------- public | migrations | table | username public | notes | table | username public | notes_id_seq | sequence | username public | users | table | username public | users_id_seq | sequence | username وهكذا نرى أن Sequelize قد أنشأت جدولًا للتهجيرات يسمح بتتبع ما نُفِّذ منها، ويبدو هذا الجدول على النحو التالي: username=> select * from migrations; name ------------------------------------------- 20211209_00_initialize_notes_and_users.js دعونا نضيف الآن بعض المستخدمين إلى قاعدة البيانات، إضافةً إلى مجموعة من الملاحظات، وبعدها نكون جاهزين لتوسعة التطبيق. يمكنك الحصول على شيفرة التطبيق بوضعه الحالي من المستودع المخصص له على GitHub ضمن الفرع part13-6. مستخدم بصلاحيات مدير وتعطيل مستخدم آخر علينا بدايةً إضافة حقلين يقبلان قيمًا منطقية إلى الجدول users، هما: admin: ويحدد فيما لو كان المستخدم مديرًا أم لا. disabled: ويحدد فيما لو أُوقف نشاط هذا المستخدم أم لا. لننشئ أيضًا ملف تهجير يُعدّل قاعدة بيانات في الملف "migrations/20211209_01_admin_and_disabled_to_users.js": const { DataTypes } = require('sequelize') module.exports = { up: async ({ context: queryInterface }) => { await queryInterface.addColumn('users', 'admin', { type: DataTypes.BOOLEAN, default: false }) await queryInterface.addColumn('users', 'disabled', { type: DataTypes.BOOLEAN, default: false }) }, down: async ({ context: queryInterface }) => { await queryInterface.removeColumn('users', 'admin') await queryInterface.removeColumn('users', 'disabled') }, } عدّل النموذج بما يتوافق مع الجدول "users": User.init({ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, username: { type: DataTypes.STRING, unique: true, allowNull: false }, name: { type: DataTypes.STRING, allowNull: false }, admin: { type: DataTypes.BOOLEAN, defaultValue: false }, disabled: { type: DataTypes.BOOLEAN, defaultValue: false }, }, { sequelize, underscored: true, timestamps: false, modelName: 'user' }) يتغير المخطط كما نريد، عندما تُنفَّذ شيفرة ملف التهجير عند إعادة تشغيل التطبيق: username-> \d users Table "public.users" Column | Type | Collation | Nullable | Default ----------+------------------------+-----------+----------+----------------------------------- id | integer | | not null | nextval('users_id_seq'::regclass) username | character varying(255) | | not null | name | character varying(255) | | not null | admin | boolean | | | disabled | boolean | | | Indexes: "users_pkey" PRIMARY KEY, btree (id) "users_username_key" UNIQUE CONSTRAINT, btree (username) Referenced by: TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) لنوسّع الآن المتحكمات على النحو التالي، بحيث نمنع المستخدم من تسجيل الدخول في حال كانت قيمة الحقل disabled هي true: loginRouter.post('/', async (request, response) => { const body = request.body const user = await User.findOne({ where: { username: body.username } }) const passwordCorrect = body.password === 'secret' if (!(user && passwordCorrect)) { return response.status(401).json({ error: 'invalid username or password' }) } if (user.disabled) { return response.status(401).json({ error: 'account disabled, please contact admin' }) } 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 }) }) دعونا نوقف نشاط المستخدم "jakousa" بالاستعانة بمعرّفه الفريد ID: username => update users set disabled=true where id=3; UPDATE 1 username => update users set admin=true where id=1; UPDATE 1 username => select * from users; id | username | name | admin | disabled ----+----------+------------------+-------+---------- 2 | lynx | Kalle Ilves | | 3 | jakousa | Jami Kousa | f | t 1 | mluukkai | Matti Luukkainen | t | تأكد من نجاح الأمر بفشل تسجيل دخوله: لننشئ تاليًا وجهةً تسمح للمدير بتغيير حالة حساب مستخدم آخر: const isAdmin = async (req, res, next) => { const user = await User.findByPk(req.decodedToken.id) if (!user.admin) { return res.status(401).json({ error: 'operation not allowed' }) } next() } router.put('/:username', tokenExtractor, isAdmin, async (req, res) => { const user = await User.findOne({ where: { username: req.params.username } }) if (user) { user.disabled = req.body.disabled await user.save() res.json(user) } else { res.status(404).end() } }) تُستخدم في هذه الشيفرة أداتان وسطيتان، تُدعى الأولى tokenExtractor وتماثل الأداة التي استخدمناها لإنشاء الوجهة route الخاصة بإنشاء ملاحظة، إذ تضع مفتاح الاستيثاق الذي فُكّ تشفيره في الحقل decodedToken من كائن الطلب؛ بينما تتحقق الأداة الثانية isAdmin فيما لوكان المستخدم ذا صلاحيات إدارية، فإن لم يكن كذلك يُضبط رمز حالة الطلب على القيمة 401 وتُعاد رسالة خطأ مناسبة. لاحظ كيف رُبطت الأداتين إلى الوجهة، إذ تُنفَّذ كلٌ منهما قبل تنفيذ معالج الوجهة الفعلي، ويمكنك ربط العدد الذي تريده من الأدوات الوسيطة إلى طلبٍ ما. تُنقل الأداة الوسطية tokenExtractor إلى الملف "util/middleware.js" كونها تُستخدم في عدّة مواضع: const jwt = require('jsonwebtoken') const { SECRET } = require('./config.js') const tokenExtractor = (req, res, next) => { const authorization = req.get('authorization') if (authorization && authorization.toLowerCase().startsWith('bearer ')) { try { req.decodedToken = jwt.verify(authorization.substring(7), SECRET) } catch{ return res.status(401).json({ error: 'token invalid' }) } } else { return res.status(401).json({ error: 'token missing' }) } next() } module.exports = { tokenExtractor } يمكن للمدير الآن السماح للمستخدم "jakousa" بمعاودة نشاطه عن طريق إرسال طلب من النوع PUT إلى الوجهة "api/users/jakousa/" إذ يأتي الطلب مع البيانات التالية: { "disabled": false } وكما رأينا في نهاية القسم 4، تحمل هذه الطريقة في إيقاف نشاط المستخدمين بعض المشاكل، إذ يجري التحقق من وضع المستخدم عند تسجيل الدخول، وإن امتلك المستخدم مفتاح استيثاق صحيح في لحظة إيقاف نشاطه، فقد يتمكن من متابعة استخدام مفتاحه لعدم وجود فترة زمنية لصلاحيته، ولن يجري التحقق من حالته عند إضافة ملاحظة جديدة. قبل أن نتابع، سنكتب سكربت npm يسمح لنا بالتراجع عن آخر تهجير، فقد لا ينجح كل شيء في المرة الأولى بالضرورة عند تطوير ملفات التهجير. لنعدّل الملف "util/db.js" على النحو التالي: const Sequelize = require('sequelize') const { DATABASE_URL } = require('./config') const { Umzug, SequelizeStorage } = require('umzug') const sequelize = new Sequelize(DATABASE_URL, { dialectOptions: { ssl: { require: true, rejectUnauthorized: false } }, }); const connectToDatabase = async () => { try { await sequelize.authenticate() await runMigrations() console.log('connected to the database') } catch (err) { console.log('failed to connect to the database') return process.exit(1) } return null } const migrationConf = { migrations: { glob: 'migrations/*.js', }, storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }), context: sequelize.getQueryInterface(), logger: console, } const runMigrations = async () => { const migrator = new Umzug(migrationConf) const migrations = await migrator.up() console.log('Migrations up to date', { files: migrations.map((mig) => mig.name), }) } const rollbackMigration = async () => { await sequelize.authenticate() const migrator = new Umzug(migrationConf) await migrator.down() } module.exports = { connectToDatabase, sequelize, rollbackMigration } // highlight-line لننشئ الملف "util/rollback.js" الذي يتيح لسكربت npm تنفيذ دالة التراجع عن التهجير: const { rollbackMigration } = require('./db') rollbackMigration() وسيكون السكربت على النحو التالي: { "scripts": { "dev": "nodemon index.js", "migration:down": "node util/rollback.js" }, } وهكذا سنتمكن من التراجع عن آخر تهجير بتنفيذ الأمر npm run migration:down من خلال سطر الأوامر. تُنفّذ ملفات التهجير تلقائيًا عندما يُشغَّل البرنامج، لكن قد يكون من الأنسب في مرحلة التطوير تعطيل التنفيذ التلقائي للتهجير وتنفيذه يدويًا من خلال سطر الأوامر. يمكنك الحصول على شيفرة التطبيق بوضعه الحالي من المستودع المخصص له على GitHub ضمن الفرع part13-7. التمرينان 13.7 و 13.8 حاول إنجاز التمارين التالية: التمرين 13.7 احذف كل الجداول من قاعدة بيانات تطبيقك ثم أنشئ ملف تهجير ليهيئ قاعدة البيانات. أضف البصمتان الزمنيتان "created_at" و "updated_at" إلى كلا الجدولين، وتذكر أن عليك إضافتها في ملف التهجير بنفسك. ملاحظة: تأكد من إزالة التعليمتين ()User.sync و ()Blog.sync اللتين تزامنان مخطط النموذج من شيفرتك وإلا سيخفق التهجير. ملاحظة: إذا كان عليك حذف الجداول باستخدام سطر الأوامر، أي إذا لم تكن تنوي التراجع عن الحذف بالتراجع عن آخر عملية تهجير، فلا بُد حينها من حذف محتوى جدول التهجير migrations إذا أردت من برنامجك تنفيذ التهجير مجددًا. التمرين 13.18 وسّع تطبيقك (مستخدمًا ملف تهجير) لكي يكون للمدونة سمةً تدل على سنة كتابتها، أي حقلٌ بالاسم يأخذ قيمًا صحيحة تبدأ من 1991 ولا يزيد عن العام الحالي. تأكد من ظهور رسالة خطأ مناسبة إن أُدخلت قيمة غير صحيحة للسنة. علاقات متعدد-إلى-متعدد many-to-many بين الجداول سنتابع توسعة التطبيق كي يُضاف كل مستخدم إلى فريقٍ أو أكثر، وطالما يمكن لأي عدد من المستخدمين الانضمام إلى فريق وكذلك يمكن لأي مستخدم الانضمام إلى أي عدد من الفرقاء، فإننا أمام علاقة متعدد-إلى-متعدد many-to-many والتي تنُفّذ تقليديًا في قواعد البيانات من خلال جدول الاتصال connection table. لنكتب الآن الشيفرة التي يحتاجها الجدول teams إضافةً إلى جدول الاتصال. سيبدو ملف التهجير أولًا على النحو التالي: const { DataTypes } = require('sequelize') module.exports = { up: async ({ context: queryInterface }) => { await queryInterface.createTable('teams', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.TEXT, allowNull: false, unique: true }, }) await queryInterface.createTable('memberships', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, user_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'users', key: 'id' }, }, team_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'teams', key: 'id' }, }, }) }, down: async ({ context: queryInterface }) => { await queryInterface.dropTable('teams') await queryInterface.dropTable('memberships') }, } تتضمن النماذج نفس شيفرة ملفات التهجير تقريبًا، وإليك شيفرة نموذج الجدول team في الملف "models/team.js": const { Model, DataTypes } = require('sequelize') const { sequelize } = require('../util/db') class Team extends Model {} Team.init({ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.TEXT, allowNull: false, unique: true }, }, { sequelize, underscored: true, timestamps: false, modelName: 'team' }) module.exports = Team وهذه هي شيفرة نموذج جدول الاتصال الموجودة في الملف "models/membership.js": const { Model, DataTypes } = require('sequelize') const { sequelize } = require('../util/db') class Membership extends Model {} Membership.init({ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, user_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'users', key: 'id' }, }, team_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'teams', key: 'id' }, }, }, { sequelize, underscored: true, timestamps: false, modelName: 'membership' }) module.exports = Membership منحنا جدول الاتصال اسمًا يصف طبيعة محتوياته membership، لكن قد لا تجد بالضرورة اسمًا يصف هذا الجدول، ولهذا يمكنك تسميته بأسماء الجداول التي يربطها تفصل بينها شرطة سفلية، مثل "user_teams" الذي يصلح أيضًا لحالتنا هذه. أجرينا إضافةً صغيرةً على الملف "models/index.js" لربط الفرقاء والمستخدمين على مستوى الشيفرة باستخدام التابع belongsToMany: const Note = require('./note') const User = require('./user') const Team = require('./team') const Membership = require('./membership') Note.belongsTo(User) User.hasMany(Note) User.belongsToMany(Team, { through: Membership }) Team.belongsToMany(User, { through: Membership }) module.exports = { Note, User, Team, Membership } لاحظ الفَرق بين ملف التهجير لجدول الاتصال ونموذجه عند تعريف مفتاح خارجي، إذ تُعرَّف الحقول باستخدام أسلوب الأفعى في ملف التهجير: await queryInterface.createTable('memberships', { // ... user_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'users', key: 'id' }, }, team_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'teams', key: 'id' }, } }) بينما تُعرَّف نفس الحقول في النموذج باستخدام أسلوب سنام الجمل: Membership.init({ // ... userId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'users', key: 'id' }, }, teamId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'teams', key: 'id' }, }, // ... }) لننشئ الآن بعض الفُرقاء وبعض الأعضاء باستخدام الطرفية: insert into teams (name) values ('toska'); insert into teams (name) values ('mosa climbers'); insert into memberships (user_id, team_id) values (1, 1); insert into memberships (user_id, team_id) values (1, 2); insert into memberships (user_id, team_id) values (2, 1); insert into memberships (user_id, team_id) values (3, 2); تُضاف معلومات أعضاء الفرقاء بعد ذلك إلى الوجهة التي تعيد كل المستخدمين: router.get('/', async (req, res) => { const users = await User.findAll({ include: [ { model: Note, attributes: { exclude: ['userId'] } }, { model: Team, attributes: ['name', 'id'], } ] }) res.json(users) }) لاحظ أن الاستعلام الذي طُبع على شاشة الطرفية يجمع ثلاثة جداول. هذا الحل جيدٌ فعلًا، لكن ما تزال هنالك ثغرة، إذ تأتي نتيجة الاستعلام مع جميع سمات الصف المطلوب من جدول الاتصال، علمًا أننا لا نحتاجها كلها. وبقراءة توثيق Sequelize جيدًا ستجد الحل: router.get('/', async (req, res) => { const users = await User.findAll({ include: [ { model: Note, attributes: { exclude: ['userId'] } }, { model: Team, attributes: ['name', 'id'], through: { attributes: [] } } ] }) res.json(users) }) يمكنك الحصول على شيفرة التطبيق بوضعه الحالي من المستودع المخصص له على GitHub ضمن الفرع part13-8. فكرة عن خاصيات كائن نموذج Sequelize تظهر خاصيات نموذجنا من خلال الأسطر التالية: User.hasMany(Note) Note.belongsTo(User) User.belongsToMany(Team, { through: Membership }) Team.belongsToMany(User, { through: Membership }) يمكّن ذلك Sequelize من إنشاء استعلامات تستخلص مثلًا كل ملاحظات المستخدمين أو كل أعضاء فريق، وبفضل تلك التعريفات نستطيع أيضًا الوصول مباشرةً إلى ملاحظات مستخدم مثلًا من خلال الشيفرة؛ ففي الشيفرة التالية مثلًا نحاول البحث عن مستخدم معرّفه المميز id=1، ثم نطبع الملاحظات المرتبطة به: const user = await User.findByPk(1, { include: { model: Note } }) user.notes.forEach(note => { console.log(note.content) }) يربط التعريف: User.hasMany(Note) الخاصية notes إلى الكائن user الذي يمنح وصولًا إلى الملاحظات التي أنشأها المستخدم، ويربط التعريف: User.belongsToMany(Team, { through: Membership }) الخاصية teams إلى الكائن user، الذي يمكن استخدامه في الشيفرة: const user = await User.findByPk(1, { include: { model: team } }) user.teams.forEach(team => { console.log(team.name) }) لنفترض أننا نريد إعادة كائن JSON من وجهة route تقود إلى مستخدم واحد يتضمن اسم المستخدم وعدد الملاحظات التي أنشأها. يمكننا تجربة الطريقة التالية: router.get('/:id', async (req, res) => { const user = await User.findByPk(req.params.id, { include: { model: Note } } ) if (user) { user.note_count = user.notes.length delete user.notes res.json(user) } else { res.status(404).end() } }) حاولنا إضافة الحقل noteCount في الكائن الذي أعادته Sequelize وإزالة الحقل notes منه، لكن ما يحدث أن هذه الطريقة لن تنفع لأن الكائن الذي تعيده Sequelize ليس كائنًا عاديًا تعمل فيه الحقول الإضافية كما نريد؛ ولذلك فإن الحل الأفضل لحالتنا هو إنشاء كائن جديد كليًا بناءً على البيانات المستخلصة من قاعدة البيانات: router.get('/:id', async (req, res) => { const user = await User.findByPk(req.params.id, { include: { model: Note } } ) if (user) { res.json({ username: user.username, name: user.name, note_count: user.notes.length }) } else { res.status(404).end() } }) نظرة ثانية إلى العلاقات متعدد-إلى-متعدد many-to-many سنختبر علاقة متعدد-إلى-متعدد أخرى في التطبيق؛ إذ ترتبط كل ملاحظة بالمستخدم الذي أنشأها من خلال مفتاح خارجي، ونريد الآن أن يدعم التطبيق ربط الملاحظة بمستخدمين آخرين، وربط المستخدم بعدد غير محدد من الملاحظات التي أنشأها آخرون، والفكرة هنا أن هذه الملاحظات هي تلك التي أشار المستخدم على أنها تهمّه. لننشئ جدول الاتصال user_notes في هذه الحالة، وسيكون ملف التهجير بسيطًا: const { DataTypes } = require('sequelize') module.exports = { up: async ({ context: queryInterface }) => { await queryInterface.createTable('user_notes', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, user_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'users', key: 'id' }, }, note_id: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'notes', key: 'id' }, }, }) }, down: async ({ context: queryInterface }) => { await queryInterface.dropTable('user_notes') }, } كما لن تجد أفكارًا جديدةً أيضًا في شيفرة النموذج: const { Model, DataTypes } = require('sequelize') const { sequelize } = require('../util/db') class UserNotes extends Model {} UserNotes.init({ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, userId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'users', key: 'id' }, }, noteId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'notes', key: 'id' }, }, }, { sequelize, underscored: true, timestamps: false, modelName: 'user_notes' }) module.exports = UserNotes يضم الملف "models/index.js" بعض التغيرات ليصبح على النحو التالي: const Note = require('./note') const User = require('./user') const Team = require('./team') const Membership = require('./membership') const UserNotes = require('./user_notes') Note.belongsTo(User) User.hasMany(Note) User.belongsToMany(Team, { through: Membership }) Team.belongsToMany(User, { through: Membership }) User.belongsToMany(Note, { through: UserNotes, as: 'marked_notes' }) Note.belongsToMany(User, { through: UserNotes, as: 'users_marked' }) module.exports = { Note, User, Team, Membership, UserNotes } يُستخدم التعريف belongsToMany مجددًا، إذ يربط الآن المستخدمين إلى الملاحظات عن طريق النموذج UserNotes المتعلق بجدول الاتصال، لكننا سنستخدم هذه المرة اسمًا بديلًا alias للسمات المُنشأة باستخدام الكلمة المحجوزة as، إذ سيتداخل overlap الاسم الافتراضي ("notes" المستخدم) مع معناه السابق وهو الملاحظات المُضافة من قِبل المستخدم. سنوسِّع الوجهة التي تقود إلى مستخدم وحيد لتعيد الفُرقاء التي ينتمي إليها المستخدم، وملاحظاتهم، والملاحظات الأخرى التي حددها المستخدم: router.get('/:id', async (req, res) => { const user = await User.findByPk(req.params.id, { attributes: { exclude: [''] } , include:[{ model: Note, attributes: { exclude: ['userId'] } }, { model: Note, as: 'marked_notes', attributes: { exclude: ['userId']}, through: { attributes: [] } }, { model: Team, attributes: ['name', 'id'], through: { attributes: [] } }, ] }) if (user) { res.json(user) } else { res.status(404).end() } }) لا بُد من استخدام الاسم البديل الذي عرّفناه من خلال السمة as خلال السياق. سننشئ بعض البيانات الاختبارية في قاعدة البيانات لاختبار الميزة: insert into user_notes (user_id, note_id) values (1, 4); insert into user_notes (user_id, note_id) values (1, 5); ستكون النتيجة النهائية على النحو التالي: لكن ماذا لو أردنا أن نضمّن معلومات تتعلق بمؤلف الملاحظة إلى الملاحظات التي يحددها مستخدم؟ يُنفَّذ الأمر بإضافة التعليمة include إلى الملاحظات المحدّدة من قبل المستخدم: router.get('/:id', async (req, res) => { const user = await User.findByPk(req.params.id, { attributes: { exclude: [''] } , include:[{ model: Note, attributes: { exclude: ['userId'] } }, { model: Note, as: 'marked_notes', attributes: { exclude: ['userId']}, through: { attributes: [] }, include: { model: User, attributes: ['name'] } }, { model: Team, attributes: ['name', 'id'], through: { attributes: [] } }, ] }) if (user) { res.json(user) } else { res.status(404).end() } }) ها هي النتيجة النهائية كما نتوقع: يمكنك الحصول على شيفرة التطبيق بوضعه الحالي من المستودع المخصص له على GitHub ضمن الفرع part13-9. التمرينات 13.19 - 13.23 حاول إنجاز التمرينات التالية. التمرين 13.19 اِمنح المستخدمين القدرة على إضافة مدوّنات إلى قائمة قراءة reading list. وعند إضافتها إلى القائمة يجب أن تكون حالتها "غير مقروءة unreaded"، ويمكن لاحقًا تعليم المدوّنة على أنها "مقروءة". نفّذ فكرة قائمة القراءة مستخدمًا جدول اتصال، وعدّل قاعدة البيانات من خلال ملف تهجير. لا يهم في هذا التمرين إضافة مدونات إلى القائمة وعرضها بنجاح أكثر من استخدام فكرة الولوج المباشر إلى قاعدة البيانات. التمرين 13.20 أضف الآن طريقةً كي يدعم التطبيق قوائم القراءة. تُضاف المدوّنة إلى قائمة القراءة من خلال الطلب HTTP POST إلى الوجهة "api/readinglists/"، ويُرفق مع الطلب المدوّنة ومعرّف المستخدم: { "blogId": 10, "userId": 3 } عدّل الوجهة "GET /api/users/:id" لإعادة قائمة المدوّنات إضافة إلى معلومات المستخدم بالتنسيق التالي: { name: "Matti Luukkainen", username: "mluukkai@iki.fi", readings: [ { id: 3, url: "https://google.com", title: "Clean React", author: "Dan Abramov", likes: 34, year: null, }, { id: 4, url: "https://google.com", title: "Clean Code", author: "Bob Martin", likes: 5, year: null, } ] } حتى هذه اللحظة، لا حاجة لإظهار إن كانت المدوّنة مقروءةً أم لا. التمرين 13.21 عدّل الوجهة التي تصل إلى مستخدم وحيد لكي تعرض فيما إذا كانت كل مدوّنة في قائمة القراءة مقروءةً أم لا، إضافةً إلى المُعرَّف المميز "id" للصف المقابل في جدول الاتصال. يمكن عرض المعلومات وفق التنسيق الآتي مثلًا: { name: "Matti Luukkainen", username: "mluukkai@iki.fi", readings: [ { id: 3, url: "https://google.com", title: "Clean React", author: "Dan Abramov", likes: 34, year: null, readinglists: [ { read: false, id: 2 } ] }, { id: 4, url: "https://google.com", title: "Clean Code", author: "Bob Martin", likes: 5, year: null, readinglists: [ { read: false, id: 2 } ] } ] } التمرين 13.22 قدّم طريقةً يستطيع من خلالها التطبيق تعليم مدوّنة ضمن قائمة القراءة على أنها مقروءة، إذ يُنفَّذ الأمر بإجراء طلب PUT إلى الوجهة "api/readinglists/:id/" وإرساله مع القيمة: { "read": true } يمكن للمستخدم أن يعلّم مدوّنة على أنها مقروءةً إذا كانت فقط ضمن قائمة القراءة الخاصة به. يستوثق من المستخدم عادةً من خلال مفتاح الاستيثاق الذي يُرفق مع الطلب. التمرين 13.23 عدّل الوجهة التي تعيد معلومات مستخدم وحيد لكي يتحكم الطلب بالمدوّنة التي ينبغي إحضارها من قائمة القراءة: "GET /api/users/:id": يعيد كامل قائمة القراءة. "GET /api/users/:id?read=true": يعيد المدوّنات المقروءة. "GET /api/users/:id?read=false": يعيد المدوّنات غير المقروءة. ملاحظات عامة يمكننا عدّ حالة تطبيقنا الآن مقبولة، لكن لا بُدّ من إلقاء نظرةٍ على بعض الأفكار قبل أن نختم هذا القسم. إحضار البيانات الكسول lazy والمتلهف eager عندما ننشئ استعلامًا مستخدمين السمة include: User.findOne({ include: { model: note } }) يحدُث ما يُسمى الإحضار المُتلهِّف eager fetch للبيانات، إذ تُجلب جميع الصفوف في كل الجداول المرتبطة بالمستخدم بواسطة الاستعلام join بنفس الوقت في مثال الملاحظات التي يُنشئها مستخدم. هذا السلوك هو ما نحتاجه عادةً، لكن ستجد في المقابل حالات تحتاج فيها إلى ما يُدعى بالإحضار الكسول أو المحدود lazy fetch مثل البحث عن فُرقاء مرتبطةٍ بمستخدم إذا لزم الأمر. لنعدّل وجهة إحضار مستخدم واحد كي تُحضر الفُرقاء التي ينتمي إليها مستخدم إذا احتوى الاستعلام على المعامل teams: router.get('/:id', async (req, res) => { const user = await User.findByPk(req.params.id, { attributes: { exclude: [''] } , include:[{ model: note, attributes: { exclude: ['userId'] } }, { model: Note, as: 'marked_notes', attributes: { exclude: ['userId']}, through: { attributes: [] }, include: { model: user, attributes: ['name'] } }, ] }) if (!user) { return res.status(404).end() } let teams = undefined if (req.query.teams) { teams = await user.getTeams({ attributes: ['name'], joinTableAttributes: [] }) } res.json({ ...user.toJSON(), teams }) }) وهكذا لن يحضر الاستعلام User.findByPk الفُرقاء، لكنها ستُجلب عند الحاجة باستخدام التابع user.getTeams الذي تولِّده Sequelize تلقائيًا لكائن النموذج. تولَّد Sequelize تلقائيًا توابع -get مماثلة وتوابع أخرى مفيدة عندما تٌعرّف ارتباطات associations بين الجداول على مستوى قاعدة بيانات. ميزات النموذج قد تصادفنا حالات لا نريد فيها معالجة كل أسطر جدول محدد افتراضيًا، إذ من الممكن مثلًا ألا نرغب في عرض المستخدمين الذين أوقفت نشاطاتهم في التطبيق، ويمكننا في هذه الحالة تعريف مجالات الرؤية الافتراضية للنموذج على النحو التالي: class User extends Model {} User.init({ // field definition }, { sequelize, underscored: true, timestamps: false, modelName: 'user', defaultScope: { where: { disabled: false } }, scopes: { admin: { where: { admin: true } }, disabled: { where: { disabled: true } } } }) module.exports = User سيضم الاستعلام الناتج عن التابع ()User.findAll عبارة WHERE التالية: WHERE "user". "disabled" = false; يمكن أن نعرّف أيضًا مجالات رؤية أخرى للنماذج: User.init({ // field definition }, { sequelize, underscored: true, timestamps: false, modelName: 'user', defaultScope: { where: { disabled: false } }, scopes: { admin: { where: { admin: true } }, disabled: { where: { disabled: true } }, name(value) { return { where: { name: { [Op.iLike]: value } } } }, } }) تُستخدم مجالات الرؤية على النحو التالي: // جميع المدراء const adminUsers = await User.scope('admin').findAll() // جميع المستخدمين غير النشطين const disabledUsers = await User.scope('disabled').findAll() // في أسمائهم jami المستخدمون الذين لديهم سلسلة نصية const jamiUsers = User.scope({ method: ['name', '%jami%'] }).findAll() كما يمكن سلسلة مجالات الرؤية (ربطها ببعضها): // في أسمائهم jami المدراء الذين لديهم سلسلة نصية const jamiUsers = User.scope('admin', { method: ['name', '%jami%'] }).findAll() وطالما أن نماذج هي أصناف جافا سكربت JavaScript، من الممكن إضافة توابع جديدة إليها، وإليك مثالين عن ذلك: const { Model, DataTypes, Op } = require('sequelize') const Note = require('./note') const { sequelize } = require('../util/db') class User extends Model { async number_of_notes() { return (await this.getNotes()).length } static async with_notes(limit){ return await User.findAll({ attributes: { include: [[ sequelize.fn("COUNT", sequelize.col("notes.id")), "note_count" ]] }, include: [ { model: Note, attributes: [] }, ], group: ['user.id'], having: sequelize.literal(`COUNT(notes.id) > ${limit}`) }) } } User.init({ // ... }) module.exports = User التابع الأول numberOfNotes هو تابع نسخة instance method، أي أن استدعاءه ممكن من نسخٍ instances عن النموذج: const jami = await User.findOne({ name: 'Jami Kousa'}) const cnt = await jami.number_of_notes() console.log(`Jami has created ${cnt} notes`) تشير الكلمة this في تابع النسخة إلى نسخة النموذج نفسها: async number_of_notes() { return (await this.getNotes()).length } أما التابع الثاني للنموذج، فيعيد هؤلاء المستخدمين الذين يملكون على الأقل "X" وهي القيمة التي يحملها المعامل وتدل على كمية الملاحظات في الصنف، أي التي تُستدعى مباشرةً عن طريق النموذج: const users = await User.with_notes(2) console.log(JSON.stringify(users, null, 2)) users.forEach(u => { console.log(u.name) }) قابلية التكرار في النماذج وملفات التهجير لقد رأينا أن الشيفرة في النموذج أو ملف التهجير تتكرر كثيرًا، فلو أخذنا نموذج الفُرقاء teams: class Team extends Model {} Team.init({ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.TEXT, allowNull: false, unique: true }, }, { sequelize, underscored: true, timestamps: false, modelName: 'team' }) module.exports = Team وملف التهجير فإنهما يضمان كمًّا كبيرًا من نفس الشيفرة: const { DataTypes } = require('sequelize') module.exports = { up: async ({ context: queryInterface }) => { await queryInterface.createTable('teams', { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, name: { type: DataTypes.TEXT, allowNull: false, unique: true }, }) }, down: async ({ context: queryInterface }) => { await queryInterface.dropTable('teams') }, } هل يمكن تحسين الشيفرة كي يُصدّر النموذج مثلًا الأجزاء المشتركة التي يحتاجها ملف التهجير؟ تكمن المشكلة في احتمال تغيُّر تعريف النموذج مع الوقت، فقد يتغير مثلًا الحقل أو يتغير نوع البيانات المُخزّنة فيه، وعلى ملف التهجير أن يُنفَّذ بنجاح في أي وقت من البداية إلى النهاية. إذا اعتمَدت ملفات التهجير على النموذج في الحصول على محتوى معين، فقد لا يكون هذا متاحًا خلال شهر أو سنة؛ لهذا ورغم وجود الكثير من الشيفرة للنسخ واللصق، لكن يُعد فصل ملف التهجير عن النموذج كاملًا أمرًا ضروريًا. قد يكون أحد الحلول هو استخدام أداة سطر أوامر Sequelize الذي يولّد كلًا من النموذج وملف التهجير بناءً على الأوامر التي تُنفِّذها، إذ سيُنفِّذ الأمر التالي النموذج User الذي يمتلك السمات name و username و admin، إضافةً إلى ملف التهجير الذي يدير شؤون إنشاء جدول قاعدة البيانات: npx sequelize-cli model:generate --name User --attributes name:string,username:string,admin:boolean يمكننا أيضًا تنفيذ أمر التراجع عن التهجيرات انطلاقًا من سطر الأوامر. لم يكتمل بعد لسوء الحظ توثيق سطر الأوامر هذا، لهذا قررنا إنشاء النماذج وملفات التهجير يدويًا في المنهاج، وقد يكون أو لايكون ما أنجزناه من حلول جيدًا. التمرين 13.24 نهاية عظيمة: أشرنا في نهاية القسم 4 إلى مشكلة جدّية في مفتاح الاستيثاق، فلو قررنا إيقاف نشاط مستخدم بعد أن دخل إلى المنظومة، سيبقى هذا المستخدم قادرًا على استخدام مفتاح الاستيثاق الذي يمتلكه وبالتالي استخدام المنظومة. الحل الإعتيادي للمشكلة هو تخزين سجلات بكل مفتاح استيثاق مُنح إلى عميل في قاعدة بيانات الواجهة الخلفية، ثم التحقق من صلاحية كل طلب، ويمكن في هذه الحالة إزالة صلاحية هذا المفتاح مباشرةً عند الحاجة. يُشار إلى هذا الأسلوب عادةً بجلسة عمل على الخادم server-side session. لنوسّع الآن المنظومة كي يُمنع المستخدم الذي فقد قدرته على الوصول إليها من إجراء أي تفاعل يتطلب تسجيل دخول. قد تحتاج إلى ما يلي لإنجاز الأمر: عمودٌ يضم قيمًا منطقية في جدول المستخدمين يشير إلى كون المستخدم نشطًا أم لا. يكفي في تمريننا أن توقف نشاط مستخدم أو تعيده من خلال قاعدة البيانات مباشرةً. جدولٌ يُخزّن جلسات العمل الجارية تُخزَّن الجلسة عند تسجيل الدخول (عند تنفيذ الطلب "POST /api/login"). يجري التحقق من وجود جلسة أو صلاحيتها عندما يُنفِّذ المستخدم عمليةً تتطلب تسجيل دخول. وجهةٌ تسمح للمستخدم بتسجيل خروجه من المنظومة لإزالة الجلسة من قاعدة البيانات، وقد يكون للوجهة المسار التالي "DELETE /api/logout". تذكر أنه لا يسمح بنجاح أي عملية تتطلب تسجيل دخول إذا كان مفتاح الاستيثاق منتهي الصلاحية، مثل الحالة التي يسجل فيها المستخدم خروجه. قد ترغب أيضًا في استخدام مكتبة npm مخصصة للتعامل مع الجلسات، ولا تنسى استخدام ملف التهجير لتنفيذ التغييرات اللازمة على قاعدة البيانات. ترجمة -وبتصرف- للفصل migrations, many-to-many relationships من سلسلة Deep Dive Into Modern Web Development. اقرأ أيضًا المقال السابق: ضم الجداول والاستعلامات المشتركة في قواعد البيانات باستخدام Sequelize المفاهيم الأساسية في قواعد البيانات وتصميمها تهجير قواعد البيانات في Laravel 5 تجريد إعداد قواعد البيانات في لارافيل باستعمال عملية التهجير Migration والبذر Seeder
  2. نستعرض في هذا المقال طريقة هيكلة التطبيق الذي عملنا عليه في المقال السابق، والاستعلام عن معلومات متنوعة تضمها قاعدة البيانات العلاقية. هيكلية التطبيق لقد كتبنا حتى اللحظة كامل الشيفرة في نفس الملف، لهذا سنحاول الآن إعطاء التطبيق هيكلًا أوضح. لننشئ إذًا المجلدات والملفات وفق الهيكلية التالية: index.js util config.js db.js models index.js note.js controllers notes.js أما محتوى الملفات فستكون على النحو التالي: الملف "util/config.js": يهتم بالتعامل مع متغيرات البيئة environment variables: require('dotenv').config() module.exports = { DATABASE_URL: process.env.DATABASE_URL, PORT: process.env.PORT || 3001, } الملف "index.js": ويهتم بتهيئة وتشغيل التطبيق: const express = require('express') const app = express() const { PORT } = require('./util/config') const { connectToDatabase } = require('./util/db') const notesRouter = require('./controllers/notes') app.use(express.json()) app.use('/api/notes', notesRouter) const start = async () => { await connectToDatabase() app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) } start() يختلف تشغيل التطبيق قليلًا عما رأيناه سابقًا، لأننا نريد التأكُّد من نجاح الاتصال بقاعدة البيانات قبل أن يبدأ التطبيق العمل الفعلي. الملف "util/d b.js": ويضم الشيفرة التي تُهيئ قاعدة البيانات: const Sequelize = require('sequelize') const { DATABASE_URL } = require('./config') const sequelize = new Sequelize(DATABASE_URL, { dialectOptions: { ssl: { require: true, rejectUnauthorized: false } }, }); const connectToDatabase = async () => { try { await sequelize.authenticate() console.log('connected to the database') } catch (err) { console.log('failed to connect to the database') return process.exit(1) } return null } module.exports = { connectToDatabase, sequelize } الملف "models/note.js": وتُخزّن فيه الملاحظات في النموذج المقابل للجدول الذي سيُحفظ. const { Model, DataTypes } = require('sequelize') const { sequelize } = require('../util/db') class Note extends Model {} Note.init({ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, content: { type: DataTypes.TEXT, allowNull: false }, important: { type: DataTypes.BOOLEAN }, date: { type: DataTypes.DATE } }, { sequelize, underscored: true, timestamps: false, modelName: 'note' }) module.exports = Note الملف "models/index.js": لا يُستخدم حاليًا تقريبًا بسبب وجود نموذج واحد فقط في التطبيق، لكنه سيصبح أكثر فائدةً عندما نبدأ إضافة نماذج جديدة، إذ سيلغي الحاجة إلى إدراج ملفات منفصلة تُعرِّف بقية النماذج: const Note = require('./note') Note.sync() module.exports = { Note } الملف "controllers/notes.js": ويضم الوجهات المرتبطة بالملاحظات، أي مسار التوجيه إلى ملاحظة: const router = require('express').Router() const { Note } = require('../models') router.get('/', async (req, res) => { const notes = await Note.findAll() res.json(notes) }) router.post('/', async (req, res) => { try { const note = await Note.create(req.body) res.json(note) } catch(error) { return res.status(400).json({ error }) } }) router.get('/:id', async (req, res) => { const note = await Note.findByPk(req.params.id) if (note) { res.json(note) } else { res.status(404).end() } }) router.delete('/:id', async (req, res) => { const note = await Note.findByPk(req.params.id) if (note) { await note.destroy() } res.status(204).end() }) router.put('/:id', async (req, res) => { const note = await Note.findByPk(req.params.id) if (note) { note.important = req.body.important await note.save() res.json(note) } else { res.status(404).end() } }) module.exports = router هكذا يبدو هيكل التطبيق جيدًا الآن، لكننا نلاحظ أنّ معالجات التوجيه route handlers الذي يتعامل مع ملاحظة واحدة يضم قليلًا من الشيفرات المكررة، فجميعها تبدأ بالسطر الذي يبحث عن الملاحظة التي يتعامل معها: const note = await Note.findByPk(req.params.id) لنعيد كتابة الشيفرة على شكل أداة وسطية middleware خاصةٍ بنا، ونطبّقها في معالجات التوجيه: const noteFinder = async (req, res, next) => { req.note = await Note.findByPk(req.params.id) next() } router.get('/:id', noteFinder, async (req, res) => { if (req.note) { res.json(req.note) } else { res.status(404).end() } }) router.delete('/:id', noteFinder, async (req, res) => { if (req.note) { await req.note.destroy() } res.status(204).end() }) router.put('/:id', noteFinder, async (req, res) => { if (req.note) { req.note.important = req.body.important await req.note.save() res.json(req.note) } else { res.status(404).end() } }) تستقبل معالجات الوجهة ثلاثة معاملات: الأول نصي يُعرّف الوجهة، والثاني الأداة الوسطية noteFinder المُعرفة مُسبقًا والتي تستخلص الملاحظة من قاعدة البيانات وتضعها في الخاصية note للكائن req. بإمكانك إيجاد الشيفرة الحالية للتطبيق كاملةً في المستودع المخصص على GitHub ضمن الفرع part13-2. التمرينات 13.5 إلى 13.7 حاول إنجاز التمارين التالية التمرين 13.5 غيّر هيكل تطبيقك ليشابه المثال السابق أو اتبع هيكليةً أخرى واضحة وملائمة. التمرين 13.6 قدِّم طريقةً تدعم تغيير عدد الإعجابات بمدوّنة في تطبيقك مستخدمًا العملية "PUT /api/blogs/:id"، إذ ينبغي أن يصل العدد الجديد للإعجابات مع الطلب: { likes: 3 } التمرين 13.7 استخدم أداةً وسطيةً للتحكم المركزي بمعالجة الأخطاء كما فعلنا في القسم 3 كما يمكنك استخدام الأداة الوسطية express-async-errors كما فعلنا في القسم 4. لا تهتم للبيانات المُعادة في سياق رسالة الخطأ. حتى اللحظة لا تتطلب سوى حالتين في التطبيق معالجةً للأخطاء، هما: إضافة مدوّنة جديدة وتغيير عدد الإعجابات، لذلك تأكد من قدرة معالج الأخطاء على التعامل مع هاتين الحالتين بما يلائمهما. إدارة المستخدمين سنضيف تاليًا جدول قاعدة بيانات يُدعى "users" تُخزّن في فيه بيانات مستخدمي التطبيق، كما سنضيف أيضًا وظيفةً لإضافة مستخدمين جدد وآلية تسجيل دخول مبنية على مفاتيح الاستيثاق كما فعلنا في القسم 4، ولكي نبسط العمل، سنُعدِّل ما أنجزناه سابقًا كي يكون لجميع المستخدمين كلمة المرور ذاتها وهي "secret". محتوى الملف "models/user.js" الذي يُعرّف المستخدمين واضحٌ تمامًا: const { Model, DataTypes } = require('sequelize') const { sequelize } = require('../util/db') class User extends Model {} User.init({ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, username: { type: DataTypes.STRING, unique: true, allowNull: false }, name: { type: DataTypes.STRING, allowNull: false }, }, { sequelize, underscored: true, timestamps: false, modelName: 'user' }) module.exports = User سيكون حقل اسم المستخدم "username" ذا قيم فريدة، لكن وعلى الرغم من إمكانية جعله المفتاح الرئيسي للجدول، إلا أننا قررنا أن ننشئ حقلًا field منفصلًا "id" ذا قيم صحيحة ليكون المفتاح الرئيسي. سيتوسع الملف "models/index.js" قليلًا: const Note = require('./note') const User = require('./user') Note.sync() User.sync() module.exports = { Note, User } لا يضم الملف "controllers/users.js" الذي يحتوي معالجات الوجهة التي تهتم بإنشاء مستخدمين جدد أي شيء مهم حاليًا سوى عرض كل المستخدمين: const router = require('express').Router() const { User } = require('../models') router.get('/', async (req, res) => { const users = await User.findAll() res.json(users) }) router.post('/', async (req, res) => { try { const user = await User.create(req.body) res.json(user) } catch(error) { return res.status(400).json({ error }) } }) router.get('/:id', async (req, res) => { const user = await User.findByPk(req.params.id) if (user) { res.json(user) } else { res.status(404).end() } }) module.exports = router أما معالج الوجهة الذي يتحكم بتسجيل الدخول (الملف "controllers/login.js") فسيكون على النحو التالي: const jwt = require('jsonwebtoken') const router = require('express').Router() const { SECRET } = require('../util/config') const User = require('../models/user') router.post('/', async (request, response) => { const body = request.body const user = await User.findOne({ where: { username: body.username } }) const passwordCorrect = body.password === 'secret' 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, SECRET) response .status(200) .send({ token, username: user.username, name: user.name }) }) module.exports = router سيُرفق اسم المستخدم وكلمة المرور مع طلب POST، ويُستخلص الكائن المتعلق باسم المستخدم أولًا من قاعدة البيانات باستخدام التابع findOne العائد للنموذج User: const user = await User.findOne({ where: { username: body.username } }) يمكنك أن تلاحظ من خلال الطرفية أن تعليمة SQL المتعلقة باستدعاء التابع السابق هي: SELECT "id", "username", "name" FROM "users" AS "User" WHERE "User". "username" = 'mluukkai'; إن وُجد المستخدم وكانت كلمة المرور صحيحة (وهي "secret" لجميع المستخدمين)، يُعاد مفتاح الاستيثاق "jsonwebtoken" متضمنًا معلومات المستخدم مع الاستجابة. لهذا سنُثبّت الاعتمادية "jsonwebtoken": npm install jsonwebtoken سيتوسع الملف "index.js" قليلًا: const notesRouter = require('./controllers/notes') const usersRouter = require('./controllers/users') const loginRouter = require('./controllers/login') app.use(express.json()) app.use('/api/notes', notesRouter) app.use('/api/users', usersRouter) app.use('/api/login', loginRouter) يمكنك الحصول على شيفرة التطبيق بوضعها الحالي من المستودع المخصص على GitHub ضمن الفرع part13-3. الاتصال بين الجداول يمكن الآن إضافة مستخدمين إلى التطبيق، كما يمكن للمستخدمين تسجيل الدخول، لكنها ليست ميزات مفيدة جدًا في وضعها الحالي، لذلك سنضيف ميزات لا يمكن للمستخدم الاستفادة منها ما لم يُسجل دخوله مثل إضافة ملاحظة جديدة، إذ ترتبط هذه الملاحظة بالمستخدم الذي أنشأها، ولهذا لا بُدّ من إضافة مفتاح خارجي foreign key إلى جدول "notes". يمكن تعريف المفتاح الخارجي عند استخدام مكتبة Sequelize بتعديل الملف "models/index.js" على النحو التالي: const Note = require('./note') const User = require('./user') User.hasMany(Note) Note.belongsTo(User) Note.sync({ alter: true }) User.sync({ alter: true }) module.exports = { Note, User } وهكذا نكون قد عرّفنا علاقة واحد-إلى-متعدد one-to-many تصل بين جدولي المستخدمين "users" والملاحظات "notes"، كما عدّلنا الخيارات في استدعاءات sync لتتطابق جداول قاعدة البيانات مع التغييرات التي حدثت على تعريفات النموذج. سيبدو مخطط قاعدة البيانات على شاشة الطرفية على النحو التالي: username=> \d users Table "public.users" Column | Type | Collation | Nullable | Default ----------+------------------------+-----------+----------+----------------------------------- id | integer | not null | nextval('users_id_seq'::regclass) username | character varying(255) | | not null | name | character varying(255) | | not null | Indexes: "users_pkey" PRIMARY KEY, btree (id) Referenced by: TABLE "notes" CONSTRAINT "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL username=> \d notes Table "public.notes" Column | Type | Collation | Nullable | Default -----------+--------------------------+-----------+----------+----------------------------------- id | integer | not null | nextval('notes_id_seq'::regclass) content | text | | not null | important | boolean | | | | date | timestamp with time zone | | | | user_id | integer | | | | Indexes: "notes_pkey" PRIMARY KEY, btree (id) Foreign-key constraints: "notes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES users(id) ON UPDATE CASCADE ON DELETE SET NULL يشير المفتاح الخارجي user_id الذي أُنشئ في الجدول notes إلى أسطر في الجدول users. لنربط الآن كل ملاحظة جديدة بالمستخدم الذي أنشأها، ولكن قبل تقديم الأمر (ربط الملاحظة بمفتاح استيثاق المستخدم الذي استخدمه لتسجيل دخوله)، لا بُد من كتابة الملاحظة التي ترتبط بالمستخدم الأول الذي يُعثر عليه يدويًا في الشيفرة: router.post('/', async (req, res) => { try { const user = await User.findOne() const note = await Note.create({...req.body, userId: user.id}) res.json(note) } catch(error) { return res.status(400).json({ error }) } }) انتبه إلى وجود العمود user_id في جدول الملاحظات "notes" على مستوى قاعدة البيانات، ويُشار إلى اسم كل صف في قاعدة البيانات بالطريقة التقليدية للمكتبة Sequelize، وذلك بكتابته على نقيض أسلوب سنام الجمل "userId"، أي كما تُكتب تمامًا في الشيفرة (حروف صغيرة). من السهل تنفيذ استعلامات مشتركة في Sequelize، لهذا سنغيّر الوجهة التي تعيد كل المستخدمين لتعرض كل ملاحظات المستخدم: router.get('/', async (req, res) => { const users = await User.findAll({ include: { model: Note } }) res.json(users) }) يُنفَّذ الاستعلام المشترك باستخدام الخيار include مثل معامل استعلام، أما تعليمة SQL المولّدة من الاستعلام، فستُطبع على شاشة الطرفية على النحو التالي: SELECT "User". "id", "User". "username", "User". "name", "Notes". "id" AS "Notes.id", "Notes". "content" AS "Notes.content", "Notes". "important" AS "Notes.important", "Notes". "date" AS "Notes.date", "Notes". "user_id" AS "Notes.UserId" FROM "users" AS "User" LEFT OUTER JOIN "notes" AS "Notes" ON "User". "id" = "Notes". "user_id"; ستبدو النتيجة النهائية كما تتوقع: الإضافة الملائمة للملاحظات لنحاول تغيير طريقة إضافة الملاحظات لتعمل بالطريقة التي عملت بها من قبل، بحيث ينجح إنشاء الملاحظات فقط إذا حمل طلب إنشائها مفتاح استيثاق صحيح عند تسجيل الدخول. تُخزّن بعدها الملاحظة ضمن قائمة الملاحظات التي أنشأها المستخدم المُعرّف بواسطة مفتاح الاستيثاق: const tokenExtractor = (req, res, next) => { const authorization = req.get('authorization') if (authorization && authorization.toLowerCase().startsWith('bearer ')) { try { req.decodedToken = jwt.verify(authorization.substring(7), SECRET) } catch{ res.status(401).json({ error: 'token invalid' }) } } else { res.status(401).json({ error: 'token missing' }) } next() } router.post('/', tokenExtractor, async (req, res) => { try { const user = await User.findByPk(req.decodedToken.id) const note = await Note.create({...req.body, userId: user.id, date: new Date()}) res.json(note) } catch(error) { return res.status(400).json({ error }) } }) يُستخلص مفتاح الاستيثاق من ترويسة الطلب ويفُك تشفيره ويوضع ضمن الكائن req بواسطة الأداة الوسطية tokenExtractor. يُضاف زمن الإنشاء إلى الحقل date أيضًا عند إنشاء الملاحظة. ضبط الواجهة الخلفية تعمل الواجهة الخلفية حتى اللحظة بنفس الطريقة التي تعمل بها في نسخة القسم 4 من التطبيق ماعدا فكرة معالجة الأخطاء. لنغيّر الآن وجهات إحضار جميع الملاحظات وجميع المستخدمين قليلًا قبل توسيع هذه الواجهة. سنضيف إلى كل ملاحظة بعض المعلومات المتعلقة بالمستخدم الذي أنشأها: router.get('/', async (req, res) => { const notes = await Note.findAll({ attributes: { exclude: ['userId'] }, include: { model: User, attributes: ['name'] } }) res.json(notes) }) كنا قد قيّدنا سابقًا القيم التي يأخذها الحقل المطلوب، إذ كنا نعيد جميع حقول البيانات الخاصة بكل ملاحظة بما في ذلك اسم المستخدم "name" الذي يرتبط بالملاحظة لكن باستثناء حقل المعرّف الفريد للمستخدم "userId". لنجرِ التغيير ذاته على الوجهة route التي تحضر جميع المستخدمين والملاحظات وذلك بإزالة الحقل userId غير الضروري من الملاحظات المرتبطة بمستخدم معين على النحو التالي: router.get('/', async (req, res) => { const users = await User.findAll({ include: { model: Note, attributes: { exclude: ['userId'] } } }) res.json(users) }) يمكنك الحصول على شيفرة التطبيق بوضعها الحالي من المستودع المخصص على GitHub ضمن الفرع part13-4. قليل من الانتباه إلى تعريفات النماذج رغم وجود العمود ‍‍‌‍‍user_id، إلا أننا لم نغيّر النموذج الذي يعرّف الملاحظات، ولكن يمكننا مع ذلك إضافة مستخدم إلى كائن الملاحظة: const user = await User.findByPk(req.decodedToken.id) const note = await Note.create({ ...req.body, userId: user.id, date: new Date() }) يعود السبب وراء ذلك إلى أننا لم نحدّد وجود علاقة واحد-إلى-متعدد في الاتصال بين جدولي المستخدمين "users" والملاحظات "notes" ضمن الملف "models/index.js": const Note = require('./note') const User = require('./user') User.hasMany(Note) Note.belongsTo(User) // ... تُنشئ المكتبة Sequelize تلقائيًا سمةً تُدعى userId في النموذج Note تمنح وصولًا إلى العمود user_id عندما يُشار إليه. وتذكّر أنه يمكنك إنشاء ملاحظة باستخدام التابع build: const user = await User.findByPk(req.decodedToken.id) // إنشاء ملاحظة دون تخزينها بعد const note = Note.build({ ...req.body, date: new Date() }) // للملاحظة المُنشأة userId وضع المعرف الفريد للمستخدم في خاصية note.userId = user.id // تخزين كائن الملاحظة في قاعدة البيانات await note.save() هكذا نرى صراحة أن userId هي سمة attribute لكائن الملاحظات، وقد كان بالإمكان تعريف النموذج على النحو التالي للحصول على النتيجة ذاتها: Note.init({ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, content: { type: DataTypes.TEXT, allowNull: false }, important: { type: DataTypes.BOOLEAN }, date: { type: DataTypes.DATE }, userId: { type: DataTypes.INTEGER, allowNull: false, references: { model: 'users', key: 'id' }, } }, { sequelize, underscored: true, timestamps: false, modelName: 'note' }) module.exports = Note لا يوجد داعٍ للتعريف على مستوى الصنف في النموذج كما فعلنا سابقًا: User.hasMany(Note) Note.belongsTo(User) وبدلًا من ذلك يمكننا تحقيق الأمر بهذه الطريقة، ولا بُد من استخدام إحدى هاتين الطريقتين وإلا لن تُدرك مكتبة Sequelize كيف يتصل الجدولين ببعضهما على مستوى الشيفرة. التمرينات 13.8 إلى 13.11 حاول إنجاز التمارين الآتية: التمرين 13.8 زِد دعم التطبيق لمستخدميه، إذ لا بُد أن يضم جدول المستخدمين الحقول التالية إضافةً إلى الحقل "ID": name: ذو قيمة نصية (لا يمكن أن يكون فارغًا). username: ذو قيمة نصية (لا يمكن أن يكون فارغًا). وعلى خلاف ما أوردنا في الشروحات النظرية، لا تمنع مكتبة Sequelize حاليًا إنشاء البصمتين الزمنيتين create_dat و update_dat لجدول المستخدمين. يمكن إعطاء كلمة المرور نفسها لجميع المستخدمين، وكذلك اختيار طريقة التحقق من كلمة المرور كما في القسم 4، وعليك أيضًا إنجاز الوجهات التالية: "POST api/users": لإضافة مستخدم جديد. "GET api/users": عرض جميع المستخدمين. "PUT api/users/:username": لتغيير اسم المستخدم وليس المعرّف "id". تأكد من إدراج البصمات الزمنية تلقائيًا من قِبل مكتبة Sequelize وعلى النحو الصحيح عند إضافة مستخدم أو تغيير اسمه. التمرين 13.9 تزودنا مكتبة Sequelize بمجموعة قواعد تحقق معرّفةٌ مسبقًا للتأكد من قيم حقول النموذج تُنفّذها قبل تخزين الكائنات في قاعدة البيانات. ولأننا نريد تغيير سياسة إنشاء مستخدم جديد تتطلب أن يكون البريد الإلكتروني المُدخل صحيحًا كما هو حال اسم المستخدم، أنجِز طريقةً للتحقق من ذلك أثناء إنشاء المستخدم. عدّل أيضًا بالأداة الوسطية المستخدمة في معالجة الأخطاء لتعرض وصفًا أكثر وضوحًا لرسالة الخطأ مثل استخدام رسائل خطأ Sequelize على سبيل المثال. { "error": [ "Validation isEmail on username failed" ] } التمرين 13.10 وسّع التطبيق لكي تربط كل مستخدم سجّل دخوله بنجاح عبر مفتاح الاستيثاق بكل مدونة يضيفها. لا بُد من تقديم وصلة endpoint تسجيل دخول "POST /api/login" تُعيد مفتاح الاستيثاق. التمرين 13.11 تأكد أن المستخدم قادرٌ فقط على حذف المدونات التي يضيفها. التمرين 13.12 عدّل الوجهات كي تكون قادرًا على استخلاص جميع المدوّنات والمستخدمين كي تعرض كل مدوّنة المستخدم الذي أضافها وأن يعرض كل مستخدم المدوّنات التي أضافها. استعلامات أوسع لا يزال تطبيقنا حتى اللحظة بسيطًا من ناحية الاستعلامات التي يجريها، إذ اقتصر البحث على صف واحد بناءً على مفتاح رئيسي من خلال التابع "findByPk"، أو البحث عن كل الصفوف باستخدام التابع "findAll". كانت هذه الاستعلامات كافيةً بالنسبة للواجهة الأمامية في نسخة القسم 5 من التطبيق، لكننا سنوّسع الواجهة الخلفية لنتمكن من تنفيذ استعلامات أعقد قليلًا. لننجز أولًا طريقة للحصول على ملاحظات مصنّفة على أنها مهمة أو غير مهمة فقط، باستخدام معامل الاستعلام important: router.get('/', async (req, res) => { const notes = await Note.findAll({ attributes: { exclude: ['userId'] }, include: { model: user, attributes: ['name'] }, where: { important: req.query.important === "true" } }) res.json(notes) }) وهكذا ستتمكن الواجهة الخلفية من استخلاص الملاحظات المهمة عند وصول الطلب: http://localhost:3001/api/notes?important=true والملاحظات غير المهمة عند وصول الطلب: http://localhost:3001/api/notes?important=false يتضمن استعلام SQL الذي ولّدته Sequelize العبارة WHERE، التي تُرشّح الصفوف المُعادة في الحالة الطبيعية: SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" WHERE "note". "important" = true; وبالطبع لن ينفع إنجاز الأمر بهذه الطريقة ما لم يهتم الاستعلام بأهمية الملاحظة، كأن يكون على النحو التالي: http://localhost:3001/api/notes يمكن تدارك الموضوع بطرق عدة، أولها -وقد لا تكون الطريقة الأفضل- على النحو التالي: const { Op } = require('sequelize') router.get('/', async (req, res) => { let important = { [Op.in]: [true, false] } if ( req.query.important ) { important = req.query.important === "true" } const notes = await Note.findAll({ attributes: { exclude: ['userId'] }, include: { model: user, attributes: ['name'] }, where: { important } }) res.json(notes) }) يُخزّن الكائن important الآن شرط الاستعلام، وسيكون الشكل الافتراضي لهذا الاستعلام على النحو التالي: where: { important: { [Op.in]: [true, false] } } أي أن العمود قد يحمل إحدى القيمتين true أو false باستخدام العامل Op.in، وهو أحد عوامل Sequelize العديدة؛ فإذا حددنا قيمة للمعامل req.query.important، سيتغير الاستعلام ليصبح بأحد الشكلين التاليين: where: { important: true } أو where: { important: false } بناءً على قيمة معامل الاستعلام. يمكن زيادة القدرة الوظيفية للتطبيق بالسماح للمستخدم بتخصيص كلمة مفتاحية لإحضار البيانات، إذ يعيد الطلب التالي: http://localhost:3001/api/notes?search=database الملاحظات التي تشير إلى الكلمة "database". كما يعيد الطلب التالي: http://localhost:3001/api/notes?search=javascript&important=true جميع الملاحظات التي حُددت أنها مهمة "important" وتشير إلى الكلمة "javascript". سيكون تنفيذ الأمر على النحو التالي: router.get('/', async (req, res) => { let important = { [Op.in]: [true, false] } if ( req.query.important ) { important = req.query.important === "true" } const notes = await Note.findAll({ attributes: { exclude: ['userId'] }, include: { model: user, attributes: ['name'] }, where: { important, content: { [Op.substring]: req.query.search ? req.query.search : '' } } }) res.json(notes) }) يوّلد التابع Op.substring الاستعلام الذي نريده باستخدام كلمة "LIKE"، فإذا أنشأت الاستعلام التالي مثلًا: http://localhost:3001/api/notes?search=database&important=true فسيولّد استعلام SQL ما نتوقعه تمامًا: SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" WHERE "note". "important" = true AND "note". "content" LIKE '%database%'; لكن لا تزال هناك ثغرة مزعجة تتمثل في أن الطلب التالي: http://localhost:3001/api/notes والذي يعني أننا نريد الحصول على جميع الملاحظات، سيتسبب في وجود عبارة WHERE غير الضرورية في الاستعلام، والتي قد تؤثر في فعاليته وفقًا لآلية عمل محرك قاعدة البيانات: SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" WHERE "note". "important" IN (true, false) AND "note". "content" LIKE '%%'; لنحسّن الشيفرة لكي تُستخدم عبارة WHERE عند الحاجة فقط: router.get('/', async (req, res) => { const where = {} if (req.query.important) { where.important = req.query.important === "true" } if (req.query.search) { where.content = { [Op.substring]: req.query.search } } const notes = await Note.findAll({ attributes: { exclude: ['userId'] }, include: { model: user, attributes: ['name'] }, where }) res.json(notes) }) وإذا احتوى الطلب على شرط للبحث مثل: http://localhost:3001/api/notes?search=database&important=true سيتولد استعلام يضم عبارة WHERE: SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id" WHERE "note". "important" = true AND "note". "content" LIKE '%database%'; وإذا لم يحتوي شرطًا، فلن يكون لوجود WHERE حاجة: SELECT "note". "id", "note". "content", "note". "important", "note". "date", "user". "id" AS "user.id", "user". "name" AS "user.name" FROM "notes" AS "note" LEFT OUTER JOIN "users" AS "user" ON "note". "user_id" = "user". "id"; يمكنك الحصول على شيفرة التطبيق بوضعها الحالي من المستودع المخصص على GitHub ضمن الفرع part13-5. التمرينات 13.13 إلى 13.16 حاول إنجاز التمرينات التالية: التمرين 13.13 قدِّم آليةً لترشيح filtering النتائج من خلال كلمة مفتاحية ضمن الوجهة التي تعيد كل المدوّنات، وينبغي أن تعمل الآلية على النحو التالي: "GET /api/blogs?search=react": تعيد كل المدونات التي تضم الكلمة "react" في الحقل علمًا أن البحث حساس لحالة الأحرف. "GET /api/blogs": يعيد جميع المدوّنات. يمكنك الاستفادة من عوامل Sequelize لتنفيذ التمرين. التمرين 13.14 وسّع آلية الترشيح للبحث عن كلمة مفتاحية في أحد الحقلين "field" أو "author"، إذ سيعيد الاستعلام: GET /api/blogs?search=jami كل المدوّنات التي تضم الكلمة في أحد الحقلين "field" أو "author". التمرين 13.15 عدّل وجهات المدوّنات لتعيد المدوّنات بناءً على عدد الإعجابات بترتيب تنازلي. اطلع من خلال توثيق Sequelize على تعليمات ترتيب نتائج الاستعلام. التمرين 13.16 أنشئ الوجهة "api/authors/" كي تعيد عدد المدونات التي أضافها المؤلف والعدد الكلي للإعجابات. قدّم العملية على مستوى قاعدة البيانات. قد تحتاج إلى وظيفة التجميع group by ودالة التجميع sequelize.fn. قد تبدو المعلومات المُعادة بتنسيق JSON على غرار المثال الآتي: [ { author: "Jami Kousa", articles: "3", likes: "10" }, { author: "Kalle Ilves", articles: "1", likes: "2" }, { author: "Dan Abramov", articles: "1", likes: "4" } ] مهمة لعلامة إضافية: رتِّب البيانات المُعادة بناءً على عدد الإعجابات، على أن تُنفّذ عملية الترتيب من خلال استعلام قاعدة البيانات. ترجمة -وبتصرف- للفصل Join tables and queries من سلسلة Deep Dive Into Modern Web Development اقرأ أيضًا المقال السابق: العمل مع قواعد بيانات علاقية باستخدام Sequelize النماذج واﻻستعلامات والتقارير في برنامج قواعد بيانات مايكروسوفت أكسس Microsoft Access التعامل مع قواعد البيانات تصميم الجداول ومعلومات المخطط وترتيب تنفيذ الاستعلامات في SQL
  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. قبل أن نبدأ البرمجة، سنستعرض بعض المبادئ المعتمدة في تطوير الويب من خلال الاطلاع على هذا التطبيق النموذجي عبر الرابط: studies.cs.helsinki.fi/exampleapp. صممت هذه التمارين لتوضيح بعض المفاهيم الأساسية في المنهاج، وليس لتقديم الطريقة التي ينبغي أن تبنى بها تطبيقات الويب. فهي على العكس تمامًا، ستشرح بعض التقنيات القديمة في تطوير الويب والتي يمكن أن تعتبر في أيامنا هذه عادات برمجية سيئة. مع هذا ستتعلم كتابة الشيفرة بالأسلوب الأنسب في القسم 1. استعمل متصفح Chrome الآن ودائمًا خلال رحلتك في هذا المنهاج. ثم استخدمه في فتح التطبيق النموذجي، قد يستغرق ذلك وقتًا. القاعدة الأولى في تطوير الويب: ابق طرفية التطوير (Development Console) مفتوحةً دومًا في متصفحك. إن كنت تعمل على نظام التشغيل Windows اضغط زر F12 أو أزرار ctrl-shift-i معًا لفتحه. أما في نظام التشغيل MacOS فاضغط زر F12 أو الأزرار Option-cmd-i معًا. قبل المتابعة، اكتشف طريقة فتح طرفية التطوير على حاسوبك، وتذكر أن تبقيها مفتوحةً دومًا عندما تعمل على تطوير تطبيقات الويب. ستظهر طرفية التطوير بالشكل التالي: تأكد أن نافذة (Network) مفتوحة، ثم ألغ تفعيل خيار (Disable Cache). إن حفظ سجل العمل (log) قد يكون مفيدًا، إذ سيحفظ سجلات تنفيذ التطبيق عند إعادة تحميل الصفحة. لاحظ جيدًا: إن أكثر النوافذ أهميةً في الطرفية هي (Network)، و سنستخدمها بكثرة في البداية. الطلبية من النوع HTTP GET يتواصل الخادم (Server) مع المتصفح باستخدام بروتوكول HTTP، وتعرض نافذة (Network) تفاصيل هذا التواصل. فعندما نطلب إعادة تحميل الصفحة ( الزر F5 أو الرمز ↺ على المتصفح)، ستُظهر الطرفية أن حدثين قد وقعا: جلب المتصفح محتوى صفحة التطبيق من الخادم. نزّل المتصفح الصورة Kuva.png. ] عليك تكبير شاشة الطرفية لترى ذلك إن كانت شاشة العرض صغيرة. ستظهر لك معلومات أكثر عند النقر على الحدث الأول: يظهر القسم العلوي (General)، أن المتصفح قدم طلبًا إلى العنوان: https://fullstack-exampleapp.herokuapp.com مستخدما أسلوب GET، وأن الطلب كان ناجحًا لأن الخادم استجاب معيدًا رمز حالة (Status Code) قيمته 200. توجد العديد من الترويسات (Headers) للطلب والاستجابة، يُظهر الشكل التالي بعضها: تعطينا ترويسات الاستجابة (Response headers) معلومات عدة، مثل حجم الاستجابة مقدرة بالبايت والوقت الدقيق لها. كذلك الأمر تخبرنا ترويسة نوع المحتوى (Content_Type) أن الاستجابة كانت على شكل ملف نصي بصيغة utf-8، وتحدد المحتوى الذي سيظهر بتنسيق HTML. وبهذا يعلم المتصفح أن الاستجابة ستكون على شكل صفحة HTML قياسية، ليعرضها كصفحة ويب. تُظهر نافذة (Response) بيانات الاستجابة على شكل صفحة HTML. حيث يحدد القسم Body هيكل الصفحة التي ستُعرض على الشاشة. وتحوي صفحة HTML أيضًا العنصر Div الذي يضم عناصر أخرى مثل معرّف العنوان h1 والرابط a إلى صفحة الملاحظات ومعّرف الصورimg وأخيرًا معرّف الفقرة p الذي يُظهر في مثالنا عدد الملاحظات التي تم إنشاؤها. ونظرًا لوجود معرّف الصور img، أرسل المتصفح طلب HTTP جديد إلى الخادم لإحضار الصورة التي عنوانها kuva.png. يُظهر الشكل التالي تفاصيل الطلب: أُرسل طلب HTTP-GET إلى العنوان: https://fullstack-exampleapp.herokuapp.com/kuva.png وتخبرنا ترويسات الاستجابة أن حجم الاستجابة يعادل 89350 بايت، ومحتواها صورة بصيغة png. يستخدم المتصفح هذه المعلومات لإظهار الصورة بشكل صحيح على الشاشة. يمثل المخطط التتابعي التالي، سلسلة الأحداث التي بدأت بفتح الصفحة https://fullstack-exampleapp.herokuapp.com: أرسل المتصفح في البداية، طلبًا إلى الخادم من نوع HTTP-GET للحصول على شيفرة HTML للصفحة. يخبر معرّف الصور img الموجود في شيفرة HTML المتصفح أن يحضر صورة بعنوان kuva.png. ثم يعالج المتصفح شيفرة HTML والصورة ويظهرهما على الشاشة. مع ذلك يصعب ملاحظة أن معالجة صفحة HTML بدأت قبل أن يحضر المتصفح الصورة من الخادم. تطبيقات الويب التقليدية يماثل عمل صفحة التطبيق النموذجي السابق عمل تطبيقات الويب التقليدية. فعند الدخول إلى الصفحة، يحضر المتصفح من الخادم ملف HTML الذي يُنسِّق طريقة عرض الصفحة ومحتواها النصي. لقد بنى الخادم هذا المستند بطريقة ما. فقد يكون المستند عبارة عن مستند نصي ذي محتوًى ثابت محفوظ على الخادم. كما يمكن للخادم أيضًا أن يبني مستند HTML ديناميكيًا وفقًا للتعليمات الموجودة في شيفرة التطبيق مستخدمًا على سبيل المثال، بيانات موجودة في قاعدة بيانات محددة. فشيفرة ملف HTML في التطبيق النموذجي السابق مثلًا، كُتبت ديناميكيًا من قبل الخادم، كونه احتوى معلومات تدل على عدد الملاحظات التي دونت في الصفحة. تظهر شيفرة HTML لصفحة التطبيق النموذجي بالشكل التالي: const getFrontPageHtml = (noteCount) => { return(` <!DOCTYPE html> <html> <head> </head> <body> <div class='container'> <h1>Full stack example app</h1> <p>number of notes created ${noteCount}</p> <a href='/notes'>notes</a> <img src='kuva.png' width='200' /> </div> </body> </html> `) } app.get('/', (req, res) => { const page = getFrontPageHtml(notes.length) res.send(page) }) بالطبع ليس عليك استيعاب الشيفرة المكتوبة حاليًا. يُحفظ محتوى صفحة HTML على شكل قالب نصي (Template String) أو على شكل سلسلة نصية تسمح على سبيل المثال، بتحديد قيمة المتغيرات الموجودة ضمنها. فالقسم الديناميكي (القابل للتغيير) في صفحة التطبيق النموذجي السابق هو القسم الذي يشير إلى عدد الملاحظات المدونة و يدعى notecount. يُستبدَل هذا القسم بالعدد الفعلي للملاحظات notes.length ضمن القالب النصي للصفحة. لاحظ أن وجود شيفرة HTML ضمن الشيفرة السابقة هو أمر غير مُحبَّذ، لكن مبرمجي مدرسة PHP التقليدية يعتبرون ذلك شيئًا طبيعيًا. يُعتبَر المتصفح في تطبيقات الويب التقليدية "محايدًا". فوظيفته فقط إحضار بيانات HTML من الخادم الذي يقع على عاتقه القيام بكل العمليات المنطقية التي يحتاجها التطبيق. يمكن بناء الخادم باستعمال لغة Java Spring كالخادم الذي استخدم في منهاج Web-palvelinohjelmointi لجامعة هلسنكي، أو استعمال لغة Python Flask كمنهاج tietokantasovellus ، أو باستعمال Ruby and Rails. سيستعمل التطبيق السابق Express من Node.j.s، وسيستخدم هذا المنهاج التقنيتين السابقتين لبناء الخادم. تشغيل التطبيق من المتصفح ابق طرفية التطوير مفتوحةً، واحذف ما فيها بالنقر على ?. عندما تنقر الآن على رابط notes، سيرسل المتصفح أربع طلبات HTTP: لهذه الطلبات أنواع مختلفة. ويمثل النوع (مستند document)، شيفرة HTML للصفحة ويظهر بالشكل التالي: الآن، عندما نقارن الصفحة الظاهرة على المتصفح مع شيفرة HTML التي يعيدها الخادم، سنلاحظ أن الشيفرة لا تحتوي على قائمة الملاحظات. تحتوي ترويسة القسم html من الشيفرة على العنصر script الذي يطلب من المتصفح إحضار ملف JavaScript يدعى main.js. تظهر شيفرة JavaScript كالتالي: var xhttp = new XMLHttpRequest() xhttp.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { const data = JSON.parse(this.responseText) console.log(data) var ul = document.createElement('ul') ul.setAttribute('class', 'notes') data.forEach(function(note) { var li = document.createElement('li') ul.appendChild(li) li.appendChild(document.createTextNode(note.content)) }) document.getElementById('notes').appendChild(ul) } } xhttp.open('GET', '/data.json', true) xhttp.send() لا تهمنا حاليًا التفاصيل الواردة في الشيفرة السابقة، لكن جزء منها قد كتب لتجميل مظهر الصور والنصوص (من المرجح أن نبدأ كتابة شيفرات في القسم 1). ونلفت انتباهك أن كتابة الشيفرة السابقة لا تتناسب إطلاقًا وتقنيات كتابة الشيفرات التي ستتعلمها في هذا المنهاج. قد يتساءل البعض عن مغزى استخدام كائن xhttp بدلًا من الأسلوب الحديث لجلب البيانات. إن السبب الرئيسي هو أننا لانريد الآن إظهار ما وعدنا به، و أيضًا للدور الثانوي الذي تلعبه هذه الشيفرة في هذا القسم من المنهاج. لكننا سنطبق في القسم الثاني الطرق الأحدث في إرسال الطلبات إلى الخادم. بعد إحضار ماطلبه عنصرscript مباشرةً، يبدأ المتصفح بتنفيذ الشيفرة. حيث يشير السطرين الأخيرين إلى أن المتصفح سيرسل طلبا من نوع HTTP-GET إلى العنوان التالي data.json/ على الخادم: xhttp.open('GET', '/data.json', true) xhttp.send() سنكون بذلك قد وصلنا إلى آخر طلب سيظهر ضمن النافذة (Network). يمكننا بطبيعة الحال الوصول إلى هذا العنوان مباشرة من نافذة المتصفح كما في الشكل التالي: سنحصل عند فتح العنوان السابق على الملاحظات مكتوبة بصيغة بيانات غير معالجة (raw data) كالتي يستخدمها JSON. لا يمكن للمتصفح في الحالة الطبيعية إظهار هذه البيانات بشكل واضح، لذلك يمكن تثبيب إضافة (Plugin) للتعامل مع هذه الصيغة من البيانات. ثبت على سبيل المثال JSONView على متصفح Chrome ثم أعد تحميل الصفحة. ستظهر البيانات الآن بشكل مفهوم. إذًا، تُحمِّل شيفرة HTML في صفحة الملاحظات Notes بيانات JSON تحتوي على الملاحظات، وترتبها على شكل قائمة نقاط. ويتم ذلك بتنفيذ الشيفرة التالية: const data = JSON.parse(this.responseText) console.log(data) var ul = document.createElement('ul') ul.setAttribute('class', 'notes') data.forEach(function(note) { var li = document.createElement('li') ul.appendChild(li) li.appendChild(document.createTextNode(note.content)) }) document.getElementById('notes').appendChild(ul) تُنشأ الشيفرة بداية قائمة غير مرتبة ul كما يلي: var ul = document.createElement('ul') ul.setAttribute('class', 'notes') ثم تضيف العنصرli (الذي يمثل عنصرًا من قائمة نقاط) لكل ملاحظة، وتضع بداخله محتويات الحقل content فقط من الملاحظة. أما القيم الزمنية الموجودة في الحقل date، فلن تستخدم هنا. data.forEach(function(note) { var li = document.createElement('li') ul.appendChild(li) li.appendChild(document.createTextNode(note.content)) }) افتح الآن النافذة Console ضمن طرفية التطوير كالتالي: سيظهر النص كاملًا عند النقر على المثلث الصغير في بداية السطر: نتج النص السابق عن تعليمة console.log الموجودة في الشيفرة التالية: const data = JSON.parse(this.responseText) console.log(data) وبالتالي ستطبع الشيفرة تلك البيانات في الطرفية بعد استقبالها من الخادم. ستعتاد التعامل مع نافذة Console ومع تعليمة Console.log خلال مسيرتك في هذا المنهاج. معالج الأحداث (Event Handler) و دوال رد النداء (Callback functions) ستبدو لك بنية هذه الشيفرة غريبةً نوعا ما: var xhttp = new XMLHttpRequest() xhttp.onreadystatechange = function() { // الشيفرة التي ستعالج استجابة الخادم } xhttp.open('GET', '/data.json', true) xhttp.send() لقد أُرسِل الطلب إلى الخادم في آخر سطر من الشيفرة، لكن التعليمات التي تعالج الاستجابة أتت في البداية. ما الذي يحدث؟ في هذا السطر: xhttp.onreadystatechange = function () { تم تحديد معالج (handler) للحدث onreadystatechange، الخاص بالكائن xhttp، أثناء إرسال الطلب إلى الخادم. فعندما تتغير حالة هذا الكائن، يستدعي المتصفح الدالة التي تمثل معالج هذا الحدث، ثم تتحق الدالة من أن قيمة الخاصة 'readystate' تساوي 4 (والتي تشير إلى أن العملية قد اكتملت)، وأن قيمة رمز الحالة (status code) لبروتوكول HTTP عند الاستجابة تساوي 200. xhttp.onreadystatechange = function() { if (this.readyState == 4 && this.status == 200) { // الشيفرة التي ستعالج استجابة الخادم } } إن هذا الأسلوب في استدعاء معالجات الأحداث شائع جدًا في لغة JavaScript، وتسمى دالة معالج الأحداث (نداء callback). لا تستدعي شيفرة التطبيق الدوال بنفسها، بل يستدعي المتصفح الذي يعتبر (بيئة التشغيل) في هذه الحالة، الدوال في الوقت المناسب وذلك عندما يقع الحدث. نموذج كائن المستند (Document Object Model DOM) يمكنك التفكير بصفحة HTML على أنها بنًى شجرية متداخلة. html head link script body div h1 div ul li li li form input input يمكن مشاهدة بنًى كهذه ضمن الطرفية /نافذة Elements: تعتمد آلية عمل المتصفح على فكرة تمثيل عناصر HTML على شكل شجرة. وبما أن نموذج كائن المستند DOM هو واجهة تطبيقات برمجية API، قادرة على تعديل العناصر الشجرية لصفحة الويب برمجيًا، فقد استخدمت شيفرة JavaScript التي رأيناها سابقًا الواجهة DOM-API لإضافة لائحة بالملاحظات المدونة في الصفحة. تضيف الشيفرة التالية عقدة جديدة للمتغيِّر ul، ثم تضيف عقدًا ضمنه (عقد أبناء): var ul = document.createElement('ul') data.forEach(function(note) { var li = document.createElement('li') ul.appendChild(li) li.appendChild(document.createTextNode(note.content)) }) وأخيرًا، بعد اكتمال فرع الشجرة الذي يمثله المتحول ul، سنضمه إلى شجرة HTML الممثلة لكامل صفحة الويب في مكانه المناسب. document.getElementById('notes').appendChild(ul) إجراء التعديلات على DOM من الطرفية يمكننا القيام بالعديد من العمليات على صفحة الويب باستخدام واجهة DOM-API. تدعى العقدة الأعلى في شجرة ملف HTML، كائن document. وللوصول إلى كائن مستند صفحة الويب من الطرفية اكتب كلمة document في النافذة Console. لنضف ملاحظة جديدة إلى الصفحة باستخدام الطرفية. دعونا نستعرض أولًا قائمة الملاحظات الموجودة في أول عنصر ul تحتويه الصفحة: list = document.getElementsByTagName('ul')[0] بعد ذلك سننشئ عنصر li جديد ونكتب نصًا ما داخله: newElement = document.createElement('li') newElement.textContent = 'Page manipulation from console is easy' وأخيرًا سنضيف العنصر الجديد إلى القائمة: list.appendChild(newElement) على الرغم من أن محتوى الصفحة قد تغير على متصفحك، إلا أنَّ هذا التغيير مؤقت. فلو حاولت إعادة تحميل الصفحة ستختفي التغيرات التي أجريتها، ذلك أن التغيرات لم ترفع إلى الخادم. إذًا، ستنشئ شيفرة JavaScript التي أحضرها المتصفح، قائمة بالملاحظات الموجودة فقط في العنوان https://fullstack-exampleapp.herokuapp.com/data.json صفحات الأنماط الانسيابية Cascading Style Sheets CSS يحتوي عنصر head في ملف HTML المرتبط بالصفحة Notes، عنصر ارتباط Link، يخبر المتصفح ضرورة إحضار ملف CSS من العنوان main.css. وتعتبر ملفات CSS لغة وصفية (Markup) تستخدم بكثرة في التحكم بمظهر صفحات الويب. يظهر ملف CSS الذي سيحضره المتصفح كالتالي: .container { padding: 10px; border: 1px solid; } .notes { color: blue; } يعرّف الملف محددي صنف (Class Selectors). يستخدم محدد الصنف لاختيار أجزاء من صفحة الويب، يطبق عليها قواعد محددة للتحكم بمظهرها. يبدأ سطر تعريف محدد الصنف بنقطة (.)، يليها اسم لهذا الصنف. وتعتبر هذه الأصناف (خاصيات attributes) يمكن إضافتها إلى عناصر HTML للتحكم بمظهر هذه العناصر. يمكن تفحص خاصيات CSS ضمن نافذة Elements في الطرفية: ] لاحظ أن العنصر div يمتلك الصنف container، بينما يمتلك العنصر ul الصنف notes. وبالعودة إلى ملف CSS، فإن أي عنصر يمتلك الصنف container سيُحاط بمربع مستمر سماكته 1 بكسل وسيُضبط بعد العنصر عن محيطه (padding) بمقدار 10 بكسل، وأي عنصر يمتلك الصنف notes سيكون النص فيه أزرق اللون. يمكن لعناصر HTML امتلاك خاصيات أخرى غير classes. فالعنصر div الذي يحتوي الملاحظات notes، يمتلك خاصية تدعىid. تستخدم JavaScript هذه الخاصية لإيجاد العنصر. يمكن استخدام النافذة Elements في الطرفية لتغيير مظهر العناصر. وكما هي الحال لن تكون التغييرات التي نجريها دائمة. إن أردت تغييرات دائمة، عليك أن تحفظها في مستند CSS الموجود على الخادم. تحميل صفحة تحتوي على شيفرة JavaScript (نظرة ثانية) دعونا نعيد النظر إلى ما يجري عندما نفتح الصفحة https://fullstack-exampleapp.herokuapp.com/notes من خلال المتصفح. يحضر المتصفح شيفرة HTML التي تحدد هيكل ومحتوى الصفحة من الخادم عبر طلب HTTP-GET. تطلب العناصر link في شيفرة HTML من المتصفح إحضار ملف CSS عنوانه main.css وكذلك ملف JavaScript عنوانه main.js من الخادم. ينفذ المتصفح شيفرة JavaScript. ترسل الشيفرة طلب HTTP-GET إلى العنوان https://fullstackexampleapp.herokuapp.com/data.json الذي سيستجيب بإرسال الملاحظات على شكل بيانات JSON. عندما تصل البيانات، سينفذ المتصفح الشيفرة الموجودة ضمن معالج حدث، وهذا الأخير سيظهر الملاحظات على صفحة الويب مستخدمًا الواجهة DOM-API. الاستمارات Forms وطلبات HTTP-POST دعونا نتفحص الآن كيف أُضيفت ملاحظة جديدة. تحتوي الصفحة Notes على عنصر استمارة Form، عندما ننقر على الزر الموجود في الاستمارة، سيرسل المتصفح المعلومات التي أدخلها المستخدم إلى الخادم. لنفتح النافذة Network ولنرى كيف يتم إرسال الاستمارة: نتج عن إرسال الاستمارة خمس طلبات HTTP. يمثل الأول حدث إرسال الاستمارة (Form Submit Event)، وهو طلب HTTP-POST إلى العنوان new_note الموجود على الخادم. سيستجيب الخادم برمز حالة رقمه 302، ويعني هذا أن عملية تحويل عنوان (URL-redirect) قد تمت. في هذه العملية يطلب الخادم من المتصفح إن يرفع طلب HTTP-GET جديد إلى العنوان notes الذي نجده ضمن الموقع Location الذي يمثل جزءًا من ترويسة الاستجابة التي أرسلها الخادم. سيعيد الخادم إذًا، تحميل صفحة الملاحظات. وتسبب تلك العملية ثلاثة طلبات HTTP: إحضار ملف CSS باسم main.css إحضار ملف JavaScript باسم main.js إحضار ملف البيانات data.json يمكن أن نشاهد البيانات التي أرسلت أيضًا في نافذة Network: يمتلك معرّف الاستمارةForm خاصيتي action وmethod اللتان تشيرا إلى أن إرسال الاستمارة سيكون على شكل طلب HTTP-POST إلى العنوان new_notes. تعتبر الشيفرة الموجودة على الخادم والمسؤولة عن الاستجابة إلى طلب المتصفح بسيطة (لاحظ: هذه الشيفرة موجودة على الخادم، وليست جزءًا من ملف JavaScript الذي يحضره المتصفح): app.post('/new_note', (req, res) => { notes.push({ content: req.body.note, date: new Date(), }) return res.redirect('/notes') }) ترسل البيانات ضمن جسم (Body) طلب الإرسال (Post-Request) حيث يتمكن الخادم عندها من الوصول إلى البيانات الموجودة في جسم كائن من النوع Request يدعى req باستعمال الطريقة req.body. ومن ثم ينشأ الخادم كائنًا جديدًا من النوع note، ويضيفه إلى مصفوفة تدعى notes: notes.push({ content: req.body.note, date: new Date(), }) يضم الكائن note حقلين، يدعى الأول content يحوي نص الملاحظة، ويدعى الآخرdate ويحوي معلومات عن تاريخ ووقت إنشاء الملاحظة. طبعًا، لم يحفظ الخادم هذه الملاحظة ضمن قاعدة بيانات، وبالتالي ستختفي الملاحظة الجديدة بانتهاء جلسة العمل. تقنية AJAX اعتمدت صفحة Notes في التطبيق السابق أسلوبًا قديمًا في تطوير الويب، بالإضافة إلى استخدامه تقنية AJAX التي تصدرت قائمة تقنيات الويب بداية العقد الماضي. تأتي التسمية AJAX من (Asynchronous JavaScript and XML). قدم هذا المصطلح في فبراير (شباط) عام 2005، على خلفية التطورات في تقنية المتصفح، لتقدم مقاربةً ثوريةً جديدةً تمكن المتصفح من إحضار محتوى صفحات الويب باستخدام شيفرة JavaScript مدمجة ضمن HTML، دون الحاجة لإعادة تكوين (Rerender) الصفحة من جديد. استخدمت جميع تطبيقات الويب أسلوب التطبيق النموذجي الذي استعرضناه سابقًا قبل ظهور Ajax. فجميع البيانات التي تعرض في الصفحة، يتم إحضارها عند تنفيذ شيفرة HTML التي أنشأها الخادم. استخدمت صفحة Note تقنية AJAX لإحضار البيانات، بينما استخدمت الطريقة التقليدية في إرسال الاستمارة. وتعكس العناوين التي استخدمها التطبيق أسلوب الزمن القديم اللامبالي، حيث أحضرت بيانات JSON من العنوان: https://fullstack-exampleapp.herokuapp.com/data.json وأرسلت الملاحظات الجديدة إلى العنوان: https://fullstack-exampleapp.herokuapp.com/new_note. لاتعتبر هذه العناوين مقبولة حاليًا، كونها لا تتقيد بالتفاهمات العامة التي جرى الاتفاق عليها بخصوص واجهة التطبيقات RESTful التي سنراها في القسم 3. شاع مصطلح AJAX كثيرًا لدرجة أنه اعتبر من المسلمات، ثم تلاشى في عالم النسان ولم يعد يسمع به أحد من الأجيال الجديدة. تطبيق من صفحة واحدة تسلك الصفحة الرئيسية في المثال النموذجي الذي عملنا عليه سلوك صفحة الويب التقليدية. حيث تجري العمليات المنطقية للتطبيق على الخادم، بينما يكّون المتصفح ملف HTML بمقتضى الشيفرة المكتوبة. أما صفحة Notes، فتلقي على المتصفح بعض المسؤولية في توليد شيفرة HTML لعرض الملاحظات الموجودة. إذ ينفذ شيفرة JavaScript التي أحضرها من الخادم، وستحضر هذه الشيفرة بدورها الملاحظات على شكل بيانات JSON، ثم يضيف إليها المتصفح عناصر HTML لعرض الملاحظات على الصفحة مستخدمًا DOM-API. سادت فكرة تصميم تطبيق من صفحة واحدة (Single-page application (SPA، في السنوات الأخيرة. فمواقع الويب التي تستخدم هذا الأسلوب، لاتحضر صفحاتها من الخادم بشكل منفصل كما فعل التطبيق النموذجي السابق، بل تضم جميع الصفحات في صفحة HTML واحدة وتحضرها، ويتم التعامل معها لاحقًا بواسطة شيفرة JavaScript التي تُنفَّذ ضمن المتصفح. تحمل صفحة Notes في تطبيقنا السابق شبهًا بتطبيقات الويب ذات الصفحة الواحدة، لكنها لم تبلغ هذا المستوى بعد. فبالرغم من أن منطق تكوين الملاحظات ينفذه المتصفح، لاتزال الصفحة تستخدم الطريقة التقليدية في إضافة الملاحظات. حيث ترسل الاستمارة البيانات إلى الخادم، الذي يوجه المتصفح لإعادة تحميل الصفحة مع إعادة توجيه نحو عنوان جديد. يحوي العنوان التالي https://fullstack-exampleapp.herokuapp.com/spa نسخة الصفحة الواحدة من تطبيقنا النموذجي. حيث نلاحظ للوهلة الأولى تطابق الطريقتين. فللطريقتين شيفرة HTML نفسها، لكن ملف JavaScript مختلف ويدعى (spa.js)، وكذلك حصل تغيير بسيط في كيفية استخدام معرّف الاستمارة Form، فلا تمتلك الاستمارة الآن خاصيات method و action لتحدد كيف وأين سترسل البيانات المدخلة ضمنها. افتح نافذة Network، وامسح محتواها بالنقر على الرمز ?. ستلاحظ أن طلبًا واحدًا فقط أرسل إلى الخادم عند إنشاء ملاحظة جديدة. يتضمن طلب الإرسال POST إلى العنوان new_note_spa ملاحظة جديدة بصيغة بيانات JSON، تضم محتوى الملاحظة content وتوقيتها date: { content: "single page app does not reload the whole page", date: "2019-05-25T15:15:59.905Z" } يفهم الخادم أن البيانات الواردة إليه مكتوبة بصيغة JSON، عن طريق الخاصية Content-Type من ترويسة الطلب. ومن غير هذه الترويسة، لن يعرف المتصفح كيف يفسر البيانات بشكل صحيح. يستجيب الخادم معلنًا نجاح عملية إنشاء الملاحظة الجديدة معيدًا رمز الحالة 201. لن يطلب من المتصفح هذه المرة إعادة توجيه الصفحة إلى عنوان آخر، بل يبقى المتصفح على نفس الصفحة دون أن يرسل أية طلبات HTTP أخرى. لاترسل نسخة SPA من التطبيق بيانات الاستمارة بالطريقة التقليدية، بل تستخدم شيفرة JavaScript التي تحضرها من الخادم. سنلقي نظرة سريعة على هذه الشيفرة، لكن ليس عليك الآن فهم تفاصيلها. var form = document.getElementById('notes_form') form.onsubmit = function(e) { e.preventDefault() var note = { content: e.target.elements[0].value, date: new Date(), } notes.push(note) e.target.elements[0].value = '' redrawNotes() sendToServer(note) } تطلب التعليمة ('document.getElementById('notes_form إحضار العنصر Form من الصفحة، وربطه بمعالج أحداث ليتعامل مع حدث إرسال الاستمارة. يستدعي معالج الأحداث مباشرة التابع ()e.preventDefault لإيقاف الاستجابة الافتراضية لحدث الإرسال. هذه الاستجابة التي لانريد حصولها، هي من ترسل البيانات إلى الخادم من خلال طلب GET جديد. بعدها ينشأ معالج الأحداث ملاحظة جديدة ويضيفها إلى قائمة الملاحظات باستعمال التعليمة (notes.push(note ثم يعيد تكوين هذه القائمة على الصفحة ويرسل الملاحظة إلى الخادم. تمثل الشيفرة التالية تعليمات إرسال الملاحظة إلى الخادم: var sendToServer = function(note) { var xhttpForPost = new XMLHttpRequest() // ... xhttpForPost.open('POST', '/new_note_spa', true) xhttpForPost.setRequestHeader( 'Content-type', 'application/json' ) xhttpForPost.send(JSON.stringify(note)) } تحدد التعليمات السابقة طريقة إرسال البيانات على شكل طلب HTTP-POST وأنها بصيغة JSON، ومن ثم ترسل البيانات على شكل سلسلة JSON النصية. تتوفر شيفرة التطبيق على العنوان https://github.com/mluukkai/example_app. وعليك أن تتذكر أن التطبيق قد صمم لإبراز مفاهيم المنهاج فقط، وأنه اعتمد أسلوبًا ضعيفًا نسبيًا لايجب عليك اتباعه عند كتابة تطبيقاتك الخاصة. وينطبق كلامنا أيضًا على طريقة استخدام العناوين التي لا تتماشى مع معايير البرمجة الأفضل المعتمدة حاليًا. مكتبات JavaScript صمم التطبيق السابق بما يسمى vanilla Javascript أي باستعمال جافاسكربت فقط، التي تستخدم DOM-API و JavaScript في تغيير بنية الصفحة. وبدلًا من استخدام التقنيتين السابقتين فقط، تستخدم مكتبات مختلفة تحتوي على أدوات يسهل التعامل معها مقارنة بتقنية DOM-API. وتعتبر JQuery أحد أشهر هذه المكتبات.طورت المكتبة JQuery في الوقت الذي استخدمت فيه تطبيقات الويب الأسلوب التقليدي المتمثل بإنشاء الخادم لصفحة HTML، وقد حسَّن ظهورها فعالية المتصفح باستخدام JavaScript المكتوبة اعتمادًا على هذه المكتبة. يعتبر أحد أسباب نجاح JQuery توافقها مع المتصفحات المختلفة (cross-browser). حيث تعمل المكتبة أيًّا كان المتصفح أو الشركة التي أنتجته، وبالتالي انتفت الحاجة إلى حلول خاصة بكل متصفح. لا تُستخدَم JQuery اليوم بالشكل المتوقع، على الرغم من تطور أسلوب Vanilla JS -أي كما ذكرنا الاعتماد على جافاسكربت الصرفة- والدعم الجيد الذي تقدمه أشهر المتصفحات لوظائفها الأساسية. دفع انتشار التطبيق وحيد الصفحة بتقنيات أحدث من jQuery إلى الواجهة، حيث جذبت BackboneJS الموجة الأولى من المطورين. لكن سرعان ما أصبحت AngularJS التي قدمتها غوغل، المعيار الفعلي لتطوير تطبيقات الويب الحديثة، بعد إطلاقها عام 2012. لكن شعبيتها انخفضت بعد أن صرّح فريق العمل في أكتوبر (تشرين الأول) لعام 2014، أن دعم النسخة الأولى سينتهي، وأن النسخة الثانية لن تكون متوافقة معها. لذلك لم تلق Angular بنسختيها ترحيبًا حارًا جدًا. تعتبر حاليًا مكتبة React التي أنتجها Facebook، الأكثر شعبية في مشاركة المتصفح عند تنفيذ منطق تطبيقات الويب. وسنتعلم خلال مسيرتنا في هذا المنهاج التعامل معها وكذلك التعامل مع مكتبة Redux التي سنستخدمها مع React بشكل متكرر. تبدو React حاليًا في موقع قوة، لكن عالم JavaScript دائم التغير. فالقادم الجديد لهذه العائلة VueJS يلقى اهتماما متزايدًا الآن (يمكنك الاطلاع على سلسلة "مقدمة إلى Vue.js" للتعرف على هذه المكتبة الرائعة). التطوير الشامل لتطبيق الويب ما الذي يعنيه مصطلح «التطوير الشامل لتطبيق الويب» (Full stack web development)؟. هذا المصطلح الذي يردده الجميع دون معرفة فعلية بمضمونه أو على الأقل ليس هناك اتفاق على تعريف محدد له. تمتلك جميع تطبيقات الويب من الناحية العملية مستويين: الأول مستوى العمل مع المتصفح وهو الأقرب إلى المستخدم النهائي (End-User) ويعتبر المستوى الأعلى. والثاني هو مستوى الخادم وهو المستوى الأدنى. وتوضع أحيانًا قواعد البيانات في مستوًى أخفض من مستوى الخادم. وبالتالي يمكننا التفكير بهندسة تطبيقات الويب على أنها مستويات متراكمة. يتم الحديث أحيانًا عن واجهة أمامية (frontend) وعن واجهة خلفية (backend). سيمثل المتصفح وشيفرة JavaScript التي ينفذها الواجهة الأمامية، بينما يمثل الخادم الواجهة الخلفية. يُفهم من مصطلح التطوير الشامل في سياق هذا المنهاج، أن العمل سيكون على كل المستويات سواء الواجهة الأمامية أو الخلفية، وكذلك على قواعد البيانات. وقد تعتبر البرمجيات الخاصة بالخادم ونظام تشغيله جزءًا من مستويات التطوير، لكننا لن ندخل في هذا الموضوع. سنكتب شيفرة الواجهة الخلفية بلغة JavaScript مستخدمين بيئة التشغيل Node.js. وسيمنح استخدام نفس لغة البرمجة على مستويات التطوير جميعها بعدًا جديدًا لعملية التطوير الشامل، علمًا أن هذا الأمر ليس ملزمًا. اعتاد المطورون على التخصص بأحد مستويي التطوير، ذلك أن التقنيات المستخدمة في المستويين مختلفة تمامًا. لكن في مضمار التطوير الشامل، لابد من وجود مطورين أكفاء في جميع المستويات بما فيها قواعد البيانات. كما ينبغي على المطورين في بعض الأحيان، امتلاك مهارات في التهيئة والإدارة لتشغيل تطبيقاتهم على منصات مختلفة كالمنصات السحابية (Cloud). جافاسكربت المتعبة يحمل التطوير الشامل للتطبيقات تحديات مختلفة. فأمور كثيرة تحدث معًا في أماكن مختلفة، وتنقيح المشاكل أصعب قليلًا مقارنة بتطبيقات سطح المكتب. فلا تتصرف JavaScript كما هو متوقع منها دائمًا (مقارنة بغيرها من اللغات)، كما أن الطريقة غير المتزامنة التي تعمل بها بيئة التشغيل ستبرز كل أنواع التحديات. المعرفة ببروتوكولHTTP ضروري جدًا لفهم الاتصال في الشبكة، وكذلك معالجة الأمور المتعلقة بقواعد البيانات وإدارة وتهيئة الخادم. لابد أيضا من امتلاك معرفة جيدة بتنسيق CSS لتقديم التطبيق بشكل لائق على الأقل. يتغير عالم JavaScript بسرعة، ولهذا الأمر حصته من التحديات. فكل الأدوات والمكتبات حتى اللغة نفسها عرضة للتطوير المستمر، حتى أن البعض سئم من التغيير المستمر، فأطلق مصطلح جافاسكربت المتعبة (أو Javascript fatigue). ستعاني من هذا التعب أنت أيضًا خلال مسيرتك في المنهاج، ولحسن حظنا، هناك طرق عدة لتسهيل الأمور، منها أننا سنبدأ بكتابة الشيفرة بدلًا من الغوص في عمليات التهيئة، والتي لا يمكننا تفاديها تمامًا، لكن سنحاول أن نتقدم خلال الأسابيع القليلة القادمة متجاوزين الأسوء في هذا الكابوس. التمارين 0.1 - 0.6 ارفع حلول التمارين على منصة GitHub، وينبغي عليك الإشارة إلى إتمام عملية التحميل ضمن منظومة تسليم الملفات submission system. يمكنك تحميل جميع التمارين في نفس المكان، أو استخدم أماكن مختلفة. اعط أسماء مناسبة للمجلدات إن رفعت التمارين التي تعود لأقسام مختلفة من المنهاج إلى نفس المكان، وأضف اسم mluukkai إلى قائمة المتعاونين، إن حمَّلت ملفاتك إلى مجلدات خاصة. تعتبر الطريقة التالية جيدة في تسمية المجلدات: part0 part1 Courseinfo unicafe anecdotes part2 phonebook countries لاحظ أن لكل قسم مجلده الخاص الذي يضم مجلدات فرعية لكل تمرين (مثل تمرين unicafe في القسم1). وانتبه جيدًا لرفع كل التمارين العائدة لقسم محدد معًا، فلو رفعت تمرينًا واحدًا فقط، لن تتمكن من رفع أية تمارين أخرى تعود لنفس القسم. 1- التمرين 0.1 (HTML) راجع أساسيات HTML بقراءة هذه الدورة التعليمية من Mozilla. لاترفع هذا التمرين إلى GitHub، يكفي أن تطلع على الدورة. 2- التمرين 0.2 (CSS) راجع أساسيات CSS بقراءة هذه الدورة التعليمية من Mozilla. لاترفع هذا التمرين إلى GitHub، يكفي أن تطلع على الدورة. 3- التمرين 0.3 (HTML forms) تعلم أساسيات استخدام استمارات HTML بقراءة هذه الدورة التعليمية من Mozilla. لاترفع هذا التمرين إلى GitHub، يكفي أن تطلع على الدورة. 4- التمرين 0.4 (ملاحظة جديدة new note) في فقرة تحميل صفحة تحتوي على شيفرة JavaScript (نظرة ثانية)، عرضت سلسلة الأحداث الناتجة عن فتح هذه الصفحة على شكل مخطط تتابعي أُنجز باستخدام خدمة websequencediagrams كالتالي: browser-`server: HTTP GET https://fullstack-exampleapp.herokuapp.com/notes server--`browser: HTML-code browser-`server: HTTP GET https://fullstack-exampleapp.herokuapp.com/main.css server--`browser: main.css browser-`server: HTTP GET https://fullstack-exampleapp.herokuapp.com/main.js server--`browser: main.js note over browser: browser starts executing js-code that requests JSON data from server end note browser-`server: HTTP GET https://fullstack-exampleapp.herokuapp.com/data.json server--`browser: [{ content: "HTML is easy"، date: "2019-05-23" }، ...] note over browser: browser executes the event handler that renders notes to display end note أنشئ مخططًا مماثلًا لتوضيح الحالة التي ينشأ فيها المستخدم ملاحظة جديدة في الصفحة https://fullstack-exampleapp.herokuapp.com/notes، وذلك بكتابة شيء ما في حقل النص ونقر زر إرسال. يمكنك إظهار العمليات على المخدم أو المتصفح كتعليقات على المخطط، إن وجدت هذا ضروريًا. ولا حاجة أن يكون المخطط تتابعيًّا، فلا بأس بأية طريقة مفهومة لعرض الموضوع. ستجد كل المعلومات المتعلقة بحل هذا التمرين مع التمرينين القادمين في نص هذا القسم. فكرة هذه التمارين قراءة النص مرة أخرى والتفكر مليًا بما يحدث. ليس من الضروري قراءة شيفرة التطبيق على الرغم من أن ذلك ممكن. 5- تمرن 0.5 (تطبيق من صفحة واحدة SPA) أنشئ مخططًا لتوضيح الحالة التي ينتقل فيها المستخدم إلى صفحة نسخة تطبيق من صفحة واحدة من تطبيق الملاحظات على الرابط https://fullstack-exampleapp.herokuapp.com/spa. 6- تمرين 0.6 (ملاحظة جديدة new note) أنشئ مخططًا لتوضيح الحالة التي ينشئ فيها المستخدم ملاحظة جديدة مستخدمًا نسخة التطبيق ذو الصفحة الواحدة. بهذا التمرين نصل إلى نهاية القسم. لقد حان الوقت لترفع إجاباتك على GitHub. لا تنسى أن تشير إلى إتمام عملية التحميل ضمن منظومة تسليم الملفات submission system. يمكنك اقتراح تعديلات على القسم أيضًا إن أحببت في المقال الأصلي. ترجمة -وبتصرف- للفصل Fundamentals of web apps من سلسلة Deep Dive Into Modern Web Development
  5. نستكشف في هذا المقال تطبيقات نود Node التي تستخدم قواعد بيانات علاقية، وسنبني خلال تقدمنا في المقال واجهةً خلفيةً تستخدم قاعدة بيانات علاقية ليتعامل معها تطبيق الملاحظات الذي عملنا عليه سابقًا. ولإكمال هذا المقال، لا بُدّ من تعميق معرفتك بقواعد البيانات العلاقية ولغة SQL. يمكنك الاطلاع على سلسلة مقالات المرجع المتقدم إلى SQL في أكاديمية حسوب، وكذلك الاطلاع على توثيق لغة SQL في موسوعة حسوب. ستجد 24 تمرينًا في هذا المقال وعليك أن تنجزها جميعًا لتكمل الدورة التعليمية، تُسلّم الحلول إلى منظومة تسليم الحلول كما هو الحال في الأقسام السابقة ما عدا الأقسام من 0 إلى 7 والتي تُسلّم حلول التمارين فيها إلى مكان آخر. إيجابيات وسلبيات قواعد بيانات المستندات استخدمنا قاعدة البيانات MongoDB في جميع المقال السابقة، وهي قاعدة بيانات مستندات، ومن أهم ميزاتها أنها لا تملك أي مخططات، أي أنّ لها معرفةٌ محدودةٌ بطبيعة البيانات المخزنة في كل مجموعة. يتواجد مخطط قاعدة البيانات schema في شيفرة البرنامج فقط، إذ يفسِّر البيانات بطريقة معينة، كأن يحدد أن بعض الحقول هي مراجعٌ لكائنات في مجموعةٍ أخرى. رأينا في المثال التطبيقي الذي يضم قاعدة بيانات تخزن الملاحظات والمستخدمين (في القسمين 3 و 4)، إذ تُخزِّن الملاحظات "notes" على النحو التالي: [ { "_id": "600c0e410d10256466898a6c", "content": "HTML is easy" "date": 2021-01-23T11:53:37.292+00:00, "important": false "__v": 0 }, { "_id": "600c0edde86c7264ace9bb78", "content": "CSS is hard" "date": 2021-01-23T11:56:13.912+00:00, "important": true "__v": 0 }, ] وتُخزّن أيضًا بيانات المستخدمين في المجموعة "users" على النحو التالي: [ { "_id": "600c0e410d10256466883a6a", "username": "mluukkai", "name": "Matti Luukkainen", "passwordHash" : "$2b$10$Df1yYJRiQuu3Sr4tUrk.SerVz1JKtBHlBOARfY0PBn/Uo7qr8Ocou", "__v": 9, notes: [ "600c0edde86c7264ace9bb78", "600c0e410d10256466898a6c" ] }, ] تعرِف MongoDB نوع البيانات في الحقول التي تُخزِّن كيانات البيانات، لكنها لا تعرف إلى أي مجموعة من الكيانات يشير المعرِّف المميز الذي سجّله المستخدم، ولا تهتم أيضًا بالحقول التي تمتلكها الكيانات المُخزنة في مجموعات البيانات؛ لهذا تترك MongoDB أمر التحقق من صحة المعلومات المخزّنة في قاعدة البيانات إلى المبرمج. هناك طبعًا إيجابيات وسلبيات لعدم وجود مخطط لقاعدة البيانات، وإحدى الإيجابيات هي المرونة التي تضيفها الميزة اللا إدارية في مخطط البيانات، إذ سيسرّع انتفاء الحاجة إلى تعريف مخطط على مستوى قاعدة البيانات من عمل المطوّر في حالات عدة، كما أنه أسهل من تعريف وتعديل المخطط في حالات أخرى؛ في حين تتعلق المشاكل الناجمة عن عدم وجود المخطط غالبًا بسهولة الوقوع في الأخطاء، فكل شيء ملقىً على عاتق المبرمج، وليس لقاعدة البيانات في هذه الحالة طريقةً للتحقق من نزاهتها honest، أي إذا احتوت كل الحقول الإجبارية على القيم الصحيحة، أو إذا أشارت الحقول ذات النوع المرجعي إلى كيانات من النوع الصحيح عمومًا وهكذا. تعتمد قاعدة البيانات العلاقية التي ستكون محور حديثنا في هذا المقال بشدة على وجود مخطط، وتكاد تكون الإيجابيات والسلبيات في استخدامها على نقيض تلك المتعلقة بقاعدة بيانات المستندات. يعود السبب الرئيسي في استخدام قاعدة البيانات MongoDB تحديدًا في الأقسام السابقة إلى طبيعتها التي لا تحتاج مخططات، الأمر الذي يسهّل استخدامها بالنسبة لذوي المعرفة المحدودة بقواعد البيانات العلاقية، لكن نفترض بالنسبة لكل الحالات التي عرضناها في منهاجنا أن القواعد العلاقية هي الأنسب. قاعدة بيانات التطبيق نحتاج في تطبيقنا إلى قاعدة بيانات علاقية، وهناك خيارات عديدة، لكننا سنستخدم حاليًا الحل الأكثر شعبيةً والمفتوح المصدر PostgreSQL؛ ويمكنك تثبيت Postgres (هكذا تُدعى قاعدة البيانات هذه غالبًا) على جهازك إذا أردت؛ والأسهل من هذا هو استخدامها بمثابة خدمة سحابية، مثل ElephantSQL. بإمكانك أيضًا الاستفادة مما جاء في جزئية سابقة من السلسلة لاستخدام Postgres محليًا من خلال دوكر Docker. اخترنا في هذا المقال الاستفادة من إمكانية إنشاء قاعدة بيانات Postgres للتطبيق على خدمة "Heroku" السحابية التي ألفنا العمل معها في المقالين 3 و4. سنبني في الجزء النظري من هذا المقال نسخةً مرتبطة بقاعدة البيانات Postgres عن الواجهة الخلفية لتطبيق تخزين الملاحظات الذي بنيناه في المقالين 3 و4. لننشئ أولًا مجلدًا مناسبًا ضمن تطبيق Heroku ونضيف إليه قاعدة بيانات، ثم نستخدم الأمر heroku config للحصول على "السلسلة النصية connect string" اللازمة للاتصال مع القاعدة: heroku create # heroku يعيد اسم التطبيق للتطبيق الذي أنشأته في heroku addons:create heroku-postgresql:hobby-dev -a <app-name> heroku config -a <app-name> === cryptic-everglades-76708 Config Vars DATABASE_URL: postgres://<username>:<password>@<host-of-postgres-addon>:5432/<db-name> يُعد الوصول إلى قاعدة البيانات مباشرةً من الأمور الأساسية التي ينبغي الانتباه إليها تحديدًا في قواعد البيانات العلاقية، نظرًا لوجود أساليب عدة لتنفيذ الأمر، ووجود عدة واجهات مستخدم رسومية، مثل pgAdmin، لكن سنستخدم أداة سطر الأوامر psql الخاصة بالقاعدة Postgres. يمكن الوصول إلى قاعدة البيانات من خلال تنفيذ أمر psql على خادم Heroku على النحو التالي (لاحظ كيف تعتمد معاملات الأمر على عنوان url للاتصال بقاعدة بيانات Heroku): heroku run psql -h <host-of-postgres-addon> -p 5432 -U <username> <dbname> -a <app-name> بعد إدخال كلمة المرور، سننفذ أمر psql الأساسي وهو d\، الذي يخبرك بمحتوى قاعدة البيانات: Password for user <username>: psql (13.4 (Ubuntu 13.4-1.pgdg20.04+1)) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) Type "help" for help. username=> \d Did not find any relations. وكما ترى، لا يوجد شيء حاليًا في قاعدة البيانات. لننشئ إذًا جدولًا للملاحظات: CREATE TABLE notes ( id SERIAL PRIMARY KEY, content text NOT NULL, important boolean, date time ); يمكننا ملاحظة بعض النقاط: يُعرِّف العمود id مفتاحًا أساسيًا primary key، ويعني ذلك أن قيم هذا العمود لا بُدّ أن تكون فريدةً لكل سطر في الجدول ولا ينبغي أن تكون قيمتها فارغة. يُعرّف نوع العمود id على أنه تسلسلي SERIAL، وهو ليس نوع حقيقي بل تمثيل لعمود ذي قيم صحيحة تعيّن Postgres قيمه الفريدة تلقائيًا وتزيد هذه القيمة بمقدار "واحد" عند إنشاء سطر جديد. يكون العمود المُسمى content من النوع النصي ويُعُرِّف كي تعيّن له قيمةٌ في كل سطر. لنلق نظرةً على الوضع الآن من خلال الطرفية، ولننفّذ أولًا الأمر d\ الذي يعرض جداول قاعدة البيانات: username=> \d List of relations Schema | Name | Type | Owner --------+--------------+----------+---------------- public | notes | table | username public | notes_id_seq | sequence | username (2 rows) إذ تُنشئ Postgres إضافةً إلى الجدول notes جدولًا فرعيًا يُدعى notes_id_seq يتتبع القيم المُسندة إلى العمود id عند إنشاء ملاحظة جديدة. وبتنفيذ الأمر d notes\ يمكننا رؤية طريقة تعريف الجدول notes: username=> \d notes; Table "public.notes" Column | Type | Collation | Nullable | Default -----------+------------------------+-----------+----------+----------------------------------- id | integer | not null | nextval('notes_id_seq'::regclass) content | text | | not null | important | boolean | | | | date | time without time zone | | | | Indexes: "notes_pkey" PRIMARY KEY, btree (id) إذًا، للعمود id قيم افتراضية تُستخرج من تنفيذ الدالة الداخلية nextval في Postgres. لنُضِف بعض المحتوى إلى الجدول: insert into notes (content, important) values ('Relational databases rule the world', true); insert into notes (content, important) values ('MongoDB is webscale', false); لنرى الآن كيف يبدو المحتوى الذي أضفناه: username=> select * from notes; id | content | important | date ----+-------------------------------------+-----------+------ 1 | relational databases rule the world | t | 2 | MongoDB is webscale | f | (2 rows) إذا حاولنا تخزين البيانات في قاعدة البيانات دون العودة إلى مخطط، فلن ينجح الأمر، لأنّ قيم الأعمدة الإجبارية لا بُد أن تكون موجودةً: username=> insert into notes (important) values (true); ERROR: null value in column "content" of relation "notes" violates not-null constraint DETAIL: Failing row contains (9, null, t, null). ولا يمكن أن تكون قيمة العمود من النوع الخاطئ: username=> insert into notes (content, important) values ('only valid data can be saved', 1); ERROR: column "important" is of type boolean but expression is of type integer LINE 1: ...tent, important) values ('only valid data can be saved', 1); ^ ولا يمكن القبول بأعمدة غير موجودة في المخطط: username=> insert into notes (content, important, value) values ('only valid data can be saved', true, 10); ERROR: column "value" of relation "notes" does not exist LINE 1: insert into notes (content, important, value) values ('only ... سننتقل تاليًا إلى طريقة الدخول إلى قاعدة البيانات من التطبيق. تطبيق Node يستخدم قاعدة بيانات علاقية لنشغّل التطبيق كما جرت العادة من خلال التعليمة npm init ونثبّت "nodemon" على أنه اعتمادية تطوير development dependency وكذلك اعتماديات زمن التشغيل التالية: npm install express dotenv pg sequelize نجد من بين هذه الاعتماديات sequelize، وهي المكتبة التي يمكننا من خلالها استخدام Postgres؛ وتنتمي هذه المكتبة إلى مكتبات الربط العلاقي للكائنات Object relational mapping -أو اختصارًا ORM-، التي تسمح بتخزين كائنات جافا سكربت JavaScript في قاعدة بيانات علاقية دون استخدام لغة SQL بحد ذاتها وبصورةٍ مشابهة للمكتبة "Mongoose" التي استخدمناها مع قاعدة البيانات MongoDB. لنختبر الآن قدرتنا على الاتصال الناجح بقاعدة البيانات، لهذا أنشئ الملف index.js وأضف إليه الشيفرة التالية: require('dotenv').config() const { Sequelize } = require('sequelize') const sequelize = new Sequelize(process.env.DATABASE_URL, { dialectOptions: { ssl: { require: true, rejectUnauthorized: false } }, }) const main = async () => { try { await sequelize.authenticate() console.log('Connection has been established successfully.') sequelize.close() } catch (error) { console.error('Unable to connect to the database:', error) } } main() ينبغي تخزين سلسلة الاتصال النصية connect string بقاعدة البيانات التي أظهرها الأمر heroku config في ملفٍ له الامتداد env.، وينبغي أن تكون مشابهةً لما يلي: $ cat .env DATABASE_URL=postgres://<username>:<password>@ec2-54-83-137-206.compute-1.amazonaws.com:5432/<databasename> لنتحقق من نجاح الاتصال: $ node index.js Executing (default): SELECT 1+1 AS result Connection has been established successfully. إذا عمل الاتصال، يمكننا حينها تشغيل الاستعلام الأول. لنعدّل البرنامج على النحو التالي: require('dotenv').config() const { Sequelize, QueryTypes } = require('sequelize') const sequelize = new Sequelize(process.env.DATABASE_URL, { dialectOptions: { ssl: { require: true, rejectUnauthorized: false } }, }); const main = async () => { try { await sequelize.authenticate() const notes = await sequelize.query("SELECT * FROM notes", { type: QueryTypes.SELECT }) console.log(notes) sequelize.close() } catch (error) { console.error('Unable to connect to the database:', error) } } main() ينبغي أن يطبع تنفيذ البرنامج ما يلي: Executing (default): SELECT * FROM notes [ { id: 1, content: 'Relational databases rule the world', important: true, date: null }, { id: 2, content: 'MongoDB is webscale', important: false, date: null } ] وعلى الرغم من كون Sequelize مكتبة ربط علاقي للكائنات ORM، أي أنها تحتاج إلى قليلٍ فقط من شيفرة SQL التي تكتبها بنفسك، فقد استخدمنا شيفرة SQL مباشرةً مع تابع Sequelize الذي يُدعى query. طالما أنّ كل شيء يعمل على ما يرام، لنحوّل التطبيق إلى تطبيق ويب. require('dotenv').config() const { Sequelize, QueryTypes } = require('sequelize') const express = require('express') const app = express() const sequelize = new Sequelize(process.env.DATABASE_URL, { dialectOptions: { ssl: { require: true, rejectUnauthorized: false } }, }); app.get('/api/notes', async (req, res) => { const notes = await sequelize.query("SELECT * FROM notes", { type: QueryTypes.SELECT }) res.json(notes) }) const PORT = process.env.PORT || 3001 app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) يبدو أن التطبيق يعمل جيدًا. لنتحول الآن إلى استخدام Sequelize بدلًا من SQL. النماذج في Sequelize يُمثَّل كل جدول من جداول قاعدة البيانات عند استخدام Sequelize بنموذج model، والذي يُعدُّ صنف JavaScript الخاص بالجدول. لنعرِّف الآن النموذج Note المتعلق بالجدول notes من التطبيق وذلك بتغيير الشيفرة على النحو التالي: require('dotenv').config() const { Sequelize, Model, DataTypes } = require('sequelize') const express = require('express') const app = express() const sequelize = new Sequelize(process.env.DATABASE_URL, { dialectOptions: { ssl: { require: true, rejectUnauthorized: false } }, }); class Note extends Model {} Note.init({ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, content: { type: DataTypes.TEXT, allowNull: false }, important: { type: DataTypes.BOOLEAN }, date: { type: DataTypes.DATE } }, { sequelize, underscored: true, timestamps: false, modelName: 'note' }) app.get('/api/notes', async (req, res) => { const notes = await Note.findAll() res.json(notes) }) const PORT = process.env.PORT || 3001 app.listen(PORT, () => { console.log(`Server running on port ${PORT}`) }) لا يوجد شيء جديد في تعريف النموذج، فكل عمودٍ له نوع معرَّف، إضافةً إلى خاصيات أخرى عند الضرورة كأن يكون العمود مفتاحًا أساسيًا للجدول. يضم المعامل الثاني في تعريف النموذج السمة sequelize إضافةً إلى غيرها من معلومات التهيئة. كما عرّفنا أن الجدول لا ينبغي أن يضم أعمدة لها بصمات زمنية timestamps، مثل created_atأنشئ عند و updated_at حُدِّث عند. عرّفنا أيضًا القيمة true إلى underscored، والتي تعني أن أسماء الجدول مشتقةٌ من أسماء النموذج لكن بصيغة الجمع وباستخدام تنسيق الأفعى snake_case، الذي تُوضع فيه الشرطة السفلية بدلًا من الفراغات بين الكلمات وتُكتب بأحرف صغيرة؛ ويعني هذا عمليًا أنه إذا كان اسم النموذج "Note"، فسيكون اسم الجدول المقابل "notes". ولو كان اسم النموذج مكونًا من جزئين مثل "StudyGroup"، فسيكون اسم الجدول المقابل "study_groups". وبدلًا من الاستدلال على أسماء الجداول تلقائيًا، تسمح لك المكتبة Sequelize بتعريف أسماء الجداول صراحةً أيضًا. تُطبق سياسة التسمية هذه على أسماء الأعمدة أيضًا. فلو عرّفنا أن ملاحظةً ما سترتبط بعام الإنشاء creationYear أي بالمعلومات المتعلقة بعام إنشاء الملاحظة، سنعرّفها في النموذج على النحو التالي: Note.init({ // ... creationYear: { type: DataTypes.INTEGER, }, }) سيكون اسم العمود المقابل في قاعدة البيانات creation_year، لكن الإشارة إلى العمود في الشيفرة تكون دائمًا باستخدام تنسيق النموذج نفسه أي طريقة سنام الجمل camel case. وعرّفنا كذلك السمة modelName التي أُسندت إليها القيمة note، وستكون القيمة الافتراضية لاسم النموذج بحرف بداية كبير "Note"، وسترى أنّ هذا أكثر ملاءمةً لاحقًا. يسهل التعامل مع قاعدة البيانات من خلال واجهة الاستعلام التي يؤمنها النموذج، إذ يعمل التابع works تمامًا كما يوحي اسمه: app.get('/api/notes', async (req, res) => { const notes = await Note.findAll() res.json(notes) }) تخبرك الطرفية أن التابع ()Note.findAll يُنفِّذ الاستعلام التالي: Executing (default): SELECT "id", "content", "important", "date" FROM "notes" AS "note"; سننجز تاليًا وصلة endpoint بغرض إنشاء ملاحظات جديدة: app.use(express.json()) // ... app.post('/api/notes', async (req, res) => { console.log(req.body) const note = await Note.create(req.body) res.json(note) }) تُضاف الملاحظة الجديدة باستدعاء التابع create الذي يوفّره النموذج "Note" بعد تمرير كائن يعرِّف قيم الأعمدة لسطر الملاحظة الجديدة على أنه وسيطٌ لهذا التابع. وبإمكانك أيضًا حفظ قاعدة البيانات بتنفيذ التابع build أولًا لإنشاء كائن نموذج من البيانات المطلوبة، ثم استدعاء التابع save وذلك بدلًا من التابع create: const note = Note.build(req.body) await note.save() لا يؤدي استدعاء التابع build إلى حفظ الكائن في قاعدة البيانات، وبالتالي من الممكن تعديله قبل تنفيذ أمر الحفظ الفعلي: const note = Note.build(req.body) note.important = true await note.save() يُعد استخدام التابع create في المثال السابق أكثر ملاءمةً لما نريده من التطبيق، لذلك سنلتزم باستخدامه من الآن فصاعدًا. إذا لم يكن الكائن المُنشأ صالحًا، ستظهر رسالة خطأ، لذلك إذا حاولت إضافة ملاحظة جديدة دون محتوى، ستخفق العملية وتكشف لك الطرفية عن السبب بأن "المحتوى لا يمكن أن يكون فارغًا": (node:39109) UnhandledPromiseRejectionWarning: SequelizeValidationError: notNull Violation: Note.content cannot be null at InstanceValidator._validate (/Users/mluukkai/opetus/fs-psql/node_modules/sequelize/lib/instance-validator.js:78:13) at processTicksAndRejections (internal/process/task_queues.js:93:5) لنضف آليةً بسيطةً لاصطياد الأخطاء عند إضافة ملاحظةٍ جديدة: app.post('/api/notes', async (req, res) => { try { const note = await Note.create(req.body) return res.json(note) } catch(error) { return res.status(400).json({ error }) } }) التمرينات 13.1 إلى 13.3 سنبني في هذه التمرينات واجهة خلفية لتطبيق مدوّنات يشابه ما فعلنا في القسم 4، وينبغي أن يتوافق مع الواجهة الأمامية التي أنشأناها في القسم 5 باستثناء معالجة الأخطاء. سنضيف أيضًا ميزات مختلفة إلى الواجهة الخلفية لا يمكن للواجهة الأمامية في القسم 5 التعامل معها. التمرين13.1 أنشئ مستودع غيت هاب GitHub مخصص للتطبيق، ثم أنشئ تطبيق Heroku خاص به إضافةً إلى قاعدة بيانات Postgres، وتأكد من قدرتك على تأسيس اتصال بين التطبيق وقاعدة البيانات. التمرين 13.2 أنشئ باستخدام سطر الأوامر الجدول "blogs" الذي يضم الأعمدة التالية: id: يمثل قيمةً فريدةً تزداد باستمرار. author: قيمة نصية. url: قيمة نصية لا يمكن أن تكون فارغة. title: قيمة نصية لا يمكن أن تكون فارغة. likes: قيمة صحيحة تبدأ من الصفر افتراضيًا. أضف مدونتين على الأقل إلى قاعدة البيانات. احفظ بعد ذلك الأوامر التي استخدمتها في ملف يُدعى "commands.sql" في جذر التطبيق. التمرين 13.3 أضف إلى تطبيقك وظيفة طباعة المدوّنات الموجودة في قاعدة البيانات باستخدام سطر الأوامر كما في المثال التالي: $ node cli.js Executing (default): SELECT * FROM blogs Dan Abramov: 'On let vs const', 0 likes Laurenz Albe: 'Gaps in sequences in PostgreSQL', 0 likes إنشاء جداول قاعدة البيانات تلقائيا يوجد في تطبيقنا الحالي جانب غير مرغوب فهو يفترض وجود قاعدة بيانات مع المخطط المعين، أي أنّ الجدول "notes" قد أُنشأ بتنفيذ الأمر create table. تُخزّن شيفرة البرنامج على غيت هاب GitHub، لذلك من المنطقي تخزين الأوامر التي أنشأت بها قاعدة البيانات ضمن سياق الشيفرة لكي يبقى مخطط قاعدة البيانات نفسه كما تتوقعه شيفرة البرنامج. يُمكن للمكتبة أن تولّد تلقائيًا مخططًا انطلاقًا من تعريف النموذج من خلال التابع sync. لندمر الآن قاعدة البيانات الموجودة من خلال أوامر الطرفية على النحو التالي: drop table notes; تأكد من تدمير القاعدة بتنفيذ الأمر d\: username=> \d Did not find any relations. لن يعمل التطبيق الآن، لهذا سننفِّذ الأمر التالي مباشرةً بعد تعريف النموذج "Note": Note.sync() عندما يعمل التطبيق سيظهر ما يلي على شاشة الطرفية: Executing (default): CREATE TABLE IF NOT EXISTS "notes" ("id" SERIAL , "content" TEXT NOT NULL, "important" BOOLEAN, "date" TIMESTAMP WITH TIME ZONE, PRIMARY KEY ("id")); وهكذا، عندما يعمل التطبيق، تُنفَّذ التعليمة: CREATE TABLE IF NOT EXISTS "notes"... التي تُنشئ الجدول "notes" إن لم يكن موجودًا. خيارات أخرى لنكمل تطبيقنا بإضافة عدة خيارات أخرى. بإمكاننا البحث عن ملاحظة محددة باستخدام التابع findByPk لأنه يبحث ضمن قيم id التي تمثّل المفتاح الرئيسي لقاعدة البيانات: app.get('/api/notes/:id', async (req, res) => { const note = await Note.findByPk(req.params.id) if (note) { res.json(note) } else { res.status(404).end() } }) يؤدي البحث عن ملاحظة محددة إلى تنفيذ أمر SQL التالي: Executing (default): SELECT "id", "content", "important", "date" FROM "notes" AS "note" WHERE "note". "id" = '1'; في حال عدم وجود أية ملاحظات، سيعيد البحث القيمة null (لا شيء)، وسيعطي رمز الحالة المناسب. تُعدَّل الملاحظة على النحو التالي، ولا يمكن تعديل سوى الحقل important لأن الواجهة الأمامية لا تحتاج أي شيء آخر: app.put('/api/notes/:id', async (req, res) => { const note = await Note.findByPk(req.params.id) if (note) { note.important = req.body.important await note.save() res.json(note) } else { res.status(404).end() } }) يُستخرج الكائن المتعلق بسطر قاعدة البيانات باستخدام التابع findByPk، ويُعدّل بعدها الكائن وتُخزَّن النتيجة باستدعاء التابع save. بإمكانك إيجاد شيفرة التطبيق كاملةً في المستودع المخصص على GitHub ضمن الفرع "part13-1". طباعة الكائن الذي تعيده Sequelize على الطرفية تُعد الأداة console.log أفضل أدوات مبرمجي JavaScript إذ يمكّنهم استعمالها المتكرر من التقاط أسوأ الثغرات. لهذا سنطبع الملاحظات على الطرفية: app.get('/api/notes/:id', async (req, res) => { const note = await Note.findByPk(req.params.id) if (note) { console.log(note) res.json(note) } else { res.status(404).end() } }) لاحظ أنّ النتيجة النهائية ليست ما نتوقعه تمامًا: note { dataValues: { id: 1, content: 'Notes are attached to a user', important: true, date: 2021-10-03T15:00:24.582Z, }, _previousDataValues: { id: 1, content: 'Notes are attached to a user', important: true, date: 2021-10-03T15:00:24.582Z, }, _changed: Set(0) {}, _options: { isNewRecord: false, _schema: null, _schemaDelimiter: '', raw: true, attributes: [ 'id', 'content', 'important', 'date' ] }, isNewRecord: false } فكل الأشياء التي يحتويها الكائن إضافةً إلى البيانات قد طُبعت على الطرفية، لهذا يمكن الوصول إلى النتيجة المرجوة باستدعاء التابع toJSON العائد إلى كائن النموذج: app.get('/api/notes/:id', async (req, res) => { const note = await Note.findByPk(req.params.id) if (note) { console.log(note.toJSON()) res.json(note) } else { res.status(404).end() } }) ها هي النتيجة الآن كما هو متوقع. { id: 1, content: 'MongoDB is webscale', important: false, date: 2021-10-09T13:52:58.693Z } في الحالة التي نريد فيها طباعة مجموعة من الكائنات، لن يعمل التابع toJSON مباشرةً، بل يجب أن يُستدعى بصورةٍ مستقلة لكل كائن في المجموعة: router.get('/', async (req, res) => { const notes = await Note.findAll() console.log(notes.map(n=>n.toJSON())) res.json(notes) }) وتبدو النتيجة على النحو التالي: [ { id: 1, content: 'MongoDB is webscale', important: false, date: 2021-10-09T13:52:58.693Z }, { id: 2, content: 'Relational databases rule the world', important: true, date: 2021-10-09T13:53:10.710Z } ] وربما من الأفضل أن تحوّل المجموعة إلى تنسيق JSON لطباعتها باستخدام التابع JSON.stringify: router.get('/', async (req, res) => { const notes = await Note.findAll() console.log(JSON.stringify(notes)) res.json(notes) }) تُعد هذه الطريقة أكثر ملاءمةً خاصةً إذا احتوت الكائنات على كائنات أخرى، كما أنها مفيدةٌ لتنسيق الكائنات على الشاشة بطريقة تُسهِّل القراءة، ويُنفَّذ ذلك من خلال الأمر التالي: console.log(JSON.stringify(notes, null, 2)) وتبدو النتيجة على النحو التالي: [ { "id": 1, "content": "MongoDB is webscale", "important": false, "date": "2021-10-09T13:52:58.693Z" }, { "id": 2, "content": "Relational databases rule the world", "important": true, "date": "2021-10-09T13:53:10.710Z" } ] التمرين 13.4 حوّل تطبيقك إلى تطبيق ويب يدعم العمليات التالية: الحصول على كل المدونات: GET api/blogs. إضافة مدوّنة جديدة: POST api/blogs. حذف مدوّنة: DELETE api/blogs/:id. ترجمة -وبتصرف- للفصل Using relational databases with Sequelize من سلسلة Deep Dive Into Modern Web Development. اقرأ أيضًا المقال السابق: أساسيات تنسيق الحاويات مقدمة عن قواعد البيانات التعامل مع قواعد البيانات مفاهيم نموذج البيانات العلائقية RDM الأساسية المهمة في تصميم قواعد البيانات مقارنة بين أنظمة إدارة قواعد البيانات العلاقية: SQLite مع MySQL مع PostgreSQL
  6. نتعرف في هذا المقال على وضع تطبيق ويب بأكمله مع جميع خدماته ضمن حاويات دوكر Docker وتنسيقها وضبط إعداداتها. استخدام الحاويات مع React سنحاول أن ننشئ تطبيق React ونضعه ضمن حاوية تاليًا، لهذا سنختار npm مديرَا للحزم علمًا أن yarn هو المدير الافتراضي لبرنامج create-react-app: $ npx create-react-app hello-front --use-npm ... Happy hacking! يُثبت create-react-app كل الاعتماديات اللازمة، فلا حاجة لتنفيذ الأمر npm install. ستكون الخطوة الثانية تحويل شيفرة JavaScript و CSS إلى ملفات ساكنة جاهزة لمرحلة الإنتاج. أمّا create-react-app فلديه build ليكون سكربت npm، لهذا سنستفيد من هذه الناحية: $ npm run build ... Creating an optimized production build... ... The build folder is ready to be deployed. ... أما الخطوة الأخيرة هي التفكير بطريقة للعمل مع خادم لتقديم الملفات الساكنة. يمكننا الاستفادة من express.static مع خادم Express لهذا الغرض، لهذا سأترك الموضوع تمرينًا لك، وسننتقل بدلًا من ذلك إلى كتابة ملف Dockerfile: FROM node:16 WORKDIR /usr/src/app COPY . . RUN npm ci RUN npm run build يبدو ما كتبناه صحيحًا تقريبًا، لهذا سنبنيه ونقيّم مسارنا، والهدف أن نبني التمرين دون أخطاء. سنحاول بعد ذلك أن نتحقق من وجود الملفات داخل الحاوية من خلال أوامر "bash". $ docker build . -t hello-front [+] Building 172.4s (10/10) FINISHED $ docker run -it hello-front bash root@98fa9483ee85:/usr/src/app# ls Dockerfile README.md build node_modules package-lock.json package.json public src root@98fa9483ee85:/usr/src/app# ls build/ asset-manifest.json favicon.ico index.html logo192.png logo512.png manifest.json robots.txt static الخيار الصحيح لتخديم الملفات الساكنة ضمن الحاوية هو serve نظرًا لوجود Node ضمن الحاوية. لنحاول تثبيت serve لتخديم الملفات الساكنة ونحن داخل الحاوية: root@98fa9483ee85:/usr/src/app# npm install -g serve added 88 packages, and audited 89 packages in 6s root@98fa9483ee85:/usr/src/app# serve build ┌───────────────────────────────────┐ │ │ │ Serving! │ │ │ │ Local: http://localhost:5000 │ │ │ └───────────────────────────────────┘ أغلق الحاوية باستخدام الاختصار "Ctrl+C" لإضافة بعض التوجيهات إلى ملف Dockerfile. يتحوّل تثبيت serve إلى عملية تشغيل RUN في ملف Dockerfile، وهكذا ستُثبّت الاعتمادية أثناء عملية البناء، وسيكون أمر تخديم مجلد البناء هو نفسه أمر تشغيل الحاوية: FROM node:16 WORKDIR /usr/src/app COPY . . RUN npm ci RUN npm run build RUN npm install -g serve CMD ["serve", "build"] يتضمن الأمر ‍‍CMD الآن قوسين مربعين ونكون بذلك قد استخدمنا ما يُعرف بالشكل التنفيذي exec form من الأمر ‍‍CMD، علمًا أنه يُكتب بثلاثة أشكال لكن الشكل السابق هو المُفضّل. عندما نبني الصورة الآن باستخدام الأمر: docker build . -t hello-front ثم تشغيلها باستخدام الأمر: docker run -p 5000:3000 hello-front سيكون التطبيق متاحًا على العنوان "http://localhost:5000". استخدام المراحل المتعددة على الرغم من أن serve خيار صحيح لكن بالإمكان إيجاد بديل أفضل، إذ أن الغاية هنا هي إنشاء صورة لا تحتوي أي شيء غير مطلوب وبأقل عدد من الاعتماديات، وبالتالي ينخفض احتمال توقف الصورة عن العمل أو أن تصبح عرضةً للهجمات مع الوقت. صُمّمت عملية البناء متعددة المراحل Multi-stage builds لتفصل عملية البناء إلى مراحل مختلفة، ومن الممكن حينها تحديد ملفات الصورة التي يُسمح لها بالانتقال من مرحلة إلى أخرى، كما يتيح ذلك أيضًا إمكانية التحكم بحجم الصورة، فلن نحتاج إلى جميع الملفات الجانبية التي تنتج عن عملية البناء ضمن الصورة الناتجة. إنّ الصورة الأصغر أسرع في التحميل والتنزيل وتساعد في تخفيض عدد نقاط الضعف في برنامجك. عند استخدام البناء متعدد المراحل، بالإمكان الاعتماد على حلول تتبع نهج المحاولة والخطأ مثل استخدام الخادم Nginx في إدارة الملفات الساكنة دون عناء شديد. تدلنا صفحة استخدام Nginx مع Docker Hub على المعلومات الضرورية لفتح المنافذ واستضافة محتوى ساكن. لنستخدم الآن ملف Dockerfile السابق بعد تغيير ما يلي التعليمة FROM لإضافة اسم المرحلة: # called build-stage الأولى الآن إلى مرحلة تُدعى FROM تشير تعليمة FROM node:16 AS build-stage WORKDIR /usr/src/app COPY . . RUN npm ci RUN npm run build # هذه مرحلة جديدة الآن، وسيختفي كل شيء قبلها ما عدا الملفات التي نريد نسخها FROM nginx:1.20-alpine # /usr/share/nginx/html إلى build-stage نسخ مجلد البناء من # docker hub page وجد الموقع الوجهة من خلال صفحة شروحات COPY --from=build-stage /usr/src/app/build /usr/share/nginx/html لقد صرحنا أيضًا عن مرحلة أخرى تُنقل إليها فقط الملفات الضرورية من المرحلة الأولى (المجلد "build" الذي يضم المحتوى الساكن). بعد بنائها ثانيةً، ستكون الصورة جاهزة لتخديم المحتوى الساكن. إن المنفذ الافتراضي لخادم Nginx هو 80 وبالتالي سينفع تنفيذ الأمر p 8000:80-، لهذا لا بد من تغيير معاملات أمر التشغيل قليلًا. تنطوي عملية البناء متعددة المراحل على تحسينات داخلية قد تؤثر على عملية البناء، إذ تتجاوز عملية البناء متعددة المراحل مثلًا المراحل التي لم تُستخدم، وبالتالي علينا تمرير بعض البيانات إلى المرحلة القادمة إن أردنا استخدام مرحلة ما لتبديل جزء من خط البناء مثل الاختبارات أو التنبيهات. لهذا الأمر تبريراته في بعض الحالات كأن تَنسخ الشيفرة من مرحلة الاختبار إلى مرحلة البناء كي تضمن بناء الشيفرة المُختبرة. التمرينان 12.13 و 12.14 حاول أن تحل التمرينين التاليين: التمرين 12.13: الواجهة الامامية لتطبيق المهام لقد وصلنا أخيرًا إلى الواجهة الأمامية، لهذا عليك أن تلقي نظرة على محتويات المجلد "todo-app/todo-frontend" وتقرأ ملف "اقرأني README". ابدأ بتشغيل الواجهة الأمامية خارج الحاوية، وتأكد من تناغم عملها مع الواجهة الخلفية. ضع التطبيق ضمن الحاوية بعد ذلك أنشئ الملف "todo-app/todo-frontend/Dockerfile" ثم استخدم التعليمة ENV لتمرير متغير البيئة REACT_APP_BACKEND_URL إلى التطبيق وشغّله مع الواجهة الخلفية. ينبغي أن تعمل الواجهة الخلفية حاليًا خارج الحاوية. وانتبه إلى ضرورة ضبط المتغير REACT_APP_BACKEND_URL قبل بناء الواجهة الأمامية وإلا لن يُعرَّف ضمن الشيفرة. التمرين 12.14: إجراء الاختبارات أثناء عملية البناء من الميزات الهامة للبناء متعدد المراحل، استخدام مرحلة البناء لإجراء الاختبارات على التطبيق testing. فإن أخفقت مرحلة الاختبار ستُخفق عملية البناء بأكملها. لكن تنفيذ الاختبارات جميعها خلال عملية بناء الصورة ليست فكرة جيدة؛ لهذا قد تكون الاختبارات المتعلقة بالحاوية هي الأنسب. استخلص مكوّن Todo يمثل مهمة واحدة من الشيفرة ، ثم اكتب اختبارًا للمكوّن الجديد وأضف تنفيذ الاختبارات إلى عملية البناء. نفّذ الاختبار من خلال الأمر CI=true npm test وإلا سيبدأ برنامج create-react-app بمراقبة التغييرات مسببًا توقف خط العمل pipeline. بإمكانك إضافة مرحلة بناء جديدة لإجراء الاختبار إن أردت ذلك. لكن تذكر في هذه الحالة أن تقرأ آخر فقرة قبل التمرين 12.13 مجددًا. التطوير ضمن الحاويات لننقل الآن تطبيق المهام بأكمله إلى حاوية. وإليك بعض الأسباب التي قد تدفعنا إلى ذلك: للإبقاء على تماثل بيئة العمل بين التطوير والإنتاج تفاديًا للثغرات التي تظهر فقط في بيئة التشغيل. لتفادي الاختلافات بين المطورين وبيئات عملهم الخاصة والتي تقود إلى صعوبات أثناء تطوير التطبيق. لمساعدة أعضاء الفريق الجدد على البدء بتثبيت بيئة تشغيل الحاوية دون الحاجة إلى أي شيء آخر. في المقابل، قد نواجه سلوكًا غير معهود عندما لا نشغّل التطبيق كما اعتدنا، ولهذا لا بد من فعل ما يلي على الأقل لنقل التطبيق إلى الحاوية: تشغيل التطبيق في وضع التطوير. الوصول إلى الشيفرة من خلال برنامج VSCode. لنبدأ بالواجهة الأمامية، وطالما أن ملف Dockerfile لمرحلة التطوير سيختلف تمامًا عن ملف Dockerfile لنسخة الإنتاج، سننشئ ملف جديد اسمه "dev.Dockerfile". لنشغّل create-react-app في وضع التطوير ومن المفترض أن يكون الأمر بسيطًا على النحو التالي: FROM node:16 WORKDIR /usr/src/app COPY . . # طالما سنعمل في وضع التطوير npm install إلى npm ci غيّر RUN npm install # npm start إن أمر التشغيل في وضع التطوير هو CMD ["npm", "start"] تُستخدم الراية f- أثناء البناء لتحديد الملف الذي يُستخدم، وإلا سيقع الاختيار على الملف Dockerfile، لهذا يكون أمر بناء الصورة على النحو التالي: docker build -f ./dev.Dockerfile -t hello-front-dev . سيُخدَّم create-react-app على المنفذ 3000، لهذا يمكنك اختباره بتشغيل الحاوية على هذا المنفذ. تقتضي المهمة الثانية الوصول إلى الملفات باستخدام VSCode، وهناك على الأقل طريقتان لإنجاز الأمر: باستخدام الموسِّع Visual Studio Code Remote - Containers. باستخدام الأقراص volumes، وبنفس طريقة تخزين البيانات في قاعدة البيانات. لنتجاوز المهمة الثانية كوننا سنضطر فيها إلى التعامل مع محررات أخرى، ولنجرب تشغيل الحاوية مع الراية v-، فإذا جرى كل شيء على ما يرام ننقل الإعدادات إلى ملف docker-compose. لاستخدام تلك الراية علينا تزويدها بالمجلد الحالي من خلال تنفيذ الأمر pwd الذي يعطي المسار إلى المجلد الحالي. حاول أن تنفِّذ ذلك من خلال الأمر (echo $(pwd في واجهة سطر الأوامر لديك وبالترتيب التالي: $ docker run -p 3000:3000 -v "$(pwd):/usr/src/app/" hello-front-dev Compiled successfully! You can now view hello-front in the browser. بإمكاننا الآن تعديل الملف "src/App.js" وستُعرض التغييرات مباشرةً على المتصفح. سننقل تاليًا الإعدادات إلى الملف "docker-compose.yml" الذي ينبغي أن يكون موجودًا في جذر المشروع: services: app: image: hello-front-dev build: context: . # يختار السياق هذا المجلد ليكون سياق البناء dockerfile: dev.Dockerfile # الذي سيُستخدم Dockerfile لاختيار ملف volumes: - ./:/usr/src/app # يمكن أن يكون المسار نسبي, so ./ is enough to say # ./ لهذا يكفي استخدام #docker-compose.yml للقول أنه نفس مكان وجود الملف ports: - 3000:3000 container_name: hello-front-dev # hello-front-dev لتسمية الحاوية بالاسم يمكننا بهذه الإعدادات الآن تشغيل التطبيق في وضع التطوير من خلال الأمر docker-compose up، ولن تحتاج حتى إلى تثبيت Node. يسبب تثبيت اعتماديات جديدة عدة مشاكل في إعداد بيئة تطوير كهذه، لهذا ستجد أن تثبيت الاعتمادية الجديدة ضمن الحاوية هو أحد الخيارات الجيدة. فبلدلًا من تنفيذ الأمر التالي مثلًا npm install axios ، ثبّت هذه الاعتمادية ضمن الحاوية التي تعمل من خلال الأمر docker exec hello-front-dev npm install axios أو أضفها إلى الملف ثم نفِّذ الأمر docker build من جديد. التمرين 12.15: إعداد بيئة تطوير الواجهة الأمامية أنشئ الملف واستخدم الأقراص لتمكين تطوير الواجهة الأمامية لتطبيق المهام عندما يعمل ضمن الحاوية. التواصل بين الحاويات في شبكة Docker تهيئ الأداة Docker شبكةً بين الحاويات وتضيف خادمًا لأسماء النطاقات DNS لربط أي حاويتين بسهولة. دعونا إذًا نضيف خدمة جديدة إلى وسنرى كيف تعمل الشبكة وخادم DNS. سنستخدم الحزمة التنفيذية Busybox التي تضم مجموعةً من الأدوات التي قد تحتاجها وتُعرف هذه الحزمة باسم "سكين الجيش السويسري الخاصة بنظام Linux المدمج". لهذا يمكننا بالتأكيد الاستفادة منها. تساعدنا Busybox في تنقيح إعداداتنا، لهذا إن لم تتمكن من حل التمرين السابق، عليك استخدام Busybox لمعرفة ما يعمل من إعداداتك وما لا يعمل. لنختبر ما قلناه الآن. تتواجد تلك الحاويات ضمن شبكة ويمكنك الربط بينها بسهولة، ويمكن إضافة Busybox إلى الخلطة بتغيير الملف "docker-compose.yml" إلى: services: app: image: hello-front-dev build: context: . dockerfile: dev.Dockerfile volumes: - ./:/usr/src/app ports: - 3000:3000 container_name: hello-front-dev Debug-helper: image: busybox لن تتضمن حاوية Busybox على أية عمليات تجري ضمنها لذلك يمكننا تنفيذ الأمر exec. عندها سيبدو الخرج الناتج عن تنفيذ التعليمة docker-compose up على النحو التالي: $ docker-compose up Pulling debug-helper (busybox:)... latest: Pulling from library/busybox 8ec32b265e94: Pull complete Digest: sha256:b37dd066f59a4961024cf4bed74cae5e68ac26b48807292bd12198afa3ecb778 Status: Downloaded newer image for busybox:latest Starting hello-front-dev ... done Creating react-app_debug-helper_1 ... done Attaching to react-app_debug-helper_1, hello-front-dev react-app_debug-helper_1 exited with code 0 hello-front-dev | hello-front-dev | > react-app@0.1.0 start hello-front-dev | > react-scripts start هذا الخرج متوقع كون الحزمة هي مجموعة أدوات مثل غيرها. لنستخدم الحزمة في إرسال طلب إلى الحاوية "hello-front-dev" لنرى كيف يعمل خادم DNS. بإمكاننا تنفيذ الطلب wget أثناء عمل الحاوية فهو أداةٌ موجودةٌ ضمن Busybox مهمتها إرسال طلب إلى الحاوية hello-front-dev من مساعد التنقيح debug-helper. يمكننا استخدام الأمر docker-compose run SERVICE COMMAND لتنفيذ خدمة مع أمر محدد، ويتطلب استخدام الأمر wget السابق الراية O- تليها - لنقل الاستجابة إلى مجرى الخرج: $ docker-compose run debug-helper wget -O - http://app:3000 Creating react-app_debug-helper_run ... done Connecting to hello-front-dev:3000 (172.26.0.2:3000) writing to stdout <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> ... يُعد عنوان URL الجزء المهم هنا، فهو يشير إلى أننا اتصلنا بالخدمة "hello-front-dev" والمنفذ 3000. لقد منحنا الحاوية اسمها "hello-front-dev" باستخدام التعليمة container_name في ملف "docker-compose"، أما المنفذ فهو المنفذ المتاح للوصول إلى التطبيق ضمن الحاوية. ولا حاجة لنشر المنفذ كي تتصل به بقية الخدمات الموجودة على نفس الشبكة، فالمنافذ المحددة في الملف "docker-compose" هي للوصول الداخلي وحسب. دعونا نغيّر رقم المنفذ في الملف "docker-compose.yml" لتوضيح الأمر: services: app: image: hello-front-dev build: context: . dockerfile: dev.Dockerfile volumes: - ./:/usr/src/app ports: - 3210:3000 container_name: hello-front-dev debug-helper: image: busybox سيُتاح التطبيق على الحاسوب المضيف وعلى العنوان http://localhost:3210 عند تنفيذ الأمر docker-compose up، لكن التطبيق لا يزال يعمل وفقًا للأمر السابق docker-compose run debug-helper wget -O - http://app:3000 طالما أن المنفذ هو 3000 أيضًا ضمن شبكة دوكر. يطلب الأمر docker-compose run -كما تشرح الصورة السابقة-من مساعد التنقيح أن يرسل طلبًا ضمن شبكة دوكر docker بينما يرسل المتصفح في الجهاز المضيف الطلب من خارج الشبكة. أما الآن وقد علمت سهولة إيجاد الخدمات في الملف "docker-compose.yml" وليس لدينا أي شيء للتنقيح، سنزيل مساعد التنقيح و نُعيد المنافذ إلى 3000:3000 في الملف "docker-compose.yml". التمرين 12.16: تشغيل الواجهة الخلفية لتطبيق المهام ضمن حاوية التطوير استخدم الأقراص والمكتبة Nodemon لتمكين عملية تطوير الواجهة الخلفية لتطبيق المهام وهي تعمل ضمن الحاوية. أنشئ الملف "todo-backend/dev.Dockerfile" وعدّل الملف "todo-backend/docker-compose.dev.yml". عليك إعادة التفكير أيضًا في الاتصالات بين الواجهة الخلفية و قاعدة البيانات MongoDB، أو Redis. ولحسن الحظ يدعم docker-compose استخدام متحولات بيئة يمكن تمريرها إلى التطبيق: services: server: image: ... volumes: - ... ports: - ... environment: - REDIS_URL=... - MONGO_URL=... وُضعت عناوين URL للخادم المحلي بطريقة خاطئة عمدًا وعليك وضع القيم الصحيحة. وتذكر أن تراقب دائمًا ما يحدث على شاشة الطرفية، فقد ستلمّح رسائل الخطأ إلى مكان المشكلة إن حدث خلل ما. إليك هذه الصورة التوضيحة التي قد تنفع في توضيح الاتصالات ضمن شبكة docker: التواصل بين الحاويات في بيئة أكثر حيوية سنضيف تاليًا خادم وكيل معكوس reverse proxy إلى الملف " docker-compose.yml". واستنادًا إلى ويكيبيديا: سيكون الخادم الوكيل المعكوس في حالتنا نقطة دخول مفردة إلى تطبيقنا، أم الهدف النهائي فهو إعداد واجهة React الأمامية و واجهة Express الخلفية معًا خلف الخادم والوكيل المعكوس. لديك عدة خيارات تساعدك في إنجاز الخادم الوكيل، مثل Traefik و Caddy و Nginx و Apache وقد رُتبت من الأحدث إلى الأقدم ظهورًا، لكن خيارنا سيكون Nginx. لنضع الآن الواجهة الأمامية المتمثلة بالحاوية "hello-frontend" خلف الخادم الوكيل المعكوس. لهذا عليك إنشاء الملف "nginx.conf" في جذر المشروع واتّبع القالب التالي بمثابة نقطة انطلاق. لا بُد من إنجاز بعض التعديلات الثانوية لتشغيل التطبيق: # الأحداث مطلوبة لكن لا بأس بالاعتماد على الأحداث الافتراضية events { } # 80 يستمع إلى المنفذ http خادم http { server { listen 80; # (/) تُعالج الطلبات التي تبدأ بالمحرف location / { # نحتاج الأسطر الثلاثة التالية للتحميل المباشر للتغييرات proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; # http://localhost:3000 تُوجَّه الطلبات مباشرةً إلى العنوان proxy_pass http://localhost:3000; } } } أنشئ تاليًا خدمة Nginx ضمن الملف "docker-compose.yml"، ثم أضف قرصًا كما ورد في إرشادات الصفحة الرسمية للأداة Docker Hub حيث يكون الجانب الأيمن على الشكل هو: etc/nginx/nginx.conf:ro/:،إذ يدل التصريح ro على أن القرص للقراءة فقط: services: app: # ... nginx: image: nginx:1.20.1 volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro ports: - 8080:80 container_name: reverse-proxy depends_on: - app # انتظر حاوية الواجهة الخلفية حتى تُقلع يمكنك الآن تنفيذ الأمر ومراقبة نتيجة العمل: $ docker container ls CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES a02ae58f3e8d nginx:1.20.1 "/docker-entrypoint.…" 4 minutes ago Up 4 minutes 0.0.0.0:8080->80/tcp, :::8080->80/tcp reverse-proxy 5ee0284566b4 hello-front-dev "docker-entrypoint.s…" 4 minutes ago Up 4 minutes 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp hello-front-dev عند الانتقال إلى العنوان http://localhost:8080 ستظهر صفحة الحالة 502 المألوفة، وهذا لأن توجيه الطلبات إلى العنوان http://localhost:3000 لن يقود إلى أي مكان، لأن حاوية الخادم Nginx لا تحتوي أي تطبيق يعمل على المنفذ 3000. إن مصطلح "خادم محلي" يُستخدم عادة للدلالة على الحاسوب الحالي الذي نلج إليه، بينما يكون الخادم المحلي فريدًا في عالم الحاويات لكل حاوية ويقود إلى الحاوية نفسها. لنختبر ذلك بالدخول إلى الحاوية Nginx واستخدام الأداة curl لإرسال طلب إلى التطبيق نفسه، وتشابه هذه الأداة من حيث الطريقة التي نستخدمها الأداة wget لكنها لا تحتاج أية رايات: $ docker exec -it reverse-proxy bash root@374f9e62bfa8:/# curl http://localhost:80 <html> <head><title>502 Bad Gateway</title></head> ... يمكننا الاستفادة من الشبكة التي يهيئها docker-compose عند تنفيذ الأمر docker-compose up، فهي تضيف كل الحاويات الموجودة في الملف "docker-compose.yml" إلى الشبكة. يتأكد خادم DNS من إمكانية إيجاد بقية الحاويات، وتُمنح كل منها اسمان الأول اسم الخدمة والآخر اسم الحاوية. طالما أننا ضمن الحاوية، سنختبر خادم DNS، لنستخدم إذًا curl لطلب الخدمة التي تُدعى (app) على المنفذ 3000 root@374f9e62bfa8:/# curl http://app:3000 <!DOCTYPE html> <html lang="en"> <head> ... <meta name="description" content="Web site created using create-react-app" /> ... هذا كل ما في الأمر. لنبدل الآن عنوان proxy_pass في الملف "nginx.conf" بهذا العنوان (http://app:3000). إن ظهرت الصفحة 502 مجددًا ، تأكد من بناء create-react-app أولًا، واقرأ الخرج الناتج عن تنفيذ الأمر docker-compose up. أمر آخر: لقد أضفنا الخيار depends_on إلى الإعدادات لنتأكد أن حاوية "nginx" لن تعمل قبل حاوية الواجهة الأمامية "app": services: app: # ... nginx: image: nginx:1.20.1 volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro ports: - 8080:80 container_name: reverse-proxy Depends_on: - app إن لم نفرض تسلسل الإقلاع هذا باستخدام الخيار depends_on فقد نقع في خطر فشل إقلاع لأنه يحاول تحليل أسماء نطاقات DNS التي يُشار إليها في ملف الإعدادات: http { server { listen 80; location / { proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_pass http://app:3000; } } } تجدر الملاحظة أن الخيار depends_on لا يضمن أن تكون الخدمة في الحاوية جاهزةً للعمل، بل يضمن أن الحاوية قد بدأت العمل وأضيف المُدخل الخاص بها إلى خادم DNS، لكن إذا أردت من خدمة أن تنتظر أخرى حتى تجهز، فعليك الاطلاع على حلول أخرى. التمرينات 12.17 - 12.19 حاول إنجاز التمرينات التالية: التمرين 12.17: إعداد خادم وكيل معكوس Nginx أمام الواجهة الأمامية لتطبيق المهام سنحاول في هذا التمرين وضع خادم Nginx أمام الواجهتين الأمامية والخلفية لتطبيق المهام todo-app. سنبدأ بإنشاء ملف docker-compose جديد "todo-app/docker-compose.dev.yml" وملف تهيئة Nginx يحمل الاسم "todo-app/nginx.conf". todo-app ├── todo-frontend ├── todo-backend ├── nginx.conf └── docker-compose.dev.yml أضف الخدمتين nginx و todo-app/todo-frontend/dev.Dockerfile إلى الملف "todo-app/docker-compose.dev.yml". التمرين 12.18: إعداد خادم ليكون أمام الواجهة الخلفية لتطبيق المهام أضف الخدمة todo-backend إلى الملف "*todo-app/docker-compose.dev.yml" في وضع التطوير، ثم أضف مكانًا جديدًا للملف كي تُخدَّم الطلبات إلى العنوان "api/" عبر الخادم الوكيل إلى الواجهة الخلفية. قد يكون القالب التالي مناسبًا لإنجاز الأمر: server { listen 80; # (/) تُعالج الطلبات التي تبدأ بالمحرف location / { # نحتاج الأسطر الثلاثة التالية للتحميل المباشر للتغييرات proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; # http://localhost:3000 تُوجَّه الطلبات مباشرةً إلى العنوان proxy_pass http://localhost:3000; } # (/api/) تُعالج الطلبات التي تبدأ بالمسار location /api/ { ... } } للتوجيه proxy_pass ميزةٌ مهمة عندما تُضاف إليه الشرطة المائلة الزائدة "/"، وطالما أننا نستخدم المسار "api/" لتحديد المكان علمًا أن تطبيق الواجهة الخلفية سيجيب فقط على العنوان "/" أو "todos/" فلا بد من إزالة "api/" من الطلب. وبكلمات أخرى، حتى لو أرسل المتصفح الطلب GET إلى العنوان "api/todos/1/" نريد من الخادم Nginx أن ينقل الطلب بالوكالة إلى العنوان "todos/1/". لتنفيذ الأمر لا بد من إضافة شرطة مائلة "/" زائدة إلى العنوان في نهاية التوجيه proxy_pass. انتبه للموضوع جيدًا فقد تقضي ساعات في البحث عن حل لمشاكل سببها إهمال الشرطة الزائدة. وهذه إحدى المشاكل الشائعة: إليك هذه الصورة التوضيحة التي قد تنفع في توضيح الأمر عندما تواجهك المشاكل: التمرين 12.19: ربط الخدمتين todo-frontend و todo-backend سلّم في هذا التمرين بيئة التطوير بأكملها بما في ذلك تطبيقي Express و React وملفات Dockerfiles والملف "docker-compose.yml". تأكد بدايةً من عمل الواجهة الأمامية مع الواجهة الخلفية، وسيتطلب ذلك تغييرات في متغير البيئة REACT_APP_BACKEND_URL. وإذا كانت الواجهة جاهزةً للعمل خلال التمرين السابق يمكنك تجاوز هذه الخطوة. تأكد من أن بيئة التطوير تعمل الآن بكامل طاقتها، أي: أن تعمل جميع ميزات تطبيق المهام. عندما تغيّر الشيفرة المصدرية لا بُد أن تظهر نتيجة التغيرات مباشرةً في حال كان التغيير في الواجهة الأمامية وبإعادة تحميل التطبيق إن كان التغيير في الواجهة الخلفية. أدوات لمرحلة الإنتاج التعامل مع الحاويات ممتعٌ في مرحلة التطوير، لكن أفضل حالات الاستخدام ستكون في مرحلة الإنتاج، إذ توجد أدوات أكثر قدرة من docker-compose في تشغيل الحاويات في مرحلة الإنتاج. تسمح لنا أداة تنسيق الحاويات Kubernetes مثلًا بإدارة الحاويات ضمن مستوى جديد بالكامل، وتخفي تلك الأدوات عمومًا التجهيزات الفيزيائية المستخدمة مما يجعل المطورين أقل انشغالًا بأمور البنية التحتية. إن كنت ترغب في الاطلاع أكثر على الحاويات باستخدام Docker فعليك بالمنهاج DevOps with Docker، وكذلك المنهاج DevOps with Kubernetes لتتعلم تنسيق الحاويات باستخدام Kubernetes. التمرينات 12.20-12.22 حاول إنجاز التمرينات التالية: التمرين 12.20 أنشئ نسخة إنتاج من الملف "todo-app/docker-compose.yml" تضم كل الخدمات: Nginx و todo-backend و todo-frontend و MongoDB و Redis. استخدم الملف "Dockerfiles" بدلًا من "dev.Dockerfiles" وتأكد من تشغيل التطبيق في وضع الإنتاج. استخدم الهيكلية التالية في هذا التمرين: todo-app ├── todo-frontend ├── todo-backend ├── nginx.conf ├── docker-compose.dev.yml └── docker-compose.yml التمرين 12.21 أنشئ بيئة تطوير تعتمد على الحاويات مشابهة لما فعلنا، وذلك لأحد التطبيقات التي مرّت معك خلال منهاجنا أو التي أنشاتها في أوقات فراغك. اجعل هيكلية تطبيقك ضمن مستودع التسليم على النحو التالي: └── my-app ├── frontend | └── dev.Dockerfile ├── backend | └── dev.Dockerfile └── docker-compose.dev.yml التمرين 12.22 أنهِ القسم بإعداد نسخة إنتاج تعتمد على الحاويات خاصة بالتطبيق الذي اخترته. جعل هيكلية تطبيقك ضمن مستودع التسليم على النحو التالي: └── my-app ├── frontend | ├── dev.Dockerfile | └── Dockerfile ├── backend | └── dev.Dockerfile | └── Dockerfile ├── docker-compose.dev.yml └── docker-compose.yml ترجمة -وبتصرف- للفصل basics of orchestration من سلسلة Deep Dive Into Modern Web Development اقرأ أيضًا المقال السابق: بناء الصور وتهيئة بيئة العمل للتطبيقات المبنية ضمن الحاويات مدخل إلى الحاويات أبرز المفاهيم التي يجب عليك الإلمام بها عن الحاويات
  7. لقد حان الوقت لتوسيع مشروعنا بعد أن أسسنا له قاعدةً معرفيةً جيدة، وسنسخّر لهذه المهمة كل إمكانيات React Native التي تعلمناها حتى الآن. سنغطي بالإضافة إلى توسيع المشروع نواحٍ جديدة، مثل الاختبارات والموارد الإضافية. اختبار تطبيقات React Native سنحتاج إلى إطار عمل للاختبارات عند البدء باختبار أي نوع من الشيفرة، لتنفيذ مجموعة من الحالات المختبرة، والتدقيق في نتائجها. فلاختبار تطبيق جافا سكربت JavaScript سنجد أن إطار العمل Jest هو الأكثر شعبية لأداء المهمة. ولاختبار تطبيقات React Native المبنية على المنصة Expo باستخدام Jest، ستزودنا Expo بمجموعة من إعدادات تهيئة على هيئة مجموعة جاهزة تدعى jest-expo. ولكي نستخدم المدقق ESLint في ملف اختبار Jest، سنحتاج إلى الإضافة eslint-plugin-jest الخاصة به. لنبدأ إذًا بتثبيت الحزم: npm install --save-dev jest jest-expo eslint-plugin-jest لاستخدام المجموعة jest-expo في Jest، لا بدّ من إضافة إعدادات التهيئة التالية في الملف "package.json"، مع سكربت الاختبار: { // ... "scripts": { // other scripts... "test": "jest" }, "jest": { "preset": "jest-expo", "transform": { "^.+\\.jsx?$": "babel-jest" }, "transformIgnorePatterns": [ "node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|react-router-native)" ] }, // ... } يطلب الخيار transform من Jest تحويل شيفرة الملفين "js." و"jsx." باستخدام مصرّف Babel. بينما يُستخدم الخيار transformIgnorePatterns لتجاهل مجلدات محددة ضمن المجلد "node_modules" أثناء تحويل الملفات. وتتطابق تقريبًا إعدادات Jest هذه مع تلك المقترحة في توثيق Expo. لاستخدام الإضافة eslint-plugin-jest، لا بدّ من وضعها ضمن مصفوفة الإضافات والموسِّعات في الملف "eslintrc.". { "plugins": ["react", "react-native"], "settings": { "react": { "version": "detect" } }, "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:jest/recommended"], "parser": "@babel/eslint-parser", "env": { "react-native/react-native": true }, "rules": { "react/prop-types": "off", "react/react-in-jsx-scope": "off" } } وللتأكد من نجاح التهيئة، أنشئ المجلد "tests" في المجلد "src"، ثم أنشئ ضمنه الملف "example.js" وضع فيه الشيفرة التالية: describe('Example', () => { it('works', () => { expect(1).toBe(1); }); }); لننفذ الآن هذا المثال من خلال الأمر: npm test. ينبغي أن يشير خرج العملية إلى نجاح الاختبار المتواجد في الملف "src/tests/example.js". تنظيم الاختبارات تقتضي إحدى طرق تنظيم ملفات الاختبارات بوضعها في مجلد وحيد يُدعى "tests". ويفضّل عند استخدام هذه الطريقة وضع ملفات الاختبار ضمن المجلدات الفرعية الملائمة كما نفعل مع ملفات الشيفرة، إذ ستُوضع الاختبارات التي تتعلق بالمكوّنات مثلًا في المجلّد "components"، وتلك التي تتعلق بالخدمات ستُوضع في المجلّد "utils"، وهكذا. سينتج عن هذا التنظيم الهيكلية التالية: src/ __tests__/ components/ AppBar.js RepositoryList.js ... utils/ authStorage.js ... ... أما الطريقة الأخرى فهي وضع ملف الاختبار بجانب ملف الشيفرة الذي سيستخدمه. ويعني ذلك أننا سنضع الملف الذي يتضمن اختبارات للمكوّن AppBar مثلًا في نفس المجلد الذي يحتوي شيفرة هذا المكوّن. وستنتج عن هذه الطريقة في التنظيم الهيكلية التالية: src/ components/ AppBar/ AppBar.test.jsx index.jsx ... ... شيفرة المكوّن في المثال السابق موجودةٌ في الملف "index.jsx"، بينما ستجد الاختبارات في الملف "AppBar.test.jsx". ولكي تجد بسهولة الملفات التي تحتوي الاختبارات، عليك أن تضعها في المجلد "tests" أو في ملفات تحمل إحدى اللاحقتين "test." أو "spec."، أو أن تهيئ يدويًا الأنماط العامة global patterns. اختبار المكونات بعد أن ضبطنا إعدادات Jest وجربناها بتنفيذ مثال بسيط، حان الوقت لنتعرف على كيفية اختبار المكوّنات. يتطلب اختبار المكوّنات، كما نعلم، طريقة لتفسير الخرج الناتج عن تصيير المكوّن ومحاكاة عملية معالجة الأحداث المختلفة مثل الضغط على زر. ولحسن الحظ تتوافر عائلة من مكتبات الاختبار تؤمن مكتبات لاختبار مكوًنات واجهة المكوّن في منصات عدة. وتتشارك جميع هذه المكتبات بواجهة برمجية واحدة API لاختبار مكونات واجهة المستخدم بأسلوب يركّز على المستخدم. تعرّفنا في القسم 5 على إحدى هذه المكتبات وهي React Testing Library. لكن لسوء الحظ فهذه المكتبة مخصصة فقط لاختبار تطبيقات الويب المبنية باستخدام React. لكن بالطبع هناك مقابل لها في React Native وهي المكتبة React Native Testing Library التي سنستخدمها في اختبار مكوّنات تطبيقات React Native. وكما ذكرنا فإن جميع هذه المكتبات تتشارك بنفس الواجهة البرمجية، ولن تضطر إلى تعلّم الكثير من المفاهيم الجديدة. بالإضافة إلى هذه المكتبة، سنحتاج إلى مجموعة من مُطابقات Jest مخصصة للمكتبة مثل toHaveTextContent و toHaveProp. تزوّدنا المكتبة jest-native بهذه المُطابقات، لذلك لا بدّ أولًا من تثبيت هذه الحزم: npm install --save-dev react-test-renderer@17.0.1 @testing-library/react-native @testing-library/jest-native ملاحظة: إذا واجهت مشاكل في اعتمادية النظير peer، فتأكد من مطابقة إصدار react-test-renderer مع نسخة react للمشروع في أمر npm install المذكور أعلاه. يمكنك التحقق من إصدار react من خلال تنفيذ الأمر: npm list react --depth=0 وفي حال فشل التثبيت بسبب مشكلات اعتمادية النظير، فحاول مرةً أخرى باستخدام الراية ‎--legacy-peer-deps في الأمر npm install. لنتمكن من استخدام هذه المُطابقات، علينا توسيع الكائن expect العائد للمكتبة Jest. ويجري تنفيذ ذلك باستخدام ملف إعداد عام. لذلك أنشئ الملف "setupTests.js" في المجلد الجذري لمشروعك، وهو نفسه المجلد الذ++ي يحتوي الملف "package.json". أضف الشيفرة التالية إلى هذا الملف: import '@testing-library/jest-native/extend-expect'; هيئ الملف السابق على انه ملف إعداد ضمن أوامر تهيئة Jest الموجودة في الملف "package.json": { // ... "jest": { "preset": "jest-expo", "transform": { "^.+\\.jsx?$": "babel-jest" }, "transformIgnorePatterns": [ "node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|@sentry/.*|react-router-native)" ], "setupFilesAfterEnv": ["<rootDir>/setupTests.js"] } // ... } تعتمد المكتبة على مفهومين أساسيين هما الاستعلامات (query) وإطلاق الأحداث (fire event)؛ إذ تُستخدم الاستعلامات لاستخلاص مجموعة من العقد من المكوّن الذي يُصيّر بواسطة الدالة render، ونستفيد منها في اختبار وجود عنصر أو كائن ما، مثل نص معين، ضمن المكوّن المُصيَّر. يمكنك تحديد العقد باستخدام الخاصية testID ثم الاستعلام عنها باستخدام الدالة getByTestId. تقبل جميع المكوّنات البنيوية في الخاصية testID. وإليك مثالًا عن كيفية استخدام الاستعلامات: import { Text, View } from 'react-native'; import { render } from '@testing-library/react-native'; const Greeting = ({ name }) => { return ( <View> <Text>Hello {name}!</Text> </View> ); }; describe('Greeting', () => { it('renders a greeting message based on the name prop', () => { const { debug, getByText } = render(<Greeting name="Kalle" />); debug(); expect(getByText('Hello Kalle!')).toBeDefined(); }); }); تعيد الدالة render الاستعلامات ودوال مساعدة أخرى مثل الدالة debug التي تطبع شجرة React بطريقة واضحة بالنسبة للمستخدم. استخدم هذه الدالة إن كنت غير متأكد من شكل المكوّن الذي صيَّرته الدالة render. يمكننا أن نحصل على العقدة Text التي تحتوي نصًا محددًا عن طريق الدالة getByText. وللاطلاع على كل الاستعلامات المتاحة، راجع توثيق المكتبة React Native Testing. يُستخدم المُطابق toHaveTextContent للتأكد من أن المحتوى النصي للعقدة صحيح. وللاطلاع كذلك على كل المُطابقات الخاصة بالمكتبة React Native، راجع توثيق jest-native. وتذكر أنك ستجد معلومات عن كل مُطابقٍ عام في توثيق Jest. أما المفهوم الثاني الذي ترتكز عليه المكتبة React Native Testing في عملها هو إطلاق الأحداث firing events، إذ يمكننا إطلاق حدث ضمن عقدة معينة باستخدام توابع الكائن fireEvent. سنستفيد من ذلك على سبيل المثال في طباعة نص ضمن حقل نصي أو على زر. وإليك مثال عن اختبار تسليم نموذج بسيط: import { useState } from 'react'; import { Text, TextInput, Pressable, View } from 'react-native'; import { render, fireEvent } from '@testing-library/react-native'; const Form = ({ onSubmit }) => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const handleSubmit = () => { onSubmit({ username, password }); }; return ( <View> <View> <TextInput value={username} onChangeText={(text) => setUsername(text)} placeholder="Username" /> </View> <View> <TextInput value={password} onChangeText={(text) => setPassword(text)} placeholder="Password" /> </View> <View> <Pressable onPress={handleSubmit}> <Text>Submit</Text> </Pressable> </View> </View> ); }; describe('Form', () => { it('calls function provided by onSubmit prop after pressing the submit button', () => { const onSubmit = jest.fn(); const { getByPlaceholderText, getByText } = render(<Form onSubmit={onSubmit} />); fireEvent.changeText(getByPlaceholderText('Username'), 'kalle'); fireEvent.changeText(getByPlaceholderText('Password'), 'password'); fireEvent.press(getByText('Submit')); expect(onSubmit).toHaveBeenCalledTimes(1); // تحوي أول وسيط من أول استدعاء onSubmit.mock.calls[0][0] expect(onSubmit.mock.calls[0][0]).toEqual({ username: 'kalle', password: 'password', }); }); }); نريد في هذا الاختبار أن نتأكد من أنّ الدالة onSubmit ستُستدعى على نحوٍ صحيح بعد أن تُملأ حقول النموذج باستخدام التابع fireEvent.changeText ويُضغط على الزر باستخدام التابع fireEvent.press. ولتحري إذا ما استُدعيت الدالة onSubmit وبأية وسطاء، يمكن استخدام الدالة المقلّدة mock function؛ والدوال المقلدة هي دوال ذات سلوك مبرمج مسبقًا كأن تعيد قيمةً محددة. كما يمكننا تحديد توقّعات expectations لهذه الدوال، فمثلًا "توقّع أن الدالة المقلّدة قد استدعيت مرةً واحدة". يمكنك الاطلاع على قائمة بكل التوقعات المتاحة في توثيق توقعات Jest. عدّل وجرّب في تلك الأمثلة السابقة، وأضف اختبارات جديدة في المجلد "tests "، بحيث تتأكد من فهمك للأفكار السابقة قبل الغوص أعمق في موضوع الاختبارات. التعامل مع الاعتماديات أثناء الاختبارات يُعد اختبار المكوّنات سهلًا نوعًا ما لأنها صرفة بشكلٍ أو بآخر. فلا تعتمد المكوّنات الصرّفة على التأثيرات الجانبية side effects، مثل طلبات الشبكة أو استخدام واجهات برمجية أصيلة، مثل AsyncStorage. فالمكوّن Form ليس صرفًا بقدر المكون Greeting، لأن تغيرات حالته قد تُعد أثرًا جانبيًا. لكن الاختبارات تبقى سهلةً مع ذلك. لنلقِ الآن نظرةً على استراتيجية لاختبار المكوّنات مع الآثار الجانبية، وسنختار على سبيل المثال المكوّن RepositoryList من تطبيقنا. لهذا المكوّن حاليًا أثر جانبي واحد وهو استعلام GraphQl الذي يحضر قائمة بالمستودعات المقيّمة. يُستخدم المكوّن حاليًا كالتالي: const RepositoryList = () => { const { repositories } = useRepositories(); const repositoryNodes = repositories ? repositories.edges.map((edge) => edge.node) : []; return ( <FlatList data={repositoryNodes} // ... /> ); }; export default RepositoryList; ينتج التأثير الجانبي عن استخدام الخطاف useRepositories الذي يرسل استعلام GraphQL. هناك عدة طرق لاختبار المكوّن، إحدى هذه الطرق هي تقليد استجابات المكتبة Apollo Client كما هو مشروح في توثيقها. أما الطريقة الأبسط هي افتراض أن المكوّن يعمل كما نتوقع (ويُفضل من خلال اختباره)، ثم استخلاص الشيفرة "الصرفة" له ووضعها في مكوّن آخر مثل RepositoryListContainer: export const RepositoryListContainer = ({ repositories }) => { const repositoryNodes = repositories ? repositories.edges.map((edge) => edge.node) : []; return ( <FlatList data={repositoryNodes} // ... /> ); }; const RepositoryList = () => { const { repositories } = useRepositories(); return <RepositoryListContainer repositories={repositories} />; }; export default RepositoryList; يحتوي المكوّن الآن التأثيرات الجانبية فقط وتنفيذه سهل. يمكن اختبار المكوّن RepositoryListContainer بتمرير بيانات عن مستودع من القائمة من خلال الخاصية repositories، والتأكد أنّ معلومات المحتوى المُصيَّر صحيحة. التمرينان 10.17 - 10.18 10.17 اختبار قائمة المستودعات المقيمة أنجز اختبارًا يتأكد أن المكوّن RepositoryListContainer سيصيِّر بصورةٍ صحيحة كلًا من: اسم المستودع ووصفه واللغة وعدد التشعبات وعدد النجوم ومعدل التقييم وعدد التقييمات. تذكر أنه بالإمكان الاستفادة من المطابق toHaveTextContent في التحقق من احتواء العقدة على سلسلة نصية محددة. const RepositoryItem = (/* ... */) => { // ... return ( <View testID="repositoryItem" {/* ... */}> {/* ... */} </View> ) }; كما يمكنك استخدام الاستعلام getAllByTestId للحصول على كل العقد التي تمتلك قيمة محددة للخاصية testID على هيئة مصفوفة، وإن كنت غير متأكد من المكوّن الذي صُيَّر، استفد من الدالة debug في تفكيك نتيجة التصيير. const repositoryItems = getAllByTestId('repositoryItem'); const [firstRepositoryItem, secondRepositoryItem] = repositoryItems; // توقع شيئًا من عنصر المستودع الأول والثاني استخدم الشيفرة التالية أساسًا لاختبارك: describe('RepositoryList', () => { describe('RepositoryListContainer', () => { it('renders repository information correctly', () => { const repositories = { pageInfo: { totalCount: 8, hasNextPage: true, endCursor: 'WyJhc3luYy1saWJyYXJ5LnJlYWN0LWFzeW5jIiwxNTg4NjU2NzUwMDc2XQ==', startCursor: 'WyJqYXJlZHBhbG1lci5mb3JtaWsiLDE1ODg2NjAzNTAwNzZd', }, edges: [ { node: { id: 'jaredpalmer.formik', fullName: 'jaredpalmer/formik', description: 'Build forms in React, without the tears', language: 'TypeScript', forksCount: 1619, stargazersCount: 21856, ratingAverage: 88, reviewCount: 3, ownerAvatarUrl: 'https://avatars2.githubusercontent.com/u/4060187?v=4', }, cursor: 'WyJqYXJlZHBhbG1lci5mb3JtaWsiLDE1ODg2NjAzNTAwNzZd', }, { node: { id: 'async-library.react-async', fullName: 'async-library/react-async', description: 'Flexible promise-based React data loader', language: 'JavaScript', forksCount: 69, stargazersCount: 1760, ratingAverage: 72, reviewCount: 3, ownerAvatarUrl: 'https://avatars1.githubusercontent.com/u/54310907?v=4', }, cursor: 'WyJhc3luYy1saWJyYXJ5LnJlYWN0LWFzeW5jIiwxNTg4NjU2NzUwMDc2XQ==', }, ], }; // أضف شيفرة اختبارك هنا }); }); }); يمكن أن تضع ملف الاختبار أينما تشاء، لكن من الأفضل أن تتبع إحدى الطرق التي ذكرناها في تنظيم ملفات الاختبار. استخدم المتغير repositories مثل مصدر للبيانات في الاختبار، ولا حاجة لتغيير قيمته. تحتوي البيانات على مستودعين، فعليك بالتالي التحقق من وجود المعلومات في كليهما. 10.18 اختبار نموذج تسجيل الدخول أنجز اختبارًا يتحقق أن الدالة onSubmit ستُستدعى بوسطاء مناسبين عندما يُملأ حقلي اسم المستخدم وكلمة المرور في النموذج ويُضغط على زر الإرسال. ينبغي أن يكون الوسيط الأول كائنًا يمثّل قيم النموذج، ويمكنك إهمال بقية وسطاء الدالة. تذكر أنك قد تستعمل توابع fireEvent لتنفيذ أحداث ودوال مقلِّدة للتحقق من استدعاء معالج الحدث onSubmit أو لا. لا حاجة لاختبار أي شيفرة متعلقة بالمكتبة Apollo Client أو AsyncStorage والموجودة في الخطاف useSignIn. وكما فعلنا في التمرين السابق، استخلص الشيفرة الصرفة وضعها في مكوّن خاص واختبره. انتبه إلى إرسالات نماذج Formik فهي غير متزامنة، فلا تتوقع استدعاء الدالة onSubmit مباشرة بعد الضغط على زر الإرسال. يمكنك الالتفاف على المشكلة بجعل دالة الاختبار دالة غير متزامنة باستخدام التعليمة Async واستخدام الدالة المساعدة waitFor العائدة للمكتبة React Native Testing. كما يمكنك يمكن أن تستخدم الدالة waitFor لانتظار تحقق التوقعات. فإن لم يتحقق التوقع خلال فترة محددة، سترمي الدالة خطأً. إليك مثالًا يشير إلى أسلوب استخدام هذه الدالة: import { render, fireEvent, waitFor } from '@testing-library/react-native'; // ... describe('SignIn', () => { describe('SignInContainer', () => { it('calls onSubmit function with correct arguments when a valid form is submitted', async () => { //صيّر المكوّن ثم املأ الحقول النصية ثم اضغط زر الإرسال await waitFor(() => { // توقع أن تُستدعى دالة الإرسال مرة واحد وأن يكون الوسيط الأول صحيحًا }); }); }); }); توسيع التطبيق سنبدأ باستخدام كل ما تعلمناه في توسيع تطبيقنا، فلا يزال التطبيق يفتقر لبعض النواحي، مثل عرض معلومات مستودع وتسجيل مستخدمين جدد. سنركز في التمارين القادمة على هذه النقاط. التمرينات 10.19 - 10.24 10.19: واجهة لعرض مستودع واحد أنجز واجهةً لعرض معلومات عن مستودع واحد، تتضمن نفس المعلومات التي تُعرض في قائمة المستودعات بالإضافة إلى زر لفتح المستودع في غيت هب Github. من الجيد أن تجد طريقةً لإعادة استخدام المكونين RepositoryItem و RepositoryList وأن تعرض الزر الذي أشرنا إليه بناء على خاصية محددة تعيد قيمة منطقية مثلًا. ستجد عنوان المستودع ضمن الحقلurl العائد للنوع "Repository" في تخطيط GraphQL. تستطيع إحضار مستودع واحد من خادم Apollo بإجراء الاستعلام repository. يُمرَّر إلى هذا الاستعلام وسيط واحد وهو المعرّف المميز id للمستودع. إليك مثالًا عن استخدام الاستعلام repository. { repository(id: "jaredpalmer.formik") { id fullName url } } اختبر استعلامك كما هو معتاد في أرضية عمل Apollo قبل استخدامه في التطبيق. إن لم تكن على دراية بتخطيط GrapQL أو الاستعلامات التي يتيحها، افتح النافذة "docs" أو النافذة "schema" في أرضية عمل GraphQL. وإن واجهتك صعوبة في استخدام المعرف id مثل متغير في الاستعلام، توقف قليلًا لتطلع على توثيق Apollo Client بما يخص الاستعلامات. لتطلع على طريقة التوجه إلى عنوان URL في المتصفح، اقرأ توثيق Expo حول واجهة الربط البرمجية Linking API، لأنك ستحتاج ذلك عند تنفيذك للزر الذي يفتح المستودع في GitHub. ينبغي أن تمتلك واجهة العرض عنوانًا خاصًا بها، ومن الجيد أن تحدد المعرّف المميز id للمستودع ضمن المسار الذي يوجهك نحو هذا العنوان مثل معامل مسار، كي تستطيع الوصول إليه باستخدام الخطاف useParams. ينبغي أن يكون المستخدم قادرًا على الوصول إلى واجهة العرض بالضغط على المستودع ضمن قائمة المستودعات. ومن الممكن تنفيذ ذلك بتغليف المكوّن RepositoryItem داخل المكّون البنيوي Pressable في المكوّن RepositoryList، ثم استخدام الدالة navigate لتغيير العنوان من خلال معالج الحدث onPress. استخدم الخطاف useNavigate أيضًا للوصول إلى الكائن دالة navigate. ستبدو النسخة النهائية لواجهة عرض مستودع واحد قريبة من الشكل التالي: 10.20: قائمة بالآراء حول مستودع بعد أن أنجزنا واجهة عرض لمستودع وحيد، سنعرض تقييمات المستخدمين ضمن هذه الواجهة. ستجد التقييمات في الحقل reviews العائد للنوع "Repository" في تخطيط GraphQL. تشكل التقييمات قائمةً صغيرةً مرقمةً يمكن الحصول عليها باستخدام الاستعلام repositories. وإليك مثالًا عن طريقة إحضار قائمة التقييمات لمستودع واحد: { repository(id: "jaredpalmer.formik") { id fullName reviews { edges { node { id text rating createdAt user { id username } } } } } } يحتوي الحقل text لكل تقييم على رأي المستخدم للنص، ويحتوي الحقل rating على قيمة عددية بين 0 و100، أما الحقل craetedAt فيحتوي على بيانات إنشاء هذا التقييم، وأخيرًا يحتوي الحقل user معلومات عن مُنشئ التقييم ويحمل النوع "User". نريد أن نعرض التقييمات على شكل شريط تمرير، مما يجعل المكوّن البنيوي FlatList ملائمًا لأداء المهمة. ولكي تعرض معلومات مستودع محدد في أعلى الواجهة، استخدم الخاصية ListHeaderComponent للمكون FlatList. يمكنك أيضًا استخدام المكوّن البنيوي ItemSeparatorComponent لإضافة مساحة بيضاء بين العناصر المعروضة كما هو الحال بين عناصر المكوّن RepositoryList مثلًا. إليك مثالًا عن هيكلية التنفيذ: const RepositoryInfo = ({ repository }) => { // Repository's information implemented in the previous exercise }; const ReviewItem = ({ review }) => { // Single review item }; const SingleRepository = () => { // ... return ( <FlatList data={reviews} renderItem={({ item }) => <ReviewItem review={item} />} keyExtractor={({ id }) => id} ListHeaderComponent={() => <RepositoryInfo repository={repository} />} // ... /> ); }; export default SingleRepository; ستبدو النسخة النهائية عن تطبيق قائمة المستودعات مشابهة للشكل التالي: البيانات التي تراها تحت اسم المستخدم لمنشئ التقييم هي تاريخ الإنشاء الذي نجده في الحقل createdAt وهو من النوع "Review"، وينبغي أن يكون تنسيق البيانات مريحًا للمستخدم مثل الصيغة "يوم.شهر.سنة". قد يساعدك تثبيت المكتبة date-fns على تنسيق تاريخ الإنشاء بالاستفادة من الدالة format. يمكن إنجاز الحاوية ذات الشكل الدائري باستخدام خاصية التنسيق borderRadius. ولتفعل هذا عليك تثبيت قيمتي الخاصيتين width و height، ثم ضبط خاصية التنسيق border-radius عند القيمة "width/2". 10.21: نموذج التقييم أنجز نموذجًا لإنشاء تقييم جديد لمستودع مستخدمًا المكتبة Formik. ينبغي أن يضم النموذج أربعة حقول هي: اسم دخول مالك المستودع. اسم المستودع. التقييم على هيئة قيمة عددية. رأي المستخدم على هيئة نص. تحقق من الحقول مستخدمًا تخطيط Yup، بحيث تتأكد أن: اسم المستخدم لمالك المستودع هو قيمة نصية إجبارية. اسم المستودع هو قيمة نصية إجبارية. التقييم هو قيمة عددية إجبارية بين 0 و100. رأي المستخدم هو قيمة نصية اختيارية. اطلع على توثيق Yup لإيجاد المُقيَّمات validators المناسبة، واستخدم رسائل خطأ مناسبة عند استخدامك لهذه المُقيِّمات، إذ يمكنك تعريف رسالة الخطأ مثل وسيط للتابع message العائد للمقيّم. يمكنك أيضًا توسيع نص الحقل ليمتد على عدة أسطر باستعمال الخاصية multiline العائدة للمكون TextInput. استخدم الطفرة createReview لإنشاء تقييم جديد، واطلع على وسطاء الطفرة بفتح إحدى النافذتين "docs" أو "schema " في أرضية عمل GraphQL. ويمكنك استخدام الخطاف useMutation لإرسال الطفرة إلى خادم Apollo. بعد نجاح عمل الطفرة createReview، حوّل المستخدم إلى واجهة عرض المستودع التي أنجزناها في التمرين السابق. نفّذ ذلك باستخدام التابع navigate بعد أن تستخرج كائن التاريخ باستخدام الخطاف useNavigate. يمتلك التقييم الذي أنشأته حقلًا باسم "repositoryId" يمكنك استخدامه لتأسيس مسار لعنوان التقييم. استخدم سياسة الإحضار "cache-and-network" لتمنع جلب بيانات الذاكرة المؤقتة مع الاستعلام repository في واجهة عرض مستودع وحيد. يمكنك استخدامها مع الخطاف على النحو التالي: useQuery(GET_REPOSITORY, { fetchPolicy: 'cache-and-network', // خيارات أخرى }); انتبه إلى أن التقييم سيكون فقط لمستودعات GitHub العامة الموجودة فعلًا، وأن المستخدم سيقيَّم مستودعًا محددًا مرة واحدة فقط. ليس عليك الآن التعامل مع حالات الخطأ هذه، بل فقط إظهار الخطأ مع الرمز المحدد والرسالة المناسبة. جرّب ما نفّذته على أحد المستودعات العامة التي تملكها، أو أية مستودعات عامة أخرى. ينبغي أن يتمكن المستخدم من الوصول إلى واجهة التقييم من خلال شريط التطبيق. لذلك أنشئ نافذة جديدة عنوانها "Create a review". ينبغي أن تكون النافذة مرئية للمستخدم الذي سجل دخوله فقط. وعليك أن تحدد أيضًا عنوانًا لواجهة التقييم. ستبدو النسخة النهائية للتطبيق مشابهة للشكل التالي: التُقطت شاشة التطبيق هذه بعد إخفاق إرسال بيانات نموذج لإظهار شكله في هذه الحالة. 10.22: نموذج تسجيل مستخدم جديد أنجز نموذجًا لتسجيل مستخدم جديد مستخدمًا Formik. ينبغي أن يضم النموذج ثلاثة حقول: اسم مستخدم، كلمة مرور، وتأكيدًا لكلمة المرور. تحقق من صحة البيانات في النموذج مستخدمًا تخطيط Yup ومتبعًا مايلي: اسم المستخدم هو سلسلة نصية إجبارية طولها بين 1 و 30. كلمة المرور هي سلسلة نصية إجبارية طولها بين 1 و 5. تأكيد كلمة المرور يتطابق تمامًا مع كلمة المرور. قد يربكك قليلًا تأكيد كلمة المرور، لكن يمكن إنجاز الأمر بالاستفادة من التابعين oneOf وref كما هو مبين في إرشادات هذا المشروع. يمكنك إنشاء مستخدم جديدة باستخدام الطفرة createUser، ويمكنك الإطلاع على كيفية استخدامها من خلال توثيق أرضية عمل Apollo. سجّل دخول المستخدم الجديد بعد إنشاء حسابه الخاص مستخدمًا الخطاف useSignIn كما فعلنا في نموذج تسجيل الدخول. وجِّه المستخدم الجديد بعد تسجيل دخوله إلى واجهة عرض المستودعات المقيّمة. ينبغي أن يكون المستخدم قادرًا على الوصول إلى واجهة تسجيل مستخدم جديد من خلال شريط التطبيق، وذلك بالضغط على نافذة "Sign up" التي ستظهر فقط للمستخدم قبل أن يسجّل دخوله. ستبدو النسخة النهائية لواجهة تسجيل مستخدم جديد مشابهة للصورة التالية: التُقطت شاشة التطبيق هذه بعد إخفاق إرسال بيانات نموذج لإظهار شكله في هذه الحالة. 10.23: ترتيب بيانات تطبيق قائمة المستودعات المقّيمة تُرتّب المستودعات حتى هذه اللحظة وفقًا لتاريخ تقييم المستودع. أنجز آلية تسمح للمستخدم أن يختار أساسًا لترتيب هذه المستودعات وفقًا لما يلي: المستودعات الأخيرة: سيكون المستودع الذي قُيَّم آخيرًا وللمرة الأولى في أعلى القائمة. هذا الترتيب هو المتبع حاليًا، وينبغي أن يكون الخيار الإفتراضي. المستودعات الأعلى تقييمًا: سيكون المستودع الذي يحمل أعلى قيمة لمعدّل التقييم في أعلى القائمة. المستودعات الأدنى تقييمًا: سيكون المستودع الذي يحمل أدنى قيمة لمعدّل التقييم في أعلى القائمة. يمتلك الاستعلام repositories الذي يحضر قائمة المستودعات وسيطًا يدعى orderby يمكنك استخدامه لتحديد أسلوب الترتيب المتبع، إذ يمتلك هذه الوسيط قيمتين فقط هما: CREATED_AT: الترتيب وفق تاريخ التقييم لأول مرة. RATING_AVERAGE: الترتيب على أساس معدل التقييم. كما يمتلك الاستعلام وسيطًا يدعى orderDirection ويستخدم لتغيير جهة الترتيب. يملك الوسيط الأخير قيمتين، هما: ASC (تصاعدي، من التقييم الأدنى إلى الأعلى) و DESC (تنازلي، من التقييم الأعلى إلى الأدنى). يمكن المحافظة على خيار الترتيب باستخدام الخطاف useState على سبيل المثال، كما يمكن تمرير المتغيرات التي يستخدمها الاستعلام repositories إلى الخطاف useRepositories مثل وسطاء. يمكنك أيضًا استخدام المكتبة react-native-picker أو المكوّن Menu العائد للمكتبة React Native Paper لإنجاز شيفرة ترتيب القائمة، كما يمكنك استخدام الخاصية ListHeaderComponent العائدة للمكوّن FlatList لتزويدك بترويسة تحتوي على مكوّن الاختيار. ستبدو النسخة النهائية لهذه الميزة، وبناء على مكوَّن الاختيار الذي اعتمدته، قريبةً من الصورة التالية: 10.24: انتقاء مستودعات محددة من القائمة يتيح خادم Apollo ترشيح المستودعات بناءً على اسمها أو اسم مالكها، ويمكن إنجاز ذلك باستخدام الوسيط searchKeyword العائد للاستعلام repositories. إليك مثالًا عن كيفية استخدام الوسيط مع الاستعلام: { repositories(searchKeyword: "ze") { edges { node { id fullName } } } } أنجز ميزةً في التطبيق لترشيح قائمة المستودعات المقيّمة بناءً على كلمة محددة. ينبغي أن يكون المستخدم قادرًا على الكتابة ضمن مربع إدخال نصي وستظهر نتائج الفلترة وفقًا للكلمة المكتوبة مباشرة أثناء الكتابة. يمكنك استخدام المكوّن البسيط TextInput، أو استخدام مكوّن أكثر عصرية مثل المكوّن Searchbar العائد للمكتبة React Native Paper على أنه مربع إدخال نصي. ضع مكوّن الإدخال النصي ضمن ترويسة المكوّن FlatList. ولتفادي تنفيذ عدد كبير من الاستعلامات أثناء كتابة المستخدم للكلمة بسرعة، اختر آخر سلسة كتبها بعد تأخير بسيط. تدعى هذه التقنية بالتريّث debouncing. تمثّل المكتبة use-debounce حلًا جيدًا لتأخير متغيّر الحالة، بعد أن تستخدم زمن انتظار معقول مثل 500 ميلي ثانية. خزّن قيمة النص الذي يُكتب في مربع الإدخال مستخدمًا الخطاف useState، ومرر القيمة المُؤخَّرة إلى الاستعلام كقيمة للوسيط searchKeyword. قد تعترضك مشكلة فقدان مكون الإدخال النصي تركيز الدخل بعد كتابة كل حرف، وذلك لأن المحتوى الذي تقدّمه الخاصية ListHeaderComponent سيُلغى باستمرار. يمكن حل المشكلة بتحويل المكوّن المسؤول عن تصيير المكوّن FlatList إلى مكوّن صنف class component ومن ثم تعريف دالة تصيير الترويسة إلى خاصية للصنف على النحو التالي: export class RepositoryListContainer extends React.Component { renderHeader = () => { // يحوي خاصيات المكون this.props const props = this.props; // ... return ( <RepositoryListHeader // ... /> ); }; render() { return ( <FlatList // ... ListHeaderComponent={this.renderHeader} /> ); } } ستبدو النسخة النهائية لميزة انتقاء المستودعات قريبةً من الصورة التالية: الترقيم بطريقة المؤشرات عندما تعيد الواجهة البرمجية قائمةً مرتبةً من العناصر من مجموعةٍ ما، فإنها ستعيد مجموعةً جزئيةً من العناصر من أصل المجموعة الكاملة لتقليل عرض حزمة الاتصال مع الخادم وتقليل الذاكرة المستخدمة لتخزين القائمة في تطبيق المستخدم. يمكن أن نطلب تلك المجموعة الجزئية لتوافق معاملات محددة لكي يستطيع المستخدم أن يطلب مثلًا أول عشرين عنصرًا منها ابتداءًا من عنصر محدد. تدعى هذه التقنية عادة بالترقيم pagination. وعندما نستطيع الحصول على قائمة بعد عنصر محدد بمؤشر معيّن، سنكون أمام الترقيم المبني على المؤشرات cursor-based pagination. فالمؤشر إذًا هو تحديدٌ لعنصر في قائمة مرتبة. لنلقي نظرةً على قائمة المستودعات المرقمة التي يعيدها الاستعلام repositories باستخدام الاستعلام التالي: { repositories(first: 2) { totalCount edges { node { id fullName createdAt } cursor } pageInfo { endCursor startCursor hasNextPage } } } يبلِّغ الوسيط first الواجهة البرمجية ان تعيد فقط أول مستودعين. وإليك مثالًا عن الاستجابة لهذا الاستعلام: { "data": { "repositories": { "totalCount": 10, "edges": [ { "node": { "id": "zeit.next.js", "fullName": "zeit/next.js", "createdAt": "2020-05-15T11:59:57.557Z" }, "cursor": "WyJ6ZWl0Lm5leHQuanMiLDE1ODk1NDM5OTc1NTdd" }, { "node": { "id": "zeit.swr", "fullName": "zeit/swr", "createdAt": "2020-05-15T11:58:53.867Z" }, "cursor": "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=" } ], "pageInfo": { "endCursor": "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=", "startCursor": "WyJ6ZWl0Lm5leHQuanMiLDE1ODk1NDM5OTc1NTdd", "hasNextPage": true } } } } يعتمد تنسيق الكائن الناتج والوسطاء على مواصفات اتصالات مؤشر أرضية عمل GraphQL للترحيل، والتي أصبحت مواصفات ترقيم صفحات شائعة جدًا واعتُمدت على نطاقٍ واسع في واجهة برمجة تطبيقات GitHub's GraphQL API. سيحتوي الكائن الذي يحمل النتيجة مصفوفةً باسم edges تحتوي بدورها على عناصر لها سمتين الأولى هي العقدة node والأخرى هي المؤشر cursor؛ إذ تحتوي العقدة على المستودع بحد ذاته كما نعرف؛ أما المؤشر فهو تمثيلٌ مشفّر للعقدة بأسلوب "Base64". ويحتوي المؤشر على المعرّف id للمستودع بالإضافة إلى تاريخ إنشائه. هذه هي كل المعلومات التي نريدها لكي نشير إلى العنصر عندما تُرتَّب العناصر وفق تاريخ إنشاء المستودع. يحتوي الكائن pageInfoعلى معلومات مثل المؤشرات على بداية ونهاية المصفوفة. دعونا نتأمل الحالة التي نريد فيها مجموعة العناصر التي تلي آخر عنصر من المجموعة الحالية وهو العنصر "zeit/swr". يمكننا إسناد قيمة الحقل endCursor إلى الوسيط after للاستعلام على النحو التالي: { repositories(first: 2, after: "WyJ6ZWl0LnN3ciIsMTU4OTU0MzkzMzg2N10=") { totalCount edges { node { id fullName createdAt } cursor } pageInfo { endCursor startCursor hasNextPage } } } والآن سنحصل على العنصرين التاليين، وسنستمر في هذه العملية حتى يأخذ الحقل hasNextPage القيمة false، وهذا يعني أننا وصلنا آخر القائمة، ولتتعمق أكثر في الترقيم المتعلق بالمؤشرات، إقرأ المقالة التي عنوانها Pagination with Relative Cursors على موقع Shopify، حيث تقدم المقالة الكثير من التفاصيل عن طريقة كتابة الشيفرة بالإضافة إلى الفوائد من استخدام الترقيم بالمؤشرات مقارنةً بالترقيم وفق ترتيب العنصر index-based. التمرير اللانهائي تُصمّم القوائم المرتبة عموديًا في تطبيقات سطح المكتب أو تطبيقات الهواتف الذكية بتقنية تدعى التمرير اللانهائي - infinite scrolling. ويعتمد مبدأ هذه التقنية على النقاط التالية: إحضار المجموعة الأولية من العناصر. عند وصول المستخدم إلى آخر عنصر من هذه المجموعة، يجري إحضار المجموعة التالية من العناصر التي تبدأ من العنصر الذي يلي آخر عنصر من المجموعة السابقة. تتكرر الخطوة الثانية حتى يتوقف المستخدم عن تمرير شريط القائمة أو عندما يتجاوز حدًا معينًا من مرات التمرير. ويشير الاسم "تمرير لانهائي" إلى أن القائمة ستبدو لانهائية، فسيبقى المستخدم قادرًا على تمرير العناصر ضمن الشريط وستُعرض العناصر بصورةٍ مستمرة أيضًا. لنرى كيف سننفذ ذلك عمليًا من خلال خطاف Apollo Client الذي يُدعى useQuery. يضم توثيق Apollo Client تفاصيل كثيرة عن طريقة تنفيذ الترقيم باستخدام المؤشرات. لننجز إذًا ميزة التمرير اللانهائي لعرض قائمة المستودعات المقيّمة. علينا أولًا أن نحدد متى يصل المستخدم إلى آخر عنصر في القائمة. ولحسن الحظ، يمتلك المكون FlatList الخاصية onEndReached التي تستدعى الدالة التي ستنفذ العملية عندما يصل المستخدم إلى آخر عنصر من القائمة. يمكنك التحكم بوقت استدعاء الدالة onEndReach باستخدام الخاصية onEndReachedThreshold. غيِّر شيفرة المكوّن FlatList الموجود ضمن المكوّن RepositoryList لكي يستدعي الدالة حالما يصل المستخدم إلى العنصر الأخير: export const RepositoryListContainer = ({ repositories, onEndReach, /* ... */, }) => { const repositoryNodes = repositories ? repositories.edges.map((edge) => edge.node) : []; return ( <FlatList data={repositoryNodes} // ... onEndReached={onEndReach} onEndReachedThreshold={0.5} /> ); }; const RepositoryList = () => { // ... const { repositories } = useRepositories(/* ... */); const onEndReach = () => { console.log('You have reached the end of the list'); }; return ( <RepositoryListContainer repositories={repositories} onEndReach={onEndReach} // ... /> ); }; export default RepositoryList; جرّب عملية التمرير إلى نهاية قائمة المستودعات، وستظهر رسالةً في سجل العمل مفادها أنك وصلت إلى العنصر الأخير. علينا الآن أن نحضر مزيدًا من المستودعات في اللحظة التي نصل فيها إلى نهاية العناصر. يمكن إنجاز ذلك باستخدام الدالة fetchMore التي يؤمنها الخطاف useQuery. يمكننا استخدام سياسة الحقل field policy لوصف عميل Apollo وكيفية دمج المستودعات الموجودة في ذاكرة التخزين المؤقت مع المجموعة التالية من المستودعات، إذ تُستخدم سياسات الحقول عمومًا لتخصيص سلوك ذاكرة التخزين المؤقت أثناء عمليات القراءة والكتابة باستخدام دوال القراءة والدمج. دعنا نضيف سياسة حقل للاستعلام repositories في ملف "apolloClient.js" على النحو التالي: import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; import { setContext } from '@apollo/client/link/context'; import Constants from 'expo-constants'; import { relayStylePagination } from '@apollo/client/utilities'; const { apolloUri } = Constants.manifest.extra; const httpLink = createHttpLink({ uri: apolloUri, }); const cache = new InMemoryCache({ typePolicies: { Query: { fields: { repositories: relayStylePagination(), }, }, }, }); const createApolloClient = (authStorage) => { const authLink = setContext(async (_, { headers }) => { try { const accessToken = await authStorage.getAccessToken(); return { headers: { ...headers, authorization: accessToken ? `Bearer ${accessToken}` : '', }, }; } catch (e) { console.log(e); return { headers, }; } }); return new ApolloClient({ link: authLink.concat(httpLink), cache, }); }; export default createApolloClient; كما ذكرنا سابقًا، يعتمد تنسيق كائن نتيجة ترقيم الصفحات والوسطاء على مواصفات ترقيم الصفحات الخاصة بالترحيل Relay، ولحسن الحظ، يوفر Apollo Client سياسة حقل محددة مسبقًا، relayStylePagination، والتي يمكن استخدامها في هذه الحالة. غيّر في شيفرة الخطاف useRepositories بحيث يعيد الدالة fetchMore بعد فَكِّ تشفيرها، بحيث تستدعي الدالة fetchMore الفعلية مع الحقل endCursor، ثم يُحدّث بعدها الاستعلام بصورة صحيحة وفقًا للبيانات المُحضرة: const useRepositories = (variables) => { const { data, loading, fetchMore, ...result } = useQuery(GET_REPOSITORIES, { variables, // ... }); const handleFetchMore = () => { const canFetchMore = !loading && data?.repositories.pageInfo.hasNextPage; if (!canFetchMore) { return; } fetchMore({ variables: { after: data.repositories.pageInfo.endCursor, ...variables, }, }); }; return { repositories: data?.repositories, fetchMore: handleFetchMore, loading, ...result, }; }; تأكد من وجود الحقلين pageInfo و cursor في الاستعلام repositories كما هو موضح في مثال الترقيم. كما ينبغي عليك أن تضيف الوسيطين first و after إلى الاستعلام. ستستدعي الدالة handleFetchMore الدالة fetchMore العائدة إلى Apollo client إن كان هناك المزيد من العناصر التي ينبغي إحضارها، وذلك بناء على قيمة الخاصية hasNextPage، ومن المفترض أن نمنع إحضار عناصر جديدة طالما أن عملية الإحضار سابقةً لا تزال قيد التنفيذ، إذ ستكون قيمة الحقل loading في هذه الحالة true. سنزود الاستعلام في الدالة fetchMore بالمتغير after الذي يتلقى آخر قيمة endCursor للحقل. وتكون الخطوة الأخيرة استدعاء الدالة fetchMore ضمن دالة معالجة الحدث onEndReach: const RepositoryList = () => { // ... const { repositories, fetchMore } = useRepositories({ first: 8, // ... }); const onEndReach = () => { fetchMore(); }; return ( <RepositoryListContainer repositories={repositories} onEndReach={onEndReach} // ... /> ); }; export default RepositoryList; استخدم قيمة صغيرة نسبيًا (8 مثًلا) للوسيط first عندما تجرب التمرير اللانهائي، وهكذا لن تضطر إلى عرض كثيرٍ من المستودعات، وقد تواجهك أيضًا مشكلة الاستدعاء المباشر لمعالج الحدث onEndReach بعد تحميل واجهة العرض، والسبب على الأرجح، هو العدد الكبير للمستودعات في القائمة، وبالتالي ستصل إلى نهاية القائمة مباشرةً. يمكنك الالتفاف على هذه المشكلة بزيادة قيمة الوسيط first. وبمجرد أن ترى أن قائمة التمرير اللانهائي تعمل جيدًا، يمكنك تجريب قيم كبيرة للوسيط first. التمرينات 10.25- 10.27 10.15: شريط تمرير لانهائي لقائمة المستودعات المقيّمة نفذ شريط تمرير لانهائي لقائمة المستودعات المقيّمة. يمتلك الحقل reviews العائد للنوع "Repository" الوسيطينafter و first كما في الاستعلام repositories. ويمتلك النوع "ReviewConnection" الحقل pageInfo كما يمتلكه النوع "RepositoryConnection". إليك مثالًا عن استعلام: { repository(id: "jaredpalmer.formik") { id fullName reviews(first: 2, after: "WyIxYjEwZTRkOC01N2VlLTRkMDAtODg4Ni1lNGEwNDlkN2ZmOGYuamFyZWRwYWxtZXIuZm9ybWlrIiwxNTg4NjU2NzUwMDgwXQ==") { totalCount edges { node { id text rating createdAt repositoryId user { id username } } cursor } pageInfo { endCursor startCursor hasNextPage } } } } يمكن أن تتشابه سياسة حقل ذاكرة التخزين المؤقت مع استعلام repositories: const cache = new InMemoryCache({ typePolicies: { Query: { fields: { repositories: relayStylePagination(), }, }, Repository: { fields: { reviews: relayStylePagination(), }, }, }, }); استخدم قيمةً صغيرةً نسبيًا للوسيط first عندما تحاول إنجاز قائمة التمرير اللانهائي، وربما ستضطر إلى إنشاء عدة مستخدمين جدد لكي تُقيّم عددًا من المستودعات، وبذلك تصبح قائمة المستودعات المقيّمة طويلة بما يكفي لتجرّب شريط التمرير. اجعل قيمة الوسيط كبيرةً بما يكفي لعدم استدعاء معالج الحدث onEndReach مباشرةً عند عرض الواجهة، وصغيرةً في نفس الوقت بحيث يعرض الشريط المستودعات من جديد عند الوصول إلى نهاية القائمة. وبمجرد أن ترى أن قائمة التمرير اللانهائي تعمل جيدًا، يمكنك تجريب قيم كبيرة للوسيط first. 10.26: واجهة عرض لتقييمات المستخدم أنجز واجهة تُمكِّن المستخدم من عرض ما قيّمه. ينبغي أن يكون المستخدم قادرًا على الوصول إلى هذه الواجهة من خلال الضغط على النافذة "My reviews" في شريط التطبيق بمجرد أن يسّجل دخوله. يمكنك تطبيق أسلوب التمرير اللانهائي إن أردت في هذا التمرين. وإليك الشكل الذي يمكن أن تظهر عليه هذه الواجهة: تذكر أنه بإمكانك الحصول على المستخدم الذي سجّل دخول من خادم Apollo مستعينًا بالاستعلام me. سيعيد الاستعلام النوع "User" الذي يمتلك الحقل review. إن كنت قد أنجزت مسبقًا الاستعلام me يمكنك إعادة استخدامه بعد جعله قادرًا على إحضار الحقل review شرطيWh، وذلك بالاستفادة من توجيه GraphQL الذي يُدعى include. لنفترض أن الاستعلام الحالي قد أنجز بصورةٍ قريبة من التالي: const GET_CURRENT_USER = gql` query { me { # user fields... } } `; يمكنك تزويد الاستعلام بالوسيط includeReviewواستخدام ذلك مع التوجيه include: const GET_CURRENT_USER = gql` query getCurrentUser($includeReviews: Boolean = false) { me { # user fields... reviews @include(if: $includeReviews) { edges { node { # review fields... } cursor } pageInfo { # page info fields... } } } } `; يمتلك الوسيط includeReview القيمة الافتراضية false، لأننا لا نريد أن نسبب حملًا زائدًا على الخادم ما لم نرد صراحةً إحضار المستودعات التي قيّمها المستخدم. إن مبدأ هذا التوجيه بسيط على النحو التالي: إن كانت قيم الوسيط if هي true، أحضر الحقل، وإلا احذفه. 10.27: إضافة أفعال إلى واجهة عرض التقييمات بعد أن أصبح المستخدم قادرًا على استعراض ما قيّمه، لنضف إلى الواجهة بعض الأفعال. ضع أسفل كل تقيم زرين، أحدهما لعرض المستودع، بحيث ينتقل المستخدم بالضغط عليه إلى واجهة عرض مستودع واحد، والآخر لحذف تقييم هذا المستودع. إليك ما قد يبدو عليه الوضع بعد إضافة الزرين: ينبغي أن يلي الضغط على زر الحذف رسالة تأكيد للحذف، فإن أكد المستخدم أمر الحذف ستُنفّذ العملية، وإلا سيُهمل الأمر. يمكنك إنجاز ذلك باستخدام الوحدة البرمجية Alert. وانتبه إلى أن استدعاء التابع Alert.alert لن يفتح رسالة التأكيد في واجهة عرض المنصة Expo، بل عليك استخدام تطبيق جوّال Expo أو المقلّد لترى كيف ستبدو هذه الرسالة. إليك الرسالة التي ينبغي أن تظهر عند الضغط على زر الحذف: يمكنك حذف التقييم باستخدام الطفرة deleteReview. تمتلك هذه الطفرة وسيطًا واحدًا وهو المعرّف id للتقييم الذي سيُحذف. ومن السهل بعد تنفيذ الطفرة تحديث استعلام قائمة المستودعات باستدعاء الدالة refetch. هكذا نكون قد وصلنا إلى التمرين الأخير في هذا المقال، وقد حان وقت تسليم الإجابات إلى GitHub والإشارة إلى التمارين التي أكملتها في منظومة تسيم التمارين. انتبه إلى وضع تمارين هذا المقال في القسم 4 من منظومة التسليم. مصادر إضافية طالما أننا وصلنا إلى نهاية هذا القسم، دعونا نلقي نظرةً على بعض المصادر الهامة للتطوير باستخدام React Native. سنبدأ من توثيق React Native باللغة العربية الذي تقدمه أكاديمية حسوب، والتي تقدم أيضًا توثيق React باللغة العربية، بالإضافة إلى القسم المتخصص باللغة React على موقع الأكاديمية، كما نشير إلى الموقع Awesome React Native الذي يقدم قائمةً مهمةً جدًا من المصادر مثل المكتبات والدورات التعليمية. وبما أن القائمة طويلة جدًا، لنطلع على بعض النقاط. المكتبة React Native Paper المكتبة Paper هي مجموعةٌ من المكوّنات المخصصة والجاهزة للاستخدام في React Native، مبنيةٌ على معايير تصميم Material التي وضعتها غوغل Google. صُممت React Native Paper من أجل لتقدم وظيفة تماثل وظائف المكتبة Material-UI، إذ تقدم مجموعةً واسعةً من مكوّنات واجهة المستخدم UI عالية الجودة التي تدعم إنشاء سمات مخصصة للتطبيق. وتتميز إعدادات استخدام React Native Paper مع تطبيقات React Native المبنية على منصة Expo بالسهولة، حيث يمكنك تجربتها مباشرة في التمارين القادمة إن أردت. المكتبة Styled-components سيعطيك استخدام القوالب المعرّفة المجرّدة tagged template literal -وهي إضافةٌ جديدة في JavaScript-، مع المكتبة styled-components إمكانية كتابة شيفرة CSS فعلية لتنسيق المكوّنات، كما يلغي استخدام هذه المكتبة الحاجة إلى الربط بين المكوّنات والتنسيق، وسيكون استخدام المكوّنات مثل وحدات بناءً تنسيقات على المستوى البرمجي المنخفض سهلًا جدًا. تستخدم المكتبة Styled-components لتنسيق مكوّنات React Native باستخدام تقنية CSS-in-JS. لقد اعتدنا على تعريف تنسيق المكوّنات في React Native باستخدام كائنات JavaScript، وبالتالي لن يكون استخدام CSS-in-JS غريبًا. لكن مقاربة المكتبة Styled-components ستختلف تمامًا عن استخدام التابع StyleSheet.createو الخاصية style. يُعرّف تسيق المكوّن في Styled-components باستخدام ميزة تُدعى القوالب المعرّفة المجرّدة tagged template literal أو كائنات جافا سكربت JavaScript الصِّرفة، التي تُمكننا من تعريف خصائص تنسيق جديدة للمكونات بناءً على خصائص تلك المكوّنات في زمن التنفيذ. سيتيح ذلك الكثير من الإمكانات مثل الانتقال بين السمات القاتمة والمضيئة، كما أنها تدعم كليًا موضوع السمات. إليك مثالًا عن إنشاء المكوّن Text مع إمكانية تغيير تنسيقه بناءً على خصائصه: import styled from 'styled-components/native'; import { css } from 'styled-components'; const FancyText = styled.Text` color: grey; font-size: 14px; ${({ isBlue }) => isBlue && css` color: blue; `} ${({ isBig }) => isBig && css` font-size: 24px; font-weight: 700; `} `; const Main = () => { return ( <> <FancyText>Simple text</FancyText> <FancyText isBlue>Blue text</FancyText> <FancyText isBig>Big text</FancyText> <FancyText isBig isBlue> Big blue text </FancyText> </> ); }; وطالما أن المكتبة styled-components ستعالج تعريف التنسيق، يمكننا استخدام أسلوب كتابة الأفعى snake case المشابه لطريقة كتابة CSS لتسمية الخصائص والواحدات (مثل % أو px)، لكن لا أهمية في الواقع للواحدات، لأن قيم خصائص التنسيق لا تقبل ذلك. للاطلاع على مزيدٍ من المعلومات عن استخدام هذه المكتبة، اقرأ التوثيق الخاص بها. المكتبة React-spring react-spring هي مكتبة رسوم متحركة أساسها المكتبة spring-physics، تغطي معظم احتياجاتك لواجهات مزودة برسوم متحركة، إذ تؤمن لك هذه المكتبة أدوات بالمرونة الكافية لتحويل جميع أفكارك إلى واجهات متحركة. تؤمن لك المكتبة React-spring واجهة برمجية ذات خطافات hook API واضحة الاستخدام لتحريك مكونات React Native. المكتبة React Navigation تؤمن مكتبة React Navigation التنقل والتوجه في تطبيقات React Native هي مكتبةٌ للتحكم بالتنقلات بين وجهات مختلفة في تطبيقات React Native. تتشابه في بعض النواحي مع المكتبة React Router. لكن وعلى خلاف React Router، تقدم مميزات أكثر قربًا للمنصة الأصيلة مثل الإيماءات الأصيلة والرسوميات الانتقالية التي تظهر عند التنقل بين الواجهات المختلفة للتطبيق. كلمة ختامية وهكذا نكون قد أكملنا تطبيقنا وأصبح جاهزًا. لقد تعلمنا خلال هذه الرحلة العديد من المفاهيم الجديدة، مثل إعداد تطبيقات React Native باستخدام المنصة Expo، وطريقة العمل مع مكوّنات React Native البنيوية، وتنسيق مظهر هذه المكوّنات، إضافةً إلى الاتصال مع الخادم واختبار التطبيقات. ستكون الخطوة الأخيرة هي نشر التطبيق على متجر Apple App أو متجر Google Play. يبقى موضوع نشر التطبيق خيارًا شخصيًا وليس أمرًا مُلحًّا، لأنك ستحتاج أيضًا إلى نشر وتوزيع الخادم rate-repository-api، أما من أجل تطبيق React Native بحد ذاته، فعليك أن تُنشئ نسخة بناء لمنصة iOS أو لمنصة أندرويد باتباع الإرشادات في توثيق Expo. وبعد ذلك ستتمكن من توزيع هذه النسخ على متجري Apple App أو Google Play، وستجد تفاصيل عن هذه الخطوة أيضًا في توثيق Expo. ترجمة -وبتصرف- للفصل Testing and extending our application من سلسلة [Deep Dive Into Modern Web Development اقرأ أيضًا المقال السابق: تواصل تطبيق React Native مع الخادم أساسيات React Native مدخل إلى التحريك في React Native
  8. ينبغي أن يبقى الفرع الرئيسي دائمًا في المنطقة الخضراء، ويعني ذلك تنفيذ كل خطوات بناء خط الإنتاج بنجاح، إذ يجب أن يُبنى المشروع بنجاح، وأن تُنفَّذ الاختبارات دون أخطاء، ولا يجب أن يعترض المدقق على أي شيء. ما أهمية هذا الأمر؟ من المرجح أنك ستنشر شيفرتك إلى العالم الخارجي من الفرع الرئيسي تحديدًا، وأي إخفاق في هذا الفرع سيعني أنّ الميزات الجديدة لن تُنشر حتى تُحل المشكلة؛ فقد تكتشف أحيانًا ثغرات في نسخة الإنتاج لم يلتقطها خط الإنتاج لمنظومة التكامل المستمر CI/CD، وفي حالات كهذه، سترغب في التراجع إلى النسخة السابقة بطريقة آمنة. كيف ستُبقي فرعك الرئيسي في المنطقة الخضراء إذًا؟ تحاشى دفع أية تغييرات إلى الفرع الرئيسي مباشرةً، بل ادفع شيفرتك إلى فرع آخر يمثّل أحدث نسخة ممكنة للفرع الرئيسي، وعندما ترى أن هذا الفرع جاهزٌ للدمج مع الرئيسي، أنشئ طلب سحب Pull Request -أو اختصارًا PR- على غيت هب GitHub. العمل مع طلبات السحب تمثل طلبات السحب جزءًا جوهريًا من عملية التعاون في تطوير أي مشروع برمجي يُنفَّذه مساهمَين على الأقل، فعندما تُجري أية تعديلات على مشروعك، ستختبرها على جهازك أولًا، ثم تعتمد هذه التغييرات وتدفعها إلى مستودع على الإنترنت -وهو غيت هب GitHub في حالتنا-، ثم تقدم طلب سحب لكي يراجع أحدٌ ما هذه التغييرات قبل أن تُدمج مع الفرع الرئيسي. هناك عدة أسباب تدفعنا لاستخدام طلبات السحب، كما أنّ مراجعة الشيفرة من قبل شخص آخر على الأقل فكرةٌ جيدةٌ دائمًا. قد تتواجد بعض الثغرات حتى في شيفرة المطورين الخبراء، وهذا مشابهٌ لمشكلة الرؤية النفقية tunnel، إذ لا يستطيع المصاب بها سوى رؤية ما أمامه مباشرة وكأنه ينظر من خلال نفق. يمتلك المراجع منظورًا مختلفًا، فقد يقدم وجهة نظر مختلفة. سيكون هناك مطورٌ واحد على الأقل على دراية بالتغيرات التي أجريتها بعد قراءتها. ستسمح لك طلبات السحب أن تُنفِّذ تلقائيًا كل المهام في خط الإنتاج لمنظومة CI قبل أن تصل الشيفرة إلى الفرع الرئيسي، وستزودك GitHub Actions بآلية لتنفيذ طلبات السحب. يمكنك تهيئة مستودع GitHub بطريقة تمنع دمج الشيفرة المرتبطة بطلب سحب إلى أن يُوافق عليها. لتقديم طلب سحب جديد، افتح فرعك في GitHub بالضغط على الزر الأخضر "Compare & pull request" في الأعلى، وسيظهر لك نموذج لتعبئته بوصفٍ لطلب السحب الذي تقدمه. تقدم لك واجهة طلبات السحب في GitHub إمكانية وصف الطلب ومناقشته، حيث تَظهر لك في الأسفل جميع علامات التحقق من منظومة CI (وهي في حالتنا كل فعل من أفعال GitHub Actions التي نستخدمها) التي هُيِّئت للعمل عند كل طلب سحب وحالة هذه العلامات. وما نهدف إليه فعلًا هو لوحة خضراء. يمكنك النقر على تفاصيل "Details" كل علامة تحقق للاطلاع عليها وتشغيل السجلات. تُنفَّذ جميع مخططات العمل التي واجهناها حتى الآن عند دفع الشيفرة إلى الفرع الرئيسي، ولجعلها تعمل عند كل طلب سحب، لا بدّ من تحديث الجزء المسؤول عن مسببات triggers إقلاع المخطط. سنستخدم المسبب "pull_request" للفرع الرئيسي ونربط هذا المسبب بالحدثين "opened"و "synchronize"؛ ويعني هذا مبدئيًا أن المخطط سيُنفَّذ عندما يُقدَّم طلب سحب إلى الفرع الرئيسي، أو عندما يُحدّث. لنغيّر إذًا الأحداث التي تسبب تشغيل مخطط العمل على النحو التالي: on: push: branches: - master pull_request: branches: [master] types: [opened, synchronize] سنمنع قريبًا دفع الشيفرة مباشرةً إلى الفرع الرئيسي، لكننا سننفذ حاليًا مخطط العمل لكل عمليات دفع الشيفرة المباشر إلى الفرع الرئيسي. التمرينان 11.13 - 11.14 يعمل مخطط العمل على النحو المطلوب للحفاظ على نوعية جيدة لشيفرتنا، لكن طالما أنه يُقلع عند دفع الشيفرة إلى الفرع الرئيسي، فسيلتقط الأخطاء متأخرًا. 11.13: طلب سحب حَدِّث مسببات إقلاع مخطط العمل كما اقترحنا سابقًا ليعمل عند تقديم طلب سحب جديد إلى الفرع الرئيسي. أنشئ فرعًا جديدًا وادفع بشيفرتك إليه، ثم افتح طلب سحب إلى الفرع الرئيسي. إذا لم تكن قد تعاملت مع الفروع سابقًا اطلع على المقالات التالية: مقدمة عن التفريع Branching في Git إدارة التفريعات (branches) في Git واستخدامها لتخطيط سير العمل إعادة تأسيس تفريعات طلب السحب وتحديثه في git تنبيه: تأكد عندما تفتح طلب سحب جديد أنك اخترت مستودعك الخاص واجهةً أساسية، إذ سيكون الاختيار افتراضيًا للمستودع الأصلي ولا يجب أن تفعل هذا: سترى في النافذة "Conversation" ضمن واجهة فتح طلب سحب آخر عملية (أو عمليات) دفع للشيفرة، كما سترى الحالة الصفراء لعلامات التحقق الذي يجري تنفيذه: ينبغي أن تكون العلامات باللون الأخضر بعد أن تنتهي عمليات التحقق. تأكد من نجاح كل اختبارات التحقق، ولا تدمج فرعك الآن، فهناك نقاط ينبغي تحسينها في خط الإنتاج. 11.14: تشغيل خطوة النشر للفرع الرئيسي فقط كل شيء يبدو على ما يرام، لكن هناك مشكلةٌ حقيقية في مخطط العمل الحالي، إذ ستُنفَّذ جميع الخطوات بما فيها خطوة النشر عندما نفتح طلب سحب، وبالتأكيد لا نريد حدوث هذا الأمر. ولحسن الحظ، هناك حلٌ بسيط لهذه المشكلة، إذ يمكننا استخدام العبارة الشرطية if في خطوة النشر لكي تضمن عدم تنفيذ هذه الخطوة إن لم تُدمج الشيفرة أو تُدفع إلى الفرع الرئيسي. يتضمن سياق context شتى أنواع المعلومات حول الشيفرة التي ينفذها مخطط العمل. ستجد المعلومات المتعلقة بالموضوع في GitHub context، إذ يمثل الحقل event_name اسم الحدث الذي يسبب تنفيذ المخطط. عندما يُدمج طلب سحب، سيظهر اسم الحدث على أنه "push"، وهو نفسه الحدث الذي يقع عندما تُدفع الشيفرة إلى المستودع، لذلك سنحصل على السلوك المطلوب بإضافة العبارة الشرطية التالية إلى خطوة النشر: if: ${{ github.event_name == 'push' }} ادفع بشيفرة إضافية إلى فرعك، وتأكد من عدم تنفيذ خطوة النشر. ادمج بعد ذلك فرعك مع الفرع الرئيسي وتأكد من حدوث خطوة النشر. إدارة إصدار نسخ البرمجيات الغاية الرئيسية من تحديد الإصدار هي التعريف الفريد للبرمجية التي تعمل والشيفرة المتعلقة بها. يشكّل ترتيب الإصدارات أيضًا جزءًا مهمًا من المعلومات، فلو احتوى الإصدار الحالي لبرنامج ما، على سبيل المثال وظيفةً حيويةً معطلة، سيلزمنا تحديد النسخة السابقة المستقرة من البرنامج لكي نتمكن من التراجع إليها. إدارة الإصدار بطريقة Semantic وبطريقة Hash تُدعى الطريقة التي يُدار بها إصدار برنامج في بعض الأحيان باستراتيجية إدارة الإصدار Versioning strategy. سنطلّع ونوازن استراتيجيتين في هذا المجال، تدعى الأولى الإدارة الدلالية لنُسخ البرمجيات Semantic، إذ يُكتب الإصدار بالشكل التالي: {major}.{minor}.{patch}. فلو كان إصدار نسخة من البرنامج 1.2.3، سيكون 1 هو رقم الإصدار الرئيسي الجذري major و 2 رقم الإصدار الثانوي (البسيط) minor و 3 هو رقم الإصدار الترميمي (الترقيع) patch. وعمومًا، فأية تغييرات تهدف لمعالجة الوظيفة المخفقة في إصدار دون المساس بالطريقة التي يعمل بها التطبيق من الخارج ستكون تغييرات ترميمية؛ أما التغييرات التي تؤدي إلى تغييرات بسيطة في الوظيفة (كما تبدو من الخارج) فستكون تغييرات ثانوية، وندعو تلك التغيرات التي ستؤثر كليًا على التطبيق (أو تغيير وظائفه بصورةٍ كبيرة) بالتغييرات الرئيسية، وقد يختلف تعريف هذه المصطلحات من مشروع لآخر. تتبع على سبيل المثال مكتبات npm ترميز semantic، ففي وقت كتابة هذه السطر (الثالث من آذار 2022)، يُعد الإصدار 17.0.2 هو الأحدث للمكتبة React إذ أن رقم الإصدار الرئيسي هو 17 والثانوي 0 والترميمي 1. أما الإصدار بترميز Hash (أو الترميز SHA) فهو مختلفٌ تمامًا، فرقم الإصدار هو سلسلة نصية عشوائية مشتقة من محتويات المستودع والتغيرات التي اعتمدت فيه. يُنفّذ ذلك في git تلقائيًا مثل رمز Hash معتمد وفريد لأي مجموعة من التغييرات. يستخدم الإصدار برمز غالبًا في الحالات المؤتمتة، فعملية نقل رمز Hash مكون من 32 محرف للتأكد من نشر كل شيء على النحو الصحيح عمليةٌ مضنية وتولّد الكثير من الأخطاء. إلى ماذا يشير الإصدار؟ تحديد الشيفرة الموجودة في كل إصدار أمرٌ مهمٌ جدًا، ويختلف أسلوبي الترميز السابقين في طريقة فعل ذلك؛ فالعملية في Hash (على الأقل في غيت هب GitHub) بسيطة ببساطة البحث عن تغييرات معتمدة للشيفرة commit لها رمز Hash معرّف ومحدد، إذ سيمنحنا ذلك إمكانية معرفة الشيفرة التي نُشرت في كل إصدار. سيتعقد الوضع قليلُا في semantic، فهناك ثلاث طرق لمقاربة الموضوع: الأولى تتعلق بالشيفرة نفسها، والثانية تتعلق بالمستودع أو البيانات الوصفية له، والثالثة تتعلق بأشياء خارج المستودع كليًا. طالما أننا لن نتطرق إلى المقاربة الثالثة (لأنها متشعبة مثل حجر الأرنب)، فستلاحظ أن العملية بسيطة أيضًا وكأنها جدول بيانات يربط بين الإصدار والشيفرة المعتمدة التي يدل عليها. تتلخص المقاربة المتعلقة بالشيفرة برقم إصدار النسخة في ملف، بينما تعتمد مقاربة المستودع وبياناته الوصفية على الوسوم tags أو في حالة GitHub على الإصدارات، إذ يشير الوسم أو الإصدار في هذه المقاربة إلى شيفرة مُعتمدة هي نفسها الموجودة في الإصدار. ترتيب إصدارات النسخ من السهل في semantic ترتيب الإصدارات حتى لو كانت مختلفة (رئيسي، ثانوي، ترميمي)، فسيأتي الإصدار 1.3.7 قبل 2.0.0 والذي سيأتي قبل 2.1.5 وهذا الأخير سيكون قبل 2.2.0. لكننا سنحتاج إلى قائمة بالإصدارت أو الوسوم ( يؤمنها مدير حزم GitHub بصورةٍ ملائمة) لمعرفة الإصدار الأخير، فمن الأسهل الاطلاع على هذه القائمة ومناقشتها، أي من الأسهل أن نقول أننا سنتراجع إلى الإصدار 3.2.4 بدلًا من التعامل بأنفسنا مع رموز hash التي تشير إلى رقم الإصدار. وطبعًا لا نعني إطلاقًا أنّ رموز hash غير ملائمة؛ فلو عرفت أي شيفرة معتمدة سببت مشكلة بعينها، فمن السهل البحث من خلال تاريخ عمليات GitHub والحصول على رموز hash للإصدار السابق؛ لكن لو كانت لديك سلسلتين من رموز Hash على النحو التالي مثلًا: d052aa41edfb4a7671c974c5901f4abe1c2db071 12c6f6738a18154cb1cef7cf0607a681f72eaff3 فلن تستطيع التمييز أيهما قبل الأخرى، وستحتاج إلى مساعدة إضافية مثل سجل عمل GitHub لكشف الترتيب الصحيح. الموازنة بين أسلوبي إدارة الإصدار لقد لمسنا بالمناقشة السابقة بعض إيجابيات وسلبيات كل أسلوب، لكن من الأفضل التطرق إلى مجال استعمال كل منهما. يعمل أسلوب semantic جيدًا عندما يكون لرقم الإصدار أهميةً خاصة أو عندما تكون هناك حاجة للبحث عنه. فكر مثلًا بمكتبات جافا سكربت JavaScript التي تستخدمها؛ فلو كنت تستخدم الإصدار 3.4.6 من مكتبة محددة وظهر تحديث لها يحمل الرقم 3.4.8، فيمكنك أن تأمل بأن انتقالك إلى الإصدار الأحدث سيكون آمنًا؛ لكن لو حمل الإصدار الجديد الرقم 4.0.1 فلن تضمن انتقالًا آمنًا إليه. يُناسب hash الحالات التي تُبنى فيها الشيفرة داخل أدوات برمجية artifact (مثل الملفات الثنائية القابلة للتنفيذ أو قوالب منصة Docker) قابلة بحد ذاتها للرفع أو التخزين، فإذا تطلبت اختباراتك على سبيل المثال بناء حزمتك داخل أداة برمجية، ثم رفعها إلى الخادم واختبارها، فمن الملائم عندها استخدام أسلوب Hash في تحديد الإصدار لأنه سيمنع وقوع الحوادث. تخيل على سبيل المثال أنك تعمل على الإصدار 3.2.2 وفشل أحد الاختبارات، أصلحت بعد ذلك الخلل ودفعت بشيفرتك إلى مستودعك، لكن طالما أنك تعمل ضمن مستودعك فلن تستطيع تحديث رقم الإصدار. لن يتغير اسم الأداة البرمجية دون تحديد إصدار hash. وإن كان هناك خطأ في رفع الأداة البرمجية، فقد يُنفَّذ الاختبار على الأداة الأقدم (طالما أنها لا تزال موجودة وبنفس الاسم). بينما لو أُعطيت الأداة رقم إصدار بأسلوب hash، ينبغي أن يتغير رقم الإصدار عند كل محاولة لدفع شيفرة معتمدة، ويعني هذا أنه في حال أخفق رفع الأداة، سيظهر خطأ لأن الأداة التي ستُنفَّذ عليها الاختبارات غير موجودة. يُعد ظهور الخطأ عندما يحدث شيء ما أفضل غالبًا من مشكلة تهملها منظومة CI بصمت. أفضل ما في الأسلوبين يمكن أن نستشف من المقارنة السابقة أن استخدام أسلوب semantic مناسب لإصدار برمجيات، بينما استخدام أسلوب hash أكثر منطقية أثناء نشر البرمجيات ولن يسبب هذا الأمر تعارضًا بالضرورة. فكر في ذلك على النحو التالي: تتلخص إدارة الإصدارات بأنها تقنية تشير إلى كمية معتمدة من الشيفرة وتعطيها اسمًا فليكن 3.5.5 لن يمنع ذلك طبعًا من إعطاء الشيفرة نفسها رمز hash. هناك أمر ما، فقد ذكرنا في بداية هذا القسم أنه علينا معرفة ما يحدث تمامًا في شيفرتنا، فعلينا أن نكون واثقين مثلًا بأننا اختبرنا الشيفرة التي نريد نشرها. سيعقد وجود أسلوبين متوازيين لإدارة الإصدار (أو تسميته) الأمور قليلًا. فعندما يكون هناك مشروع يستخدم نسخًا من أداة برمجية بحيث يُدار إصدار هذه النسخ بأسلوب hash أثناء اختبارها، يمكننا دائمًا تعقب نتيجة خطوات البناء والتدقيق والاختبار، وبالتالي سيعلم المطورون حالة هذه النسخ. ويحدث ذلك آليًا وبصورةٍ واضحة بالنسبة للمطور، فليس على المطور الاهتمام بما تفعله منظومة CI في الخفاء من حيث استخدام ترميز hash لتسمية النسخة واختبارها. عندما يدمج المطور شيفرته ضمن الفرع الرئيسي، تتولى منظومة CI الأمر من جديد، إذ ستبني هذه المرة وتختبر كل الشيفرة ومن ثم تمنحها رقم إصدار بأسلوب semantic، وتربط هذا الرقم بآخر شيفرة معتمدة ومختبرة وتحمل وسم git. سيكون البرنامج الذي سنصدره في الحالة السابقة مُختبرًا لأن منظومة CI ستتأكد أنّ الاختبارات قد نجحت على الشيفرة الموسومة بوسم git المحدد، وليس خطأً أن نقول أن المشروع سيستخدم أسلوب semantic في إدارة الإصدار ويتجاهل ببساطة فكرة أن منظومة CI ستختبر الفروع الخاصة بكل مطور أو طلبات السحب الخاصة بكل منهم بأسماء مبنية على أسلوب hash في إدارة الإصدار. نقول ذلك لأن النسخة التي تعنينا (التي سنصدرها) ستُمنح رقم إصدار semantic. التمرينان 11.15 - 11.16 لنوسّع مخطط العمل الذي نبنيه لكي يزيد تلقائيًا رقم الإصدار عندما تجري الموافقة على طلب سحب وتنفيذ عملية الدمج ضمن الفرع الرئيسي وأن يوسم النسخة المصّدرة برقم الإصدار. سنستخدم anothrNick/github-tag-action وهو فعل GitHub Actions مفتوح المصدر طوره أحدهم. 11.15: إضافة رقم إصدار سنوّسع مخطط العمل بإضافة خطوة واحدة: - name: Bump version and push tag uses: anothrNick/github-tag-action@1.36.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} سنمرر متغير البيئة environment variable التالي secrets.GITHUB_TOKEN إلى الفعل، وطالما أنّ مصدر الفعل طرف ثالث، سنحتاج إلى شهادة استيثاق token في مستودعك. يمكن الاطلاع أكثر عن الاستيثاق في توثيق GitHub. يمكن أن نمرر عدة متحولات بيئة إلى الفعل anothrNick/github-tag-action. تعدّل هذه المتغيرات الطريقة التي يُعرِّف بها الفعل الإصدارات. يمكنك الاطلاع على تفاصيل أكثر ضمن ملف README الخاص بالفعل على GitHub واستخدام ما يناسبك. وكما سترى في التوثيق سيزداد الرقم الثانوي لرقم الإصدار تلقائيًا minor bump. عدّل التهيئة في الشيفرة السابقة لكي يكون هناك زيادةٌ تلقائية في رقم الترميم patch bump لرقم الإصدار، أي سيزداد الرقم الأخير تلقائيًا. تذكر أننا نريد فقط زيادة رقم الإصدار عندما تطرأ تغييرات على الفرع الرئيسي. لذلك أضف العبارة الشرطية "if" لمنع زيادة رقم الإصدار عند فتح طلب سحب كما فعلنا في التمرين 11.14. أكمل مخطط العمل وحاول تجربته، ولا تضفه مثل خطوة إضافية فقط، وإنما أجري إعداده على أنه عمل منفصل يعتمد على العمل المهتم بتدقيق وكشف الأخطاء والفحص والنشر. أجرِ التغييرات التالية على تعريف مخطط العمل: name: Deployment pipeline on: push: branches: - master pull_request: branches: [master] types: [opened, synchronize] jobs: simple_deployment_pipeline: runs-on: ubuntu-20.04 steps: // steps here tag_release: needs: [simple_deployment_pipeline] runs-on: ubuntu-20.04 steps: // steps here تُنفَّذ أعمال مخطط العمل على التوازي كما ذكرنا سابقًا، لكن طالما أننا نريد إجراء التدقيق والاختبار والنشر أولاً، سنحتاج لضبط اعتمادية tag_release بحيث تنتظر عملًا آخر ليُنفّذ أولًا نظرًا لأننا لا نريد وضع علامة على الإصدار ما لم يجتاز الاختبارات ويُنشر. إذا لم تكن متأكدًا من إعدادات التهيئة، يمكنك ضبط قيمة DRY_RUN على true، وبالتالي سيعطي الفعل رقم الإصدار التالي دون إنشاء وسم للإصدار. حالما يعمل مخطط العمل على نحوٍ صحيح، سيشير المستودع إلى وجود بعض الوسوم: وبالنقر على هذه الوسوم ستُعرض ضمن قائمة بكل الوسوم (والتي تمثل آلية لتعريف الإصدار ووسمه): 11.16: تجاوز إنشاء وسوم لحزمة شيفرة معتمدة ونشرها من الأفضل في أغلب الأوقات نشر الفرع الرئيسي إلى وضع الإنتاج.، لكن قد تكون هناك بعض الأسباب المقنعة لتجاوز إنشاء وسم شيفرة معتمدة أو طلب سحب جرى الموافقة على دمجه أو نشرهما. عدّل إعداداتك لكي لا تُنشر إلى وضع الإنتاج أية شيفرة جرى دمجها إن احتوت رسالة طلب السحب الكلمة "skip#"، ولا توسم برقم الإصدار. تلميح: أسهل طريقة لإنجاز ذلك هو تبديل العبارة الشرطية "if" للخطوة المناسبة. ويمكنك كما فعلنا في التمرين 11.14 الحصول على المعلومات المطلوبة من سياق GitHub لمخطط العمل: يمكنك الإستعانة بالشيفرة التالية مثل نقطة انطلاق: name: Testing stuff on: push: branches: - main jobs: a_test_job: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v2 - name: gihub context env: GITHUB_CONTEXT: ${{ toJson(github) }} run: echo "$GITHUB_CONTEXT" - name: commits env: COMMITS: ${{ toJson(github.event.commits) }} run: echo "$COMMITS" - name: commit messages env: COMMIT_MESSAGES: ${{ toJson(github.event.commits.*.message) }} run: echo "$COMMIT_MESSAGES" لاحظ ما الذي طُبع في سجل مخطط العمل. ملاحظة: يمكنك الوصول إلى الشيفرة المعتمدة أو رسائلها عند الدفع أو الدمج مع الفرع الرئيسي فقط، وبالتالي لن تحمل الخاصية github.event.commits أية قيمة. لا حاجة لهذه القيمة على أية حال طالما أننا نرغب في تجاوز هذه الخطوة. ستحتاج غالبًا إلى الدالتين contains و join ضمن عبارة if الشرطية. لا يُعد نشر مخطط العمل أمرًا سهلًا، وغالبًا ما يكون الخيار المتاح هو المحاولة والخطأ، وينصح أحيانًا بوجود مستودع منفصل للتأكد من صحة تهيئة الإعدادات، ثم تُنقل هذه الإعدادات إلى المستودع الفعلي إن كانت صحيحة. كما يمكن أيضًا تثبيت أداة مثل act، التي تساعدك على تشغيل مخطط العمل على جهازك. وعندما تواجهك حالات مختلفة عن التي ناقشناها، كأن تنشئ أفعال خاصة بك، فإن اقتحام غمار أدوات مثل act وتجربتها سيستحق العناء. ملاحظة لاستخدام الأفعال التي يطورها طرف ثالث عندما تستخدم فعل يؤمنه طرف ثالث مثل github-tag-action، من الأفضل أن تحدد أسلوب hash لإدارة الإصدار بدلًا من رقم الإصدار. والسبب في ذلك أن رقم الإصدار الذي أنجز بوسم git قد يكون عرضة للتغيير، فالإصدار الذي يحمل الرقم 1.33.0 هذا اليوم على سبيل المثال، قد يشير إلى شيفرة مختلفة عن تلك الموجودة في نفس الإصدار لكن في الأسبوع القادم. مع ذلك، فالشيفرة المعتمدة على ترميز hash محددة لا تتغير أيًا كانت الظروف، لذا فإن أردنا التيقن 100% من الشيفرة التي نستخدمها، سيكون الخيار الأكثر أمانًا hash. إنّ الإصدار 1.33.0 من الفعل السابق يتوافق مع شيفرة ترميز hash لها هو الإصدار: eca2b69f9e2c24be7decccd0f15fdb1ea5906598، لذلك من الأفضل أن نغير إعدادات التهيئة على النحو التالي: - name: Bump version and push tag uses: anothrNick/github-tag-action@eca2b69f9e2c24be7decccd0f15fdb1ea5906598 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} وعلينا أن نثق عند استخدام أفعال تزودنا بها GitHub بأنها لن تعبث بوسوم الإصدار version tags وأن نختبر شيفرتها بدّقة. وقد ينتهي بك المطاف عند استخدام أفعال من طرف ثالث أمام أفعال تحتوي ثغرات أو حتى مثيرة للشبهة، وحتى لو لم تكن نية المطور الذي كتب الشيفرة سيئة، فقد ينس معلومات الاستيثاق الخاصة به في ركن إنترنت عام، ومن يدري ما قد يحدث بعدها. سنتأكد عند استخدام أسلوب hash أن هذه شيفرة التي نستخدمها لتشغيل مخطط العمل لن تتغير، لأن تغيير الشيفرة المعتمدة المسؤولة عن تنفيذ المخطط سيؤدي إلى تغيير hash. أبق الفرع الرئيسي آمنا سيسمح لك غيت هب أن تهيئ فروعًا محمية، فمن المهم حماية الفرع الأكثر أهمية الذي يجب ألا يخفق أبدًا وهو الفرع الرئيسي. يمكنك أن تختار بين عدة مستويات حماية عبر إعدادات المستودع. لن نغطي كل هذه الخيارات، لكن يمكنك طبعًا الاطلاع عليها من خلال توثيق GitHub. ومن وجهة نظر منظومة CI، ستتمثل الحماية الأكثر أهمية في نجاح خطوات التحقق من الحالة قبل أن يدمج طلب السحب ضمن الفرع الرئيسي. فلو أعددت أفعال GitHub لتنفذ مهام التدقيق والاختبار، فلن يُدمج طلب السحب قبل تصحيح جميع أخطاء التدقيق وقبل أن تنجح جميع الاختبارات. ونظرًا لكونك مدير مستودعك، ستجد خيارًا لتخطي التقييدات هذه. لن يظهر هذا الخيار لغير المدراء. لتهيئ إعدادات الحماية للفرع الرئيسي، توجه إلى إعدادات "Settings" المستودع من القائمة العليا ضمن المستودع. اختر من قائمة الجهة اليسرى فروع "Branches" ثم انقر زر أضف قاعدة "Add rule" بجوار العنصر قواعد حماية الفروع "Branches protection rules". اكتب نموذج لاسم الفرع ("master" سيكون مناسبًا)، ثم اختر طريقة الحماية التي تريد إعدادها. اختر على الأقل "يجب التحقق من نجاح خطوات التحقق من الحالة قبل الدمج Require status checks to pass before merging" لتضمن تسخير الإمكانات الكاملة لاستضافة GitHub Actions. وتحت هذا الخيار عليك اختيار "يجب أن تكون الفروع محدّثة قبل الدمج Require branches to be up to date before merging"، وفعّل جميع الخيارات التي تتحقق من الحالة قبل دمج أية طلبات سحب. التمرين 11.17 إضافة حماية إلى الفرع الرئيسي أضف حماية لفرعك الرئيسي. إذ عليك حمايته بضمان التالي: الموافقة على جميع طلبات السحب قبل دمجها. نجاح جميع خطوات التحقق من الحالة قبل الدمج. لا تفعِّل الخيار "Include administrators" حاليًا، لأنك لو فعلت ذلك فعليك الانتظار حتى يراجع شخص ما طلب السحب الذي قدمته قبل نشر الشيفرة. ترجمة -وبتصرف- للفصل Keeping Green من سلسلة Deep Dive Into Modern Web Development. اقرأ أيضًا المقال السابق: نشر التطبيقات وتوزيعها وفق نهج التسليم المستمر نشر تطبيقات الويب الموجهة لبيئة الإنتاج نشر تطبيقات iOS على متجر Apple Store
  9. استخدمنا في جزئية سابقة من هذه السلسلة صورتين مختلفتين هما "ubuntu" و "node" ونفّذنا بعض الأعمال يدويًا لتشغيل تطبيق "Hello, World". ستساعدنا الأدوات والأوامر التي تعلمناها سابقًا في هذه الجزئية من السلسلة، إذ نتعلم فيه بناء الصور وتهيئة بيئة عمل التطبيق. سنبدأ ببناء واجهة خلفية نمطية باستخدام Express/Node.js ثم نبني عليها مستخدمين خدمات أخرى مثل قواعد بيانات MongoDB. ملفات Dockerfile بإمكاننا إنشاء صورة جديدة تتضمن التطبيق "!Hello, World" بدلًا من تعديل الحاوية بنسخ ملفات جديدة إليها، وتساعدنا في ذلك أداة تُدعى Dockerfile، وهي ملفٌ نصي بسيط يحتوي كل التعليمات الخاصة بإنشاء صورة. سنبدأ إذًا بإنشاء مثال عن Dockerfile من تطبيق "!Hello, World". أنشئ مجلدًا جديدًا على جهازك ثم أنشئ ضمنه الملف "Dockerfile " إن لم تكن قد فعلت ذلك مسبقًا. ولنضع كذلك الملف "index.js" الذي يضم الشيفرة ('!console.log('Hello, World إلى جواره. ستبدو هيكلية المجلد على النحو التالي: ├── index.js └── Dockerfile سنخبر الصورة من خلال "Dockerfile" بثلاثة أمور: استخدم node:16 أساسًا للصورة. ضع الملف "index.js" ضمن الصورة، كي لا نُضطر إلى نسخه يديويًا إلى الحاوية. استخدم node لتنفيذ شيفرة الملف "index.js" عندما نشغّل الحاوية من الصورة. توضع هذه النقاط الثلاث ضمن ملف "Dockerfile" وأفضل مكان لإنشاء هذا الملف هو جذر المشروع، وسيبدو هذا الملف على النحو التالي: FROM node:16 WORKDIR /usr/src/app COPY ./index.js ./index.js CMD node index.js FROM: تخبر هذه التعليمة برنامج دوكر Docker أنّ أساس الصورة هو node:16. COPY: تنسخ هذه التعليمة الملف index.js من الجهاز المضيف إلى ملف بنفس الاسم ضمن الصورة. CMD: تخبر هذه التعليمة البرنامج ما يجب أن يحدث عند تنفيذ الأمر docker run. وهذه التعليمة هي تعليمة تنفيذ افتراضية يمكن استبدالها بالمعامل الذي يُعطي بعد اسم الصورة. اكتب الأمر docker run --help إن نسيت. WORKDIR: وضعت هذه التعليمة للتأكد من أننا لن نفعل شيئًا يتداخل مع محتوى الحاوية. إذ سيضمن أنّ كل التعليمات التي ستليه ستكون ضمن المجلد "usr/src/app/" الذي يُعد مجلد العمل في هذه الحالة. فإن لم يكن هذا المجلد موجودًا في الصورة، سيُنشأ تدريجيًا. لم نحدد مجلد عمل WORKDIR، فقد نجازف بتغيير ملفات هامة بطريق الخطأ. لو تحققت من الجذر / للصورة بتنفيذ الأمر docker run node:16 ls ستجد العديد من المجلدات والملفات في هذه الصورة. نستطيع الآن استخدام الأمر docker build لبناء الصورة بناءً على ملف Dockerfile، لكننا سنضيف الراية t- إلى هذا الأمر كي تساعدنا في إعادة تسمية الصورة: $ docker build -t fs-hello-world . [+] Building 3.9s (8/8) FINISHED ... ستكون نتيجة تنفيذ الأمر هي: "docker please build with tag fs-hello-world the Dockerfile in this directory" والتي تشير إلى بناء صورة بالاسم "fs-hello-world" بالاعتماد على ملف Dockerfile الموجود في المجلد. يمكنك الإشارة إلى أي ملف Dockerfile لكن في حالتنا البسيطة يكفي وضع . للإشارة إلى هذا الملف ضمن المجلد لهذا انتهى الأمر بالنقطة. يمكنك تشغيل الحاوية الآن باستخدام الأمر docker run fs-hello-world ويمكن نقل الصورة أو تنزيلها أو حذفها فهي في طبيعتها ملفات. إذ يمكنك تشكيل قائمة بالصور التي يضمها حاسوبك باستخدام الأمر docker image ls أو حذف الصورة ‍‍docker image rm. اطلع على بقية الأوامر المتاحة بتنفيذ أمر المساعدة docker image --help. صور أكثر فائدة ينبغي أن يكون نقل خادم Express إلى حاوية ببساطة نقل تطبيق "!Hello, World"، إذ يكمن الاختلاف الوحيد بين الحالتين في وجود ملفات أكثر في حالة الخادم، لكن ستغدو الأمور أبسط بوجود التعليمة COPY. لنحذف الآن الملف "index.js" وننشئ خادم Express باستخدام express-generator الذي يساعدنا على بناء هيكلية بسيطة للتطبيق. $ npx express-generator ... install dependencies: $ npm install run the app: $ DEBUG=playground:* npm start لنشغل التطبيق الآن كي نرى ما فعلنا، وانتبه أن أمر التشغيل قد يختلف في جهازك، فالمجلد في المثال السابق يُدعى "playground". $ npm install $ DEBUG=playground:* npm start playground:server Listening on port 3000 +0ms يمكنك الانتقال الآن إلى العنوان "http://localhost:3000" حيث يعمل التطبيق. إن ضم الملفات في حاويات هي عملية سهلة نوعًا ما بناءً على ما واجهناه حتى الآن: استخدم الأساس node. اضبط مجلد العمل كي لا يتداخل عملك مع بقية محتويات الصورة. انسخ كل ملفات المجلد إلى الصورة. ابدأ بتنفيذ الأمر DEBUG=playground:* npm start بعد الأمر CMD. لنضع ملف Dockerfile التالي في جذر المشروع: FROM node:16 WORKDIR /usr/src/app COPY . . CMD DEBUG=playground:* npm start سنبني الآن الصورة انطلاقًا من ملف باستخدام الأمر: docker build -t express-server . وسنشغلها باستخدام الأمر: docker run -p 3123:3000 express-server تبلّغ الراية p- دوكر بضرورة فتح منفذ الجهاز المضيف وتوجيهه إلى منفذ للحاوية ولهذا الأمر الصيغة التالية: p host-port:application-port- $ docker run -p 3123:3000 express-server > playground@0.0.0 start > node ./bin/www Tue, 29 Jun 2021 10:55:10 GMT playground:server Listening on port 3000 إن لم يفلح الأمر، تجاوز الفقرة التالية، فهناك تفسير لعدم نجاح الأمر حتى لو اتبعت الخطوات السابقة تمامًا. سيبدأ التطبيق عمله الآن، لهذا سنختبره بإرسال الطلب GET إلى العنوان "/http://localhost:3123". بالنسبة لإيقاف الخادم فهو أمر عصيبٌ حاليًا، لهذا افتح نافذة أخرى للطرفية ونفذ الأمر docker kill لإيقاف التطبيق؛ إذ يرسل هذا الأمر الإشارة SIGKILL إلى التطبيق ويجبره على الإنهاء. ستحتاج إلى اسم أو معرّف الحاوية ID مثل وسيط لتنفيذ الأمر السابق. وتجدر الإشارة أنه يكفي استخدام بداية المعرّف id عند تمريره وسيطًا، إذ سيعرف دوكر مباشرةً الحاوية المقصودة. $ docker container ls CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 48096ca3ffec express-server "docker-entrypoint.s…" 9 seconds ago Up 6 seconds 0.0.0.0:3123->3000/tcp, :::3123->3000/tcp infallible_booth $ docker kill 48 48 لنعمل من الآن وصاعدًا على نفس المنفذ في كلا الجانبين p-. وهكذا لن تضطر إلى تذكُّر ما المنفذ الذي عليك اختياره. إصلاح المشاكل المحتملة الناتجة عن عملية النسخ واللصق لا بُد من تغيير بعض الخطوات لإنشاء ملف Dockerfile متقدم، وقد لا يعمل المثال الذي أوردناه سابقًا على الإطلاق، لأننا أهملنا خطوة هامة. عندما تنفِّذ الأمر npm install على حاسوبك، فقد يُثبّت مدير حزم Node بعض الاعتمادات التي تتعلق بنظام التشغيل أثناء تقدم التثبيت. وقد ننقل صدفةً أجزاءً غير وظيفية إلى الصورة عند استخدام التعليمة COPY، ويحدث ذلك بسهولة إن نسخنا المجلد "node_modules" إلى الصورة. من المهم جدًا إبقاء تلك النقاط في ذاكرتنا عند بناء الصورة، فمن الأفضل أن ننفذ معظم الأعمال مثل npm install أثناء عملية البناء ضمن الحاوية بدلًا من تنفيذها قبل البناء؛ إذ أن القاعدة الجوهرية هنا هي نسخ الملفات التي ستدفعها إلى غيت هب GitHub فقط، ولا ينبغي نسخ الاعتماديات ومتطلبات البناء كونها أشياء يمكن تثبيتها أثناء بناء الصورة. يمكنك استخدام الملف "dockerignore." لحل المشكلة، وهو ملفٌ شبيه بملف التجاهل "gitignore." لمنع نسخ الملفات غير المطلوبة إلى الصورة. يُوضع هذا الملف إلى جوار الملف Dockerfile، وإليك مثالًا عن محتوياته: .dockerignore .gitignore node_modules Dockerfile لكننا سنحتاج إضافةً إلى ملف "dockerignore." في حالتنا إلى تثبيت الاعتماديات خلال خطوة البناء، لهذا سيتغير ملف Dockerfile إلى الشكل: FROM node:16 WORKDIR /usr/src/app COPY . . RUN npm install CMD DEBUG=playground:* npm start قد يكون تنفيذ الأمر npm install خطرًا، لهذا يزوّدنا npm بأداة أفضل لتثبيت الاعتماديات وهو الأمر ci. تُلخّص الاختلافات بين ci و install على النحو التالي : قد يُحدّث install الملف "package-lock.json". قد يُثبِّت install نسخةً مختلفةً من الاعتمادية إن ظهرت المحارف "^" أو "~" في نسخة الاعتمادية. سيحذف ci المجلد "node_modules" قبل تثبيت أي شيء. سيتبع ci الملف "package-lock.json" ولا يبدّل أي ملف. باختصار: يقدم ci نسخًا يمكن الاعتماد عليها، بينما يُستخدم install عند تثبيت اعتماديات جديدة. طالما أننا لن نثبِّت أي شيء جديد في خطوة البناء، ولا نريد تغييرات فجائية في النسخ، سنستخدم الأمر ci: FROM node:16 WORKDIR /usr/src/app COPY . . RUN npm ci CMD DEBUG=playground:* npm start كما يمكننا تحسين الحالة أكثر باستخدام الأمر npm ci --only=production كي لا نهدر الوقت في تثبيت الاعتماديات. سيحذف ci المجلد "node_modules" كما أشرنا قبل قليل، وبالتالي لن نضطر إلى إنشاء الملف "dockerignore.". مع ذلك، يُعد هذا الملف أداةً رائعةً عندما تريد تحسين عملية البناء، وسنتحدث باختصار عن هذا الموضوع لاحقًا. ينبغي أن يعمل الملف من جديد، لهذا حاول تنفيذ الأمر التالي: docker build -t express-server . && docker run -p 3000:3000 express-server لاحظ كيف وصلنا هنا أمري باش bash باستخدام &&، وسنحصل تقريبًا على نفس النتيجة إذا نفذنا كلا الأمرين كلًّا على حدة؛ لكن عندما تربط أمرين باستخدام &&، فلن يُنفَّذ الأمر الآخر إذا فشل تنفيذ أحدهما. ضبطنا سابقًا متغير البيئة :DEBUG=playground من خلال الأمر CMD لتشغيل npm، كما يمكننا أيضًا استخدام التعليمة ENV في ملف Dockerfiles لضبط متغيرات البيئة، فلنفعل ذلك: FROM node:16 WORKDIR /usr/src/app COPY . . RUN npm ci ENV DEBUG=playground:* CMD npm start أفضل الممارسات المتعلقة باستخدام Dockerfiles عليك اتباع القاعدتين الجوهريّتين التاليتين عند إنشاء الصور: حاول أن تبني صورةً آمنة قدر المستطاع. حاول أن تُنشئ صورةً صغيرة قدر الإمكان. تُعد الصور الأصغر أكثر أمانًا لأن مجال الهجوم عليها محدود، كما يمكن نقلها بسرعة أكبر ضمن أنابيب النشر. وأخيرًا لا بُد من إصلاح آخر نقطة أهملناها وهي تشغيل التطبيق مثل جذر بدلًا من مستخدم بصلاحيات منخفضة: FROM node:16 WORKDIR /usr/src/app COPY --chown=node:node . . RUN npm ci ENV DEBUG=playground:* USER node CMD npm start التمرين 12.5: إنشاء حاوية لتطبيق Node يضم المستودع الذي نسخته في التمرين الأول تطبيق لائحة مهام todo-app. اطلع على الواجهة الخلفية "todo-app/todo-backend" للتطبيق واقرأ الملف "اقرأني README". لن نقترب حاليًا من الواجهة الأمامية "todo-frontend" الخطوة الأولى: وضع الواجهة الخلفية "todo-backend" ضمن حاوية بإنشاء الملف "todo-app/todo-backend/Dockerfile" ثم بناء الصورة. الخطوة الثانية: تشغيل الصورة على المنفذ الصحيح، والتأكد أن عدّاد الزيارات سيزداد عند استخدامه عبر المتصفح على العنوان "/http://localhost:3000" (أو على أي منفذ آخر قد تهيّئه). تلميح: شغّل التطبيق خارج الحاوية أولًا للتحقق منه قبل وضعه في الحاوية. استخدام الأداة docker-compose أنشأنا في جزئية سابقة من هذه السلسلة خادمًا وعلمنا أنه يعمل على المنفذ 3000 وشغّلناه باستخدام الأمر: docker build -t express-server . && docker run -p 3000:3000 express-server ويبدو أننا سنحتاج إلى سكربت لتذكر هذه التعليمات، لكن لحسن الحظ يقدّم دوكر لنا حلًا أفضل. تُعد الأداة Docker-compose من الأدوات الرائعة الأخرى التي تساعدك على إدارة الحاوية، لهذا سنبدأ استخدام هذه الأداة خلال رحلتنا في دراسة الحاويات، إذ ستساعد على توفير بعض الوقت عند تهيئة الحاوية. ثبّت الأداة Docker-compose ثم تأكد من عملها على النحو التالي: $ docker-compose -v docker-compose version 1.29.2, build 5becea4c سنحوّل الآن الأوامر السابقة إلى ملف yaml يمكن تخزينه في مستودع غيت Git. أنشئ الملف "docker-compose.yml" وضعه في جذر المشروع إلى جوار ملف Dockerfile، ثم ضع المحتوى التالي ضمنه: version: '3.8' # نسخة جديدة لا بد أن تعمل services: app: # اسم الخدمة وقد يكون أي شيء image: express-server # صرّح عن الصورة التي تريد استخدامها build: . # حدد مكان بناء الصورة إن لم تكن موجودة ports: # حدد المنفذ الذي يرتبط به التطبيق - 3000:3000 وضعنا شرحًا لكل سطر إلى جواره، لكن إذا أردت معرفة المواصفات الكاملة فعُد إلى التوثيق. سنتمكن الآن من استخدام الأمر docker-compose up في بناء وتشغيل التطبيق، وإن أردت إعادة بناء الصور، استخدم الأمر docker-compose up --build. بإمكانك أيضًا تشغيل التطبيق في الخلفية باستخدام الأمر docker-compose up -d (الراية d- لفصل التطبيق) وإيقافه بتنفيذ الأمر docker-compose down. يُصرِّح إنشاء الملفات بهذه الطريقة عمّا تريده بدلًا من ملفات السكربت التي عليك تنفيذها وفق ترتيبٍ محدد أو عددٍ محددٍ من المرات، وهذه ممارسةٌ جيدةٌ جدًا. التمرين 12.6: الأداة docker-compose أنشئ الملف "todo-app/todo-backend/docker-compose.yml" الذي يعمل مع تطبيق node من التمرين السابق. وعليك الانتباه إلى عدّاد الزيارات فهو الميزة الوحيدة التي ينبغي أن تعمل. استخدام الحاويات في مرحلة التطوير يمكن استخدام الحاويات أثناء تطوير التطبيقات بطرق متعددة لتسهيل عملك، ومن إحدى فوائدها تجاوز تثبيت وتهيئة الأدوات مرتين. قد لا يكون خيارك الأفضل أن تنقل كامل بيئة التطوير إلى الحاوية، لكن إذا أردت ذلك فهذا ممكن. سنعود إلى هذه الفكرة في آخر القسم، لكن حتى ذلك الوقت عليك تشغيل تطبيق node بنفسه خارج الحاويات. يستخدم التطبيق الذي تعرفنا عليه في التمارين السابقة MongoDB، لهذا دعونا نستخدم Docker Hub لإيجاد صورة MongoDB، إذ أنه المكان الافتراضي الذي نسحب الصور منه، كما يمكنك استخدام مسجلات أخرى أيضًا، لكن طالما أننا نتعمق في دوكر فهو خيارٌ جيد. يمكنك أن تجد من خلال بحث سريع الصورة المطلوبة على العنوان https://hub.docker.com/_/mongo. أنشئ ملف yaml يُدعى "todo-app/todo-backend/docker-compose.dev.yml" يحتوي ما يلي: version: '3.8' services: mongo: image: mongo ports: - 3456:27017 environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: example MONGO_INITDB_DATABASE: the_database يُوضَّح معنى أول متغيري بيئة معرفين في الشيفرة السابقة في صفحة Docker Hub: "تُنشِئ المتغيرات المُستخدمة على التوازي مستخدمًا جديدًا وتضبط كلمة المرور له. يُنشأ هذا المستخدم في قاعدة بيانات إدارة الاستيثاق ويعطى دور الجذر root، وهو دور المستخدم الأعلى superuser." يخبر متغير البيئة الأخير MONGO_INITDB_DATABASE قاعدة البيانات MongoDB أن تنشئ قاعدة بيانات بهذا الاسم. بإمكانك استخدام الراية f- لتخصيص ملف لتشغيل أمر Docker Compose مثل: docker-compose -f docker-compose.dev.yml up شغًل الآن MongoDB من خلال الأمر: docker-compose -f docker-compose.dev.yml up -d أما الراية d- فلتشغيل العملية في الخلفية. يمكنك متابعة سجلات الخرج بتنفيذ الأمر: docker-compose -f docker-compose.dev.yml logs -f وتُستخدم الراية f- للتأكد من متابعة السجلات. لا نحتاج حاليًا لتشغيل تطبيق Node ضمن الحاوية، فهذا أمرٌ ينطوي على قدر من التحدي، لكننا سنكتشف هذا الخيار في آخر قسم. نفِّذ الأمر القديم npm install على جهازك لإعداد تطبيق Node، ثم شغل التطبيق باستخدام متغيرات البيئة اللازمة. يمكنك تعديل الشيفرة لجعل متغيرات البيئة متغيرات افتراضية أو استخدم الملف env.. لا ضرر من وضع هذه المفاتيح على غيت هب لأنها تُستخدم ضمن بيئة التطوير المحلية، لذلك سأضعها هناك عن طريق الأمر npm run dev لمساعدتك في النسخ واللصق. $ MONGO_URL=mongodb://localhost:3456/the_database npm run dev لن يكون ذلك كافيًا، بل نحتاج إلى إنشاء مستخدم نستوثِق منه ضمن الحاوية، إذ سيقود الولوج إلى العنوان " http://localhost:3000/todos" إلى خطأ في الاستيثاق. [nodemon] 2.0.12 [nodemon] to restart at any time, enter `rs` [nodemon] watching path(s): *.* [nodemon] watching extensions: js,mjs,json [nodemon] starting `node ./bin/www` (node:37616) UnhandledPromiseRejectionWarning: MongoError: command find requires authentication at MessageStream.messageHandler (/Users/mluukkai/opetus/docker-fs/container-app/express-app/node_modules/mongodb/lib/cmap/connection.js:272:20) at MessageStream.emit (events.js:314:20) ربط وتركيب وتهيئة قاعدة البيانات ستجد في صفحة MongoDB Docker Hub تحت عنوان " تهيئة نسخة جديدة Initializing a fresh instance" معلومات عن تنفيذ شيفرة لتهيئة قاعدة البيانات ومستخدم لها. في المشروع التجريبي ملف "todo-app/todo-backend/mongo/mongo-init.js" يضم المحتوى التالي: db.createUser({ user: 'the_username', pwd: 'the_password', roles: [ { role: 'dbOwner', db: 'the_database', }, ], }); db.createCollection('todos'); db.todos.insert({ text: 'Write code', done: true }); db.todos.insert({ text: 'Learn about containers', done: false }); يهيئ الملف قاعدة البيانات مع مستخدم وبعض المهام المخزّنة في القاعدة، وعلينا في الخطوة التالية نقلها إلى الحاوية وتشغيلها. من الممكن إنشاء صورة جديدة من mongo ثم نسخ الملف إلى الداخل، أو استخدام الأمر لتركيب الملف ضمن الحاوية، لكننا سنختار الطريقة الأخيرة. إن التركيب بالربط Bind mount هي عملية ربط ملف على حاسوبك المحلي بملف في الحاوية، ويمكننا تنفيذ ذلك باستخدام الراية v- مع الأمر بالصيغة التالية: v FILE-IN-HOST:FILE-IN-CONTAINER-. يُعرّف التركيب بالربط تحت المفتاح volumes في الملف "docker-compose"، أما صياغة التصريح فتكون على النحو التالي "مضيف ثم حاوية": mongo: image: mongo ports: - 3456:27017 environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: example MONGO_INITDB_DATABASE: the_database volumes: - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js إن النتيجة هي أنّ الملف "mongo-init.js" في مجلد mongo على حاسوبك سيكون نفسه الملف "mongo-init.js" في المجلد "docker-entrypoint-initdb.d/" من الحاوية، وسيؤدي تعديل أحدهما إلى تعديل الآخر. لا حاجة لتغيير أي شيء أثناء التشغيل، وهذا هو مفتاح تطوير البرمجيات ضمن الحاويات. نفّذ الأمر التالي للتأكد من وجود كل شيء في مكانه: docker-compose -f docker-compose.dev.yml down --volumes ثم ابدأ لائحة جديدة بتنفيذ الأمر التالي لتهيئة قاعدة البيانات: docker-compose -f docker-compose.dev.yml up إذا واجهك خطأ على النحو التالي: mongo_database | failed to load: /docker-entrypoint-initdb.d/mongo-init.js mongo_database | exiting with code -3 فقد يكون لديك مشكلةً في إذن القراءة، وهذا أمرٌ قد يحدث عند التعامل مع الأقراص volumes. بإمكانك في حالتنا استخدام الأمر chmod a+r mongo-init.js الذي يمنح أيًا كان إمكانية قراءة الملف، لكن كن حذرًا عند استخدام التعليمة chmod لأن السماح بإذونات أكبر قد يقود إلى مشاكل أمنية، لهذا استخدم تلك التعليمة على الملف "mongo-init.js" الموجود على جهازك فقط. سيعمل تطبيق express الآن عبر متغيرات البيئة الصحيحة: $ MONGO_URL=mongodb://the_username:the_password@localhost:3456/the_database npm run dev لنتأكد أن الطلب إلى العنوان http://localhost:3000/todos سيعيد كل المهام الموجودة في قاعدة البيانات، فمن المفترض أن يعيد المهمتان اللتان هيأناهما، ولا بد من استخدام Postman لاختبار وظائف التطبيق الأساسية مثل إضافة وحذف مهام من قاعدة البيانات. البيانات المقيمة في الأقراص لا تُخزّن الحاويات بياناتنا افتراضيًا، فعندما تغلق حاوية قد تستطيع أو لا تستطيع استعادة البيانات. وبصورةٍ عامة هناك طريقتان مختلفتان لتخزين البيانات: التصريح عن مكان ضمن منظومة الملفات (عملية الربط بالتركيب bind mount). ترك الأمر لبرنامج دوكر كي يقرر تخزين البيانات (استخدام الأقراص volume) يُفضّل الخيار الأول عادةً في معظم الحالات التي تحتاج فيها حقًا إلى تفادي حذف البيانات، وسنرى الأسلوبين بالتطبيق العملي: services: mongo: image: mongo ports: - 3456:27017 environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: example MONGO_INITDB_DATABASE: the_database volumes: - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js - ./mongo_data:/data/db ستُنشئ الإعدادات السابقة مجلدًا يُدعى mongo_data ضمن منظومة الملفات في حاسوبك ثم تربطه بالحاوية بالاسم data/db/. أي أنّ البيانات الموجودة في المجلد data/db/ ستُخزّن خارج الحاوية لكن بإمكانها الوصول إليها. وتذكر إضافة المجلد إلى ملف التجاهل "gitignore.". يمكن تحقيق الأمر ذاته باستخدام أقراص التخزين المسماة: services: mongo: image: mongo ports: - 3456:27017 environment: MONGO_INITDB_ROOT_USERNAME: root MONGO_INITDB_ROOT_PASSWORD: example MONGO_INITDB_DATABASE: the_database volumes: - ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js - mongo_data:/data/db volumes: mongo_data: يُنشأ القرص الآن ويدار من قبل دوكر، وبإمكانك قبل تشغيل التطبيق استعراض الأقراص الموجودة بتنفيذ الأمر docker volume ls، أو فحصّها docker volume inspect، أو حذفها docker volume rm. ليس اختيار مكان تخزين البيانات محليًا في هذا الخيار أمرًا قليل الأهمية موازنةً بالخيار السابق. التمرين 12.7: كتابة القليل من الشيفرة للتعامل مع MongoDB نفترض في هذا التمرين أنك أنجزت جميع الإعدادات التي تحدثنا عنها سابقًا بعد التمرين 12.5. وسيبقى تشغيل التطبيق خارج الحاوية وستوضع قاعدة البيانات MongoDB فقط ضمن الحاوية. لا توجد طريق ملائمة حتى الآن للحصول على مهمة واحدة (GET* /todos/:id*) وتحديث مهمة واحدة (PUT* /todos/:id*). جد حلًا لهاتين المشكلتين. تنقيح المشاكل في الحاويات لا بُد من تعلم استعمال بعض الأدوات لتنقيح التطبيقات ضمن الحاويات، لأننا لا نستطيع استخدام الأمر console.log دائمًا. عندما تظهر الثغرات في شيفرتك، فلا بد وأن يعمل شيء ما في شيفرتك لتنطلق منه. وعمومًا هناك وضعان لتبدأ منهما: الأول هو تطبيق يعمل والثاني هو تطبيق لا يعمل، لهذا سنطلع على بعض الأدوات التي تساعد في تنقيح التطبيق في الحالة الثانية. يمكن أن تنتقل أثناء تطوير البرنامج في عملك خطوة خطوة لتتاكد طوال الوقت أن كل شيء يعمل كما تتوقع. لكن لا ينطبق هذا الأمر عند ضبط الإعدادات، فقد تخفق الإعدادات التي تكتبها حتى لحظة الإنتهاء منها؛ لهذا إذا كتبت ملف "docker-compose.yml" طويل أو ملف Dockerfile ولم يعمل، عليك التروي برهة والتفكير بالطرق المختلفة التي تتأكد منها أن شيء ما يعمل بالشكل المطلوب. لا تزال طريقة التشكيك بكل شيء واردة هنا، وكما قلنا في القسم الثالث: عليك أن تكون منظّما، وطالما أنّ المشكلة قد تكون في أي مكان، عليك التشكيك بكل شيء، وإزالة كل مصادر الخطأ واحدًا تلو الآخر. فالتوقف والتفكير بالمشكلة بدلًا من كتابة مزيدٍ من الإعدادات هي الطريقة الأنسب في الحصول على حل بسيط، كما أن البحث السريع باستخدام محركات البحث قد يساعدك في التقدم. الأمر exec يمكن استخدام الأمر exec للقفز مباشرةً إلى الحاوية وهي تعمل. لهذا سنهيّئ خادم ويب في الخلفية ونجري بعض الإعدادات كي نشغّله ليعرض الرسالة "!Hello, exec" ضمن المتصفح. سنستخدم الخادم Nginx القادر على تخديم الملفات الساكنة، ويدعم الملف "index.html" الذي يمثل الصفحة الافتراضية ويسمح لنا بتعديلها أو استبدالها: $ docker container run -d nginx الأسئلة المطروحة الآن هي: أين سنتوجه عبر المتصفح؟ هل يعمل الخادم بالفعل؟ لكن نعرف كيف نجيب على هذه الأسئلة وذلك باستعراض الحاويات التي تعمل: $ docker container ls CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 3f831a57b7cc nginx "/docker-entrypoint.…" About a minute ago Up About a minute 80/tcp keen_darwin لقد حصلنا على جواب السؤال الأول فعلًا، فيبدو أن الخادم ينصت إلى المنفذ 80. لهذا سنطفئ الخادم ثم نعيد تشغيله مستخدمين الراية p- للسماح للمتصفح بالوصول إليه: $ docker container stop keen_darwin $ docker container rm keen_darwin $ docker container run -d -p 8080:80 nginx لنلقي نظرةً على التطبيق بالانتقال إلى العنوان http://localhost:8080، وستجد أنّ التطبيق يعرض رسالة خاطئة، لهذا سنقفز إلى الحاوية مباشرةً لإصلاحها. أبقِ متصفحك مفتوحًا، فلا نريد إغلاق الحاوية عند إصلاحها، بل سنستخدم أوامر الطرفية ضمن الحاوية، ولا تنسى استخدام الراية it- لضمان قدرتك على التفاعل مع الحاوية: $ docker container ls CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 7edcb36aff08 nginx "/docker-entrypoint.…" About a minute ago Up About a minute 0.0.0.0:8080->80/tcp, :::8080->80/tcp wonderful_ramanujan $ docker exec -it wonderful_ramanujan bash root@7edcb36aff08:/# بعد أن قفزنا داخل الحاوية، لا بد من إيجاد الملف الخاطئ واستبداله، إذ يتضح لنا من خلال بحث سريع باستخدام محرك بحث أن الملف هو "usr/share/nginx/html/index.html/". لننتقل إلى المجلد ونحذف الملف: root@7edcb36aff08:/# cd /usr/share/nginx/html/ root@7edcb36aff08:/# rm index.html لو انتقلنا الآن إلى العنوان http://localhost:8080 فسنحصل على الصفحة 404 لأننا حذفنا الملف الصحيح، لهذا سنستبدله بملف آخر يضم المحتوى الصحيح: root@7edcb36aff08:/# echo "Hello, exec!" > index.html أعد تحميل الصفحة وسترى كيف يعرض المتصفح الرسالة الصحيحة. وهكذا نرى كيف نستفيد من الأمر exec في التفاعل مع الحاوية. ستُفقد كل التغييرات التي أجريتها عند حذف الحاوية، ولتحفظ هذه التغييرات لا بد من دفعها باستخدام الأمر commit. التمرين 12.8: واجهة سطر أوامر Mongo استخدم script لتسجيل ما تفعله، ثم احفظ الملف بالاسم "script-answers/exercise12_8.txt". حاول الولوج إلى قاعدة البيانات في التمرين السابق أثناء تشغيل MongoDB باستخدام سطر أوامر CLI. يمكنك تنفيذ الأمر باستخدام exec، ثم أضف بعد ذلك مهمة جديدة عبر سطر الأوامر CLI. شغّل سطر الأوامر وأنت ضمن الحاوية باستخدام الأمر mongo. يتطلب سطر أوامر mongo رايتي اسم مستخدم وكلمة مرور للاستيثاق على نحوٍ صحيح،. وستعمل الرايات u root -p example- جيدًا، وتكون القيم مأخوذةً من الملف docker-compose.dev.yml. الخطوة الأولى: شغّل MongoDB. الخطوة الثانية: استخدم الأمر exec للدخول إلى الحاوية. الخطوة الثالثة: افتح واجهة سطر أوامر mongo. عندما تصل إلى واجهة سطر أوامر mongo يمكنك أن تطلب منها عرض قواعد البيانات: > show dbs admin 0.000GB config 0.000GB local 0.000GB the_database 0.000GB للولوج إلى قاعدة البيانات الصحيحة: > use the_database ولإيجاد المجموعات: > show collections todos يمكنك الآن الوصول إلى البيانات الموجودة ضمن تلك المجموعات: > db.todos.find({}) { "_id" : ObjectId("611e54b688ddbb7e84d3c46b"), "text" : "Write code", "done" : true } { "_id" : ObjectId("611e54b688ddbb7e84d3c46c"), "text" : "Learn about containers", "done" : false } أضف مهمةً جديدة نصها: "Increase the number of tools in my toolbelt" مع ضبط حالتها على false. راجع التوثيق لتتعلم إضافة المدخلات إلى قاعدة البيانات، وتأكد من رؤية المهمة الجديدة في تطبيق Express وعند الاستعلام عنه عبر سطر أوامر Mongo CLI. قاعدة البيانات Redis Redis هي قاعدة بيانات من الشكل (مفتاح-قيمة) مما يجعلها قاعدة لحفظ البيانات بهيكلية أدنى من MongoDB مثلًا، فلن تجد فيها مجموعات أو جداول بل كميات من البيانات يمكن الحصول عليها وفقًا لقيم المفاتيح المرتبط بالبيانات (القيم). تعمل قاعدة البيانات Redis ضمن الذاكرة المؤقتة أي أنها لا تحتفظ بالبيانات دائمًا، ومن أفضل طرق الاستفادة منها هي استخدامها مثل مخزن مؤقت لتطبيق cache، إذ تُستخدم المخازن المؤقتة غالبًا لتخزين البيانات التي قد تكون بطيئة عند إحضارها وحفظها حتى تفقد صلاحيتها فيتوجب عليك إحضارها مجددًا بعد ذلك وتخزينها وهكذا. لا علاقة لقاعدة البيانات Redis بالحاويات لكن وطالما أننا قادرين على إضافة أي خدمة مصدرها طرف آخر إلى التطبيق، فلماذا لا نتعلم شيئًا جديدًا؟ التمارين 12.9 - 12.11 التمرين 12.9: إعداد Redis للمشروع هُيّئ خادم Express مسبقًا للتعامل مع Redis ويفتقد فقط إلى متغير البيئة REDIS_URL، إذ يستخدم التطبيق متغير البيئة للاتصال بقاعدة البيانات. اطلع على عمل Redis مع Docker Hub ثم أضفها إلى الملف "todo-app/todo-backend/docker-compose.dev.yml" من خلال تعريف خدمة جديدة بعد mogo. services: mongo: ... redis: ??? بما أن صفحة Docker Hub لا تضم جميع المعلومات الكافية، استخدم غوغل مثلًا في البحث. ستجد المنفذ الافتراضي للقاعدة بإجراء البحث الموضح في الصورة التالية: لا نعلم بعد إن كان الإعداد سيعمل ما لم نجرّب. لن يستخدم التطبيق Redis من تلقاء نفسه طبعًا، وهذا ما سنراه في التمرين التالي. حالما تهيئ Redis وتشغله، أعد تشغيل الواجهة الخلفية ومرر لها متغير البيئة ‍‍REDIS_URL بالصيغة redis://host:port. $ REDIS_URL=insert-redis-url-here MONGO_URL=mongodb://localhost:3456/the_database npm run dev يمكنك الآن اختبار الإعداد بإضافة السطر التالي: const redis = require('../redis') إلى مثال خادم Express في الملف "routes/index.js". إن لم يحدث شيء فقد طُبِّق الإعداد بصورةٍ صحيحة وإلا سينهار الخادم: events.js:291 throw er; // Unhandled 'error' event ^ Error: Redis connection to localhost:637 failed - connect ECONNREFUSED 127.0.0.1:6379 at TCPConnectWrap.afterConnect [as oncomplete] (net.js:1144:16) Emitted 'error' event on RedisClient instance at: at RedisClient.on_error (/Users/mluukkai/opetus/docker-fs/container-app/express-app/node_modules/redis/index.js:342:14) at Socket.<anonymous> (/Users/mluukkai/opetus/docker-fs/container-app/express-app/node_modules/redis/index.js:223:14) at Socket.emit (events.js:314:20) at emitErrorNT (internal/streams/destroy.js:100:8) at emitErrorCloseNT (internal/streams/destroy.js:68:3) at processTicksAndRejections (internal/process/task_queues.js:80:21) { errno: -61, code: 'ECONNREFUSED', syscall: 'connect', address: '127.0.0.1', port: 6379 } [nodemon] app crashed - waiting for file changes before starting... التمرين 12.10 ثُبتت قاعدة البيانات https://www.npmjs.com/package/redis في المشروع مسبقًا وأضيفت إليه دالتين تعيدان وعودًا وهما getAsync و setAsync: تقبل الدالة setAsync قيمةً ومفتاحًا، ويُستخدم المفتاح لتخزين القيمة. تقبل الدالة getAsync مفتاحًا وتعيد قيمته المقابلة في الوعد promise. أنجز عدادًا للمهام يخزّن عدد المهام التي تُنشئها في قاعدة البيانات Redis: الخطوة الأولى: زد العداد بمقدار واحد في أي لحظة يُرسل فيها طلب لإضافة مهمة. الخطوة الثانية: أنشئ وصلة GET/statics ساكنة يمكنك طلب معلومات وصفية منها، وينبغي أن تُعاد بصيغة JSON على النحو التالي: { "added_todos": 0 } التمرين 12.11 استخدم script لتسجيل ما تفعله، ثم تحفظ الملف بالاسم "script-answers/exercise12_11.txt". إن لم يسلك التطبيق السلوك المطلوب، فقد يساعدك الولوج المباشر إلى قاعدة البيانات في الدلالة على المشاكل. لنلقي نظرةً إذًا على طريقة استخدام واجهة سطر أوامر Redis في الوصول إلى قاعدة البيانات: انتقل إلى حاوية redis باستخدام الأمر docker exec، ثم افتح الواجهة redis-cli. ابحث عن المفتاح الذي استخدمته باستخدام الأمر * KEYS. تحقق من قيمة المفتاح من خلال الأمر GET. راجع أوامر redis-cli للبحث عن الأمر المناسب لضبط قيمة العداد على 9001. تأكد من أن القيمة الجديدة ستعمل عند تحديث الصفحة http://localhost:3000/statistics. احذف المفتاح باستخدام واجهة سطر الأوامر وتأكد أن العداد سيعمل عند إضافة مهام جديدة. الذاكرة المقيمة وقاعدة البيانات Redis أشرنا سابقًا أنّ قاعدة البيانات Redis لا تحتفظ بالبيانات افتراضيًا، لكنه أمر يمكن حله بسهولة، إذ كل ما علينا فعله هو تشغيل Redis بأمر مختلف كما توضح صفحة Docker hub: services: redis: # أي شيء آخر command: ['redis-server', '--appendonly', 'yes'] # CMD تعديل volumes: # التصريح عن القرص - ./redis_data:/data ستقيم البيانات الآن في المجلد على الجهاز المُضيف، وتذكّر إضافة المجلد إلى الملف gitignore.. إمكانات أخرى لقاعدة البيانات Redis تقدم Redis عدّة ميزات إضافةً إلى عمليات إضافة وضبط وحذف المفاتيح، فهي تحدد مثلًا فترة صلاحية المفتاح وهذه ميزة شديدة الأهمية عند استعمالها مثل مخزن مؤقت. كما يمكن استخدام Redis في إنجاز نماذج نشر-اشتراك publish-subscribe -أواختصارًا PubSub-، وهي آلية تواصل غير متزامنة للتطبيقات الموزّعة. تعمل Redis في هذه الحالة مثل وسيط بين تطبيقين أو أكثر، ينشر بعضها رسائل بإرسالها إلى Redis وعند وصول هذه الرسائل تُبلغ Redis جميع الأطراف بأنها اشتركت بهذه الرسائل. التمرين 12.12 البيانات المقيمة و Redis تأكد أن البيانات لا تُخزّن افتراضيًا في قاعدة البيانات Redis بأن تكون قيمة العداد 0 بعد تنفيذ الأمرين docker-compose -f docker-compose.dev.yml down و docker-compose -f docker-compose.dev.yml up. أنشئ بعد ذلك قرصًا للبيانات عن طريق تعديل الملف "todo-app/todo-backend/docker-compose.dev.yml"، وتأكد من بقاء البيانات بعد تنفيذ الأمرين docker-compose -f docker-compose.dev.yml down وdocker-compose -f docker-compose.dev.yml up. ترجمة -وبتصرف- للفصل Building and Configuring Environments من سلسلة Deep Dive Into Modern Web Development اقرأ أيضًا المقال السابق: مدخل إلى الحاويات أبرز المفاهيم التي يجب عليك الإلمام بها عن الحاويات حاوية دوكر Docker ومخزن APCu في PHP
  10. تتمتع البرمجيات بدورة حياة كاملة ابتداءً من التصورات الأولية وانتقالًا إلى البرمجة ومنها إلى إصدار البرنامج إلى المستخدم النهائي وصيانته. سنتعرف في هذا المقال على مفهوم الحاويات Containers، وهي أداةٌ عصرية تُستخدم في المراحل النهائية من دورة حياة البرنامج. تُغلّف الحاويات تطبيقك ضمن حزمةٍ واحدة تتضمن كل الاعتماديات التي يحتاجها التطبيق، وتكون النتيجة حاويةً يمكن أن تعمل بصورةٍ مستقل عن غيرها من الحاويات. تمنع الحاويات التطبيق من الوصول واستخدام ملفات وموارد الأجهزة، وقد يعطي مطورو التطبيقات بعض الأذونات للبرنامج الذي تضمه الحاوية في الوصول إلى ملفات محددة وتخصيص موارد محددة أيضًا. ولنكون أكثر دقة، تُعدّ الحاويات بمثابة بيئات عمل افتراضية على مستوى نظام التشغيل، وأكثر ما يشابهها من الناحية التقنية هي الآلات الافتراضية Virtual machines التي تساعد على تشغيل عدة أنظمة تشغيل على جهاز فيزيائي واحد. وينبغي على الآلات الافتراضية تشغيل النظام بأكمله، بينما تُشغّل الحاوية التطبيق بالاستفادة من نظام التشغيل الذي يستضيفها. وهكذا سيكون الاختلاف بين الحاويات والآلات الافتراضية هي أنك لن تلاحظ إلا نادرًا جدًا زيادة في تحميل النظام عند استخدام الحاويات، فهي عادةً ما تحتاج إلى تنفيذ عملية واحدة. يمكن للحاويات أن تكون سريعةً وقابلةً للتوسع لكونها خفيفة الوزن موازنةً بالآلات الافتراضية على الأقل، وطالما أنها تعزل التطبيق الذي يُنفَّذ ضمنها، سيساعد ذلك في الحصول على نفس الأداء تمامًا وفي أي مكان. وبالنتيجة، تُعد الحاويات خيارًا ممتازًا في أي بيئة تشغيل سحابية أو للتطبيقات التي يكثر مُستخدميها. تدعم معظم الخدمات السحابية، مثل AWS و Google Cloud و Microsoft Azure الحاويات وبأشكال متعددة، بما في ذلك AWS Fargate و Google Cloud Run وهما خدمتان لتشغيل الحاويات دون خادم serverless إذ لا حاجة مطلقًا لتنفيذ الحاوية إن لم تُستخدم. يمكن أيضًا تثبيت مقومات تشغيل البيئة على أغلب الأجهزة وتشغيل الحاويات بنفسك، بما في ذلك حاسوبك الخاص. طالما أن الحاويات تعمل في البيئات السحابية وحتى أثناء تطوير التطبيقات، فما الفائدة من الفعلية منها إذًا؟ إليك حالتي الاستخدام التاليتين: الحالة الأولى: عندما تحاول أن تطور تطبيقًا من المفترض أن يعمل على نفس الجهاز لدعم إصدار أقدم، فكلاهما يحتاج إلى تثبيت نسخ مختلفة من Node.js. بإمكانك استخدام nvm أو آلة افتراضية أو حتى اختراع طريقة سحرية لجعل النسختين تعملان معًا، لكن تبقى الحاويات حلًا ممتازًا لأنك تستطيع تشغيل كلا التطبيقين كلًا ضمن حاويته الخاصة، فهما معزولان عن بعضهما ولا يتداخلان. الحالة الثانية: عندما يعمل تطبيقك على حاسوبك وتحاول نقله إلى خادم، فمن الأمور الشائعة ألا يعمل التطبيق على الخادم علمًا أنه يعمل جيدًا على حاسوبك. قد يكون الأمر اعتماديات مفقودة، أو اختلافات أخرى في بيئات التشغيل. في حالة كهذه، تظهر الحاويات مثل حل رائع، لأنها قادرة على تنفيذ التطبيق في نفس بيئة التنفيذ على حاسوبك أو على الخادم. وقد لا يكون حلًا مثاليًا نظرًا لاختلاف العتاد الصلب، لكن يمكنك أن تجعل الاختلافات في بيئات التنفيذ محدودةً جدًا. قد تسمع أحيانًا عبارات من قبيل "يعمل ضمن حاويتي"، إذ تصف هذه العبارة الحالة التي يعمل فيها التطبيق جيدًا ضمن حاوية على جهازك لكنها تُخفق في تنفيذ التطبيق عندما تنقلها إلى الخادم. هذه العبارة هي تلاعب في العبارة "يعمل على جهازي"، فالحاويات مصممةٌ لتعمل، وغالبًا ما تكون هذه الحالة خطأ في الاستخدام. حول هذا القسم لن نركز اهتمامنا في هذا القسم على شيفرة جافاسكربت JavaScript، بل تهيئة البيئة التي سيُنفَّذ فيها التطبيق. لهذا قد لا تحتوي التمارين أية شيفرات. كما ستجد التطبيق جاهزًا على غيت هب GitHub ويحتاج فقط إلى التهيئة. ينبغي رفع التمارين إلى مستودع GitHub واحد يضم كل الشيفرات المصدرية وإعدادات التهيئة التي نفّذتها في هذا القسم. لا بد أن تمتلك معرفة مبدئية بالتعامل مع Node و Express و React، وعليك إكمال الأقسام الجوهرية من 1 إلى 5 للمتابعة في هذا القسم. التمرين 12.1 تنبيه سنخرج في هذا القسم من دائرة الراحة الخاصة بمطوري JavaScript، وقد يتطلب منك ذلك جولةً لتتآلف مع أوامر الصدفة shell أو سطر الأوامر command line أو محث الأوامر command prompt أو الطرفية terminal قبل أن تبدأ؛ فإذا كنت تستخدم واجهة مستخدم رسومية ولم تلمس أبدًا طرفية لينوكس Linux أو ماك Mac، وشعرت بأنك تائه لا تعرف كيف تبدأ حل التمرين، ننصحك بالاطلاع على القسم الأول من كتاب "Computing tools for CS studies"، فهو يضم كل ما تحتاجه لمتابعة العمل هنا. التمرين 12.1: استخدام الحاسوب دون واجهة مستخدم رسومية الخطوة الأولى: اقرأ النص الذي يلي التنبيه. الخطوة الثانية: نزّل مستودع التمرين واجعله مستودع تسليم التمارين لهذا القسم. الخطوة الثالثة: انتقل إلى العنوان "http://helsinki.fi" واحفظ الخرج ضمن ملف، ثم احفظ الملف ضمن مستودعك بالاسم "script-answers/exercise12_1.txt". وتذكر أنك أنشأت بالفعل المجلد "script-answers" في الخطوة السابقة. أدوات العمل تحتاج إلى بعض الأدوات الأساسية التي تختلف وفقًا لنظام التشغيل. ستحتاج WSL 2 terminal على نظام ويندوز. الطرفية على نظام ماك. سطر الأوامر على نظام لينوكس. تثبيت كل ما تحتاجه للعمل سنبدأ بتثبيت البرامج التي نحتاجها، وقد تشكل هذه الخطوة إحدى العقبات المحتملة، إذ لا بد من أذونات واسعة النطاق على جهازك طالما أننا نتحدث عن شكلٍ من البيئات الافتراضية على مستوى نظام التشغيل، وهذا ما يتطلب الولوج إلى نواته. تتركز مادة القسم حول دوكر Docker، وهو يمثّل مجموعةً من المنتجات التي سنستخدمها في تشكيل وإدارة الحاويات، لكن إن لم تستطع تثبيته فلن تتمكن لسوء الحظ من متابعة العمل في هذا القسم. ستختلف إرشادات عملية تثبيت المنتجات على حاسوبك وفقًا لنظام التشغيل، لذلك عليك أن تجد الإرشادات الصحيحة لتثبيت المنتج من خلال صفحة Docker المخصصة للغرض، وتذكر وجود خيارات عدة للتثبيت على نفس نظام التشغيل. لنفترض أن كل شيء جرى على ما يُرام، لهذا سنتأكد من تطابق الإصدارات، إذ لا بُد أن يكون إصدار نسختك أعلى من هذا الإصدار: $ docker -v Docker version 20.10.5, build 55c4c88 الحاويات والصور هناك مفهومان جوهريان فيما يتعلق بالحاويات، ومن السهل الخلط بينهما: الحاوية والصورة؛ إذ أن الحاوية هي نسخة التشغيل من الصورة، لهذا فكلا العبارتين التاليتين صحيحة: تضم الصور الشيفرات والاعتماديات والإرشادات اللازمة لتشغيل التطبيق. تُحزّم الحاويات البرنامج ضمن وحدات معيارية. وبالتالي لا عجب من سهولة الخلط بينهما. للمساعدة في تجاوز الأمر، تُستخدم كلمة "حاوية" للإشارة إلى كلا المفهومين. لكنك لن تتمكن أبدًا من بناء حاوية أو تنزيلها لأنها موجودةٌ فقط أثناء التشغيل؛ أما الصورة فهي ملفاتٌ غير قابلة للتغيير، وبالتالي لن تكون قادرًا على تعديل صورة بعد إنشائها. لكن يمكنك استخدام صورة موجودة لإنشاء صورة جديدة بإضافة طبقات جديدة فوق القديمة. وبعبارات مجازية مستوردة من عالم الطبخ: الصورة هي وجبة مطبوخة مسبقًا ومجمّدة. الحاويات هي تلك الوجبة اللذيذة الجاهزة للأكل. يُعد دوكر Docker أكثر تقنيات إنشاء الحاويات شهرةً وقد أبدع المعايير التي تستخدمها معظم تقنيات إنشاء الحاويات حاليًا. هذا المنتج تقنيًا هو مجموعة منتجات تساعد في إدارة الصور والحاويات وتمنحنا القدرة على العمل مع كل ميزاتها، إذ سيتولى Docker مثلًا مهمة تحويل الصور إلى حاويات. هناك أداةٌ تُدعى Docker Compose لإدارة حاويات دوكر، إذ تسمح لك بتنسيق العمل على عدة حاويات في نفس الوقت، لهذا سنستخدمها في هذا القسم لبناء بيئة تطوير محلية مركّبة، ولم يعد هناك حاجةٌ لنثبت Node عند الانتهاء من إعداد بيئة التطوير هذه. يبقى هناك مجموعةٌ من المصطلحات التي علينا أن نعرّج عليها، لكننا سنتجاهل ذلك مؤقتًا لنتعلم أكثر عن دوكر. لنبدأ من الأمر docker container run الذي يُستخدم لتشغيل الصور ضمن حاوية. لهذا الأمر الهيكلية التالية: container run *IMAGE-NAME* التي تخبر دوكر بإنشاء حاويةٍ من الصورة. ومن الميزات الجيدة لهذا الأمر هو إمكانية تشغيل حاوية حتى لو لم تُنزّل الحاوية على الجهاز بعد. لننفِّذ الأمر: § docker container run hello-world ستكون النتيجة العديد من المُخرجات، لهذا سنحاول فصلها إلى عدة أقسام كي نفسر هذه المخرجات معًا. رقمنا الأسطر لسهولة شرح ما فيها، فلن تجد أية أرقام لأسطر الخرج في الواقع: 1. Unable to find image 'hello-world:latest' locally 2. latest: Pulling from library/hello-world 3. b8dfde127a29: Pull complete 4. Digest: sha256:5122f6204b6a3596e048758cabba3c46b1c937a46b5be6225b835d091b90e46c 5. Status: Downloaded newer image for hello-world:latest لم يعثُر الأمر على الصورة في جهازك، لذلك نزّلها من مُسجّل مجاني يُدعى Docker Hub. بإمكانك الاطلاع على صفحة الويب الخاصة بهذا المسجّل باستخدام المتصفح. تنص الرسالة الأولى أن الصورة "hello-world:latest" غير موجودة بعد، وهذا ما يكشف بعض التفاصيل عن الصور بحد ذاتها، إذ تتألف أسماء الصور من عدة أجزاء بصورةٍ مشابهة لعناوين URL وهي على الشكل: registry/organisation/image:tag في حالتنا يعوَّض عن الحقول الثلاثة المفقودة بالقيم الافتراضية: index.docker.io/library/hello-world:latest يعرض السطر الثاني اسم المنظمة والمكتبة التي تحصل على الصورة منها، ويُختصر عنوان المكتبة في Docker Hub إلى _. يعرض السطران الثالث والخامس الحالة فقط، لكن ما يضمه السطر الرابع هو المهم. لكل صورة معرّف تعمية مختزل وفريد digest بناءً على الطبقات التي بُنيت منها الصورة. وفي واقع الأمر تُنشئ كل تعليمة أو خطوة استُخدمت في بناء الصورة طبقةً فريدة، لهذا يستخدم دوكر معرّف التعمية المختزل لتمييز أن الصورة بقيت كما هي. وهكذا نرى أن خرج الأمر السابق هو سحب ثم إخراج معلومات عن الصورة. تخبرنا الحالة بعد ذلك أن نسخةً جديدةً من الصورة "hello-world:latest" قد نُزّلت بالفعل. بإمكانك سحب الصورة باستخدام الأمر docker image pull hello-world ومتابعة ما سيحدث. يمثل التالي خرجًا ناتجًا عن حاوية، ويشرح ما يجري عند تنفيذ الأمر docker container run hello-world: Hello from Docker! This message shows that your installation appears to be working correctly. To generate this message, Docker took the following steps: 1. The Docker client contacted the Docker daemon. 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. (amd64) 3. The Docker daemon created a new container from that image which runs the executable that produces the output you are currently reading. 4. The Docker daemon streamed that output to the Docker client, which sent it to your terminal. To try something more ambitious, you can run an Ubuntu container with: $ docker container run -it ubuntu bash Share images, automate workflows, and more with a free Docker ID: https://hub.docker.com/ For more examples and ideas, visit: https://docs.docker.com/get-started/ يتضمن الخرج بعض الأشياء الجديدة التي يجب تعلّمها مثل: Docker daemon: وهي خدمة في الخلفية تتحقق من عمل الحاوية. Docker client: واجهة للتفاعل مع الخدمة السابقة. لقد تفاعلنا مع الصورة الأولى وأنشأنا حاوية من هذه الصورة، وتلقينا الخرج أثناء تنفيذ الحاوية. التمرين 12.2 لا تتطلب منك بعض هذه التمارين كتابة أية شيفرات أو إعدادات تهيئة في ملف. عليك أن تستخدم في هذا التمرين الأمر script لتسجيل الأوامر التي استخدمتها. جرّب ذلك بتنفيذ التعليمة script لتبدأ التسجيل ثم الأمر "echo "hello لتوليد خرج ما، ومن ثم التعليمة exit لإيقاف التسجيل. تُسجِّل تلك التعليمات أفعالك في ملف اسمه "typescript". يمكنك أيضًا نسخ ولصق جميع الأوامر التي استخدمتها في ملف نصي إن لم تستطع استخدام script. التمرين 12.2 تشغيل حاويتك الثانية استخدم script لتسجيل ما تفعله واحفظ الملف بالاسم "script-answers/exercise12_2.txt". نفّذ ما يلي: الخطوة الأولى: شغّل حاوية بنفس أسلوب تشغيل الحاوية، إذ ستربطك هذه الخطوة مباشرةً مع الحاوية عبر تعليمات bash وستكون قادرًا على الوصول إلى كل الملفات والأدوات الموجودة ضمن الحاوية. لهذا نفذ الخطوات التالية ضمن الحاوية. الخطوة الثانية: أنشئ المجلد "usr/src/app/". الخطوة الثالثة: أنشئ الملف "usr/src/app/index.js/". الخطوة الرابعة: نفّذ التعليمة exit للخروج من الحاوية. ابحث عن طريقة إنشاء الملفات أو المجلدات بمساعدة محركات البحث إن لزم الأمر. صورة Ubuntu يحتوي الأمر التالي المُستخدم في تشغيل حاوية "ubuntu": docker container run -it ubuntu bash بعض الإضافات عن أمر تشغيل الحاوية "hello-world". سنستخدم التعليمة help-- لفهم الموضوع أكثر، وسنجتزئ بعض المعلومات التي تتعلق بحديثنا مما يرد في خرج العملية: $ docker container run --help Usage: docker container run [OPTIONS] IMAGE [COMMAND] [ARG...] Run a command in a new container Options: ... -i, --interactive Keep STDIN open even if not attached -t, --tty Allocate a pseudo-TTY ... تتحقق الرايتان أو الخياران it- من قدرتنا على التفاعل مع الحاوية، ثم نحدد بعد ذلك أن الصورة التي نشغلها هي "ubuntu"، ثم لدينا الأمر bash الذي يُنفَّذ داخل الحاوية عندما نشغلها. يمكنك تجريب أوامر أخرى يمكن للصورة أن تُنفذها، مثل: docker container run --rm ubuntu ls إذ يرتب الأمر ls كل الملفات في المجلد ضمن قائمة، بينما يُزيل الأمر rm-- الحاوية بعد التنفيذ، فلا تُحذف الحاويات تلقائيًا عادةً. لنتابع الآن مع أول حاوية "ubuntu" من خلال الملف "index.js" داخلها. لقد توقفت الحاوية عن العمل في اللحظة التي خرجنا منها. ولاستعراض جميع الحاويات، نستخدم الأمر container ls -a. إذ تستعرض الراية a- أو all-- كل الحاويات التي خرجنا منها توًا. $ docker container ls -a CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES b8548b9faec3 ubuntu "bash" 3 minutes ago Exited (0) 6 seconds ago hopeful_clarke أمامنا خياران يتعلقان بالتخاطب مع الحاوية: المعرّف في العمود الأول الذي يمكن استخدامه للتفاعل مع الحاوية في أي وقت، كما تقبل معظم الأوامر تمرير اسم الحاوية إليها. لاحظ أن الحاوية في مثالنا قد ولّدت تلقائيًا باسم "hopeful_clarke". تُظهر حالة الحاوية أننا خرجنا منها منذ 6 ثوان ويمكنك تشغيلها مجددًا باستخدام الأمر start الذي يقبل معرّف الحاوية id أو اسمها: $ docker start hopeful_clarke hopeful_clarke يشغّل الأمر الحاوية نفسها التي عملنا عليها سابقًا، لكن انتبه إلى أننا أغفلنا لسوء الحظ استخدام الراية interactive-- وبالتالي لن نتمكن من التفاعل معها. مع ذلك فالحاوية تعمل فعلًا، ويُظهر تنفيذ الأمر container ls -a ذلك، إلا أننا لا نستطيع التواصل معها: $ docker container ls -a CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES b8548b9faec3 ubuntu "bash" 7 minutes ago Up (0) 15 seconds ago hopeful_clarke يمكنك أيضًا تجاهل الراية a- في الأمر السابق لعرض الحاويات التي تعمل فقط: $ docker container ls CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES 8f5abc55242a ubuntu "bash" 8 minutes ago Up 1 minutes hopeful_clarke لنُنهي عمل الحاوية باستخدام الأمر kill يليه اسم الحاوية أو معرّفها ثم نحاول ثانية: $ docker kill hopeful_clarke hopeful_clarke يُرسل الأمر الإشارة SIGKILL إلى العملية ليجبرها على التوقف، ونستطيع التأكد من حالتها بتنفيذ الأمر container ls -a: $ docker container ls -a CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES b8548b9faec3 ubuntu "bash" 26 minutes ago Exited 2 seconds ago hopeful_clarke لنشغل الحاوية الآن في وضع التفاعل: $ docker start -i hopeful_clarke root@b8548b9faec3:/# سنجري بعض التعديلات على الملف"index.js" بإضافة شيفرة جافا سكربت، لكن تنقصنا الأداة اللازمة لذلك. سيكون المحرر Nano خيارًا جيدًا حاليًا، لذلك سننزّله ونشغله. يمكنك الاطلاع على طريقة استخدام هذا المحرر بإجراء بحث بسيط ضمن أي محرك بحث. انتبه أنه لا حاجة لاستخدام التعليمة sudo فنحن فعليًا داخل المجلد الجذري: root@b8548b9faec3:/# apt-get update root@b8548b9faec3:/# apt-get -y install nano root@b8548b9faec3:/# nano /usr/src/app/index.js وهكذا يكون Nano جاهزًا للاستخدام. التمرينان 12.3 - 12.4 حاول أن تحل التمرينين التاليين: التمرين 12.3: 101 Ubuntu استخدم script لتسجيل ما تفعله واحفظ الملف بالاسم "script-answers/exercise12_3.txt" واستخدم المحرر النصي Nano لتعديل الملف "usr/src/app/index.js/" ضمن الحاوية بإضافة السطر التالي: console.log('Hello World') يمكنك الاطلاع على طريقة استخدام محرر Nano بإجراء بحث بسيط ضمن أي محرك بحث. التمرين 12.4: 102 Ubuntu استخدم script لتسجيل ما تفعله واحفظ الملف بالاسم "script-answers/exercise12_4.txt" ثم ثبّت Node طالما أنك ضمن الحاوية، ثم شغِّل الملف "index" باستخدام الأمر ‍‍node /usr/src/app/index.js. من الصعب أحيانًا إيجاد تعليمات واضحة لتثبيت Node لهذا إليك بعض الأوامر التي يمكنك نسخها ولصقها: curl -sL https://deb.nodesource.com/setup_16.x | bash apt install -y nodejs لا بد أيضًا من تثبيت curl ضمن الحاوية بطريقة مشابهة لتثبيت Nano، وتأكد بعد اكتمال التثبيت من قدرتك على تنفيذ شيفرتك داخل الحاوية. root@b8548b9faec3:/# node /usr/src/app/index.js Hello World أوامر دوكر أخرى بعد أن ثبتنا Node ضمن الحاوية يمكننا تنفيذ شيفرة جافاسكريبت JavaScript داخلها. لنحاول الآن إنشاء صورة جديدة من الحاوية بعد التعديلات التي أجريناها. للأمر الصيغة التالية: "commit" + اسم أو معرف الحاوية + اسم الصورة الجديدة. يمكنك استخدام الأمر container diff للتحقق من التغييرات بين الصورة الأصلية والجديدة. $ docker commit hopeful_clarke hello-node-world كما يمكنك عرض قائمة بالصور الموجودة باستخدام image Is على النحو التالي: $ docker image ls REPOSITORY TAG IMAGE ID CREATED SIZE hello-node-world latest eef776183732 9 minutes ago 252MB ubuntu latest 1318b700e415 2 weeks ago 72.8MB hello-world latest d1165f221234 5 months ago 13.3kB وستتمكن من تشغيل الصورة الجديدة على النحو التالي: docker run -it hello-node-world bash root@4d1b322e1aff:/# node /usr/src/app/index.js هناك عدة طرق لتحقيق النتيجة نفسها. لهذا دعونا نتحوّل إلى حل أفضل، وسنبدأ أولًا بإزالة الحاوية القديمة باستخدام الأمر container rm: $ docker container ls -a CONTAINER ID IMAGE COMMAND CREATED STATUS NAMES b8548b9faec3 ubuntu "bash" 31 minutes ago Exited (0) 9 seconds ago hopeful_clarke $ docker container rm hopeful_clarke hopeful_clarke أنشئ الملف "index.js" في المجلد الحالي واكتب التعليمة ('console.log('Hello, World ضمنها. لا حاجة للحاويات بعد، ولنتفادى أيضًا تثبيت Node. هناك العديد من الصور المفيدة الجاهزة للاستخدام يقدمها Docker Hub، لهذا سنستخدم الصورة " https://hub.docker.com/_/Node" التي تضم Node، وعلينا فقط اختيار الإصدار المناسب. كما تساعد الراية name-- في الأمر container run على تسمية الحاوية: $ docker container run -it --name hello-node node:16 bash سننشئ الآن مجلدًا للشيفرة ضمن الحاوية: root@77d1023af893:/# mkdir /usr/src/app وطالما أننا ضمن الحاوية، شغّل نسخة جديدة من الطرفية ونفّذ الأمر container cp لنسخ الملف من مكان وجوده في جهازك إلى الحاوية: $ docker container cp ./index.js hello-node:/usr/src/app/index.js يمكنك الآن تنفيذ الأمر ضمن الحاوية، كما يمكن تحضيرها على هيئة صورة جديدة، لكن هناك حل أفضل أيضًا. وهذا ما سنتابع البحث فيه لاحقًا. ترجمة -وبتصرف- للفصل Introduction to Containers من سلسلة Deep Dive Into Modern Web Development اقرأ أيضًا أبرز المفاهيم التي يجب عليك الإلمام بها عن الحاويات حاوية دوكر Docker ومخزن APCu في PHP كيفية تثبيت دوكر Docker على فيدورا لينكس
  11. لقد أضفنا حتى الآن عدة ميزات إلى تطبيقنا لكن دون أي اتصال بالخادم، إذ يستخدم تطبيق قائمة المستودعات المقيَّمة الذي نفذناه في المقال السابق أساسيات React Native -على سبيل المثال- بيانات وهمية، ولا يرسل نموذج تسجيل الدخول بيانات التحقق من المستخدم إلى أية وصلة تخديم endpoint مفوَّضة باستقبال هذه البيانات، لذا سنتعلم في هذا المقال كيف سنتصل بالخادم مستخدمين طلبات HTTP، وكيف سنستخدم المكتبة Apollo Client ضمن تطبيقات React Native، وكذلك طريقة تخزين البيانات في جهاز المستخدم. سنحتاج قبل الشروع بتعلم تلك النقاط إلى خادم لنتواصل معه، لذلك فقد أنجزنا خادمًا كاملًا ووضعناه في المستودع rate-repository-api. سيلبي الخادم "rate-repository-api" كل ما تحتاجه الواجهة البرمجية لتطبيقنا خلال هذا القسم، إذ يستخدم "rate-repository-api" قاعدة بيانات SQLite والتي لا تحتاج إلى إعدادات، كما يؤمن واجهة برمجية خاصة بالمكتبة Apollo GraphQL، إضافةً إلى بعض وصلات التخديم التي تؤمن واجهة برمجية متوافقة مع REST. قبل أن تغوص أعمق في مادة هذا المقال، عليك أن تُعدَّ الخادم "rate-repository-api" باتباع الإرشادات الموجودة في ملف README الخاص بالمستودع. ننصحك إن كنت ستستخدم المقلّد emulator لتطوير التطبيق، أن تشغله على نفس الحاسوب الذي يشغّل الخادم، فسيسهل ذلك طلبات الاتصال عبر الشبكة على نحوٍ واضح. طلبات HTTP تزودنا React Native بالواجهة البرمجية Fetch API لتنفيذ طلبات HTTP الخاصة بتطبيقنا، كما تدعم أيضًا الواجهة البرمجية القديمة XMLHttpRequest API والتي ستمكننا من استخدام مكتبات خارجية مصدرها طرف ثالث مثل Axios. وهذه الواجهات هي نفسها التي استخدمناها في بيئة المتصفح، كما أنها متاحة لكامل التطبيق ولا حاجة لإدراجها. قد يتفق المطورون الذين تعاملوا مع الواجهتين البرمجيتين السابقتين، أن Fetch API أسهل استخدامًا وأكثر عصرية، لكن للواجهة XMLHttpRequest API استعمالاتها أيضًا، ولأجل تبسيط الموضوع سنتعامل فقط مع Fetch API في أمثلتنا. يمكن استخدام Fetch API لإرسال طلبات HTTP باستخدام الدالة fetch وسيكون الوسيط الأول لهذه الدالة عنوان المورد URL وطلب HTTP الافتراضي لها هو GET: fetch('https://my-api.com/get-end-point'); أما الوسيط الثاني للدالة فهو كائن خيارات يمكنك استعماله لإنشاء طلب HTTP آخر، أو ترويسة طلب، أو متن body طلب: fetch('https://my-api.com/post-end-point', { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify({ firstParam: 'firstValue', secondParam: 'secondValue', }), }); انتبه إلى أنّ العناوين "URL's" مصطنعة ولن تستجيب لطلباتك غالبًا. تعمل Fetch API موازنةً بالمكتبة Axios في مستوى أدنى قليلًا، فلا توجد مثلًا أية عمليات تحويل أو تفسير لمتن الطلب أو الاستجابة، فعليك مثلًا أن تحدد نوع المحتوى content-type بنفسك وأن تستخدم التابع JSON.stringify لتحويل متن الطلب إلى نص. تعيد الدالة fetch وعدًا (promise) يُحَل resolves إلى كائن الاستجابة Response، وانتبه إلى أنّ رموز الحالة للأخطاء التي تحصل مثل 400 أو 500 لن تُرفض كما هو الوضع في Axios مثلًا. يمكن أن نفسّر متن الاستجابة المنسقة بأسلوب JSON باستخدام التابع Response.json: const fetchMovies = async () => { const response = await fetch('https://reactnative.dev/movies.json'); const json = await response.json(); return json; }; وللاطلاع على أساسيات Fetch API بشيء من التفصيل، اقرأ مقال الواجهة البرمجية fetch في JavaScript ومقال استعمالات متقدمة للواجهة البرمجية Fetch في جافاسكربت وأضف إليهما مقال تتبع تقدم عملية تنزيل البيانات عبر Fetch ومقاطعتها في جافاسكربت. لنبدأ بالاستخدام التطبيقي للمكتبة Fetch API، إذ يؤمن الخادم rate-repository-api وصلة تخديم تعيد قائمةً مرقمةً من المستودعات المقيَّمة. ستتمكن من الولوج إلى وصلة التخديم الموجودة على العنوان "http://localhost:5000/api/repositories" بمجرد أن يعمل الخادم. تُرقّم البيانات بأسلوب الترقيم المعتمد على المؤشرات cursor based pagination format، وستجد البيانات الفعلية خلف المفتاح "node" في المصفوفة "edges". لا يمكن، لسوء الحظ، الوصول إلى الخادم rate-repository-api مباشرةً من خلال تطبيقنا بطلب العنوان "http://localhost:5000/api/repositories"، إذ نحتاج لإرسال طلب إلى وصلة التخديم هذه عبر التطبيق الوصول إلى الخادم باستخدام عنوان IP الخاص به في الشبكة المحلية التي ينتمي إليها. ولإيجاد هذا العنوان، افتح أدوات تطوير Expo بتنفيذ الأمر npm start. سترى في أدوات التطوير عنوانًا له بالبادئة "//:exp" فوق شيفرة QR: انسخ عنوان IP الموجود بين البادئة "//:exp" والنهاية ":"، وسيكون في مثالنا 192.168.100.16. أنشئ بعد ذلك عنوان URL بالتنسيق التالي "http://:5000/api/repositories" ثم توجه إليه من خلال المتصفح، وستحصل على نفس الاستجابة التي حصلت عليها باستخدام عنوان الخادم المحلي "localhost". بعد أن حددنا عنوان وصل التخديم، سنستخدم البيانات الفعلية التي يزودنا بها الخادم في تطبيق قائمة المستودعات المقيَّمة. أشرنا سابقًا أننا نستخدم بيانات وهمية مخزّنة في المتغير repositories. اِحذف هذا المتغير واستعض عن البيانات الوهمية في الملف "RepositoryList.jsx" الموجود في المجلد "components" بهذه الشيفرة: import { useState, useEffect } from 'react'; // ... const RepositoryList = () => { const [repositories, setRepositories] = useState(); const fetchRepositories = async () => { // ‫صحح عنوان IP إن كان مختلفًا لديك const response = await fetch('http://192.168.100.16:5000/api/repositories'); const json = await response.json(); console.log(json); setRepositories(json); }; useEffect(() => { fetchRepositories(); }, []); // Get the nodes from the edges array const repositoryNodes = repositories ? repositories.edges.map(edge => edge.node) : []; return ( <FlatList data={repositoryNodes} // Other props /> ); }; export default RepositoryList; استخدمنا الخطاف useState للحفاظ على حالة قائمة المستودعات، كما استخدمنا الخطاف useEffect لاستدعاء الدالة fetchRepositoriesعندما يُثبّت المكوّن RepositoryList ضمن شجرة المكوّنات. استخلصنا المستودعات الفعلية ووضعناها في المتغير repositoryNode واستبدلنا به المتغير السابق repositories الموجود في الخاصية data العائدة للمكوّن FlatList، وبذلك يمكننا أن نرى البيانات الفعلية التي يعطيها الخادم ضمن التطبيق. من الجيد عادةً تسجيل استجابة الخادم لتتمكن من تحرّيها كما فعلنا في الدالة fetchRepositories. ينبغي أن تكون قادرًا على الاطلاع على السجلات في أدوات تطوير Expo عن طريق الوصول إلى سجلات جهازك كما تعلمنا في المقال السابق (فقرة عرض سجلات العمل). إن استخدمت تطبيق جوّال Expo وأخفق طلب HTTP، تأكد أن هاتفك وحاسوبك الذي يشغّل الخادم ينتميان إلى نفس الشبكة اللاسلكية. وإن لم تستطع ذلك، استخدم المقلّد على نفس الحاسوب الذي يعمل عليه الخادم أو هيئ نفق توصيل tunnel إلى الخادم المحلي. يمكنك استخدام المكتبة Ngrok مثلًا لإنجاز ذلك. يمكن إعادة كتابة شيفرة إحضار البيانات الموجودة في المكوّن RepositoryList، إذ يعرف المكوّن مثلًا تفاصيل الطلبات عبر الشبكة كعنوان وصلة التخديم، وبالإضافة إلى ذلك، تحمل شيفرة إحضار البيانات إمكانية لإعادة استخدامها. لنعد إذًا كتابة شيفرة المكوّن بنقل شيفرة إحضار البيانات إلى خطاف خاص بها. سننشئ أولًا مجلدًا باسم "hooks" ضمن المجلد "src" ومن ثم سنضع فيه الملف "useRepositories.js" الذي يحتوي الشيفرة التالية: import { useState, useEffect } from 'react'; const useRepositories = () => { const [repositories, setRepositories] = useState(); const [loading, setLoading] = useState(false); const fetchRepositories = async () => { setLoading(true); // ‫صحح عنوان IP إن كان مختلفًا لديك const response = await fetch('http://192.168.100.16:5000/api/repositories'); const json = await response.json(); setLoading(false); setRepositories(json); }; useEffect(() => { fetchRepositories(); }, []); return { repositories, loading, refetch: fetchRepositories }; }; export default useRepositories; لنستخدم الآن الخطاف الجديد useRepositories في المكوّن RepositoryList: // ... import useRepositories from '../hooks/useRepositories'; const RepositoryList = () => { const { repositories } = useRepositories(); const repositoryNodes = repositories ? repositories.edges.map(edge => edge.node) : []; return ( <FlatList data={repositoryNodes} // Other props /> ); }; export default RepositoryList; وهكذا لن يكون لدى المكوّن RepositoryList أية فكرة عن طريقة الحصول على مستودعات القائمة، وربما سنطلب هذه المستودعات مستقبلًا باستخدام الواجهة البرمجية للمكتبة GraphQL بدلًا من واجهة REST، ونرى ما الذي سيحدث. العمل مع المكتبتين GraphQL و Apollo client تعرفنا في القسم 8 على GraphQL وكيفية إرسال استعلامات إلى خادم Apollo في تطبيقات React بالاستفادة من المكتبة Apollo Client. ولحسن الحظ، يمكنك استخدام Apollo client في تطبيقات React Native بنفس الطريقة التي استخدمناها في بناء تطبيقات React الخاصة بالويب. وكما أشرنا سابقًا، سيزودنا الخادم "rate-repository-api" بواجهة برمجية للمكتبة GraphQL نُفِّذت باستخدام Apollo client، وبالتالي ستتمكن من الولوج إلى أرضية عمل GraphQL بالتوجه إلى العنوان http://localhost:5000/graphq. تمثل أرضية عمل GraphQL أداة تطوير لإرسال استعلامات GraphQL، وتفقد تخطيط الواجهة البرمجية للمكتبة GraphQL، وعرض توثيق هذه المكتبة. ويفضل أن تجرب دائمًا أية استعلامات سيرسلها تطبيقك من خلال أرضية عمل GraphQL قبل تضمينها في الشيفرة. ومن الأسهل أيضًا تنقيح الأخطاء المحتملة في الاستعلامات ضمن أرضية عمل GraphQL موازنةً مع تنقيحها باستخدام التطبيق. وإن لم تكن متأكدًا من الاستعلامات المتاحة أو كيفية استخدامها، انقر على النافذة "docs" لفتح التوثيق: سنستعمل في تطبيقات React Native المكتبة Apollo Boost، وهو أسلوب لا يتطلب أية قواعد للتهيئة zero-config للبدء باستخدام Apollo client. وللتكامل مع React، سنستخدم المكتبة apollo/react-hooks@ والتي تؤمن خطافات مثل useQuery و useMutation تساعدنا في العمل مع المكتبة Apollo client. لنبدأ بتثبيت الاعتماديات اللازمة: npm install @apollo/client graphql سنحتاج قبل البدء باستخدام عميل Apollo إلى إعدادٍ جزئي لمجمّع Metro لمعالجة الملفات ذات الامتداد "cjs." التي يستخدمها عميل Apollo. لنثبّت أولًا الحزمة "expo/metro-config@" التي تحتوي على إعدادات Metro الافتراضية: npm install @expo/metro-config يمكننا بعد ذلك إضافة الإعداد التالي إلى الملف "metro.config.js" في المجلد الجذري للمشروع: const { getDefaultConfig } = require('@expo/metro-config'); const defaultConfig = getDefaultConfig(__dirname); defaultConfig.resolver.sourceExts.push('cjs'); module.exports = defaultConfig; أعِد تشغيل أدوات تطوير Expo لتطبيق الإعدادات المُجراة. لننشئ الآن دالةً خدميّة utility function لتكوين عميل Apollo وتهيئته على النحو المطلوب. أنشئ بدايةً المجلد "utils" ضمن المجلد "src"، وأنشئ ضمنه الملف "apolloClient.js"، ثم هيئ عميل Apollo ضمن هذا الملف ليتصل مع خادم Apollo: import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; const httpLink = createHttpLink({ // ‫صحح عنوان IP إن كان مختلفًا لديك uri: 'http://192.168.100.16:4000/graphql', }); const createApolloClient = () => { return new ApolloClient({ link: httpLink, cache: new InMemoryCache(), }); }; export default createApolloClient; يستخدم عميل Apollo للاتصال بخادم Apollo العنوان URL ذاته الذي استخدمناه مع Fetch API ونفس رقم المنفذ 4000، ما عدا أنّ المسار هو "graphql/". وأخيرًا علينا تزويد التطبيق بعميل Apollo وذلك باستخدام سياق العمل ApolloProvider. سنضيفه إذًا إلى المكوّن App في الملف "App.js": import { NativeRouter } from 'react-router-native'; import { ApolloProvider } from '@apollo/client'; import Main from './src/components/Main'; import createApolloClient from './src/utils/apolloClient'; const apolloClient = createApolloClient(); const App = () => { return ( <NativeRouter> <ApolloProvider client={apolloClient}> <Main /> </ApolloProvider> </NativeRouter> ); }; export default App; تنظيم الشيفرة المتعلقة بالمكتبة GraphQL سيعود الأمر إليك دائمًا في تنظيم الشيفرة المتعلقة بالمكتبة GraphQL ضمن تطبيقك. لكن ولأجل الحصول على هيكلية مرجعية، لنلق نظرةً على طريقة بسيطة لكنها فعالة في تنظيم هذه الشيفرة. عرّفنا في هذه الهيكلية كلًا من الاستعلامات والطفرات mutations والاجتزاءات fragments وغيرها من الكيانات التي قد نستخدمها في ملفات خاصة بها وتتواجد في نفس المجلد. إليك صورة توضح هيكلية يمكنك اعتمادها: يمكنك استيراد معرّف القالب المجرّد gql المُستخدم في تعريف استعلامات من مكتبة "apollo/client@". وإن اتبعنا الهيكلية التي اقترحناها منذ قليل، فمن الأفضل إنشاء الملف "queries.js" في المجلد "graphql" ليحتوي الشيفرة اللازمة لتنفيذ استعلامات GraphQL في تطبيقنا. يمكن أن نخزّن كل استعلام ضمن متغّير خاص ثم نصدّره كالتالي: import { gql } from '@apollo/client'; export const GET_REPOSITORIES = gql` query { repositories { ${/* ... */} } } `; // other queries... يمكن إدراج تلك المتغيرات واستخدامها بالاستفادة من الخطاف useQuery على النحو التالي: import { useQuery } from '@apollo/client'; import { GET_REPOSITORIES } from '../graphql/queries'; const Component = () => { const { data, error, loading } = useQuery(GET_REPOSITORIES); // ... }; وينطبق على الطفرات ما ينطبق على الاستعلامات. طبعًا لا بدّ من تعريف الطفرات في ملف منفصل مثل "mutations.js"، ويُفضّل استخدام الاجتزاءات (fragments) في الاستعلامات لتفادي تكرار كتابة الحقول نفسها عدة مرات. تطوير هيكلية التطبيق عندما يأخذ التطبيق في النمو، سيزداد حجم بعض الملفات لتصبح ربما كبيرة جدًا لإدارتها بصورةٍ صحيحة. لنفترض أنه لدينا مكوّن A سيصيّر مكونين B و C. ستُعرّف جميع هذه المكونات ضمن الملف "A.jsx" والذي سيوضع في المجلد"components". نريد الآن أن ننقل المكونين A و B إلى ملفين مستقلين "B.jsx" و "C.jsx" دون أن نجري تغييرًا كبيرُا في تنظيم الشيفرة. سيكون أمامنا في هذه الحالة خيارين: الأول، إنشاء الملفين "B.jsx" و"C.jsx" في المجلد "components". وستكون هيكلية التطبيق على النحو التالي: components/ A.jsx B.jsx C.jsx ... الثاني، إنشاء مجلد A في المجلد "components" ثم إنشاء الملفين "B.jsx"و"C.jsx" ضمنه. ولتفادي إخفاق المكونات التي تُدرِج الملف "A.jsx" في شيفرتها، انقل الملف "A.jsx" إلى المجلد "A" وغيّر اسمه إلى "index.jsx". سينتج عن هذا الأسلوب الهيكلية التالية: components/ A/ B.jsx C.jsx index.jsx ... يُعد الخيار الأول مناسبًا تمامًا إن لم يكن المكونين C وB قابلين للاستخدام خارج حدود المكوّن A، فلا فائدة من وضعهما في ملفات منفصلة في المجلد "components"؛ أما الخيار الثاني فهو مناسبٌ لأسلوب الوحدات المستقلة، إذ أنه لا يسبب أية مشاكل في إدراج المكونات، لأن إدراج مسار مثل "A/." سيطابق إدراج كلا الملفين "A.jsx" و"A/index.jsx". التمرين 10.11 إحضار المستودعات باستخدام Apollo client نريد استبدال ما نفذناه ضمن الخطاف useRepositories مستخدمين المكتبة Fetch API باستعلام GraphQL. شغّل أرضية عمل Apollo على العنوان "http://localhost:4000"، ثم افتح توثيق المكتبة بالنقر على النافذة "docs". يستخدم الاستعلام عدة وسطاء اختيارية، فلا حاجة لتحديدها الآن. كوّن استعلامًا ضمن أرضية عمل Apollo لإحضار المستودعات بالاستفادة من قيم الحقول التي تعرضها حاليًا في واجهة التطبيق. ستُرقّم النتائج التي تضم قرابة 30 نتيجة افتراضيًا. يمكنك تجاهل موضوع الترقيم حاليًا. عندما يعمل الاستعلام ضمن أرضية عمل Apollo، اِستخدمه ليحل مكان شيفرة المكتبة FetchAPI في الخطاف useRepositories. يمكن أن تنفِّذ ذلك باستخدام الخطاف useQuery. أدرج بعدها معّرف القالب المجرّد gql من المكتبة بالطريقة التي شرحناها سابقًا. حاول أن تستخدم الهيكلية التي اقترحناها سابقًا لتنظيم الشيفرة المتعلقة بالمكتبة GraphQL. ولتجنب مشاكل الذاكرة الخبيئة مستقبلًا، استخدم سياسة الإحضار (fetch policy) "cache-and-network"، التي يمكن استخدامها مع الخطاف useQuery على النحو التالي: useQuery(MY_QUERY, { fetchPolicy: 'cache-and-network', // خيارات أخرى }); يجب أن لا تؤثر التغييرات في شيفرة الخطاف useRepositories على أداء المكوّن RepositoryList بأي شكل من الأشكال. متغيرات البيئة سيعمل كل تطبيق على الأرجح في أكثر من بيئة، ومن الواضح أن بيئتي التطوير والإنتاج أقرب مثالين عن الفكرة، لكن خارج هاتين البيئتين، هناك البيئة التي نشغّل عليها التطبيق حاليًا. تحتاج البيئات المختلفة عادةً اعتماديات مختلفة، فقد يستخدم الخادم المحلي الذي نتعامل معه قاعدة بيانات محليّة، بينما سيتعامل الخادم الذي سيُنشَر في بيئة الإنتاج مع قاعدة بيانات بيئة الإنتاج. ولكي نجعل شيفرتنا مستقلة عن بيئة التشغيل، لا بد من ربط هذه الاعتماديات بمعاملات أو متغيرات. لاحظ مثلًا أننا نستخدم في تطبيقنا قيمة جاهزة تعتمد بشدة على بيئة التشغيل وهي عنوان الخادم. تعلمنا سابقًا أنه بالإمكان تزويد البرامج قيد التشغيل بمتغيرات بيئة، إذ يمكن تعريف هذه المتغيرات من خلال سطر الأوامر أو من خلال ملف تهيئة بيئة التشغيل الذي ينتهي بلاحقة مثل "env." ومكتبات يؤمنها طرف ثالث مثل Dotenv. لكن ولسوء الحظ لا تدعم React Native الاستخدام المباشر لمتغيرات البيئة، لكن يمكننا الوصول إلى إعدادت تهيئة Expo الموجودة في الملف "app.json" في زمن التشغيل من خلال شيفرة JavaScript. وبالتالي يمكن استخدام هذه الإعدادات لتعريف المتغيرات المتعلقة بالبيئة والوصول إليها. نستطيع الوصول إلى إعدادات التهيئة بإدراج الثابت Constants من الوحدة "expo-constants" كما فعلنا ذلك عدة مرات مسبقًا. وبإدراج هذا الثابت، ستزودنا الخاصية Constants.manifest بكل الإعدادات. لنجرب ذلك بكتابة شيفرة ضمن المكوّن App وظيفتها طباعة محتويات هذه الخاصية في سجل العمل: import { NativeRouter } from 'react-router-native'; import { ApolloProvider } from '@apollo/client'; import Constants from 'expo-constants'; import Main from './src/components/Main'; import createApolloClient from './src/utils/apolloClient'; const apolloClient = createApolloClient(); const App = () => { console.log(Constants.manifest); return ( <NativeRouter> <ApolloProvider client={apolloClient}> <Main /> </ApolloProvider> </NativeRouter> ); }; export default App; ستجد الآن كل إعدادات التهيئة في سجل العمل. تقتضي الخطوة الثانية استخدام الإعدادات لتعريف متغيّرات تعتمد على بيئة التشغيل. لنبدأ المهمة بتغير اسم الملف "app.json" إلى "app.config.json". بهذا التغيير سنصبح قادرين على استخدام شيفرة جافا سكريبت داخل ملف التهيئة. غير محتوى الملف بحيث يتحوّل الكائن: { "expo": { "name": "rate-repository-app", // … بقية الضبط } } إلى كائن قابل للتصدير، يحتوي على الخاصية expo: export default { name: 'rate-repository-app', // … بقية الضبط }; يتلقى الكائن expo الخاصية extra ضمن إعدادات التهيئة، وذلك لتمرير أية إعدادات خاصة بالتطبيق. ولنرى كيف يعمل هذا الأمر، سنضيف المتغير env إلى إعدادات تهيئة تطبيقنا: export default { name: 'rate-repository-app', // … بقية الضبط extra: { env: 'development' }, }; أعد تشغيل أدوات تطوير Expo لتطبيق التغييرات. من المفترض أن تتغير قيمة الخاصية Constans.manifest بحيث تضم الخاصية extra التي تحتوي على إعدادات التهيئة الخاصة بتطبيقنا. يمكننا الآن الوصول إلى المتغير env من خلال الخاصية Constants.manifest.extra.env ولأن وضع القيم الجاهزة في الشيفرة أمر طائش، سنستخدم متغير بيئة بديلًا لذلك: export default { name: 'rate-repository-app', // rest of the configuration... extra: { env: process.env.ENV, }, }; تعلمنا سابقًا كيفية إسناد قيمة لمتغير البيئة من خلال سطر الأوامر، وذلك بتحديد اسم المتغيّر وقيمته قبل الأمر الفعلي. فعلى سبيل المثال، شغل أدوات تطوير Expo، ثم أسند القيمة "test" إلى متغير البيئة ENV: ENV=test npm start لو ألقيت نظرةً على سجل العمل، فستجد أن الخاصية Constants.manifest.extra.env قد تغيرت. يمكننا كذلك تحميل متغيرات البيئة من الملف "env." كما تعلمنا في الأقسام السابقة. لكن علينا أولًا تثبيت المكتبة Dotenv: npm install dotenv لنضف الآن الملف "env." إلى المجلد الجذري للمشروع على أن يحتوي ما يلي: ENV=development سندرج أخيرًا المكتبة ضمن الملف "app.config.js": import 'dotenv/config'; export default { name: 'rate-repository-app', // … بقية الضبط extra: { env: process.env.ENV, }, }; أعد تشغيل أدوات تطوير Expo لتطبيق التغييرات التي أجريتها على الملف "env". ملاحظة: لا تضع بيانات حساسة ضمن إعدادات التطبيق، لأن المستخدم بعد تنزيل التطبيق قد يكون قادرًا من الناحية النظرية على تحليل شيفرتك وسيحصل بالتالي على هذه البيانات الحساسة. التمرين 10.12 متغيرات البيئة استخدم متغير بيئة ضمن ملف "env." لتحديد عنوان خادم Apollo بدلًا من كتابته ضمن الشيفرة، وذلك عند تهيئة عميل Apollo. يمكنك تسمية هذا المتغير "APOLLO_URI" مثلًا. لا تحاول الوصول إلى متغير البيئة على النحو التالي: process.env.APOLLO_URI خارج الملف "app.config.js"، بل استخدم الكائن Constants.manifest.extra كما فعلنا في المثال السابق. كذلك لا تدرج المكتبة Dotenv خارج الملف "app.config.js" وإلا قد تواجهك المشاكل. تخزين البيانات في جهاز المستخدم قد تحتاج في بعض الأحيان إلى تخزين بعض البيانات في جهاز المستخدم. ومن أبرز السيناريوهات التي تضطرك إلى ذلك هو تخزين شهادة التحقق من المستخدم token لكي تكون قادرًا على الحصول عليها عندما يغلق المستخدم التطبيق ثم يعيد فتحه. لقد استخدمنا عند تطوير تطبيقات الويب الذاكرة المحلية للمتصفح من خلال الكائن localStorage. تؤمن Recat Native بالمقابل ذاكرة تخزين مقيمة هي الذاكرة غير المتزامنة (AsyncStorage). يمكن استخدام أمر التثبيت في Expo لتثبيت نسخة من الحزمة reactnativecommunity/async-storage@ متوافقة مع نسخة Expo SDK المستخدمة: expo install @react-native-async-storage/async-storage تتشابه الواجهة البرمجية للحزمة AsyncStorage مع الواجهة البرمجية للذاكرة المحلية localStorage بعدة نواحٍ، فكلاهما ذاكرة تعتمد مبدأ "مفتاح-قيمة". ويكمن الاختلاف الكبير بينهما، كما يوحي الاسم، أنّ عمليات AsyncStorage غير متزامنة. من المفيد أن نُجري تجريدًا بسيطًا لشيفرة العمليات، طالما أن AsyncStorage ستعمل مع مفاتيح نصية ضمن فضاء الأسماء العام global namespace. يمكن إنجاز هذا التجريد باستخدام الأصناف class. إذ يمكن، على سبيل المثال، إنشاء صنف لبطاقة تسوق، نخزّن فيها المنتجات التي يريد أن يشتريها مستخدم: import AsyncStorage from '@react-native-async-storage/async-storage'; class ShoppingCartStorage { constructor(namespace = 'shoppingCart') { this.namespace = namespace; } async getProducts() { const rawProducts = await AsyncStorage.getItem( `${this.namespace}:products`, ); return rawProducts ? JSON.parse(rawProducts) : []; } async addProduct(productId) { const currentProducts = await this.getProducts(); const newProducts = [...currentProducts, productId]; await AsyncStorage.setItem( `${this.namespace}:products`, JSON.stringify(newProducts), ); } async clearProducts() { await AsyncStorage.removeItem(`${this.namespace}:products`); } } const doShopping = async () => { const shoppingCartA = new ShoppingCartStorage('shoppingCartA'); const shoppingCartB = new ShoppingCartStorage('shoppingCartB'); await shoppingCartA.addProduct('chips'); await shoppingCartA.addProduct('soda'); await shoppingCartB.addProduct('milk'); const productsA = await shoppingCartA.getProducts(); const productsB = await shoppingCartB.getProducts(); console.log(productsA, productsB); await shoppingCartA.clearProducts(); await shoppingCartB.clearProducts(); }; doShopping(); ومن الجيد أيضًا أن نحدد فضاء أسماء للمفاتيح في AsyncStorage، ذلك أنها قيم نصيّة. وفي هذا السياق، سنجد أن فضاء الأسماء هو بادئة نزوّد بها مفاتيح التخزين، إذ سيمنع استخدام فضاء الأسماء مفاتيح التخزين من التصادم مع مفاتيح أخرى للمكتبة AsyncStorage. عرّفنا فضاء الأسماء في المثال السابق على شكل وسيط للدالة البانية، كما استخدمنا التنسيق "namespace:key مفتاح: فضاء أسماء" من أجل مفاتيح التخزين. يمكن إضافة عنصر إلى ذاكرة التخزين مستخدمين التابع AsyncStorage.setItem. سيكون مفتاح العنصر هو الوسيط الأول للتابع بينما ستكون قيمة المفتاح هي المعامل الثاني. وطالما أن قيمة المفتاح هي قيمة نصية، فلا بد من تحويل القيم غير النصية إلى نصية باستخدام التابع JSON.stringify. يُستخدَم التابع AsyncStorage.getItem للحصول على عنصر محدد من ذاكرة التخزين، ووسيط هذا التابع هو مفتاح العنصر. أما التابع AsyncStorage.removeItem فيُستخدم لحذف عنصر من ذاكرة التخزين بتحديد مفتاحه. التمرينان 10.13 - 10.14 10.13: استخدام الطفرات في نموذج تسجيل الدخول لا يستطيع نموذج تسجيل الدخول حاليًا فعل أي شيء حيال معلومات توثيق المستخدم التي يرسلها، لذلك سنحاول تحسين الوضع قليلًا في هذا التمرين. اطلع أولًا على توثيق الاستيثاق authentication documentation الخاص بالخادم "rate-repository-api"، ثم اختبر الاستعلامات التي تُزوَّد بها ضمن أرضية عمل Apollo. إن لم تحتوي قاعدة البيانات على أي مستخدمين يمكن وضع بعض البيانات فيها، وستجد إرشاداتٍ لذلك في الفقرة "getting started"من الملف "README" الموجود في المستودع "rate-repository-ap". عندما تتفهم الطريقة التي تعمل بها استعلامات الاستيثاق، أنشئ الملف "useSignIn.js" في المجلد "hooks" وضع فيه شيفرة الخطافuseSignIn الذي يرسل الطفرة باستخدام الخطاف useMutation. تقبل الطفرة authenticate وسيطًا وحيدًا هو credentials من النوع "AuthenticateInput". يحتوي نوع عنصر الإدخال (input type) حقلين لاسم المستخدم وكلمة المرور. ستكون القيمة التي يعيدها الخطاف على الشكل [signIn, result]، إذ يمثل الوسيط result نتيجة الطفرات كما يعيدها الخطاف useMutation، ويمثل الوسيط signIn دالة تنفيذ الطفرة باستخدام وسيط هو كائن من الشكل {username, password}. تلميح: لا تمرر دالة الطفرة إلى القيمة المعادة مباشرةً، بل أعد دالة تستدعي دالة الطفرة على النحو التالي: const useSignIn = () => { const [mutate, result] = useMutation(/* mutation arguments */); const signIn = async ({ username, password }) => { // استدعي دالة الطفرة بالوسطاء المناسبين }; return [signIn, result]; }; بعد إنجاز الخطاف، استخدمه في دالة الاستدعاء onSubmit العائدة للمكوّن SignIn على النحو التالي مثلًا: const SignIn = () => { const [signIn] = useSignIn(); const onSubmit = async (values) => { const { username, password } = values; try { const { data } = await signIn({ username, password }); console.log(data); } catch (e) { console.log(e); } }; // ... }; يمكنك عدُّ التمرين مكتملًا عندما تُسجَّل نتيجة طفرات authenticate في سجل عمل التطبيق بعد إرسال بيانات النموذج. وينبغي أن تتضمن نتيجة الطفرة شهادة التحقق من المستخدم. 10.14: تخزين شهادة التحقق- الخطوة 1 بعد أن حصلنا على شهادة التحقق لا بدّ من تخزينها. أنشئ الملف "authStorage.js" في المجلد "utils" بحيث يحتوي الشيفرة التالية: import AsyncStorage from '@react-native-async-storage/async-storage'; class AuthStorage { constructor(namespace = 'auth') { this.namespace = namespace; } getAccessToken() { // احصل على شهادة التحقق } setAccessToken(accessToken) { // أضف الشهادة إلى ذاكرة التخزين } removeAccessToken() { // احذف شهادة التحقق من ذاكرة التخزين } } export default AuthStorage; أنجز شيفرة التوابع التالية: AuthStorage.getAccessToken و AuthStorage.setAccessToken و AuthStorage.removeAccessToken. استخدم المتغير namespace لمنح المفاتيح فضاء أسماء كما فعلنا في المثال السابق. تحسين طلبات المكتبة Apollo Client بعد أن أنجزنا آلية لتخزين شهادة التحقق من المستخدم، حان الوقت لاستخدامها. هيئ ذاكرة التخزين في المكوّن App: import { NativeRouter } from 'react-router-native'; import { ApolloProvider } from '@apollo/client'; import Main from './src/components/Main'; import createApolloClient from './src/utils/apolloClient'; import AuthStorage from './src/utils/authStorage'; const authStorage = new AuthStorage(); const apolloClient = createApolloClient(authStorage); const App = () => { return ( <NativeRouter> <ApolloProvider client={apolloClient}> <Main /> </ApolloProvider> </NativeRouter> ); }; export default App; مررنا نسخةً من الصنف الذي يتعامل مع ذاكرة التخزين AuthStorage إلى الدالة createApolloClient مثل وسيط، لأننا سنرسل لاحقًا شهادة التحقق إلى خادم Apollo مع كل طلب. يتوقع خادم Apollo أن تكون شهادة التحقق ضمن ترويسة الاستيثاق وبنفس تنسيق حامل الشهادة <ACCESS_TOKEN>. يمكن تحسين طلب Apollo Client باستخدام الدالة setContext. لنرسل شهادة التحقق إلى خادم Apollo من خلال عميل Apollo بتعديل الدالة createApolloClientفي الملف "apolloClient.js": import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client'; import Constants from 'expo-constants'; import { setContext } from '@apollo/client/link/context'; // ‫قد تحتاج إلى تغيير هذا بناءً على كيفية تكوين URI لخادم Apollo const { apolloUri } = Constants.manifest.extra; const httpLink = createHttpLink({ uri: apolloUri, }); const createApolloClient = (authStorage) => { const authLink = setContext(async (_, { headers }) => { try { const accessToken = await authStorage.getAccessToken(); return { headers: { ...headers, authorization: accessToken ? `Bearer ${accessToken}` : '', }, }; } catch (e) { console.log(e); return { headers, }; } }); return new ApolloClient({ link: authLink.concat(httpLink), cache: new InMemoryCache(), }); }; export default createApolloClient; استخدام React Context لتوزيع البيانات يبقى علينا في النهاية إجراء تكامل بين ذاكرة التخزين والخطاف useSignIn. ولكي ننجز ذلك لا بدّ من السماح للخطاف بولوج شهادة التحقق التي هيئنا نسخة منها في المكوّن App. سنستخدم الواجهة Context التي تزودنا بها React لأداء هذه المهمة. أنشئ المجلد "contexts" ضمن المجلد "src"، ثم أنشئ ضمنه الملف "AuthStorageContext.js" وضع فيه الشيفرة التالية: import React from 'react'; const AuthStorageContext = React.createContext(); export default AuthStorageContext; ستتمكن الآن من استخدام المكوّن AuthStorageContext.Provider لتوزيع نسخة من ذاكرة التخزين على المكوّنات الأبناء ضمن سياق التنفيذ context. لنضفه إلى المكوّن App: import { NativeRouter } from 'react-router-native'; import { ApolloProvider } from '@apollo/client'; import Main from './src/components/Main'; import createApolloClient from './src/utils/apolloClient'; import AuthStorage from './src/utils/authStorage'; import AuthStorageContext from './src/contexts/AuthStorageContext'; const authStorage = new AuthStorage(); const apolloClient = createApolloClient(authStorage); const App = () => { return ( <NativeRouter> <ApolloProvider client={apolloClient}> <AuthStorageContext.Provider value={authStorage}> <Main /> </AuthStorageContext.Provider> </ApolloProvider> </NativeRouter> ); }; export default App; يمكن الوصول إلى نسخة ذاكرة التخزين في دالة الخطاف useSignIn باستخدام الخطاف useContext العائد للمكتبة React على النحو التالي: // ... import { useContext } from 'react'; import AuthStorageContext from '../contexts/AuthStorageContext'; const useSignIn = () => { const authStorage = useContext(AuthStorageContext); // ... }; انتبه إلى أنه من غير الممكن الولوج إلى القيمة التي يعيدها سياق العمل باستخدام الخطاف useContext مالم يُستخدم هذا الخطاف في مكوّن ابن للمكون Context.Provider. يُعد الوصول إلى نسخة ذاكرة التخزين باستخدام (useContext(AuthStorageContext مطولًا تمامًا ويكشف عن تفاصيل التنفيذ. دعنا نحسن هذا من خلال تنفيذ خطاف useAuthStorage في ملف "useAuthStorage.js" في مجلد "hooks": import { useContext } from 'react'; import AuthStorageContext from '../contexts/AuthStorageContext'; const useAuthStorage = () => { return useContext(AuthStorageContext); }; export default useAuthStorage; يعد تنفيذ الخطاف بسيطًا جدًا ولكنه يحسن إمكانية قراءة الخطافات والمكونات التي تستخدمها وصيانتها. يمكننا استخدام الخطاف لإعادة تشكيل خطاف useSignIn كما يلي: // ... import useAuthStorage from '../hooks/useAuthStorage'; const useSignIn = () => { const authStorage = useAuthStorage(); // ... }; ستجعل إمكانية توزيع البيانات على المكونات الأبناء من React Context مفتاحًا لحل عدد هائل من الحالات. لتطلع أكثر على هذه الحالات، اقرأ المقالة How to use React Context effectively للمؤلف "Kent C. Dodd"، لتعرف كيف توافق بين استخدام الخطاف useReducer وReact context لتنفيذ آلية لإدارة الحالة. وربما ستجد هذا مفيدًا لحل التمارين القادمة. التمرينان 10.15 و 10.16 10.15: تخزين شهادة التحقق- الخطوة 2 حسّن شيفرة الخطاف useSignIn لكي يخزّن شهادة التحقق التي تنتج عن الطفرة authenticate، بحيث لا تتغير القيمة التي يعيدها الخطاف. ينبغي أن يكون التغيير الوحيد في المكوّن SignIn هو تحويل المستخدم إلى واجهة عرض قائمة المستودعات بعد أن يسجل دخوله بنجاح. يمكنك إنجاز ذلك باستخدام الخطاف useNavigate. عليك أن تعيد ضبط مخزن عميل Apollo بعد تنفيذ الطفرة authenticate وتخزين شهادة التحقق في ذاكرة التخزين. سيسبب ذلك مسح الذاكرة المؤقتة وإعادة تنفيذ كل الطلبات النشطة. يمكن إنجاز ذلك باستخدام التابع resetStore العائد للمكتبة Apollo Client. يمكن الوصول إلى عميل Apollo عبر الخطاف useSignIn باستخدام الخطاف useApolloClient. وانتبه إلى أنّ ترتيب خطوات التنفيذ مهم جدًا وينبغي أن يكون على النحو التالي: const { data } = await mutate(/* خيارات */); await authStorage.setAccessToken(/* شهادة التحقق مأخوذة من البيانات */); apolloClient.resetStore(); 10.16: تسجيل الخروج ستكون الخطوة الأخيرة هي تنفيذ ميزة تسجيل الخروج. يمكن استخدام الاستعلام me للتحقق من بيانات الشخص الذي سجّل دخوله. فإن كانت نتيجة الاستعلام "null" فلن يكون الشخص مخوّلًا بالوصول. شغّل أرضية عمل Apollo، ثم نفّذ الاستعلام التالي: { me { id username } } ستكون النتيجة غالبًا "null"، والسبب في ذلك أنّ أرضية عمل Apollo غير مفوّضة بالوصول. ويعني ذلك أنها لم ترسل شهادة تحقق مع الطلب. راجع توثيق الاستيثاق لتفهم آلية استخلاص شهادة تحقق من الطفرة authenticate. استخدم هذه الشهادة في ترويسة الاستيثاق كما هو موّضح في التوثيق. نفّذ بعد ذلك الاستعلام me من جديد وسترى معلومات المستخدم الذي سجّل دخوله. افتح الملف "AppBar.jsx" الذي يحتوي على المكوّنAppBar والذي يحتوي بدوره على النافذتين "Sign in" و"Repositories". عدّل النوافذ بحيث تظهر النافذة "Sign out" إن سجل المستخدم دخوله، والنافذة "Sign in" إن سجّل خروجه. يمكنك إنجاز ذلك باستخدام الاستعلام me مع الخطاف useQuery. من المفترض أن تُحذف شهادة التحقق من ذاكرة التخزين عند الضغط على النافذة "Sign out"، وأن يُعاد ضبط مخزن عميل Apollo باستخدام التابع resetStore، إذ سيُعاد تنفيذ كل الاستعلامات النشطة تلقائيًا عند استدعاء التابع resetStore، ويعني ذلك إعادة تنفيذ الاستعلام me. تذكّر أن ترتيب خطوات التنفيذ مهم جدًا، ويجب إزالة شهادة التحقق من ذاكرة التخزين قبل إعادة ضبط مخزن عميل Apollo. ترجمة -وبتصرف- للفصل Communicating with server من سلسلة Deep Dive Into Modern Web Development. اقرأ أيضًا المقال السابق: أساسيات React Native مدخل إلى React Native مدخل إلى التحريك في React Native الاتصال مع الخادم في تطبيق React معتمد على Redux
  12. الآن وبعد أن كتبنا تطبيقًا جيدًا، قد حان الوقت لنشره وتوزيعه على المستخدمين الفعليين. لقد فعلنا ذلك في القسم 3 بدفع مستودع git إلى خوادم الاستضافة السحابية Heroku بكل بساطة. تُعد عملية إصدار برمجيات على منصة هيروكو Heroku أمرًا بسيطًا موازنةً بغيرها من الاستضافات، لكن لا تزال هناك طبعًا بعض المخاطر، فلا شيء سيمنعنا من الدفع بشيفرة لا تعمل إلى وضع الإنتاج عن طريق الخطأ. سنتعرف في الفقرات القادمة على مبادئ النشر الآمن للتطبيقات، ومبادئ نشر البرمجيات بالمقاييس الصغيرة والواسعة. يمكن لأي شيء أن يحدث خطأ نريد تحديد بعض القواعد التي تضبط عملية نشرنا للتطبيقات، لكن علينا بداية إلقاء نظرةٍ على بعض المحدوديات الواقعية: "إن أمكن لشيءٍ ما أن يخفق، فسيخفق": هي عبارة يتضمنها قانون مورفي، ومن المهم تذكرها دائمًا عند التخطيط لمنظومة النشر التي سنتبعها، ومن الأشياء التي ينبغي أخذها بالحسبان أيضًا: ماذا لو توقف الحاسوب عن العمل أثناء نشر التطبيق؟ ماذا سيحدث إن انقطع الاتصال بالإنترنت أثناء نشر التطبيق؟ ماذا سيحدث إن أخفقت أية تعليمات من سكربت أو منظومة النشر؟ ماذا سيحدث إن لم يعمل تطبيقي، لسببٍ أو لآخر، على الخادم بعد نشره؟ هل يمكنني التراجع rollback إلى النسخة السابقة؟ ماذا سيحدث إذ أرسل مستخدم ما طلب HTTP إلى التطبيق قبل أن يكتمل نشره (لا يوجد وقتٌ لإرسال استجابة له)؟ هذه عينة صغيرة من المشاكل التي قد تواجهك أثناء النشر أو بالأحرى عينة من الأمور التي ينبغي التخطيط لها. لا ينبغي لمنظومة النشر التي نعتمدها أن تترك البرنامج في حالة إخفاق أيًا كان سببه، كما ينبغي علينا أن نعرف (أو أن نجد بسهولة) حالة منظومة النشر. وطالما نتحدث عن النشر وعن التكامل المستمر CI عمومًا، هناك قاعدةٌ مهمةٌ أخرى عليك تذكرها: "الأخطاء الصامتة قاتلة". لا يعني هذا طبعًا عرض الأخطاء على مستخدم البرنامج، بل يعني أنه يجب علينا الانتباه إلى أي شيء قد يخفق. فلو كنا على دراية بمشكلة محتملة الوقوع، يمكننا إصلاحها. فإن لم تعطي منظومة النشر أية أخطاء لكنها فشلت، سنصل إلى المرحلة التي نعتقد فيها أننا أصلحنا كل الأخطاء الحرجة ومع ذلك تخفق المنظومة، وبالتالي سنترك الثغرة في بيئة الإنتاج غير مدركين لخطورة الوضع. ما الذي تمنحه منظومة النشر الجيدة؟ من الصعب وضع قواعد محددة أو متطلبات خاصة بمنظومة النشر، لكننا سنحاول ذلك: عندما تخفق المنظومة في أية خطوة، يجب أن تخفق دون أية آثار جانبية. لا ينبغي أبدًا أن تترك المنظومة البرنامج في حالة فشل. ينبغي أن تخبرنا المنظومة عن أية حالات إخفاق، فمن الأفضل تنبيهنا إلى حالات الإخفاق عوضًا عن النجاح. ينبغي على المنظومة أن تسمح لنا بالتراجع إلى النسخة المنشورة سابقًا. يفضل أن نتراجع بسهولة إلى نسخة أقل عرضة للإخفاق بالموازنة مع النسخة المنشورة كاملةً. يبقى الخيار الأفضل لنا هو التراجع الآلي في حال أخفق النشر. ينبغي أن تكون المنظومة قادرةً على التعامل مع طلبات HTTP قبل انتهاء أو أثناء عملية النشر. ينبغي أن تتأكد المنظومة أنّ البرنامج الذي ننشره سيلبّي المتطلبات التي وضعناها لعملية النشر (لا تنشر مثلًا برنامج أخفق في أحد الاختبارات). لنحدد أيضًا بعض الأشياء التي نريدها في منظومة النشر الافتراضية التي نقترحها: نريدها أن تكون سريعة. لا نريد وقتًا يتوقف فيه البرنامج عن العمل downtime أثناء النشر (هذا أمر مختلف عن معالجة طلبات المستخدمين قبل انتهاء أو أثناء النشر). التمارين 11.10 - 11.12 قبل أن نبدأ بالتمارين عليك إعداد تطبيقك لبيئة عمل هيروكو Heroku. وهنا لن ندفع الشيفرة بأنفسنا، إذ سينفِّذ مخطط عمل GitHub Actions المهمة من أجلنا. تأكد من تثبيت Heroku CLI ثم سجِّل دخولك مستخدمًا واجهة سطر الأوامر CLI عن طريق heroku login. أنشئ تطبيقًا على هيروكو HeroKu باستخدام الأمر: heroku create --region eu {your-app-name}‎ وانتق منطقةً قريبةً من موقعك الجغرافي. ولِّد شهادة تحقق Token لملفك على Heroku باستخدام الأمر: heroku authorizations:create ثم خزّن معلومات الاستيثاق على ملف ضمن حاسوبك لكن لا تدفعه إلى GitHub. ستحتاج إلى شهادة التحقق من أجل مخطط عمل نشر التطبيق. اطلع أكثر على شهادة تحقق هيروكو عبر الإنترنت. 11.10: نشر تطبيقك على منصة هيروكو Heroku وسّع مخطط العمل بخطوة لنشر تطبيقك على منصة هيروكو Heroku. نفترض فيما سيأتي أنك ستستخدم AkhileshNS/heroku-deploy وهو فعل GitHub Actions الخاص بالنشر على هيروكو والذي طوره مجتمع GitHub Actions. تحتاج إلى شهادة التفويض التي حصلت عليها سابقًا من أجل نشر تطبيقك. ومن الأفضل تمرير قيمتها إلى GitHub Actions باستخدام أسرار المستودع repository secrets: يمكنك الآن الوصول إلى قيمة الشهادة على النحو التالي: ${{secrets.HEROKU_API_KEY}} إذا جرى كل شيء على ما يرام، سيبدو مخطط العمل على الشكل التالي: جرِّب تطبيقك عبر متصفح، لكن الأخطاء ستظهر غالبًا. سنجد أنّ منصة هيروكو تفترض وجود الملف "Procfile" في المستودع والذي يوجّهه لطريقة تشغيل البرنامج. أضف إذًا ملف "Procfile" مناسب إلى المستودع وتأكد أنّ التطبيق سيعمل جيدًا. تذكرة: راقب باستمرار ما يحدث عبر سِجِل الخادم عندما تجرب عملية النشر. استخدم لذلك الأمر heroku logs دائمًا. 11.11: التحقق من عمل التطبيق قبل التوسّع أكثر في التطبيق، تحقق أن التطبيق يعمل جيدًا بعد النشر. لا نحتاج في الواقع لخطوة جديدة في مخطط العمل، إذ يحتوي الفعل deploy-to-heroku على خيار يفي بالغرض. أضف وصلة تخديم end point بسيطة مهمتها التحقق من عمل التطبيق في الواجهة الخلفية. ويمكنك أيضًا نسخ الشيفرة التالية: app.get('/health', (req, res) => { res.send('ok') }) من الجيد أيضًا وجود وصلة تخديم اختبارية في التطبيق، لكي يمتلك إمكانية إجراء بعض التعديلات في الشيفرة ويتأكد من أن التغيرات قد ظهرت أيضًا في النسخة المنشورة: app.get('/version', (req, res) => { res.send('1') // غير هذه القيمة للتأكد من أنها نُشرت في النسخة الجديدة }) راجع التوثيق لتعرف كيف ستضم آلية التحقق من عمل التطبيق إلى خطوة النشر، واستخدم عنوان وصلة التخديم التي أنشأتها للتحقق من عمل التطبيق، وقد تحتاج غالبًا إلى الخيار "checkstring" حتى يُنفَّذ الأمر. تأكد من أنّ GitHub Actions سينتبه إذا ما سبب النشر إخفاق التطبيق. استخدم لاختبار ذلك أمر إقلاع خاطئ مثلًا في الملف "Procfile". قبل الانتقال إلى التمرين التالي، أصلح مشاكل خطوة النشر وتأكد أن التطبيق سيعمل بالشكل المطلوب مجددًا. 11.12: التراجع من الأفضل إذا أخفق التطبيق بعد النشر أن تتراجع إلى الإصدار السابق. ولحسن الحظ، يجعل هيروكو Heroku هذا الأمر بسيطًا جدًا. ينتج عن كل عملية نشر على Heroku إصدار، ويمكنك معرفة الإصدارات الموجودة لتطبيق بتنفيذ الأمر heroku releases: $ heroku releases === calm-wildwood-40210 Releases - Current: v8 v8 Deploy de15fc2b mluukkai@iki.fi 2022/03/02 19:14:22 +0200 (~ 8m ago) v7 Deploy 8748a04e mluukkai@iki.fi 2022/03/02 19:06:28 +0200 (~ 16m ago) v6 Deploy a617a93d mluukkai@iki.fi 2022/03/02 19:00:02 +0200 (~ 23m ago) v5 Deploy 70f9b219 mluukkai@iki.fi 2022/03/02 18:48:47 +0200 (~ 34m ago) v4 Deploy 0b2db00d mluukkai@iki.fi 2022/03/02 17:53:24 +0200 (~ 1h ago) v3 Deploy f1cd250b mluukkai@iki.fi 2022/03/02 17:44:32 +0200 (~ 1h ago) v2 Enable Logplex mluukkai@iki.fi 2022/03/02 17:00:26 +0200 (~ 2h ago) v1 Initial release mluukkai@iki.fi 2022/03/02 17:00:25 +0200 (~ 2h ago) يمكن التراجع إلى إصدار محدد بكتابة أمر واحد ضمن سطر الأوامر، والأفضل من ذلك سيتولى الفعل deploy-to-heroku مهمة التراجع نيابةً عنا. اقرأ توثيق GitHub Actions مجددًا وعدّل مخطط العمل لتمنع إخفاق التطبيق عند النشر. يمكنك محاكاة الأمر أيضًا بكتابة أمر خاطئ في الملف "Procfile" والتجربة: تحقق أنّ التطبيق سيستمر في العمل حتى لو أخفق النشر. تنبيه: على الرغم من إمكانية التراجع التلقائي، قد يخفق البناء في العالم الحقيقي. لذلك يُعد إيجاد سبب المشكلة وإصلاحها بسرعة أمرًا جوهريًا. ويبقى سجل هيروكو Heroku عادةً هو المكان الأنسب للبحث والتقصي عن المسببات: ترجمة -وبتصرف- للفصل Deployment من سلسلة Deep Dive Into Modern Web Development. اقرأ أيضًا المقال السابق: استخدام GitHub Actions لتحقيق التكامل المستمر والنشر المستمر نشر تطبيقات الويب الموجهة لبيئة الإنتاج نشر تطبيقات iOS على متجر Apple Store
  13. ستختلف التمارين قليلًا في القسم السابع من منهاج full_stack_101 عن ما سبقها. فستجد في هذا الفصل وفي الفصل الذي يليه تمارين تتعلق بالأفكار التي سنقدمها في هذا الفصل، بالإضافة إلى سلسلة من التمارين التي سنراجع من خلالها ما تعلمناه خلال تقدمنا في مادة المنهاج، وذلك بتوسيع التطبيق Bloglist الذي عملنا عليه في القسمين 4 و5. هيكلية التنقل ضمن التطبيق سنعود حاليًا إلى React دون استخدام Redux. من الشائع أن تحتوي التطبيقات إلى شريط تنقل يساعد على تغيير الصفحات التي يعرضها التطبيق. فيمكن أن يتضمن تطبيقنا صفحة رئيسية: وصفحات منفصلة لعرض معلومات عن الملاحظات والمستخدمين: تتغير الصفحات التي يحتويها تطبيق وفقًا للمدرسة القديمة باستخدام طلب HTTP-GET يرسله المتصفح إلى الخادم ومن ثم يصيّر شيفرة HTML التي تعرض الصفحة المُعادة. لكننا سنبقى عمليًا في نفس الصفحة في التطبيقات وحيدة الصفحة. حيث يوحي تنفيذ شيفرة JavaScript ضمن المتصفح بوجود عدة صفحات. فلو أرسل طلب HTTP عند تغيير الواجهة المعروضة، فهو فقط لإحضار البيانات بصيغة JSON والتي قد يحتاجها التطبيق لعرض الواجهة الجديدة. من السهل جدًا إضافة شريط التنقل أو عرض تطبيق بعدة واجهات استخدام React. تمثل الشيفرة التالية إحدى الطرق: import React, { useState } from 'react' import ReactDOM from 'react-dom' const Home = () => ( <div> <h2>TKTL notes app</h2> </div> ) const Notes = () => ( <div> <h2>Notes</h2> </div> ) const Users = () => ( <div> <h2>Users</h2> </div> ) const App = () => { const [page, setPage] = useState('home') const toPage = (page) => (event) => { event.preventDefault() setPage(page) } const content = () => { if (page === 'home') { return <Home /> } else if (page === 'notes') { return <Notes /> } else if (page === 'users') { return <Users /> } } const padding = { padding: 5 } return ( <div> <div> <a href="" onClick={toPage('home')} style={padding}> home </a> <a href="" onClick={toPage('notes')} style={padding}> notes </a> <a href="" onClick={toPage('users')} style={padding}> users </a> </div> {content()} </div> ) } ReactDOM.render(<App />, document.getElementById('root')) تضاف كل واجهة عرض على هيئة مكوّن خاص. وتُخزّن معلومات المكوّن في حالة التطبيق التي تحمل الاسم page. تخبرنا هذه المعلومات عن المكوّن الذي يمثل الواجهة التي ستعرض أسفل شريط القوائم. لكن هذا الأسلوب ليس الأسلوب الأمثل لتنفيذ ذلك. حيث يمكن أن نلاحظ من الصور التي عرضناها أن العنوان سيبقى نفسه للواجهات المختلفة. ومن المفضل أن تحمل كل واجهة عرض عنوانها الخاص، لتسهيل إنشاء اختصارات لها ضمن المتصفح على سبيل المثال. لن يعمل الزر "back" كما هو متوقع في التطبيق، أي أنه لن ينقلك إلى الواجهة السابقة بل إلى مكان مختلف كليًا. فإن توسع التطبيق وهذا ما نريده، عند إنشاء واجهات منفصلة لكل مستخدم أو ملاحظة فسيغدو هذا الأسلوب الذي اتبعناه في التوجه، والذي يدير آلية التنقل في التطبيق معقدًا بشكل كبير. لحسن الحظ تؤمن لنا المكتبة React router حلًا ممتازًا لإدارة التنقل بين الواجهات في تطبيقات React. لنغيّر التطبيق السابق بحيث يستخدم React-router. إذًا علينا أولًا تثبيت المكتبة بتنفيذ الأمر: npm install react-router-dom تُفعَّل آلية التنقل التي تؤمنها المكتبة السابقة كالتالي: import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom" const App = () => { const padding = { padding: 5 } return ( <Router> <div> <Link style={padding} to="/">home</Link> <Link style={padding} to="/notes">notes</Link> <Link style={padding} to="/users">users</Link> </div> <Switch> <Route path="/notes"> <Notes /> </Route> <Route path="/users"> <Users /> </Route> <Route path="/"> <Home /> </Route> </Switch> <div> <i>Note app, Department of Computer Science 2020</i> </div> </Router> ) } تستخدم آلية التنقل أو التصيير الشرطي للمكوّنات المسؤولة عن عرض الواجهات بناء على عنوانها الظاهر على المتصفح، بوضع هذه المكوّنات كأبناء للمكوّن Router، أي داخل المُعِّرف <Router>. لاحظ أنه على الرغم من أنّ اسم المكوّن Router، فإننا نتحدث في الواقع عن موجِّه المتصفح BrowserRouter حيث غيرنا اسم الكائن عندما أدرجناه: import { BrowserRouter as Router, Switch, Route, Link } from "react-router-dom" وفقًا لدليل استخدام المكتبة: يحمّل المتصفح عادة صفحة جديدة عندما يتغير العنوان ضمن شريط العناوين. لكن سيمكننا مكوّن موجّه المتصفح BrowserRouter بالاستفادة من الواجهة البرمجية HTML5 history API من استخدام العنوان الموجود في شريط العناوين للتنقل الداخلي ضمن الواجهات التي يعرضها تطبيق React.فحتى لو تغيّر العنوان ضمن شريط عناوين المتصفح سيتم التلاعب بمحتوى الصفحة باستخدام شيفرة JavaScript ولن يحمّل المتصفح محتوًى جديدًا من الخادم. وسيكون استخدام أفعال التراجع أو التقدم وحفظ الاختصارات منطقيًا كأي صفحة ويب تقليدية. نعرّف داخل الموجّه روابط link لتعديل شريط العناوين بمساعدة المكوّن Link. تنشئ الشيفرة التالية على سبيل المثال: <Link to="/notes">notes</Link> رابطًا داخل التطبيق له النص "notes" يغيّر بالنقر عليه العنوان في شريط عناوين المتصفح إلى العنوان "notes/". تٌعرّف المكوِّنات التي يجري تصييرها وفقًا للعنوان على المتصفح باستخدام المكوّن Route. فوظيفة الشيفرة التالية على سبيل المثال: <Route path="/notes"> <Notes /> </Route> هو تصيير المكوّن Note إن كان العنوان المحدد في المتصفح هو "notes/". سنغلف المكونات التي ستُصيّر بناء على عنوان المتصفح داخل المكوّن Switch: <Switch> <Route path="/notes"> <Notes /> </Route> <Route path="/users"> <Users /> </Route> <Route path="/"> <Home /> </Route> </Switch> يصير المكوّن switch أول مكوّن داخله يتطابق مساره مع العنوان الموجود ضمن شريط عناوين المتصفح. وانتبه إلى أهمية ترتيب المكوّنات. فإن أردنا وضع المكوّن Home ذو المسار"/"=path أولًا، فلن يصير أي مكوّن آخر لأن المسار "/" غير موجود أصلًا فهو بداية كل المسارات: <Switch> <Route path="/"> <Home /> </Route> <Route path="/notes"> <Notes /> </Route> // ... </Switch> إسناد معاملات إلى الموجه لنتفحص النسخة المعدّلة من المثال السابق. يمكنك أن تجد الشيفرة الكاملة للمثال على Github. يعرض التطبيق الآن خمس واجهات مختلفة يتحكم الموجّه بآلية عرضها. فبالإضافة إلى المكونات Home وUser وNotesمن المثال السابق سنجد المكوّن Login الذي يعرض واجهة لتسجيل الدخول والمكوّن Note الذي يعرض ملاحظة واحدة. لم نغير المكونين Home وUsers، لكن Notes أعقد قليلًا لانها تصير قائمة الملاحظات التي تُمرّر إليها كخاصية بطريقة تمكننا من النقر على اسم كل ملاحظة: تأتي إمكانية النقر على اسم الملاحظة من المكوّن Link، فالنقر على اسم الملاحظة التي تحمل المعرّف 3 سيسبب وقوع الحدث الذي يغيّر العنوان في المتصفح إلى "notes/3": const Notes = ({notes}) => ( <div> <h2>Notes</h2> <ul> {notes.map(note => <li key={note.id}> <Link to={`/notes/${note.id}`}>{note.content}</Link> </li> )} </ul> </div> ) نعرّف العناوين ذات المعاملات في الموجّه ضمن المكوّن App كالتالي: <Router> <div> <div> <Link style={padding} to="/">home</Link> <Link style={padding} to="/notes">notes</Link> <Link style={padding} to="/users">users</Link> </div> <Switch> <Route path="/notes/:id"> <Note notes={notes} /> </Route> <Route path="/notes"> <Notes notes={notes} /> </Route> <Route path="/"> <Home /> </Route> </Switch> </Router> نعرّف الموجّه الذي يصير ملاحظة محددة " بتنسيق express" بتعليم المعامل بالوسم "id:": <Route path="/notes/:id"> فعندما ينتقل المتصفح إلى عنوان الملاحظة المحددة، "notes/3/" على سبيل المثال، يُصّير المكوّن Note: import { // ... useParams} from "react-router-dom" const Note = ({ notes }) => { const id = useParams().id const note = notes.find(n => n.id === Number(id)) return ( <div> <h2>{note.content}</h2> <div>{note.user}</div> <div><strong>{note.important ? 'important' : ''}</strong></div> </div> ) } يتلقى المكوّن Notes كل الملاحظات ضمن الخاصيّة notes، ويمكنه بعدها الوصول إلى معامل العنوان (معرّف الملاحظة التي ستُعرض) باستخدام الدالة useParams العائدة للمكتبة react-router. استخدام الدالة useHistory أضفنا أيضًا طريقة بسيطة لتسجيل الدخول في تطبيقنا. فإن سجّل المستخدم دخوله ستُخزّن معلومات تسجيل الدخول في الحقل user من حالة المكوّن App. يُصيّر خيار الانتقال إلى واجهة تسجيل الدخول شرطيًا في القائمة: <Router> <div> <Link style={padding} to="/">home</Link> <Link style={padding} to="/notes">notes</Link> <Link style={padding} to="/users">users</Link> {user ? <em>{user} logged in</em> : <Link style={padding} to="/login">login</Link> } </div> // ... </Router> فلو سجلّ المستخدم دخوله للتو، فسيُظهر التطبيق اسم المستخدم بدلًا من الانتقال إلى واجهة تسجيل الدخول: تعطي الشيفرة التالية وظيفة تسجيل الدخول لتطبيقنا: import { // ... useHistory} from 'react-router-dom' const Login = (props) => { const history = useHistory() const onSubmit = (event) => { event.preventDefault() props.onLogin('mluukkai') history.push('/') } return ( <div> <h2>login</h2> <form onSubmit={onSubmit}> <div> username: <input /> </div> <div> password: <input type='password' /> </div> <button type="submit">login</button> </form> </div> ) } إن ما يلفت الانتباه في هذا المكوّن هو استخدامه الدالة useHistory. حيث يمكن للمكوّن الولوج إلى كائن محفوظات history باستخدام تلك الدالة. ويستخدم كائن المحفوظات لتغيير عنوان المتصفح برمجيًا. فعند تسجيل الدخول، يُستدعى التابع ('/')history.push العائد لكائن المحفوظات والذي يسبب تغييرًا في عنوان المتصفح إلى "/" ويصيِّر بعدها التطبيق المكوّن Home. تمثل كلا الدالتين useParams وuseHistory دوال خطافات تمامًا كالدوال useState وuseEffect والتي استخدمناها عدة مرات سابقًا. وكما أسلفنا في القسم 1 فهنالك الكثير من القواعد لاستخدام دوال الخطافات. وقد هيئت تطبيقات Creat-react-app لتنبيهك إن أغفلت تلك القواعد، كاستدعاء دوال الخطافات من داخل العبارات الشرطية. إعادة التوجيه يبقى هناك تفصيل مهم يتعلق بالمسار Users: <Route path="/users" render={() => user ? <Users /> : <Redirect to="/login" /> } /> إن لم يسجل المستخدم دخوله، فلن يصيّر المكوّن Users. وبدلًا من ذلك سيعاد توجيه المستخدم إلى واجهة تسجيل الدخول باستخدام المكوّن Redirect. <Redirect to="/login" /> من الأفضل في الواقع أن لا نظهر الروابط التي تحتاج إلى تسجيل الدخول في شريط التنقل إن لم يسجل المستخدم دخوله إلى التطبيق. تمثل الشيفرة التالية المكوّن App بشكله الكامل: const App = () => { const [notes, setNotes] = useState([ // ... ]) const [user, setUser] = useState(null) const login = (user) => { setUser(user) } const padding = { padding: 5 } return ( <div> <Router> <div> <Link style={padding} to="/">home</Link> <Link style={padding} to="/notes">notes</Link> <Link style={padding} to="/users">users</Link> {user ? <em>{user} logged in</em> : <Link style={padding} to="/login">login</Link> } </div> <Switch> <Route path="/notes/:id"> <Note notes={notes} /> </Route> <Route path="/notes"> <Notes notes={notes} /> </Route> <Route path="/users"> {user ? <Users /> : <Redirect to="/login" />} </Route> <Route path="/login"> <Login onLogin={login} /> </Route> <Route path="/"> <Home /> </Route> </Switch> </Router> <div> <br /> <em>Note app, Department of Computer Science 2020</em> </div> </div> ) } نعرّف في الشيفرة السابقة مكوّنًا شائع الاستخدام في تطبيقات الويب الحديثة ويدعى footer والذي يعرّف الجزء السفلي من شاشة عرض التطبيق خارج نطاق المكوّن Router، وبالتالي سيظهر دائمًا بغض النظر عن المكوّن الذي سيُعرض. مرور آخر على إسناد المعاملات إلى الموجه لاتزال هناك ثغرة في تطبيقنا. حيث يتلقى المكوّن Note كل الملاحظات على الرغم من أنه سيعرض الملاحظة التي يتطابق معرّفها مع معامل العنوان: const Note = ({ notes }) => { const id = useParams().id const note = notes.find(n => n.id === Number(id)) // ... } هل يمكن أن تعدّل التطبيق لكي يتلقى المكون Note المكوّن الذي سيُعرض فقط؟ const Note = ({ note }) => { return ( <div> <h2>{note.content}</h2> <div>{note.user}</div> <div><strong>{note.important ? 'important' : ''}</strong></div> </div> ) } تعتمد إحدى الطرق المتبعة في تنفيذ ذلك على استخدام الخطاف useRouteMatch لتحديد معرّف الملاحظة التي ستُعرض ضمن المكوّن App. من غير الممكن أن نستخدم الخطاف useRouteMatch في المكوّن الذي يعرّف الشيفرة المسؤولة عن التنقل. لننقل إذًا المكوّن Router خارج المكوّن App: ReactDOM.render( <Router> <App /> </Router>, document.getElementById('root') ) سيصبح المكون App كالتالي: import { // ... useRouteMatch} from "react-router-dom" const App = () => { // ... const match = useRouteMatch('/notes/:id') const note = match ? notes.find(note => note.id === Number(match.params.id)) : null return ( <div> <div> <Link style={padding} to="/">home</Link> // ... </div> <Switch> <Route path="/notes/:id"> <Note note={note} /> </Route> <Route path="/notes"> <Notes notes={notes} /> </Route> // ... </Switch> <div> <em>Note app, Department of Computer Science 2020</em> </div> </div> ) } في كل مرة ستغير فيها العنوان على المتصفح سيجري تنفيذ الأمر التالي: const match = useRouteMatch('/notes/:id') إن تطابق العنوان مع القيمة "notes/:id/"، فسيُسند إلى متغير التطابق كائن يمكنه الولوج إلى القسم الذي يحوي المعامل من مسار العنوان وهو معرّف الملاحظة التي ستُعرض، وبالتالي يمكن إحضار الملاحظة المطلوبة وعرضها. const note = match ? notes.find(note => note.id === Number(match.params.id)) : null ستجد الشيفرة كاملة على GitHub. التمارين 7.1 - 7.3 سنعود إلى العمل في تطبيق الطرائف. استخدم نسخة التطبيق التي لا تعتمد على Redux والموجودة على GitHub كنقطة انطلاق للتمارين. إن نسخت الشيفرة ووضعتها في مستودع git موجود أصلًا، لاتنس حذف ملف تهيئة git لنسختك من التطبيق: Exercises 7.1.-7.3. cd routed-anecdotes // توجه أوّلًا إلى المستودع الذي يحوي نسختك من التطبيق rm -rf .git شغّل التطبيق بالطريقة الاعتيادية، لكن عليك أوّلًا تثبيت الاعتماديات اللازمة: npm install npm start 7.1 تطبيق الطرائف بشريط للتنقل: الخطوة 1 أضف موجّه React إلى التطبيق بحيث يمكن تغيير الواجهة المعروضة عند النقر على الروابط في المكوّن Menu. أظهر قائمة الطرائف عند الوصول إلى جذر التطبيق (الموقع الذي يحمل المسار "/") ينبغي أن يظهر المكوّن Footer بشكل دائم في أسفل الشاشة. كما ينبغي إنشاء الطرفة الجديدة في مسار خاص، ضمن المسار "create" على سبيل المثال: 7.2 تطبيق الطرائف بشريط للتنقل: الخطوة 2 أضف إمكانية عرض طرفة واحدة: انتقل إلى الصفحة التي تعرض طرفة واحدة بالنقر على اسم الطرفة: 7.3 تطبيق الطرائف بشريط للتنقل: الخطوة 3 ستغدو الوظيفة الافتراضية لنموذج إنشاء طرفة جديدة مربكة قليلًا لأن شيئًا لن يحدث عند إنشاء طرفة جديدة باستخدام النموذج. حسّن الأمر بحيث ينتقل التطبيق تلقائيًا إلى إظهار كل الطرائف عند إنشاء الطرفة الجديدة، ويظهر تنبيه للمستخدم مدته عشر ثوان، لإبلاغه بإضافة طرفة جديدة ترجمة -وبتصرف- للفصل React-Router من سلسلة Deep Dive Into Modern Web Development
  14. سنلقي نظرةً قبل أن نبدأ التعامل مع GitHub Actions على ماهيته والطريقة التي يعمل بها. يعمل GitHub Actions على مبدأ مخططات العمل workflows؛ ومخطط العمل هو سلسلة من الأعمال التي تُنفَّذ عندما يقع حدثٌ معين، وتتضمن هذه الأعمال بحد ذاتها التعليمات التي يجب أن يُنفّذها GitHub Actions. سيبدو تنفيذ مخطط عمل نموذجي على النحو التالي: وقوع حدثٌ ما، مثل دفع push شيفرة إلى الفرع الرئيسي. البدء بتنفيذ مخطط العمل. الإنهاء والتنظيف. الاحتياجات الأساسية نحتاج عمومًا إلى المتطلبات التالية لتشغيل منظومة التكامل المتواصل Continuous Integration -أو اختصارًا CI- ضمن مستودع: مستودع. بعض التعريفات بالمهام التي ستنفذها منظومة CI، وقد يكون ذلك على هيئة ملف ضمن المستودع، أو يمكن أن يُعرَّف ضمن منظومة CI. يجب أن تعرف المنظومة أنّ المستودع (والملف في داخله) موجودٌ فعلًا. تحتاج المنظومة إلى الأذونات اللازمة لتنفيذ الأفعال التي يفترض أن تُنفذها، فلو أردنا من المنظومة أن تكون قادرةً على النشر في بيئة الإنتاج مثلًا، فستحتاج إلى بيانات الاستيثاق الخاصة بهذه البيئة. سنحتاج ما ذكرناه على الأقل في النموذج التقليدي للمنظومة. سنرى لاحقًا كيفية اختصار بعض الخطوات أو تنفيذها بطريقة مريحة لا تسبب لك أية مشاكل تقلق منها. لاستضافة GitHub Actions إيجابياتٌ عظيمة بالموازنة مع الاستضافة الذاتية؛ إذ سيُستضاف المستودع مع مزوِّد لمنظومة CI، أي سيؤمن لك GitHub Actions المستودع ومنظومة CI معًا، ويعني ذلك أننا عندما نُمكِّن الأفعال ضمن المستودع، سيكون GitHub Actions على علم مسبقًا بأنه لدينا مخططات عمل مُعرًّفة، وبما تعنيه هذه التعريفات. التمرين 11.2 سننفذ في معظم تمرينات هذا القسم خط إنتاج لمنظومة CI/CD من أجل المشروع الصغير الموجود على غيت هب GitHub. تنبيه: قد لا تعمل الشيفرة مع الإصدار 15 من Node، وإذا حدث ذلك ولم يُقلع المشروع، انتقل للعمل على الإصدار 14 وإلا عليك حل مشاكلك بنفسك. المشروع النموذجي عليك أولًا إنشاء نسخةٍ من مستودع المشروع ضمن حسابك الخاص ولاستخدامك الشخصي. لاشتقاق المستودع، انقر على الزر "Fork" في أعلى ويمين واجهة المستودع بجانب الزر "Star": سيبدأ بعد ذلك بإنشاء مستودع جديد يُدعى "github_username}/full-stack-open-pokedex}"، وستُنقل بعد انتهاء العملية إلى مستودعك الجديد تلقائيًا: انسخ المشروع الآن إلى جهازك، وكما جرت العادة إبدأ بتفقد الملف "package.json" فهو المكان الأنسب للاطلاع على تفاصيل مشروع جديد. حاول أن تنفّذ ما يلي: تثبيت الاعتماديات باستخدام الأمر npm install. تشغيل الشيفرة في وضع التطوير. إجراء الاختبارات. تدقيق lint الشيفرة. قد تلاحظ وجود بعض أخطاء التدقيق والاختبارات المخفقة في المشروع، دعها كما هي حاليًا، إذ سنتكفل بحلها في التمارين القادمة. وكما ذكرنا في القسم 3، لا ينبغي تشغيل شيفرة React في وضع التطوير بعد نشرها في وضع الإنتاج، لذلك حاول أن تُنفِّذ ما يلي: أنشئ نسخة إنتاج من المشروع. شغل نسخة الإنتاج على جهازك محليًا. ستجد سكربتًا خاصًا لتنفيذ المهمتين السابقتين في المشروع. خذ وقتًا لدراسة هيكيلية المشروع. وكما ستلاحظ، فإن الواجهتين الأمامية والخلفية موجودتان في نفس المستودع، وقد اعتدنا في الأقسام الأولى على وجودهما في مستودعين منفصلين. لكن هذا الأمر سيبسط الأمور عند إعداد بيئة CI. ستجد بالمقابل أنّ الواجهة الأمامية في معظم مشاريع منهاجنا لم تُنفّذ باستخدام "create-react-app"، لكنها تمتلك إعدادات تهيئة بسيطة باستخدام webpack تتكفل بإنشاء بيئة التطوير وبإنشاء حزمة الإنتاج. التعامل مع مخططات العمل تُعد مخططات العمل الأساس الذي تبنى عليه منظومات CI في غيت هب GitHub، فهي مساراتٌ لعملية يمكنك إعدادها لتنفيذ مهام تلقائيًا، مثل بناء التطبيقات واختبارها وتدقيقها وإصدارها ونشرها. تبدو عادةً هيكلية مخططات العمل على النحو التالي: مخطط العمل مهمة خطوة خطوة مهمة خطوة ينبغي أن يحدد كل مخطط مهمةً واحدةً على الأقل تحتوي على خطوات لتنفيذ إجراءات مستقلة. ستُشغّل هذه المهمات على التوازي بينما تُنفًّذ الخطوات في كل مهمة على التتالي. تتنوع الخطوات من تنفيذ سطر أوامر محدد إلى الأفعال المعرَّفة مسبقًا، لهذا تحمل الاستضافة اسم أفعال غيت هب "GitHub Actions". يمكنك إنشاء الأفعال الخاصة بك أو أن تستخدم أفعالُا نشرها آخرون، وهي في الواقع كثيرة، وسنعود إليها لاحقًا. ينبغي أن تُحدِّد مخططات العمل الخاصة بك ضمن المجلد "github/workflows." في مستودعك كي يميزها غيت هب، ولكل مخطط عمل ملفٌ مستقلٌ خاصٌ به والذي يجب تهيئته باستخدام لغة تقسيم البيانات YAML. يُشتق الاسم YAML من العبارة "YAML Ain't Markup Language" والتي تعني "ليست لغة توصيف"؛ فكما تُلمّح إليه التسمية، فالهدف منها أن تكون مفهومةً للبشر. تُستخدم هذه اللغة في كتابة ملفات التهيئة، وستجد أنها سهلة الفهم فعلًا. عليك الانتباه إلى أهمية الانزياحات Indentation في بداية الأسطر البرمجية في YAML، وللاطلاع أكثر حول قواعد اللغة يمكنك الرجوع إلى شبكة الإنترنت. يتضمن مخطط العمل في مستند YAML هذه العناصر الثلاث: name الاسم: ويشير إلى اسم المخطط. triggers المُسبب: الأحداث التي تؤدي إلى تنفيذ المخطط. jobs المهام: المهام المختلفة التي سيُنفّذها المخطط، وقد يحتوي المخطط البسيط مهمةً واحدةً فقط. سيبدو تعريف مخطط بسيط على النحو التالي: name: Hello World! on: push: branches: - master jobs: hello_world_job: runs-on: ubuntu-20.04 steps: - name: Say hello run: | echo "Hello World!" وكما هو واضح، سيكون المُسبب هو عملية دفع الشيفرة إلى الفرع الرئيسي. يحتوي المخطط أيضًا على مهمة واحدة اسمها hello_world_job سيجري تنفيذها ضمن بيئة افتراضية تعمل على نظام التشغيل أوبنتو Ubuntu 20.04، وتتضمن هذه المهمة خطوة واحدة اسمها Say hello ستُنفِّذ الأمر "!echo "Hello World ضمن الصدفة shell. ربما تتساءل، متى يبدأ غيت هب بتنفيذ مخطط معين؟ هناك العديد من الخيارات المتاحة، لكن وبصراحة، يمكنك أن تهيئ المخطط ليعمل حالما: يقع حدث ما، مثل الحالة التي يدفع فيها أحدهم اعتمادًا commit إلى مستودع، أو عندما تحدث مشكلة أو يقدم أحدهم طلب سحب pull request. تُنفَّذ مهمةٌ مجدولةٌ مسبقًا ومحددة باستخدام تعابير cron. يقع حدث خارجي، كأن يُنفَّذ أمر ضمن تطبيق خارجي، مثل تطبيق الرسائل Slack. وللاطلاع على مزيدٍ من الأحداث المسببة لبدء تنفيذ مخطط عمل، عُد إلى توثيق GitHub Actions. التمرينان 11.3- 11.4 لنربط كل ما خلصنا إليه، دعونا نشغِّل GitHub Actions ضمن المشروع النموذجي. 11.3: تطبيق Hello world أنشئ مخططًا يُظهر العبارة "!Hello World" للمستخدم. عليك أن تنشئ المجلد "github/workflows."، والملف "hello.yml" الذي سيحتوي الإعدادات ضمن مستودعك. ولتطلع على ما أنجزه مخطط عمل GitHub Actions الخاص بك، يمكن التوجه إلى النافذة Action ضمن واجهة غيت هب GitHub، حيث من المفترض أن ترى مخطط العمل في مستودعك والخطوات التي أنجزها. ستبدو نتيجة تنفيذ مخططك على النحو التالي إن كانت تهيئة المخطط صحيحة: يُفترض أن ترى الرسالة "!Hello World" نتيجةً لتنفيذ المخطط، وإذا حدث ذلك، ستكون جميع الخطوات قد نُفِّذت بنجاح، وسيكون أول مخططات عمل GitHub Actions الخاصة بك قيد العمل. سيعطيك أيضًا GitHub Actions معلومات عن البيئة (نظام التشغيل، وإعداداته) التي يعمل ضمنها مخطط العمل. وتظهر أهمية ذلك أثناء حدوث أمر طارئ، إذ سيسهل هذا الأمر تنقيح الأخطاء إن استطعت تكرار كل الخطوات على جهازك. 11.4: التاريخ ومحتويات المجلد وسًع مخطط العمل لإضافة خطوات لطباعة التاريخ ومحتويات المجلد الحالي بالصياغة الطويلة long format. يمكن تنفيذ هاتين الخطوتين باستخدام الأمرين date و ls بكل بساطة. سيبدو مخطط عملك الآن على النحو التالي: وطالما أن الأمر ls -l سيُظهر افتراضيًا البيئة الافتراضية التي يعمل عليها مخطط العمل، ستلاحظ أنها لا تحتوي على أية شيفرات. إعداد وتجهيز خطوات التدقيق والاختبار والبناء بعد إنجاز التمارين الأولى، سيكون لدينا مخطط عمل بسيط لكنه لن يفيدنا في عملية الإعداد. لنجعل مخطط العمل قادرًا على فعل مهمة حقيقية. سننجز فعلًا action قادرًا على تدقيق الشيفرة. فإذا أخفق التدقيق، سيُظهر Github Actions الحالة الحمراء. سيبدو المخطط الذي سنخزنه ضمن الملف "pipeline.yml" مبدئيًا على النحو التالي: name: Deployment pipeline on: push: branches: - master jobs: قبل أن نتمكن من تنفيذ الأمر الذي سيبدأ خطوة التدقيق، لا بدّ من تنفيذ فعلين لإعداد البيئة الملائمة للمهمة. إعداد بيئة العمل إعداد بيئة العمل أمرٌ ضروري لتهيئة خط الإنتاج Pipeline، لذلك سنستخدم بيئة العمل الافتراضية Ubuntu 20.04 لأنها ستكون البيئة التي ستعمل عليها نسخة الإنتاج. ولا بدّ من تطابق البيئة نفسها في منظومة CI وفي وضع الإنتاج قدر المستطاع، لتحاشي الحالات التي تعمل فيها الشيفرة نفسها بصورةٍ مختلفة في كليهما، وبالتالي لن تتحقق الغاية من استخدام CI. سنوضح تاليًا الخطوات في مهمة "البناء" لكي تستطيع منظومة CI تنفيذها. وكما لاحظنا في التمارين السابقة، لن تحتوي بيئة العمل افتراضيًا أية شيفرات، لذلك لا بدّ من التحقق من الشيفرة من المستودع. هذه الخطوة سهلة وهي على النحو التالي: name: Deployment pipeline on: push: branches: - master jobs: simple_deployment_pipeline: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v3 تُستخدم التعليمة uses لإخبار المخطط أن يُنفِّذ فعلًا محددًا. ويُعرَّف الفعل بأنه قطعة من الشيفرة قابلة لإعادة الاستخدام مثل الدوال، ويمكن تعريف الأفعال داخل المستودع ضمن ملف مستقل، أو استخدام الأفعال الموجودة في المستودعات العامة. سنستخدم هنا الفعل العام actions/checkout وبنسخة محددة هي "v3@" لتلافي فشل التعديلات إن جرى تحديث الفعل. ويُنفِّذ الفعل checkout ما يوحي به اسمه، إذ يتحقق من شيفرة المشروع المصدرية من git. وطالما أنّ التطبيق قد كتب باستخدام جافا سكربت، فلا بدّ من تهيئة "Node.js" لكي نتمكن من استخدام الأوامر الموجودة في الملف "package.json". ولإعداد "Node.js" يمكن استخدام الفعل actions/setup-node. سنختار الإصدار "16" لأنه الإصدار الذي تستخدمه بيئة الإنتاج. # name and trigger not shown anymore... jobs: simple_deployment_pipeline: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v2 with: node-version: '16' تُستخدم التعليمة with لإسناد معامل parameter إلى الفعل، إذ يحدد المعامل هنا نسخة Node.js التي ننوي استخدامها. أخيرًا، لا بُد من تثبيت الاعتماديات اللازمة. وكما تفعل عادةً على جهازك، نفِّذ الأمر npm install. ستبدو خطوات المهمة على النحو التالي: jobs: simple_deployment_pipeline: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v2 with: node-version: '16' - name: npm install run: npm install وهكذا ستكون البيئة الآن جاهزةً لأداء مهمة حقيقية ومهمة. التدقيق Lint يمكنك الآن تنفيذ السكربتات التي يحتويها الملف "package.json" كما لو أنك تنفذها على حاسوبك الشخصي، فكل ما عليك فعله لتدقيق الشيفرة، هو إضافة إعدادٍ لتشغيل الأمر npm run eslint: jobs: simple_deployment_pipeline: runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v2 with: node-version: '16' - name: npm install run: npm install - name: lint run: npm run eslint التمرينات 11.5 - 11.9 11.5: تدقيق مخطط عمل نفّذ أو انسخ والصق مخطط العمل "Lint" واعتمد شيفرته في مستودعك، ثم أنشئ ملف yml جديد لهذا المخطط، يمكنك تسميته "pipeline.yml". ادفع بشيفرتك إلى الفرع الرئيسي ثم توجه إلى النافذة "ِActions" وانقر على المخطط الذي أنشأته على اليسار. ستجد أن هذا المخطط سيفشل في الإقلاع: 11.6: إصلاح الشيفرة ستجد بعض المشاكل في الشيفرة تتطلب الإصلاح. افتح سِجِل مخطط العمل وتحقق من وجود أخطاء. إليك تليمحين مهمين: من الأفضل أن تصلح أحد الأخطاء بتحديد ملف مناسب لعملية التدقيق (راجع القسم الثالث لمزيد من المعلومات عن كيفية تنفيذ ذلك)؛ كما يمكن إيقاف أحد الاعتراضات التي تخص تنفيذ الأمر console.log بإيقاف القاعدة التي تسببه في السطر الذي توجد به. أجرِ التعديلات اللازمة على الشيفرة المصدرية لكي ينجح مخطط العمل في الإقلاع، وحالما تعتمد الشيفرة الجديدة سيعمل المخطط وستجد خرجًا مُحدَّثًا باللون الأخضر من جديد. 11.7: البناء والاختبار لنوسِّع مخطط العمل الذي يُنفِّذ حاليًا مهمة التدقيق. أضف أوامر البناء والاختبار إلى مخطط العمل، وستبدو النتيجة قريبةً من التالي: ستلاحظ أيضًا وجود بعض المشاكل. 11.8: العودة إلى الوضع الصحيح تحقق من الاختبارات التي أخفقت وأصلح المشكلة في الشيفرة (لا تُغيّر الاختبارات). سيعمل المخطط ويظهر الخرج باللون الأخضر مجددًا عند إصلاح المشاكل. 11.9: اختبار مشترك بسيط للواجهتين تستخدم مجموعة الاختبارات الحالية jest لتتأكد من عمل مكونات React بالطريقة المطلوبة، وهذا تمامًا ما فعلناه في فصل "اختبار تطبيقات React" من القسم 5. يُعد اختبار كل مكوًن بصورةٍ منفصلة أمرًا مفيدًا لكنه لا يضمن عمل كل النظام بالطريقة المطلوبة. وللتأكد أكثر، سنكتب اختبارًا بسيطًا مشتركًا بين الواجهتين الأمامية والخلفية end to end testing، مستخدمين المكتبة Cypress وعلى نحوٍ مشابه للعمل الذي أنجزناه في الفصل الذي يحمل العنوان "الاختبار المشترك للواجهتين" في القسم 5. سنهيئ cypress (ستجد الطريقة في الفصل الذي أشرنا إليه)، ثم سننفذ الاختبار التالي: describe('Pokedex', function() { it('front page can be opened', function() { cy.visit('http://localhost:5000') cy.contains('ivysaur') cy.contains('Pokémon and Pokémon character names are trademarks of Nintendo.') }) }) عرِّف سكربت npm التالي: test:e2e لتشغيل الاختبار المشترك للواجهتين "e2e" باستخدام سطر الأوامر. تنبيه: لا تستخدم الكلمة "spec" في تسمية ملف اختبار cypress، لأنها ستجعل jest ينفذ شيفرة الملف أيضًا، وقد يسبب ذلك عدة مشاكل. تنبيه آخر: على الرغم من قدرة الصفحة على تصيير أسماء "مخلوقات البوكيمون Pokemon" لتبدأ بأحرف كبيرة، إلا أنها مكتوبةٌ بأحرف صغيرة في المصدر، فاسم البوكيمون "Ivysaur" هو أصلًا "ivysaur" تحقق من نجاح الاختبار على جهازك، وتذكر أن الاختبارات ستفترض أن تطبيقك يعمل عندما تُنفِّذ الاختبار. وإن كنت قد نسيت بعض التفاصيل، راجع القسم 5. حالما يعمل الاختبار المشترك للواجهتين على جهازك، ضعه في مخطط عمل GitHub Action، وأسهل الطرق لتنفيذ ذلك هو استخدام الفعل الجاهز cypress-io/github-action، وستكون الخطوة المناسبة لحالتنا على النحو التالي: - name: e2e tests uses: cypress-io/github-action@v2 with: command: npm run test:e2e start: npm run start-prod wait-on: http://localhost:5000 لقد استخدمنا ثلاثة خيارات: command: ويحدد كيف سنشغل اختبار cypress. start: يزودنا بسكربت npm الذي يُشغِّل الخادم. wait-on: يمنع الاختبار من العمل قبل أن يقلع الخادم على العنوان http://localhost:5000. عندما تتأكد من عمل خط الإنتاج، اكتب اختبارًا آخر للتأكد أنّ المستخدم قادرٌ على الانتقال من الصفحة الرئيسية إلى صفحة "بوكيمون" محدد مثل "ivysaur". لا حاجة لتعقيد الاختبار، بل تأكد فقط أن الصفحة التي تنتقل إليها بنقر الرابط، ستحتوي بعض المعلومات الصحيحة مثل النص "chlorophyll" في حالة البوكيمون "ivysaur". ملاحظة: تُكتب أسماء البوكيمون بالأحرف الصغيرة، ويمكن الكتابة بالأحرف الكبيرة في CSS، لذلك لا تكتب أثناء البحث الاسم "Chlorophyll"، بل اكتب "chlorophyll". ملاحظة: لا تحاول الانتقال إلى صفحة البوكيمون "bulbasaur"، فربما لسببٍ ما لن تعمل صفحة هذا البوكيمون. ستبدو النتيجة النهائية على الشكل التالي: إن الأمر الجيد المتعلق بالاختبار المشترك للواجهتين هو الثقة بأنّ البرنامج سيعمل على نحوٍ جيد من وجهة نظر المستخدم النهائي. لكن سيكون الثمن الذي تدفعه بالمقابل هو الاستجابة البطيئة، إذ سيستغرق تنفيذ مخطط العمل بالكامل وقتًا أطول بكثير. ترجمة -وبتصرف- للفصل Getting started with GitHub Actions من سلسلة Deep Dive Into Modern Web Development. اقرأ أيضًا المقال السابق: مدخل إلى التكامل المستمر والنشر المستمر CI/CD التكامل المستمر: تثبيت Concourse CI على أوبنتو إعداد التكامل المستمر والنشر المستمر باستخدام الخدمتين CircleCI وCoveralls إعادة تأسيس تفريعات طلب السحب وتحديثه في git
  15. سنبني خلال هذا المقال خط إنتاج Pipeline لنشر المشروع النموذجي الذي بدأناه في التمرين 11.2. إذ سننشئ نسخة خاصةً بنا من مستودع المشروع لكي نتعامل معها، ثم سنبني في آخر تمرينين خط إنتاج آخر لنشر بعض التطبيقات الخاصة بنا والتي بنيناها سابقًا. يضم القسم 21 تمرينًا من المفترض إكمالها جميعًا حتى تنهي هذا المنهاج، وعليك أن تسلم حلول هذه التمرينات إلى منظومة تسليم التمارين بنفس الأسلوب المُتبع في القسم السابق، إذ تُسلَّم التمرينات إلى نسخة مختلفة من المنهاج. ستعتمد المعلومات في هذا القسم على مفاهيم اطلعنا عليها سابقًا، لذلك ننصحك بإكمال الأقسام 0-5 قبل البدء بهذا القسم. لن تكتب الكثير من أسطر الشيفرة في هذا القسم، فهو يغطي في معظم الأوقات مواضيع عن التهيئة. وعلى الرغم من أن تنقيح الشيفرة صعب، لكن تنقيح أوامر التهيئة أصعب بكثير، فعليك إذًا التحلي بالصبر والمثابرة في هذا القسم. تجهيز التطبيقات لمرحلة الإنتاج ستضطر بعد الانتهاء من كتابة الشيفرة عاجلًا أم آجلًا إلى نقل تطبيقك إلى مرحلة الإنتاج، أي إلى مستخدميه الحقيقيين، ثم ستضطر بعدها إلى صيانة هذا التطبيق باستمرار وإصدار نسخٍ جديدة منه والعمل مع مطورين آخرين لتوسيعه. لقد استخدمنا حاليًا غيت هب GitHub لتخزين الشيفرة المصدرية، لكن ما الحل عندما نعمل ضمن فريق من المطورين؟ ستظهر عدة مشاكل عند تعاون عدة مطورين، فقد يعمل التطبيق جيدًا على حاسوبي، لكن قد يخفق على حواسيب مطورين آخرين يعملون على أنظمة تشغيل مختلفة، أو يستخدمون نسخًا مختلفة من المكتبات التي أدرجناها في التطبيق. يُشار إلى هذه المشكلة بالعبارة "التطبيق يعمل على جهازي works on my machine". هناك مشكلة أخرى، فلو أجرى مطورين تعديلات على شيفرة التطبيق ولم يتفقا من سينشر التعديلات التي أنجزها، من سيمنع أحدهما من محي تعديلات الآخر وكتابة تعديلاته مكانها؟ سنغطي في هذا القسم أساليب العمل المشترك لبناء ونشر التطبيقات بطريقة منضبطة ومحددة جيدًا، يظهر فيها بكل وضوح ما الذي سيحدث وتحت أية ظروف. بعض المصطلحات المفيدة نستخدم في هذا القسم مصطلحات قد لا تعرفها أو لم تفهمها جيدًا، سنناقش هذه المصطلحات تاليًا، وحتى لو كنت على دراية بهذه المصطلحات، اطلع على هذه الفقرة لنكون على وفاق في فهم المصطلح عند وروده. الفروع Branches يسمح نظام غيت Git بوجود نسخ، أو مسارات streams، أو إصدارات مختلفة من الشيفرة تتعايش معًا دون الحاجة لإلغاء بعضها بعضًا؛ فعندما تُنشئ مستودعًا سيكون هذا المستودع بمثابة الفرع الرئيسي (ندعوه في غيت بالاسم "main" أو "master"، لكن الأمر مختلف في المشاريع القديمة). لا بأس بهذا الأسلوب إذا كان هناك مطور واحد يستخدم هذا الفرع، ويعمل على تطوير ميزة واحدة للتطبيق كل مرة. سيكون استخدام الفروع مفيدًا عندما تتعقد بيئة التطوير، إذ سيعمل كل مطور على فرع أو أكثر، وسيمثل كل فرع نسخةً مختلفةً قليلًا عن نسخة الفرع الرئيسي. وعندما تكتمل الميزة التي يجري تطويرها في أحد الفروع ستُدمج مع الفرع الرئيسيٍ، وبالتالي ستصبح هذه التغييرات أو الميزات الجديدة جزءًا من التطبيق الرئيسي. وهكذا سيتمكن كل مطور من العمل على مجموعة من التعديلات بحيث لا يعيق بقية المطورين من العمل على أفكارهم الخاصة قبل إكمال هذه التعديلات. لكن ما الذي سيحدث لبقية الفروع الخاصة بالمطورين الآخرين عندما يدمج أحد المطورين تعديلاته ضمن الفرع الرئيسي؟ إذ سيبتعد بقية المطورين عن النسخة القديمة التي يعملون عليها. وكيف سيعرف المطورون أن تعديلاتهم التي يجرونها على النسخة السابقة ستكون متوافقةً مع الحالة الجديدة التي يفرضها دمج التعديلات ضمن الفرع الرئيسي؟ سنحاول الإجابة عن هذا السؤال الجوهري في هذا القسم. يمكنك الاطلاع على مزيدٍ من المعلومات حول الفروع من خلال قراءة المقالة "أساسيات التفريع (Branching) والدمج (Merging) في Git". طلبات السحب Pull requests يجري عادةً دمج فرع ضمن الفرع الرئيسي وفق آلية تُعرف بطلب السحب، أو كما يُعرف أحيانًا بالاختصار "PR"، إذ يطلب المطور الذي أجرى بعض التعديلات أن تُدمج التعديلات التي أجراها ضمن الفرع الرئيسي، وحالما يُقبل هذا الطلب أو يُفتتح للمراجعة، يمكن لمطور آخر أن يتحقق من أن كل شيء سيجري على مايرام بعد الدمج. لو اقترحت مثلًا تعديلًا على مادة منهجنا، فقد قدمت فعلًا طلب سحب. بناء التطبيق Build لهذا المصطلح معانٍ مختلفة في لغات البرمجة المختلفة. فلا حاجة في بعض اللغات المترجمة، مثل روبي Ruby، أو بايثون Python إلى خطوة بناء فعلية إطلاقًا. عندما نتكلم عن البناء عمومًا، فإننا نعني بذلك إعداد التطبيق ليعمل على المنصة التي صُمِّم لأجلها، وقد يعني ذلك، على سبيل المثال، أننا لو أردنا كتابة التطبيق بلغة TypeScript ثم تنفيذه على Node، فستكون خطوة البناء هي عملية نقل شيفرة TypeScript إلى جافا سكربت JavaScript. ستغدو هذه الخطوة إجبارية وأكثر تعقيدًا في اللغات المُصرَّفة، مثل C و Rust، إذ تتطلب شيفرتها التصريف إلى شيفرة قابلة للتنفيذ. وكنا قد اطلعنا في القسم 7 على برنامج webpack، وهو أداةٌ رائدة حاليًا في بناء نسخ إنتاج من تطبيقات React أو تطبيقات واجهة أمامية مكتوبة بلغة جافاسكربت JavaScript أو TypeScript. نشر التطبيقات Deploy يشير مصطلح "النشر" إلى وضع التطبيق في المكان الذي يمكن للمستخدم النهائي الوصول إليه واستخدامه؛ فقد يعني هذا المصطلح بالنسبة للمكتبات مثلًا وضعها ضمن حزم ثم دفعها إلى أرشيف للحزم (مثل npmjs.com) بحيث يمكن للمستخدمين إيجادها وتضمينها في مشاريعهم. تختلف صعوبة نشر خدمة (مثل تطبيق ويب) حسب تعقيدها، فقد اشتمل مخططنا لنشر التطبيق في القسم 3 مثلًا على تنفيذ بعض السكربتات يدويًا ثم دفع شيفرة المستودع إلى خدمة استضافة Heroku. سنطور في هذا القسم "خط إنتاج" لنشر كل جزء من شيفرتك آليًا إلى Heroku إن لم تسبب هذه الشيفرة أية مشاكل. وقد يكون النشر أحيانًا على درجة عالية من التعقيد خاصةً إذا تطلب التطبيق احتياجات خاصة، كأن يكون متاحًا دائمًا للعمل أثناء تطويره "لا إيقاف من أجل النشر zero downtime deployment"، أو كان علينا أن نأخذ في الحسبان بعض الأمور مثل نقل قواعد البيانات. لن نغطي النقاط المتعلقة بالنشر الذي يحمل تعقيدات مثل التي ذكرناها، لكن من المهم أن تدرك وجودها. ما هو التكامل المتواصل؟ يختلف المفهوم الدقيق لمصطلح التكامل المتواصل Continuous Integration -أو اختصارًا "CI"- عن كيفية استخدامه في مجال التطوير. ستجد نقاشًا قديمًا لكنه يحمل أثرًا كبيرًا عن استخدام هذا المصطلح في مدونة "مارتن فلور". وإذا أردنا التحديد الدقيق للمصطلح CI سنقول أنه يعني غالبًا "دمج التغييرات التي نفذها المطور مع الفرع الرئيسي" ثم أضافت ويكيبيديا Wikipedia العبارة "عدة مرات في اليوم" إلى المصطلح. وهذا الأمر صحيح عادةً، لكن عندما نشير إلى CI في مجال البرمجيات، فنحن نتحدث غالبًا عما سيحدث بعد عملية الدمج الفعلية للتغيرات، إذ قد نحتاج إلى إنجاز بعضٍ من هذه الخطوات: التدقيق lint: للحفاظ على الشيفرة واضحة وقابلة للصيانة. البناء build: وضع كل الشيفرة المُنجزة في برنامج واحد. الاختبار test: لضمان عدم إخفاق أية ميزات موجودة مسبقًا. التحزيم packaging: وضع كل ما نحتاجه في حزمة سهل الحمل والنقل. التحميل/ النشر deploy: لجعل البرنامج متاحًا للاستخدام. سنناقش كل خطوة من تلك الخطوات (عندما نجد فائدةً من ذلك) بشيء من التفصيل لاحقًا. وعلينا أن نتذكر دائمًا أنّ هذه العملية ستكون محددةً بدقة. يعيق التحديد الدقيق strict definition عادةً سرعة التطوير أو الإبداع في إيجاد الحلول، لكن من المفترض أن لا يكون هذا صحيحًا بالنسبة للتكامل المتواصل، إذ يجب وضع هذه القيود بطريقة مرنة تسمح بالتطوير والعمل المشترك. سيسمح استخدام أنظمة CI جيدة، مثل GitHub Actions الذي سنغطيه في هذا القسم بتنفيذ كل ما ذكرناه آليًا وبصورة سحرية. التحزيم والنشر مثل جزء من نظام التكامل المتواصل CI ينبغي التذكير من حين إلى آخر، أنّ عملية التحزيم وعملية النشر خاصة لا تُعدان أحيانًا جزءًا من نظام CI، لكننا سنعدّهما جزءًا من النظام، لأنه من المنطقي في العالم الحقيقي أن نضع كل الخطوات التي أشرنا إليها معًا. ويعود جزءٌ من ذلك للحفاظ على التسلسل في سياق العمل وخط إنتاج البرنامج (الطريق إلى إيصال المنتج إلى المستخدم النهائي)، بينما يعود الأمر في شقه الآخر إلى حقيقة أنّ هذه النقطة هي ما يسبب إخفاق العملية ككل. ستظهر المشاكل غالبًا في مرحلة التحزيم من نظام CI كونها مرحلةٌ لا تختبر محليًا، فمن المنطقي إذًا اختبار عملية تحزيم المشروع خلال سير العمل في CI حتى لو لم نضع هذه الحزمة موضع العمل، كما يمكن في بعض مسارات العمل أن نختبر حزمةً قد بُنيت فعلًا مسبقًا. سيؤكد ذلك أننا اختبرنا الشيفرة بنفس الشكل الذي سننشرها فيه خلال مرحلة الإنتاج. إذًا ما هو وضع عملية النشر؟ سنتحدث عن الاستمرارية وإمكانية التكرار مطولًا في الفصول القادمة، لكن تجدر الإشارة هنا إلى أننا نحتاج إلى عملية تبدو متماثلةً دائمًا، سواءٌ أجرينا الاختبارات في فرع تطوير أو في الفرع الرئيسي. نحتاج في الواقع إلى العملية نفسها "حرفيًا" بحيث يظهر الاختلاف فقط في نهايتها، وذلك عند تحديد ما إذا كنا في الفرع الرئيسي أم لا، وإن كنا نريد النشر أم لا. وفي هذا السياق من المنطقي تضمين خطوة النشر في سير عمل CI طالما أنها ستخضع للصيانة في نفس الوقت الذي نطورها فيه. يشير المصطلحان: "التسليم المتواصل Continuous Delivery" و"النشر المتواصل Continuous Deployment" -أو اختصارًا CD- إلى منظومة CI عندما تتضمن مرحلة النشر. لن نزعجك بالتعريف الدقيق، إذ يمكنك الاطلاع عليه من خلال Wikipedia، أو منشورات مدونة "مارتن فلور"، أو من خلال أكاديمية حسوب، لكننا نستخدم الاختصار CD ليشير إلى أسلوب ممارسة عملي يقتضي إبقاء الفرع الرئيسي متاحًا للنشر والتطوير في أي وقت. لكن ماذا عن المنطقة الغامضة بين مصطلحي CI وCD؟ فلو كان علينا مثلًا أن نجري بعض الاختبارات قبل أن ندمج أية شيفرات جديدة ضمن الفرع الرئيسي، هل نعد ذلك شكلًا من أشكال CI لأننا سنجري دمجًا متواصلًا مع الفرع الرئيسي؟ أم شكلًا من أشكال CD لأننا نتحقق بذلك أن الفرع الرئيسي قابلًا للنشر باستمرار؟ تقع بعض المفاهيم إذًا على الخط الفاصل بين المصطلحين، فمن المنطقي كما أشرنا سابقًا، أن نعد CD جزءًا من CI. لهذا السبب، سيُستخدم غالبًا المصطلح CI/CD للدلالة على العملية برمتها. وانتبه إلى أننا سنستخدم المصطلحين CI و CI/CD بالتناوب طوال الوقت في هذا القسم. أين تكمن أهمية استخدام نظام CI؟ لقد ذكرنا سابقًا مشكلة "يعمل على جهازي" ومشكلة نشر تغييرات متعددة المصادر، لكن ماذا عن بقية المشاكل؟ ماذا لو حملّت "سارة" الشيفرة مباشرةً إلى الفرع الرئيسي؟ ماذا لو استخدم "أحمد" فرعًا ولم يكلف نفسه عناء إجراء بعض الاختبارات قبل دمج ما عدّله ضمن الفرع الرئيسي؟ ماذا لو حاول "عبد الله" بناء نسخة إنتاج من التطبيق لكن بمعاملات خاطئة؟ سيمكِّننا استخدام التكامل المتواصل وفق طريقة منهجية في العمل في: منع رفع الشيفرات مباشرةً إلى الفرع الرئيسي. تنفيذ عملية CI لكل طلبات السحب إلى الفرع الرئيسي وتمكينها من دمج التغييرات. بناء حزم إنتاج للبرامج التي نطورها في بيئة معروفة تمتلك نظام CI. وهنالك أيضًا بعض الإيجابيات لتوسيع هذه الإعدادات: إذا استخدمنا CD في كل مرةٍ ننفذ فيها عملية دمج مع الفرع الرئيسي، سنكون واثقين من عمل الفرع الرئيسي وفق نظام الإنتاج. إذا سمحنا بإتمام عملية الدمج فقط في الحالة التي يكون فيها الفرع الرئيسي محدّثًا، سنضمن أن لا يلغي المطورون التغييرات التي نفذها غيرهم. تنبيه: سنفترض في هذا القسم أن الشيفرة الموجودة في الفرع الرئيسي ("mian" أو "master") ستعمل وفق نظام الإنتاج. ونظرًا لوجود عدد هائل من مخططات العمل (workflows) على git، قد يكون هناك على سبيل المثال حالات يظهر فيها فرع خاص للإصدار release branch يحتوي على الشيفرة التي تعمل وفق نظام الإنتاج. مبادئ هامة من المهم أن نتذكر أنّ CI/CD ليس هدفًا بحد ذاته، لكن الهدف هو تطوير أسرع وأفضل للتطبيقات بأقل عددٍ من الثغرات، وتحقيق أفضل تعاون بين المطورين. ولهذا لا بدّ من تهيئة نظام CI بما يلبّي المهمة التي ننفذها والمشروع بحد ذاته، كما ينبغي أن نبقي الهدف الرئيسي من استخدامه في حساباتنا دائمًا. يمكننا التفكير بنظام CI بمثابة جوابٍ على الأسئلة التالية: كيف سنتأكد أنّ جميع الاختبارات ستُطبّق على كامل الشيفرة التي ستُنشر؟ كيف سنضمن جاهزية الفرع الرئيسي للنشر في كل الأوقات؟ كيف سنتأكد أنّ النسخ التي تُبنى ستكون متوافقة، وستعمل دائمًا على المنصات التي صُمِّمت للنشر عليها؟ كيف سنضمن أن التغييرات الجديدة لن تلغي التغييرات الأقدم؟ كيف سننجز عملية النشر بضغطة زر أو بطريقة آلية، عندما يدمج مطور التغييرات التي أجراها مع الفرع الرئيسي؟ وهناك أيضًا دلائل علمية على الفائدة الهائلة التي يقدمها نظام CI/CD، فبناءً على دراسة واسعة في كتاب ": Building and Scaling High Performing Technology Organizations"، يقترن استخدام CI/CD بشدة مع النجاحات على صعيد المؤسسات (تحسين الأرباح وجودة المنتج وزيادة الحصة السوقية وتقليل زمن التسويق)، وكذلك من ناحية المطورين، فقد جعلتهم أكثر سعادةً وأقل إرهاقًا. سلوك موثق تشيع في أوساط المبرمجين طرفةٌ مفادها أنّ الثغرات هي "ميزات غير موثقة". لكن يجب علينا تفادي أية حالات لا نعلم تمامًا نتيجتها. فلو اعتمدنا مثلًا على عنوان طلب السحب لنحدد ما إذا كان إصدار البرنامج رئيسيًا major، أو ثانويًا minor، أو ترميميًا patch (سنتحدث عن معنى كل إصدار لاحقًا)، سيكون علينا الانتباه إلى الحالة التي يُغفل فيها المطور من وضع عنوانٍ لطلب السحب الذي قدمه. وماذا لو وضع هذا العنوان بعد بدء عملية البناء أو الاختبار؟ أو أنه غيَّر العنوان خلال مرحلةٍ ما، فما الذي سيكون عليه هذا الإصدار؟ من الممكن أن تغطي جميع الحالات التي قد تتوقعها، ومع ذلك، قد تظهر بعض الثغرات عندما يُنفّذ المطور شيئًا "إبداعيًا" لم تتوقعه إطلاقًا، فمن الجيد إذًا أن تنتهي العملية التي ستخفق بأمان في هذه الحالة. فلو عدنا إلى المثال السابق عندما يغير المطور عنوان الطلب أثناء تنفيذ عملية البناء. إذا لم نتمكن من توقع سيناريو مثل هذا مسبقًا، فمن الأفضل ربما أن تتوقف عملية البناء، وأن نبلِّغ المستخدم بأنّ شيئًا غير متوقع قد حدث، فنشر إصدار خاطئ سينتج حتمًا مشاكلًا أكبر، وسيكون إيقاف العملية وإخطار المستخدم هو الحل الأكثر أمانًا. يحدث الشيء ذاته في كل مرة قد نملك أفضل اختبارات لبرمجياتنا يمكن تخيلها، وقد تكون قادرةً على اصطياد أية مشكلة محتملة، لكنها ستبقى عديمة الجدوى إن لم ننفذها على الشيفرة قبل نشرها. علينا أن نضمن أنّ الاختبارات قابلةً للتنفيذ، كما نريد أن نضمن أنها ستعمل مع الشيفرة التي سننشرها فعليًا. فلن يفيدنا الاختبار الذي ينجح مثلًا ضمن فرع "سارة" ويفشل عند دمجه مع الفرع الرئيسي. وطالما أننا سننشر محتويات الفرع الرئيسي، لذا علينا أن نتأكد من نجاح الاختبارات على نسخة من الفرع الرئيسي بعد أن ندمج معه فرع "سارة". ستوصلنا المناقشة السابقة إلى مبدأ جوهري وهو التأكد أنّ السلوك نفسه سيحدث كل مرة، أو أنّ المهام المطلوبة ستُنفَّذ جميعها وبالترتيب الصحيح. يجب أن تبقى الشيفرة قابلة للنشر دائما إنّ وجود شيفرة قابلة للتوزيع والنشر دائمًا أمر مريح جدًا وخاصةً عندما يحتوي الفرع الرئيسي على شيفرة تعمل في وضع الإنتاج؛ فإذا ظهرت ثغرةٌ مثلًا وتطلب الأمر معالجتها، يمكنك سحب نسخة من الفرع الرئيسي (وهي الشيفرة التي تُنفًّذ في وضع الإنتاج) وحل المشكلة، ثم تقديم طلب سحب لإعادة الشيفرة إلى الفرع الرئيسي (دمج من جديد). ويجري هذا الأمر عادةً مباشرةً دون أية مشاكل. إذا اختلفت الشيفرة الموجودة في الفرع الرئيسي عن شيفرة الإنتاج ولم يكن الفرع الرئيسي قابلًا للنشر، سيكون عليك البحث عن الشيفرة التي تعمل في وضع الإنتاج وسحب نسخة عنها، ثم إيجاد الثغرة ومعالجتها، كما عليك إيجاد طريقة لإعادة الشيفرة إلى الفرع الرئيسي والعمل على نشر الجزء المحدد الذي أصلحته. لن يكون هذا الأمر مريحًا وستكون طريقة تنفيذه مختلفةً كليًا عن مخطط نشر التطبيقات الاعتيادي. معرفة أجزاء الشيفرة المنشورة- مجموع SHA/الإصدار من المهم غالبًا تحديد الشيفرة التي تعمل فعليًا في وضع الإنتاج. ولقد ناقشنا سابقًا أن شيفرة الفرع الرئيسي هي من تعمل في هذا الوضع في الحالة النموذجية، لكن هذا الأمر ليس ممكنًا دائمًا؛ فقد تتعرض عملية البناء للإخفاق عندما نحاول تشغيل شيفرة الفرع الرئيسي في وضع الإنتاج، وقد نجري عدة تغييرات على الشيفرة ونريدها أن تُنشر جميعها مباشرةً. ما نحتاج إليه في حالات مثل هذه (وهي فكرة جيدة عمومًا)، هو معرفة الشيفرة التي تعمل في وضع الإنتاج بالضبط؛ إذ يمكن معرفة ذلك أحيانًا عن طريق رقم الإصدار version، وأحيانًا بمجموع SHA sum (وهو جدول HASH يعرِّف بصورةٍ فريدة اعتماد git) متصل بالشيفرة. سنناقش موضوع تحديد رقم الإصدار لاحقًا في هذا القسم. الآلية الأكثر فائدةً هي ربط معلومات الإصدار مع تاريخ جميع الإصدارات، فلو اكتشفنا مثلًا وجود ثغرة في اعتماد محدد، يمكننا أن نعرف متى أُصدر هذا الاعتماد وكم عدد المستخدمين الذين تأثروا بالثغرة. وتظهر فائدة هذه المقاربة عندما تسبب الثغرة أخطاءً في بيانات قواعد البيانات، وهكذا من الممكن تقفي أثر البيانات الخاطئة بناءً على تاريخ إنشائها. أنماط إعدادات منظومة CI علينا تخصيص خادم منفصل لتشغيل مهام منظومة التكامل المتواصل CI لكي نلبي بعضًا من المتطلبات التي ذكرناها سابقًا، إذ سيقلل وجود خادم منفصل المخاطر الناتجة عن اختلاط عمليات أخرى بعملية منظومة CI/CD جاعلةً نتيجتها غير متوقعة. هنالك خياران: استضافة خادم خاص بنا أو الخدمة السحابية cloud service. استضافة Jenkins وإعدادات بيئة الاستضافة الذاتية self-host تُعد استضافة Jenkins الخيار الأكثر شعبيةً من بين خيارات الاستضافة الذاتية، فهي مرنةٌ جدًا وتمتلك عدة إضافات plugin لكل شيء تقريبًا (ماعدا الشئ الوحيد الذي تريده). تمثل هذه الاستضافة خيارًا جيدًا للعديد من التطبيقات. يعني استخدام الاستضافة الذاتية أن تكون بيئة الاستضافة تحت سيطرتك بالكامل، وكذلك جميع الموارد، ولن تكون أسرارك عرضةً للكشف من قبل الآخرين، كما ستتمكن من فعل ما تشاء بالعتاد الصلب. لكن ولسوء الحظ هناك جانبٌ مظلم لاستضافة Jenkins فهي صعبة الإعداد، إذ تتطلب المرونة الكبيرة في الإعداد وجود العديد من قوالب template الشيفرة التي تتطلب الإعداد ليعمل كل شيء، ويتطلب استخدام Jenkins بالتحديد إعداد منظومة CI/CD باستخدام لغة Jenkins الخاصة بالنطاق domain-specific. ولا ننس مخاطر إخفاق العتاد الصلب والذي قد يسبب مشكلةً في حالات الازدحام. تعتمد فاتورة الاستضافة الذاتية على العتاد الصلب الذي تختاره غالبًا، فأنت تدفع مقابل الخادم وما ستفعله على الخادم لن يغير من فاتورتك. استخدام GitHub Actions وحلول أخرى معتمدة على الاستضافة السحابية لن تضطر للتفكير كثيرًا عند إعداد بيئة الاستضافة في الاستضافة السحابية، فكل ما عليك فعله هو أن تطلب من الاستضافة ما تريده، ويتضمن ذلك عادةً وضع ملف في مستودعك (ثم إخبار منظومة CI بقراءته)، أو التحقق من وجود ملف كهذا في المستودع. سيكون ملف تهيئة CI في الاستضافة السحابية أبسط قليلًا، وخاصةً إن لم تتعدّى الحدود "الطبيعية" للاستخدام، لكن قد تغدو خيارات هذه الإستضافة محدودةً قليلًا إن أردت فعل أشياء خاصة بين الفينة والأخرى، أو قد تجد صعوبةً في ذلك، خاصةً في إنجاز مهام محددة لم تُبنى المنصة السحابية لكي تنجزها. سنركز في هذا القسم على الاستخدامات التي تعد "طبيعية"، فقد تتطلب إعدادات بعض المهام الخاصة عتادًا صلبًا خاصًا على سبيل المثال. وبعيدًا عن إعدادات التهيئة التي ذكرناها سابقًا، قد تواجهنا مشاكلًا تتعلق بمحدودية الموارد على الاستضافة السحابية، فلو بدت النسخة بطيئة على الاستضافة الذاتية، يمكنك ببساطة حجز خادم أقوى وتضع ما تحتاجه من الموارد ضمنه، لكن هذا الأمر قد يكون مستحيلًا في الاستضافة السحابية. ستعمل العقد التي تبنيها في GitHub Actions مثلًا على معالجين افتراضيين (vCPU) و8 جيجابايت من ذواكر الوصول العشوائي. ستعتمد فاتورتك في الاستضافة السحابية على الوقت المستغرق في بناء تطبيقك، وهذا أمر يجدر أخذه بالحسبان. لماذا نفضل خيارا على آخر؟ إن كان مشروعك صغيرًا إلى متوسط الحجم ولن يحتاج أية متطلبات خاصة (مثل الحاجة إلى بطاقة رسومية لتنفيذ الاختبارات)، ستكون الاستضافة السحابية الحل المناسب غالبًا، إذ ستكون الإعدادات بسيطةً ولن تدخل دوامة إعدادات النظام الخاص بك، كما ستكون الكلفة أقل للمشاريع الصغيرة خاصة؛ أما بالنسبة للمشاريع الأضخم والتي تحتاج إلى موارد أكبر أو بالنسبة للشركات الكبيرة التي تضم عدة فرق للتطوير وعدة مشاريع، قد يكون اختيار منظومة استضافة ذاتية هو الخيار الأنسب. لماذا استخدمنا GitHub Actions في هذه السلسلة؟ يُعدّ GitHub Actions الخيار الأنسب كوننا نستخدم غيت هب GitHub، إذ سيؤمن لنا منظومة CI قوية تعمل مباشرةً دون الحاجة إلى إعداد خادم أو خدمة سحابية يؤمنها طرف ثالث. إضافةً إلى سهولة استخدامه، يُعد GitHub Actions خيارًا جيدًا من عدة جوانب، فقد يكون أفضل خدمة سحابية موجودة حتى اللحظة، إذ يحظى بشعبية كبيرة منذ إصدار النسخة الأساسية منه في نوفمبر (تشرين الثاني) عام 2019. التمرين 11.1 قبل أن ننغمس في إعداد خطوط إنتاج لأنظمة CI/CD، لا بدّ من مراجعة ما قرأناه. لنتهيأ للانطلاق فكر بحالة افتراضية نكون فيها أمام تطبيق يجري تطويره من قِبل فريق من 6 مطورين وسيصدر قريبًا. لنفترض أنّ اللغة المستخدمة في تطوير التطبيق ليست JavaScript/TypeScript فلنقل Python، أو Java، أو Ruby، أو أية لغات قد تفكر بها. اكتب موضوعُا بسيطًا من 200-300 كلمة تجيب فيه أو تناقش النقاط التي سنطرحها تاليًا. يمكنك التحقق من عدد الكلمات التي تكتبها عبر موقع wordcounter. خزّن إجابتك في ملف اسمه "exercise1.md" في جذر المستودع الذي ستنشئه في التمرين 11.2 ناقش النقاط التالية: بعض الخطوات الشائعة في إعداد CI بما فيها التدقيق والاختبار والبناء، وكذلك الأدوات اللازمة لتنفيذ هذه الخطوات في بيئة عمل اللغة التي تختارها. يمكنك البحث عن الأجوبة عبر غوغل Google. ما هي بدائل إعداد منظومات CI عن GitHub Actions أو Jenkins؟ يمكنك الاستعانة بمحرك البحث غوغل. أين سيكون تطبيق هذه الإعدادات أفضل، على استضافة ذاتية أو استضافة سحابية؟ لماذا؟ ما هي المعلومات التي ستحتاجها لاتخاذ قرارك؟ تذكر أنه لا توجد إجابات صحيحة أو خاطئة لجواب هذا السؤال. ترجمة -وبتصرف- للفصل Introduction to CICD من سلسلة Deep Dive Into Modern Web Development اقرأ أيضًا ما هو Git إعادة تأسيس تفريعات طلب السحب وتحديثه في git إعداد التكامل المستمر والنشر المستمر باستخدام الخدمتين CircleCI وCoveralls التكامل المستمر: تثبيت Concourse CI على أوبنتو
  16. انصب تركيزنا في هذا القسم على بناء منظومة تكامل مستمر CI بسيطة وفعالة ومحكمة، تساعد المطورين على العمل معًا والحفاظ على جودة الشيفرة ومن ثم نشرها بأمان. ما الذي يمكننا فعله أكثر؟ لنتذكر أنه في العالم الحقيقي ستمتد أيادٍ إلى الكعكة غير أياد المطورين والمستخدمين. حتى وإن لم يكن ذلك صحيحًا، وخاصة بالنسبة للمطورين، إلا أنّ هناك الكثير الذي تقدمه منظومة CI غير ما اطلعنا عليه. مجال الرؤية والفهم في جميع الشركات ما عدا الصغيرة منها، لا تُتخذ القرارات حول تطوير المنتجات حصرًا من طرف المطورين. يشير مصطلح أصحاب الشأن "stakeholder" إلى الأشخاص داخل وخارج فريق التطوير، الذين لديهم مصلحة في مراقبة تقدم عملية التطوير.عند هذا المستوى، هناك غالبًا تكاملٌ بين git وأي برنامج لإدارة المشاريع أو تتبع الثغرات قد يستخدمه الفريق. يُعد امتلاك مرجع إلى نظام التتبع في طلبات السحب أو في الشيفرة المعتمدة أكثر الطرق شيوعًا لتنفيذ ذلك. وهكذا، عندما تعمل على النقطة رقم 123 مثلًا، بإمكانك عندها تسمية طلب السحب الخاص بك BUG-123: Fix user copy issue، وسيلاحظ نظام تتبع الثغرات عندها الجزء الأول من اسم طلب السحب، وسينقل النقطة إلى قائمة النقاط المنفَّذة Done عندما يُدمج الطلب. تنبيهات عندما تنتهي عملية CI بسرعة، فمن المناسب أن نراقب العملية فقط وننتظر النتائج، لكن مع ازدياد حجم المشاريع ستزداد أيضًا فترة تنفيذ عمليات بناء واختبار الشيفرة. سيقودنا ذلك إلى الحالة التي تستغرق فيها ظهور نتائج بناء الشيفرة وقتًا طويلًا، مما يدفع المطور للتفكير في العمل على مهمة أخرى، وهذا سيقود بدوره إلى نسيان عملية البناء. ستظهر المشكلة في طرحنا هذا خاصة عندما نتحدث عن دمج طلبات السحب التي قد تؤثر على عمل مطور آخر، إما بالتسبب بمشاكل في عمله أو تأخير هذا العمل. وقد يقودنا ذلك أيضًا إلى الحالة التي تعتقد فيها أنك نشرت شيئًا، لكنك في الواقع لم تنهي عملية النشر، وسيؤدي ذلك إلى سوء التواصل مع أعضاء فريقك أو مع الزبائن (كأن تقول: "جرب من جديد، لا بدّ أنّ المشكلة قد حُلَّت"). هناك عدة أساليب لحل هذه المشكلة، تتدرج من إنشاء تنبيهات بسيطة إلى عمليات أكثر تعقيدًا تدمج ببساطة الشيفرة التي تمرر إليها إن تحققت جملةٌ من الشروط الموضوعة مسبقًا. سنناقش حاليًا التنبيهات مثل حل بسيط، كونها الوحيدة التي تتعلق بسير مخطط عمل الفريق. يرسل غيت هب افتراضيًا بريدًا إلكترونيًا عند إخفاق البناء. يمكن تغيير ذلك لإرسال تنبيهات بغض النظر عن حالة البناء، كما يمكن تهيئته لإنذارك على واجهة ويب غيت هب GitHub. طبعًا هذا الأمر رائع، لكن ماذا لو أردنا أكثر من ذلك؟ ماذا لو لم يعمل هذا الحل لسببٍ ما مع حالتنا؟ يمكن أن يتكامل غيت هب GitHub مع عدة تطبيقات رسائل مثل Slack لإرسال التنبيهات، وستكون هذه التطبيقات قادرةً على تحديد متى ترسل هذه التنبيهات بناءً على منطق يفرضه غيت هب GitHub. التمرين 11.18 أعددنا قناة fullstack_webhookعلى مجموعة Discord على العنوان "https://study.cs.helsinki.fi/discord/join/fullstack" لاختبار تكامل منظومة الرسائل. ستحتاج طبعًا إلى بريد إلكتروني للتسجيل، وانتبه إلى ضرورة استخدام عنوان خطاف الويب webhook الخاص بالتطبيق Discord لتنفيذ هذا التمرين. ستجد هذا الخطاف في الرسالة المنبثقة القناة "fullstack_webhook". ويُستحسن عدم الالتزام بالخطاف على غيت هب GitHub. أفعال لتنبيه المستخدم إلى نجاح أو فشل البناء ستجد العشرات من أفعال GitHub Actions التي طورها مطورون مستقلون على GitHub Action Marketplace بمجرد أن تبحث عن العبارة discord. اختر واحدًا لتستخدمه في هذا التمرين. وقد اخترنا discord-webhook-notify لأنه حصل على تقييم مرتفع وله توثيق جيد. هيئ الفعل بحيث يعطي نوعين من التنبيهات: مؤشر على النجاح إن نُشرت النسخة. مؤشر على الفشل إن أخفق البناء. في حال وقوع خطأ، لا بدّ أن تكون رسالة الخطأ مسهبة لمساعدة المطور على إيجاد الخطأ بسرعة وإيجاد الشيفرة المعتمدة التي سببت هذا الخطأ. ابحث في توثيق GitHub عن كيفية التحقق من حالة المهمة. يمكن أن تبدو التنبيهات على النحو التالي: المقاييس Metrics لقد أشرنا في الفقرة السابقة أنه عندما ينمو المشروع ستزداد مدة إنجاز خطوات البناء والاختبار، وهذا ليس بالأمر المثالي، فكلما طالت حلقة وصول النتائج ستصبح دورة التطوير بطيئة. وعلى الرغم من إمكانية القيام بأشياء لتقليل الوقت اللازم للبناء، من المفيد أيضًا أن ننظر جيدًا إلى الصورة الكلية للوضع. فمن الجيد أن نعرف كم استغرقت عملية البناء قبل أشهر عدة موازنةً مع ما تستغرقه الآن. هل يسلك الاختلاف منحًى خطيًا أم يظهر على هيئة قفزات فجائية؟ كما أنّ معرفة السبب الكامن خلف زيادة زمن البناء سيساعدنا جدًا في حل المشكلة؛ فإذا زاد زمن التنفيذ على نحوٍ خطي من 5 إلى 10 دقائق خلال سنة، فيمكننا أن نتوقع بأنه سيزداد أيضًا بضع دقائق خلال عدة أشهر قادمة ليصل إلى 15 دقيقة، وستكون لدينا فكرةً عن قيمة ما ننفقه من وقت لتسريع عمل منظومة CI. يمكن أن تكون المقاييس ذاتية- التسجيل self-reported وتدعى أيضًا مقاييس "دفع-Push"، إذ تسجل كل عملية بناء الوقت الذي تستغرقه، أو أن نحضر البيانات من واجهة برمجية بعد انتهاء العملية وتدعى أحيانًا مقاييس "سحب-Pull". يتمثل خطر التسجيل الذاتي في الوقت الذي ستستغرقه عملية التسجيل بحد ذاتها، والتي قد يكون لها أثر على الوقت الكلي المستغرق للبناء. يمكن أن تُرسل هذه البيانات إلى قاعدة بيانات تعمل وفق سلاسل زمنية time-series DB، أو إلى سِجِل من نوع آخر. هناك عدة خدمات سحابية لتجميع قياساتك بكل سهولة، ومن الخيارات الجيدة الخدمة Datadog. المهام الدورية هناك بعض المهام الدورية التي يجب تنفيذها ضمن فرق تطوير البرمجيات، إذ يمكن أتمتة بعضها من خلال بعض الأدوات المتوفرة، وعليك أتمتة بعضها الآخر. تتضمن الفئة الأولى من المهام الدورية التحقق من الحزم لتفادي الاختراقات الأمنية، وقد تساعدك في ذلك العديد من الأدوات الجاهزة. سيكون بعض هذه الأدوات مجانيًا لمشاريع محددة (مفتوحة المصدر مثلًا) مثل الأداة Dependabot التي يقدمها GitHub. وإليك النصيحة التالية: من الأفضل دائمًا استخدام أدوات جاهزة لأداء هذه المهام، إن سمحت بذلك ميزانيتك، بدلًا من الاعتماد على حلولك الخاصة، فإن لم تكن مهتمًا بتطوير أدوات تتعلق بأمور الأمن مثلًا، استخدم Dependabot للتحقق من الثغرات الأمنية بدلًا من تطوير الأداة بنفسك. لكن ماذا عن المهام التي لا تتوفر لها أدوات مساعدة؟ يمكنك أتمتة هذه المهام بنفسك عن طريق GitHub Actions أيضًا، إذ يزودك GitHub Actions بمُطلِقات أحداث المجدولة scheduled trigger التي يمكن استخدامها لتنفيذ مهمة محددة في وقت محدد. التمارين 11.19 -11.21 11.19: التحقق الدوري من سلامة البرمجيات لدينا الثقة الآن بأنّ خط الإنتاج الذي عملنا عليه سيمنع نشر الشيفرة المخفقة، لكن مع ذلك هناك مصادر عديدة للأخطاء؛ فعندما يعتمد تطبيقنا مثلًا على قاعدة بيانات قد لا تكون متوفرة دائمًا، سيتوقف التطبيق عن العمل في لحظةٍ ما. لهذا من المفيد جدًا التحقق دوريًا من سلامة عمل التطبيق عن طريق إرسال طلب HTTP-GET إلى الخادم. ندعو هذه العملية عادة بعملية التحقق من الاتصال "ping". من الممكن أن نجدول أفعال GitHub لتُحدّث دوريًا وبصورة منتظمة. استخدم الفعل url-health-check أو أي بديل له وجدول عملية التحقق من الخادم الذي تنشر عليه التطبيق. حاول محاكاة الحالة التي يخفق فيها تطبيقك، تأكد حينها أن عملية التحقق ستكتشف الخطأ. اكتب مخطط العمل الدوري الذي أوجدته على ملف خاص. تنبيه: سيستغرق GitHub Actions وقتًا طويلًا حتى يبدأ بتنفيذ مخطط العمل الذي جدولته للمرة الأولى، فقد تطلب منا الأمر قرابة الساعة، لذلك تُعد فكرة إطلاق مخطط العمل مباشرةً بتنفيذ عملية دفع للشيفرة إلى الاستضافة فكرةً جيدة. انتقل بعد ذلك لتنفيذ المخطط المجدول بعد أن تنجح معك هذه الحيلة. تنبيه: عندما تنجح في تنفيذ ما طلبناه، من الأفضل أن تقلل مرات التحقق من الاتصال مع الخادم إلى مرة كل 24 ساعة أو عطل القاعدة، وإلا ستكلفك عملية التحقق المستمرة ساعاتك المجانية المتاحة خلال الشهر. 11.20: خط إنتاج خاص بك ابنِ خط إنتاج CI/CD لبعض التطبيقات التي أنجزتها سابقًا. من التطبيقات المرشحة للعمل عليها تطبيق دليل الهاتف الذي طورناه في القسمين 2 و3 من المنهاج، أو تطبيق المدونة الذي طورناه في القسم 4 والقسم 5، أو تطبيق الطرائف الذي بنيناه بالاستعانة بالمكتبة Redux في القسم 6. ستضطر غالبًا إلى إعادة هيكلية التطبيق لتجمع قطع الشيفرة المتفرقة، وعليك في الخطوة الأولى وضع الواجهتين الأمامية والخلفية في مستودع واحد. لست مجبرًا على ذلك طبعًا، لكنه سيسهل عليك الأمر كثيرًا. إحدى الهيكليات المقترحة هي وضع الواجهة الخلفية في جذر المستودع وأن تضع الواجهة الخلفية ضمن مجلد فرعي، ويمكنك أيضًا نسخ ولصق بنية التطبيق النموذجي الذي نفذناه في هذا القسم، أو أن تحاول الاستفادة من التطبيق النموذجي الذي اطلعنا عليه في القسم 7. ربما من الأفضل أن تنشئ مستودعًا جديدًا لهذا التمرين، ومن ثم تنسخ وتلصق الشيفرة ضمنه. قد تضطر غالبًا في العمل الواقعي إلى تنفيذ ذلك ضمن المستودع القديم، لكن الإنطلاقة الجديدة الآن ستسهل علينا الأمر. سيستغرق منك هذا التمرين وقتًا وجهدًا، لكن الحالة التي تواجهها هنا، وهي بناء خط إنتاج لنشر شيفرة قديمة، شائعة جدًا في الحياة العملية. لن يُنفّذ هذا التمرين في نفس المستودع الذي نفذت فيه التمارين السابقة، لأنك لا تستطيع أن تعيد سوى مستودع واحد إلى منظومة تسليم التمارين، ضع رابطًا إذًا للمستودع الآخر ضمن المستودع الذي أشرت إليه في نموذج التسليم. 11.21: حماية الفرع الرئيسي وتقديم طلب سحب احمِ الفرع الرئيسي للمستودع الذي أنجزت فيه التمرين السابق، وامنع المدراء أيضًا هذه المرة من دمج الشيفرة مع الفرع الرئيسي دون مراجعتها. قدم طلب سحب، واطلب من أحد مستخدمي GitHub التالية أسماؤهم mluukkai و/ أو kaltsoon أن يراجع شيفرتك، وبعد أن تنتهي المراجعة، ادمج الشيفرة مع الفرع الرئيسي، وانتبه إلى أن يكون المراجع من المتعاونين معك في المستودع. اتصل بنا على Discord لكي نراجع شيفرتك، ومن الأفضل إرسال رسالة خاصة تتضمن رابط دعوة. بعدها يكون العمل قد أُنجز. ترجمة -وبتصرف- للفصل Expanding further من سلسلة Deep Dive Into Modern Web Development اقرأ أيضًا المقال التالي: مدخل إلى الحاويات المقال السابق: نشر إصدارات التطبيق بأمان في منظومة التكامل والتسليم المستمر نشر التطبيقات وتوزيعها وفق نهج التسليم المستمر إعداد التكامل المستمر والنشر المستمر باستخدام الخدمتين CircleCI وCoveralls
  17. سنربط في الفقرات التالية تطبيق الواجهة الأمامية الذي أنشأناه في القسم السابق من هذه السلسلة مع تطبيق الواجهة الخلفية. رأينا في القسم السابق، أن الواجهة الأمامية قادرة على الوصول إلى قائمة بكل الملاحظات من خادم 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
  18. مكونات الأصناف استخدمنا حتى اللحظة في المنهاج مكوّنات React التي عرفناها على شكل دوال JavaScript. ولم يكن هذا ممكنًا لولا الوظائف التي أمنتها الخطافات التي أتت مع النسخة 16.8 من React. فلقد كان على المطوّر أن يستخدم العبارة Class عندما يعرّف مكونًا له حالة. من المفيد أن نطّلع إلى حدٍّ ما على مكوّنات الأصناف، لأنّ هناك الكثير من تطبيقات React القديمة التي لم تُكتب من جديد باستخدام القواعد الأحدث. لنتعرّف إذًا على الميزات الرئيسية لمكوّنات الأصناف، وذلك باستخدامها مع تطبيق " الطرائف" الذي أصبح مألوفًا. سنخزّن الطرائف في الملف "db.json" مستخدمين خادم JSON. يمكن نسخ محتويات الملف من GitHub تبدو النسخة الأساسية من مكوّن الأصناف كالتالي: import React from 'react' class App extends React.Component { constructor(props) { super(props) } render() { return ( <div> <h1>anecdote of the day</h1> </div> ) } } export default App يمتلك المكوّن الآن دالة بانية، والتي لا تقوم بأي عمل حاليًا، كما يتضمن أيضًا التابع render. سيعرّف التابع render كما توقعنا، كيف وماذا سيُعرض على الشاشة. لنعرّف حالة لقائمة الطرائف وللطرفة التي تُعرض حاليًا. بالمقارنة مع استخدام الخطاف useState فمكوّن الأصناف يحتوي فقط حالة واحدة. فإن تكوّنت الحالة من عدة أجزاء يجب حفظها كخصائص للحالة. سنهيّئ الحالة ضمن البانية: class App extends React.Component { constructor(props) { super(props) this.state = { anecdotes: [], current: 0 } } render() { if (this.state.anecdotes.length === 0 ) { return <div>no anecdotes...</div> } return ( <div> <h1>anecdote of the day</h1> <div> {this.state.anecdotes[this.state.current].content} </div> <button>next</button> </div> ) } } تخزّن حالة المكوّن في المتغيّر this.state. وستكون الحالة عبارة عن كائن بخاصيتين هما this.state.anecdotes وتمثل قائمة الطرائف وthis.state.current والتي تخزن رقم الطرفة المعروضة. إنّ المكان الصحيح لإحضار البيانات من الخادم في مكوّنات الدوال هو داخل خطاف التأثير، والذي يُنفّذ عندما يصيّر المكوّن أو خلال فترات أبعد إن اقتضى الأمر، كالتصيير للمرة الأولى فقط. تقدّم توابع دورة العمل في مكوّنات الأصناف وظائف مقابلة. وسيكون المكان الأمثل لإحضار البيانات من الخادم داخل تابع دور العمل componentDidMount، والذي يُنفَّذ مرة واحدة عند كل تصيير للمكوّن: class App extends React.Component { constructor(props) { super(props) this.state = { anecdotes: [], current: 0 } } componentDidMount = () => { axios.get('http://localhost:3001/anecdotes') .then(response => { this.setState({ anecdotes: response.data }) }) } // ... } تُحدِّث دالة استدعاء طلب HTTP المكوّن باستخدام التابع setState. يؤثر التابع فقط على المفاتيح التي عُرّفت في الكائن الذي سنمرّره إلى التابع كمعامل. وستبقى قيمة المفتاح current كما هي. كما يفعّل استدعاء التابع setState تابع التصيير render. سنكمل عملنا على المكوّن بإضافة إمكانية تغيير الطرفة المعروضة. تمثل الشيفرة التالية المكوّن بالكامل، ولوِّنت الشيفرة التي تنفذ الإضافة السابقة: class App extends React.Component { constructor(props) { super(props) this.state = { anecdotes: [], current: 0 } } componentDidMount = () => { axios.get('http://localhost:3001/anecdotes').then(response => { this.setState({ anecdotes: response.data }) }) } handleClick = () => { const current = Math.floor( Math.random() * this.state.anecdotes.length ) this.setState({ current }) } render() { if (this.state.anecdotes.length === 0 ) { return <div>no anecdotes...</div> } return ( <div> <h1>anecdote of the day</h1> <div>{this.state.anecdotes[this.state.current].content}</div> <button onClick={this.handleClick}>next</button> </div> ) } } وعلى سبيل المقارنة، فالشيفرة التالية هي الشيفرة المقابلة لمكوّنات الدوال: const App = () => { const [anecdotes, setAnecdotes] = useState([]) const [current, setCurrent] = useState(0) useEffect(() =>{ axios.get('http://localhost:3001/anecdotes').then(response => { setAnecdotes(response.data) }) },[]) const handleClick = () => { setCurrent(Math.round(Math.random() * (anecdotes.length - 1))) } if (anecdotes.length === 0) { return <div>no anecdotes...</div> } return ( <div> <h1>anecdote of the day</h1> <div>{anecdotes[current].content}</div> <button onClick={handleClick}>next</button> </div> ) } إنّ الاختلاف بينهما ثانوي فيما يتعلق بتطبيقنا. أما الفرق الجوهري بين المكونين هو أنّ حالة مكوّن الأصناف هي كائن واحد، وأنّ التابع setState هو من يحدث الحالة. بينما يمكن في مكوّنات الدوال تخزين الحالة ضمن عدة متغيرات، لكل منها دالة تحديث خاصة بها. وعلى مستويات مقارنة أعلى، سيقدم خطاف التأثير آلية تحكم أفضل بالتأثيرات الجانبية مقارنة بتوابع دورة العمل التي تأتي مع مكوّنات الأصناف. والفائدة الملحوظة باستخدام مكوّنات الدوال تكمن في انعدام الحاجة لاستخدام المؤشر الذاتي this الذي تستخدمه أصناف JavaScript. لا توفر مكوّنات الأصناف بناء على رأي الكثيرين أية ميزات تفوق بها مكوّنات الدوال المدعّمة بالخطافات، ما عدا آلية محيط الخطأ (error boundary) والتي لم تستخدم حتى الآن من قبل مكوّنات الدوال. لا توجد أسباب منطقية تدفعك لاستخدام مكونات الأصناف عند كتابة شيفرة لتطبيق جديد، إن كان مشروعك سيستخدم React 16.8 أو أعلى. ولا حاجة حاليًا لإعادة كتابة كل شيفرات React القديمة كمكوّنات دوال. تنظيم الشيفرة في تطبيقات React لقد اتبعنا في معظم التطبيقات المبدأ التالي في التنظيم: حيث وضعنا المكوّنات في المجلد "components"، ووضعنا دوال الاختزال في المجلد"reducers"، وضعنا الشيفرة المسؤولة عن الاتصال مع الخادم في المجلد "services". تلائم الطريقة السابقة تنظيم التطبيقات الصغيرة. لكن عند زيادة عدد المكوّنات، سيتطلب الأمر حلولًا أفضل. ليست هنالك طريقة واحدة صحيحة لتنظيم المشروع، لكن قد تقدم لك المقالة التي تحمل العنوان The 100% correct way to structure a React app (or why there’s no such thing) بعض الإضاءات بهذا الشأن. الواجهة الخلفية والأمامية في المجلد نفسه وضعنا حتى اللحظة الواجهتين الأمامية والخلفية في مستودعين منفصلين. وهذه المقاربة تقليدية جدًا. لكننا نقلنا الملف المُجمّع للواجهة الأمامية عند نشر التطبيق إلى مستودع الواجهة الخلفية. وربما يكون نشر شيفرة الواجهة الأمامية بشكل مستقل مقاربة أفضل، وخاصة للتطبيقات التي أنشئت باستخدام creat-react-app لأنها عملية سهلة التنفيذ بفضل المكتبة buildpack. ربما ستصادفنا حالات تقتضي أن يوضع التطبيق بأكمله داخل مستودع واحد. في حالات كهذه، يشيع وضع الملفين في جذر المشروع، ووضع شيفرة الواجهتين الأمامية والخلفية في مجلدات خاصة بكل منهما مثل "client" و"server". يمكن الاطلاع على طريقة لتنظيم الأمور تستخدمها كنقطة انطلاق على GitHub. التغييرات على الخادم إنّ حدثت أية تغيرات في حالة الخادم، كإضافة مدونة من قبل مستخدمين آخرين إلى خدمة قائمة المدونات، لن تتمكن الواجهة الأمامية التي طورناها خلال منهاجنا من تمييز هذه التغيرات حتى تُحمّل الصفحة من جديد. وتظهر المشكلة ذاتها عندما تُفعِّل الواجهة الأمامية عمليات حسابية مستهلكة للوقت ضمن الواجهة الخلفية. فكيف سنعكس نتائج العمليات على الواجهة الأمامية؟ إحدى الطرق المتبعة هي تنفيذ عملية انتخاب في الواجهة الأمامية، حيث تُكرر الطلبات إلى الواجهة البرمجية للواجهة الخلفية باستخدام الأمر setInterval مثلًا. يمكن العمل بطريقة أكثر تعقيدًا باستخدام مقابس الويب (WebSockets) التي تؤمن قناة اتصال باتجاهين بين المتصفح والخادم. لا يضطر المتصفح في هذه الحالة إلى انتخاب الواجهة الخلفية، بل عليه فقط تعريف دوال استدعاء للحالات التي يرسل فيها الخادم بيانات تتعلق بتحديث الحالة مستخدمًا مقبس الويب. ومقابس الويب هي واجهات برمجية يزودنا بها المتصفح، لكنها غير مدعومة بشكل كامل من قبل جميع المتصفحات: ينصح باستخدام المكتبة Socket.io بدلًا من الاستخدام المباشر لواجهة مقبس الويب البرمجية. تؤمن هذه المكتبة عدة خيارات للتراجع إن لم يدعم المتصفح المقابس بشكل كامل. سنتعرف في القسم 8 على GraphQL والتي ستزودنا بآلية جيدة لتنبيه الزبون بأية تغييرات في الواجهة الخلفية. DOM افتراضية يظهر موضوع DOM الافتراضية عندما نناقش تقنيات React. ماذا تعنيه تلك العبارة؟ كما ذكرنا في القسم 0، تزودنا المتصفحات بواجهة برمجية لنموذج DOM، والذي يمكِّن شيفرة JavaScript العاملة ضمن المتصفح من تغيير العناصر التي تحدد مظهر الصفحة. عندما يستخدم المطوّر React، فهو نادرًا ما يعدّل DOM مباشرة أو قد لا يعدلها أبدًا. فالدالة التي تعرّف كائن React يُعيد مجموعة من عناصر React. على الرغم من أن بعضها يبدو كعناصر HTML عادية. const element = <h1>Hello, world</h1> كما أنها أيضًا عناصر React مبنية على شيفرة JavaScript في صميمها. تُشكّل عناصر React التي تحدد مظهر المكونات في التطبيق DOM الافتراضية، والتي تُخزَّن في ذاكرة النظام أثناء تشغيل التطبيق. وتصيّر DOM الافتراضية بمساعدة المكتبة ReactDOM إلى DOM الحقيقية التي يمكن للمتصفح عرضها باستخدام "DOM API". ReactDOM.render( <App />, document.getElementById('root') ) عندما تتغير حالة التطبيق تُعرَّف DOM افتراضية جديدة بواسطة المكوّنات. تمتلك React النسخة السابقة منها في ذاكرة النظام، وبدلًا من تصيير النسخة الجديدة مباشرة باستخدام "DOM API"، تقدر React الطريقة المثلى لتحديث DOM (إزالة أو إضافة أو تعديل العناصر ضمن DOM). وهكذا ستعكس DOM النسخة الجديدة دائمًا. دور React في التطبيقات ربما لم نظهر بوضوح خلال تقدمنا في المنهاج أنّ React هي بشكل أساسي مكتبة لإدارة إنشاء واجهات عرض للتطبيقات. فلو نظرنا إلى النمط التقليدي للمتحكم بنموذج العرض(MVC-Model View Controller)، فسيكون نطاق استخدام React هو العرض(View). وللمكتبة React مجال تطبيق أضيق من Angular على سبيل المثال، والتي تمثل إطار عمل للتحكم بوحدة عرض الواجهات الأمامية. وهكذا لا تمثل React ما ندعوه "إطار عمل"، بل هي مكتبة. تخزّن البيانات التي يتعامل معها تطبيق React في حالة مكوناته، فيمكننا إذًا التفكير بحالة التطبيق على أنها نموذج من معمارية MVC. لا نشير عادة إلى معمارية MVC عند الحديث عن تطبيقات React. فلو استخدمنا Redux فسيتبع التطبيق معمارية Flux وسيركّز دور React هنا أكثر على إنشاء واجهات العرض. إذ ستعالج حالة Redux ومولدات الأفعال منطق التطبيق. أما عند استخدام redux thunk التي تعرفنا عليها في القسم 6، فسنجد أنّ منطق التطبيق سينفصل كليًا عن شيفرة React. وطالما أن React وFlux من إنتاج Facebook، فيمكننا القول أنّ استخدام React كمكتبة للتعامل مع واجهة المستخدم (UI) هي الطريقة التي ينبغي استخدامها، كما أن توافقها مع معمارية Flux سيضيف ميزة إلى التطبيق. لكن لو تحدثنا عن التطبيقات الصغيرة أو النماذج الأولية سيكون من المفيد استعمال React بطريقة "خاطئة" غير التي صممت لأجلها، ذلك أنّ تجاوز الحدود التقنية نادرًا ما يثمر. وكما أشرنا في نهاية الفصل الأخير من القسم 6، تقدم React الواجهة البرمجية والتي تمثل حلًا بديلًا لمركزية إدارة الحالة دون استخدام طرف ثالث مثل Redux. يمكنك الاطلاع على المقالة ?can't replace redux with hooks والمقالة ?how to usecontext with usereducer أمن تطبيقات React/Node لم نتطرق حتى الآن في مادة المنهاج إلى أمن المعلومات. وليس لدينا الوقت الكافي أيضًا. لكن لحسن الحظ قسم علوم الحاسب في جامعة هلسنكي يقدم المنهاج Securing Software لهذا الغرض. ينشر المشروع المفتوح لأمن تطبيقات الويب OWASP قائمة سنوية بأكثر الأخطار الأمنية شيوعًا في تطبيقات الويب. ستجد اللائحة الحالية على الموقع https://owasp.org/www-project-top-ten. ويمكن أن تتكرر الأخطار ذاتها سنويًا. ستتصدر القائمة أخطار الإدخالات المشبوهة (injection). ويعني ذلك تفسير النص الذي أرسل عبر استمارة تطبيق بشكل مختلف كليًا عما أراده المطوّر. وأكثر هذه الإدخالات شهرة هي إدخالات SQL لنفترض على سبيل المثال، أنّ استعلام SQL التالي سيُنفّذ ضمن تطبيق قابل للاختراق: let query = "SELECT * FROM Users WHERE name = '" + userName + "';" لنفترض الآن أنّ المستخدم المشبوه Arto Hellas سيعرّف اسمه بالشكل التالي: Arto Hell-as'; DROP TABLE Users; -- سيحوي الاسم علامة التنصيص المفردة ' والتي تمثل محرف بداية ونهاية السلسلة النصية في لغة SQL. وكنتيجة لتنفيذ عبارتي SQL السابقتين، ستدمر العبارة الثانية الجدول Users من قاعدة البيانات. SELECT * FROM Users WHERE name = 'Arto Hell-as'; DROP TABLE Users; --' يُمنع هذا النوع من الأخطار بتطهير النص المدخل، وذلك بالتحقق أنّ الاستعلام لا يحتوي على محارف ممنوعة الاستخدام، كعلامة التنصيص المفردة في حالتنا. فإن وجدت مثل هذه المحارف ستستبدل ببدائل آمنة لها باستخدام محارف الهروب. يمكن أن يحصل هذا النوع من الهجوم على قواعد بيانات لا تستخدم SQL. فقاعدة البيانات Mongoose تحبط هذا الهجوم بعملية تطهير الاستعلام. يمكنك الاطلاع على المزيد حول هذا الموضوع على الموقع blog.websecurify.com/2014/08/hacking-nodejs-and-mongodb.html. من الأخطار الأخرى هي السكربت العابرة للمواقع (Cross-sites scripting XSS). إذ يمكن لهذا الهجوم أن يدخل شيفرة JavaScript مشبوهة إلى تطبيق ويب شرعي. ستُنفَّذ عندها هذه الشيفرة في متصفح الضحية. فلو حاولنا إدخال الشيفرة التالية داخل تطبيق الملاحظات: <script> alert('Evil XSS attack') </script> لن يتم تنفيذ الشيفرة بل ستصيّر على شكل نص على الصفحة: ذلك أنّ React تُطهّر البيانات التي تحملها المتغيرات. لقد كانت بعض نسخ React عرضة لهجوم XSS. بالطبع تم سد هذه الثغرات، لكن لا أحد يضمن ما سيحدث. على المطور أن يبقى حذرًا عند استخدام المكتبات، وعليه أن يحدّث مكتباته بشكل مستمر إن ظهرت لها تحديثات أمنية جديدة. ستجد التحديثات المتعلقة بالمكتبة Express ضمن توثيق المكتبة، بينما ستجد تحديثات Node ضمن المدونة https://nodejs.org/en/blog. كما يمكنك التحقق من وضع اعتمادياتك باستخدام الأمر: npm outdated --depth 0 لقد احتوت الحلول النموذجية لتمرينات القسم 4 بعض الاعتماديات التي احتاجت إلى تحديث العام الماضي: يمكن تحديث الاعتمادية بتحديث الملف "package.json" وتنفيذ الأمر npm install. وانتبه إلى أنّ النسخ القديمة من الاعتماديات ليست بالضرورة خطرًا أمنيًا. يمكن أن تستخدم الأمر audit للتحقق من أمن الاعتماديات. حيث يقارن أرقام نسخة الاعتمادية في تطبيقك مع قائمة بأرقام النسخ التي ثَبُت احتواؤها على تهديد أمني ضمن قاعدة بيانات مركزية. إنّ تنفيذ الأمر npm audit على تمرين من تمرينات القسم الرابع من منهاج العام الفائت سيطبع لك قائمة طويلة بالمشاكل وطريقة حلها، والتقرير التالي هو جزء من التقرير الكلي الناتج: $ bloglist-backend npm audit === npm audit security report === # Run npm install --save-dev jest@25.1.0 to resolve 62 vulnerabilities SEMVER WARNING: Recommended action is a potentially breaking change ┌───────────────┬──────────────────────────────────────────────────────────────┐ │ Low │ Regular Expression Denial of Service │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Package │ braces │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Dependency of │ jest [dev] │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Path │ jest > jest-cli > jest-config > babel-jest > │ │ │ babel-plugin-istanbul > test-exclude > micromatch > braces │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ More info │ https://npmjs.com/advisories/786 │ └───────────────┴──────────────────────────────────────────────────────────────┘ ┌───────────────┬──────────────────────────────────────────────────────────────┐ │ Low │ Regular Expression Denial of Service │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Package │ braces │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Dependency of │ jest [dev] │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Path │ jest > jest-cli > jest-runner > jest-config > babel-jest > │ │ │ babel-plugin-istanbul > test-exclude > micromatch > braces │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ More info │ https://npmjs.com/advisories/786 │ └───────────────┴──────────────────────────────────────────────────────────────┘ ┌───────────────┬──────────────────────────────────────────────────────────────┐ │ Low │ Regular Expression Denial of Service │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Package │ braces │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Dependency of │ jest [dev] │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ Path │ jest > jest-cli > jest-runner > jest-runtime > jest-config > │ │ │ babel-jest > babel-plugin-istanbul > test-exclude > │ │ │ micromatch > braces │ ├───────────────┼──────────────────────────────────────────────────────────────┤ │ More info │ https://npmjs.com/advisories/786 │ └───────────────┴──────────────────────────────────────────────────────────────┘ ... found 416 vulnerabilities (65 low, 2 moderate, 348 high, 1 critical) in 20047 scanned packages run `npm audit fix` to fix 354 of them. 62 vulnerabilities require semver-major dependency updates. فخلال عام فقط، امتلأ التطبيق بالتهديدات الأمنية الصغيرة. ولحسن الحظ لم يكن هنالك سوى تهديد خطير واحد. لننفذ الأمر npm audit fix كما يوصي التقرير: $ bloglist-backend npm audit fix + mongoose@5.9.1 added 19 packages from 8 contributors, removed 8 packages and updated 15 packages in 7.325s fixed 354 of 416 vulnerabilities in 20047 scanned packages 1 package update for 62 vulns involved breaking changes (use `npm audit fix --force` to install breaking changes; or refer to `npm audit` for steps to fix these manually) بقيت التهديدات لأن الإصلاح الافتراضي باستخدام audit لا يحدّث الاعتماديات إن زاد الرقم الأساسي لنسخها عن 62. وتحديث هذه الاعتماديات قد يسبب انهيارًا كاملًا في التطبيق. نتجت بقية التهديدات عن اعتمادية التطوير Jest. فرقم نسخة التطبيق هو 23.6.0 بينما تحمل النسخة الآمنة الرقم 25.0.1. وطالما أنها اعتمادية تطوير فلن يكون التهديد موجودًا أصلًا. مع ذلك سنحدّث المكتبة لنبقى في دائرة الأمان: npm install --save-dev jest@25.1.0 سيبدو الوضع جيدًا بعد التحديث: $ blogs-backend npm audit === npm audit security report === found 0 vulnerabilities in 1204443 scanned packages من التهديدات الأخرى المذكورة في قائمة OWASP، سنجد إخفاق التحقق (Broken Authentication) وإخفاق التحكم بالوصول (Broken Access Control). إنّ التحقق المبني على الشهادات الذي استخدمناه قوي بما يكفي إن استخدم مع بروتوكول النقل المشفّر HTTPS. لذلك عندما نضيف ميزة التحكم بالوصول إلى التطبيق، ينبغي أن لا نكتفي بالتحقق من هوية المستخدم على المتصفح، بل على الخادم أيضًا. فمن الأخطاء الأمنية، منع بعض الأفعال من الحدوث بإخفاء خيارات التنفيذ في الشيفرة التي ينفذها المتصفح فقط. ستجد دليلًا جيدًا جدًا عن أمن مواقع الويب على موقع Mozilla's MDN، والذي سيثير الموضوع المهم التالي: يحتوي توثيق Express على فصل حول موضوع الأمن بعنوان Production Best Practices: Security من المفيد الاطلاع عليه. كما ننصحك بإضافة مكتبة تدعى Helmet إلى الواجهة الخلفية. حيث تحتوي هذه المكتبة على أدوات وسطية تزيل بعض الثغرات الأمنية في تطبيقات Express. كما تستحق الأداة security-plugin العائدة للمدقق ESlint التجربة. المسارات الحالية للتطور لنتحدث أخيرًا عن بعض التكنولوجيا المستقبلية (الحالية في الواقع)، والمسارات التي يسلكها تطور الويب. نسخ بمتغيرات نمطية من JavaScript إنّ التحقق الديناميكي من أنماط المتغيرات في JavaScript قد يسبب بعض التغيرات المزعجة. فلقد تحدثنا في القسم 5 بإيجاز عن الخصائص النمطية وهي آلية تمكن المطوّر من التحقق من نمط الخاصية التي ستُمرّر إلى مكوّن React. زاد الاهتمام مؤخرًا بالتحقق الساكن من الأنماط. وتعتبر نسخة المتغيرات النمطية من JavaScript التي قدمتها Microsoft باسم Typescript الأكثر شعبية، وسنتعرف عليها في القسم 9. التصيير من جهة الخادم والتطبيقات الإيزومورفية والشيفرة الموّحدة لا تصيّر مكونات React في نطاق المتصفحات فقط. إذ يمكن أن ينجز التصيير ضمن الخادم. يزداد استخدام هذه المقاربة بحيث يقدم الخادم صفحة مصيّرة مسبقًا باستخدام React عند الدخول إلى التطبيق للمرة الأولى. ومن ثم تُستأنف العمليات بالشكل المعتاد، أي سينفذ المتصفح منطق React الذي يغيّر في DOM التي سيعرضها المتصفح. تدعى عملية التصيير على الخادم بالاسم "التصيير ضمن الخادم". إنّ إحدى دوافع التصيير ضمن الخادم هو استمثال محركات البحث (SEO). فلطالما كانت محركات البحث سيئة في تمييز المحتوى الناتج عن تصيير شيفرة JavaScript. لكن على ما يبدو أنّ هذا الأمر في انحسار. يمكنك الاطلاع على ذلك بزيارة المقالين Will Google find your React content?‎ و SEO vs. React: Web Crawlers are Smarter Than You Think. وبالطبع لا يقتصر التصيير ضمن المخدم على شيفرة React أو JavaScript. فاستخدامنا لنفس لغة البرمجة ضمن منهاجنا من حيث المبدأ، هو لتبسيط تنفيذ المفاهيم، إذ أنّ اللغة نفسها ستعمل على الواجهتين الخلفية والأمامية. إلى جانب التصيير ضمن الخادم، تدور الأحاديث عن ما يسمى بالتطبيقات الإيزومورفية والشيفرة الموحدة، على الرغم من الجدل القائم حول تعريفهما. واستنادًا إلى بعض التعاريف فتطبيق الويب الإيزومورفي، هو التطبيق الذي يُصيّر ضمن كلتا الواجهتين الأمامية والخلفية. بينما تُعرّف الشيفرة الموحدة بأنها شيفرة قابلة للتنفيذ في معظم بيئات التشغيل، أي الواجهتين الأمامية والخلفية. تمنحنا React وNode خيارات مرغوبة في كتابة تطبيقات ايزومورفية بشيفرة موحدة. إنّ كتابة شيفرة موحدة باستخدام React عملية صعبة. لكن أثارت مؤخرًا المكتبة Next.js التي تًدرج أعلى تطبيق React، الكثير من الاهتمام، وتعتبر خيارًا جيدًا في كتابة تطبيقات موحّدة. تطبيقات الويب العصرية بدأ المطورون باستخدام مصطلح تطبيقات الويب العصرية (progressive web app - PWA) الذي أطلقه Google. ونتحدث باختصار عن تطبيقات الويب التي تعمل بأفضل شكل ممكن على كل المنصات مستفيدة من الميزات الأبرز لعناصر المنصة. ولا يجب أن يحد حجم شاشة الأجهزة النقالة من القدرة على استعمال هذه التطبيقات. كما يجب أن تعمل هذه التطبيقات بلا مشاكل دون اتصال أو مع الاتصالات البطيئة بالإنترنت. وينبغي أيضًا أن تُثبّت على الأجهزة النقالة كأي تطبيق آخر. وأخيرًا يجب أن تكون جميع البيانات المنقولة عبرها مشفّرة. تعتبر التطبيقات التي تُنشئها الأداة create-react-app عصرية افتراضيًا. لكن سيتطلب جعل التطبيق الذي يستخدم بيانات مصدرها الخادم عصريًا جهدًا. كما تنجز وظيفة العمل دون اتصال بالاستفادة من الواجهة البرمجية service workers. معمارية الخدمات الدقيقة لقد تعلمنا خلال منهاجنا بعض المواضيع السطحية فقط عن التعامل مع الخادم. فلقد اعتمدت تطبيقاتنا على واجهة خلفية متراصة، بمعنى أنها تتألف من تطبيق واحد يدير كل شيء، ويعمل على خادم واحد، ويؤمن واجهة برمجية تخدم عدة زبائن. لكن ستسبب الواجهة الخلفية المتراصة المشاكل عندما ينمو التطبيق على صعيدي الأداء والصيانة. تعتبر المعمارية الدقيقة (الخدمات الدقيقة-microservices) طريقة لكتابة الواجهة الخلفية لتطبيق على شكل عدة خدمات منفصلة ومستقلة تتواصل مع بعضها عبر شبكة الاتصال. وتكون الغاية من كل خدمة إدارة منطق وظيفة محددة بالكامل. ولا تستخدم الخدمات في معمارية الخدمات الدقيقة النقيّة أية قواعد بيانات مشتركة. فيمكن لتطبيق قائمة المدونات على سبيل المثال أن يتكون من خدمتين: تتعامل الأولى مع المستخدمين والأخرى مع المدونات. وستكون مسؤولية الوظيفة الأولى تسجيل دخول المستخدم والتحقق منه، بينما ستهتم الثانية بمنطق التعامل مع المدونات. يوضح الشكل التالي الاختلاف بين بنية تطبيق تعتمد على معمارية الخدمات الدقيقة وآخر يعتمد على بنية تقليدية متراصة: لا يختلف دور الواجهة الأمامية (محاطة بمربع في الشكل السابق) كثيرًا بين النموذجين. وقد يتواجد أحيانًا ما يسمى بوابة الواجهة البرمجية (API gateway) بين الخدمات الدقيقة والواجهة الأمامية، والتي تعطي انطباعًا بأنها بنية تقليدية، "حيث يوجد كل شيء على الخادم نفسه". تستخدم Netflix وغيرها مقاربة مماثلة لقد تطورت معمارية الخدمات المصغرة لتلبي احتياجات تطبيقات الإنترنت واسعة النطاق. ولقد اعتمدت Amazon هذه الفكرة قبل أن يظهر مصطلح الخدمات الدقيقة بمدة طويلة. وكانت نقطة البدء الحاسمة بريدًا إلكترونيًا أرسله "جيف بيزوس" المدير التنفيذي لشركة Amazon إلى جميع الموظفين عام 2020: تعتبر حاليًا Netflix من الشركات الرائدة في استخدام الخدمات الدقيقة. تأخذ هذه الخدمات وبشكل تدريجي ومستقر طابع الطلقة الرابحة، بمعنى أنها تُوصف كحل لكل المشاكل تقريبًا. لكن بالطبع هنالك العديد من المشاكل عند تطبيق معماريتها، ومن المنطقي أن نجرب البنية المتراصة أولًا ببناء واجهة خلفية تضم كل شيء كبداية. وربما لا. تضاربت الآراء حول الموضوع. ويستشهد كلا الطرفين بما ورد في موقع "مارتن فولر"،فحتى الخبراء لا يمكنهم الجزم أي الطريقتين هي الأصح. لسوء الحظ لا يمكننا الغوص عميقًا في هذه الفكرة المهمة، وحتى لو أردنا إلقاء نظرة سريعة عليها فقد تستغرق 5 أسابيع أخرى. الاستقلال عن الخوادم بدأت معالم مسار جديد في تطوير تطبيقات الويب بالتكوّن، بعد إصدار الخدمة السحابية lambda من قبل Amazon عام 2014. إنّ المعلم الرئيسي في lambda وفي الوظائف السحابية لشركة Google، وكذلك الوظائف السحابية على Azure أنها قادرة على تنفيذ وظائف فردية في السحابة. وقد كانت أصغر وحدة قابلة للتنفيذ على السحابة هي عملية مفردة، أي بيئة تشغيل تعتمد على Node كواجهة خلفية. يمكن على سبيل المثال تصميم تطبيقات مستقلة عن الخادم (serverless) باستخدام بوابة الواجهة البرمجية التي تقدمها Amazon. حيث تستجيب الوظائف السحابية على الطلب المرسل إلى الواجهة البرمجية التي تدير طلبات HTTP. حيث تعمل الوظائف عادة باستخدام البيانات المخزّنة ضمن قاعدة بيانات الخدمة السحابية. لا يعني الاستقلال عن الخوادم عدم وجود خادم في التطبيقات، لكن كيفية تعريف الخادم. فيمكن لمطوري البرمجيات نقل جهودهم البرمجية إلى مستوى أعلى من التجريد، عندما لا تكون هنالك حاجة لتحديد مسارات طلبات HTTP برمجيًا، أو تحديد العلاقات بين قواعد البيانات وغير ذلك، طالما أن البنية التحتية للسحابة ستؤمن كل ذلك. تستطيع الوظائف السحابية إنشاء ودعم الأنظمة واسعة النطاق. فيمكن للخدمة السحابية Lambda تنفيذ كمية هائلة من الوظائف السحابية في الثانية. ويحدث كل ذلك تلقائيًا ضمن البنية التحتية، ولا حاجة لتهيئة أية خوادم إضافية. مكتبات مفيدة وروابط مهمة أنتج مجتمع تطوير JavaScript عددًا ضخمًا من المكتبات المتنوعة المفيدة. فإن كنت بصدد تطوير أي شيء أكثر أهمية، تحقق من وجود حلول جاهزة متاحة، ويمكنك أن تجد الكثير من المكتبات في الموقع https://applibslist.xyz. وستجد في آخر الفقرة بعض المكتبات التي ينصح بها شركاء موثوقين. إن رأيت أن تطبيقك سيعالج بيانات معقدة، فالمكتبة lodash التي نصحنا بها في القسم 4، من المكتبات الجيدة. وإن كنت تفضل البرمجة باستخدام الدوال يمكنك أن تحاول مع المكتبة ramda. إن كنت تتعامل مع الوقت والتاريخ فالمكتبة date-fns أداة جيدة. تساعدك المكتبتان Formik وredux-form في معالجة النماذج بطريقة أسهل. وإن كنت ستستخدم الرسوميات في تطبيقك فهناك خيارات عدة. وننصحك باستخدام recharts وhighcharts. تؤمن المكتبة immutable.js التي طورتها Facebook، وكما يوحي اسمها، إدراج بعض بنى البيانات الثابتة. إذ يمكن استخدام المكتبة عند استخدام Redux، لأنه وكما أشرنا في القسم 6، أن دوال الاختزال يجب أن تكون دوال نقية، بمعنى أنها لن تعدِّل حالة مخزن Redux، بل ستستبداله بآخر جديد عندما تحدث أية تغييرات. لقد أثر ظهور المكتبة Immer على شعبية immutable.js في السنة الماضية. فهي تؤمن نفس الوظائف لكن ضمن حزمة أسهل استخدامًا نوعًا ما. تؤمن المكتبة Redux-saga طرقًا بديلة لإنشاء أفعال غير متزامنة للمكتبة redux thunk التي تعرفنا عليها في القسم 6. إن جمع البيانات التحليلية للتفاعل بين الصفحة في تطبيقات الصفحة الواحدة وبين المستخدمين سيحمل قدرًا من التحدي. ستقدم لك المكتبة React Google Analytics حلًا لتطبيقات الويب التي تُحمِّل الصفحة بأكملها. يمكنك أن تستفيد من خبرتك في React عند تطوير تطبيقات الهواتف النقالة باستخدام مكتبة Facebook المشهورة جدًا React Native. تتقلب الأحوال كثيرًا في مجتمع تطوير JavaScript فيما يتعلق بإدارة تجميع وحزم الملفات المشاريع. وتتغير معايير الممارسة الأفضل بشكل سريع: 2011 Bower 2012 Grunt 2013-14 Gulp 2012-14 Browserify 2015- Webpack فقد البرنامج Hipsters لأهميته بعد أن سيطر Webpack على السوق. ثم بدأ البرنامج Parcel قبل عدة سنوات بكسب عدة جولات لصالحه في السوق، كأداة أبسط وأسرع من Webpack (فالبرنامج Webpack ليس بسيطًا على الإطلاق). لكن بعد انطلاقته الواعدة، لم يستطع البرنامج المنافسة أكثر، ويبدو أن Webpack سيبقى في رأس القائمة. يزودك الموقع https://reactpatterns.com بقائمة تضم الممارسات الأفضل عند تطوير التطبيقات باستخدام React. وقد تعرفنا بالفعل على بعضها ضمن مادة المنهاج. وهنالك قائمة مشابهة لها هي react bits. سيساعدك Reactiflux وهو مجتمع محادثات لمطوري React على Discord، في الحصول على الدعم بعد إكمالك للمنهاج. ستجد على سبيل المثال قنوات مستقلة للتحدث عن عدد هائل من المكتبات. ترجمة -وبتصرف- للفصل Class components, Miscellaneous من سلسلة Deep Dive Into Modern Web Development
  19. سنوجّه اهتمامنا في هذا القسم إلى التعامل مع الواجهة الخلفية وآلية تطوير التطبيق بإضافة وظائف جديدة تنفذ هذه المرة من قبل الخادم. سنبني تلك الوظائف باستخدام 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
  20. الآن وقد انتهينا من إعداد بيئة التطوير، سنتعرف على أساسيات React Native وسنبدأ بتطوير تطبيقنا. سنتعلم في هذا الفصل كيف سنبني واجهة مستخدم بالاستعانة بمكوّنات React Native البنيوية، وكيف سننسق مظهر هذه المكونات البنيوية، وكيف سننتقل من واجهة عرض إلى أخرى، كما سنتعلم كيف سندير حالة النماذج بفعالية. المكونات البنيوية تعلمنا في الأقسام السابقة طريقة استخدام React في تعريف المكوّنات كدوال تتلقى الخصائص كوسطاء وتعيد شجرة من عناصر React. تُمثَّل هذه الشجرة عادةً باستخدام شيفرة JSX. كما رأينا كيفية استخدام المكتبة ReactDOM في بيئة المتصفح من أجل تحويل المكوّنات إلى شجرة DOM قابلة للتصيير ضمن المتصفح. لاحظ الشيفرة التالية التي تمثل مكوّنًا بسيطًا: import React from 'react'; const HelloWorld = props => { return <div>Hello world!</div>; }; يعيد المكوّن HelloWorld عنصر <div> أُنشئ باستخدام عبارة JSX. وتذكر أن عبارة JSX ستُصرّف إلى استدعاءات للتابع React.createElement كالتالي: React.createElement('div', null, 'Hello world!'); سيُنشئ سطر الشيفرة السابق عنصر <div> دون أية خصائص props وله عنصر ابن وحيد هو النص "Hello World". وعندما يُصيَّر هذا المكوّن إلى عنصر DOM جذري بتطبيق التابع ReactDOM.render، سيُصيَّر العنصر <div> إلى عنصر DOM المقابل. وهكذا نرى أن React لا ترتبط ببيئة محددة كبيئة المتصفح، لكن وبالاستعانة بمكتبات مثل ReactDOM ستتمكن من تصيير مجموعة من المكوّنات المعرّفة مسبقًا كعناصر DOM في بيئة محددة. ندعو هذه المكوّنات المعرّفة مسبقًا في React Native بالمكوّنات البنيوية core components. فالمكوّنات البنيوية هي مجموعة مكوّنات تؤمنها React Native قادرة على استخدام المكونات الأصلية لمنصة خلف الستار. لننفذ المثال السابق باستخدام React Native: import React from 'react'; import { Text } from 'react-native'; const HelloWorld = props => { return <Text>Hello world!</Text>;}; لاحظ كيف أدرجنا المكوّن Text من React Native، واستبدلنا العنصر<div> بالعنصر <text>. ستجد الكثير من عناصر DOM المقابلة لعناصر React Native، وسنستعرض بعض الأمثلة التي استقيناها من توثيق المكونات البنيوية: المكوّن Text: ويعتبر المكون الوحيد من مكونات React Native والذي يمتلك أبناء على شكل قيم نصية، ويشابه العناصر <h1> و<strong>. المكوّن View: ويمثل وحدات البناء الرئيسية لواجهة المستخدم، ويشابه العنصر <div>. المكوّن TextInput: وهو مكوّن لحقل نصي، ويشابه العنصر <input>. المكوّن TouchableWithoutFeedback: وغيره من المكونات القابلة للمس Touchable ويستخدم لالتقاط مختلف حوادث الضغط والنقر، ويشابه العنصر <button>. هنالك بعض الاختلافات الواضحة بين المكوّنات البنيوية وعناصر DOM. يظهر الاختلاف الأول بأن المكوّن Text هو المكوّن الوحيد الذي يمتلك أبناء على شكل قيم نصية في React Native. وبمعنًى آخر، لن تستطيع استبدال هذا المكوّن بالمكوّن View مثلًا في المثال الذي أوردناه سابقًا. يتعلق الاختلاف الثاني بمعالجات الأحداث. فقد اعتدنا عند العمل مع عناصر DOM على إضافة معالج أحداث مثل onClick إلى أية عناصر مثل <div> و<btton>. لكن عليك قراءة توثيق الواجهة البرمجية في React Native لمعرفة أية معالجات وأية خصائص أخرى سيقبلها المكوّن الذي تستخدمه. إذ تؤمن -على سبيل المثال- عائلة مكوّنات اللمس Touchable components القدرة على التقاط الحركات التي تُرسم على الشاشة، كما يمكنها إظهار استجابات محددة عند تمييز هذه الحركات. ومن هذه المكوّنات أيضًا TouchableWithoutFeedback الذي يقبل الخاصية onPress: import React from 'react'; import { Text, TouchableWithoutFeedback, Alert } from 'react-native'; const TouchableText = props => { return ( <TouchableWithoutFeedback onPress={() => Alert.alert('You pressed the text!')} > <Text>You can press me</Text> </TouchableWithoutFeedback> ); }; سنبدأ الآن في هيكلة مشروعنا بعد أن اطلعنا على أساسيات المكونات البنيوية. لننشئ مجلدًا باسم "src" في المجلد الجذري للمشروع، ولننشئ ضمنه مجلدًا آخر باسم "components". أنشئ ملفًا باسم "Main.js" وضعه في المجلد "components"، على أن يحتوي هذا الملف الشيفرة التالية: import React from 'react'; import Constants from 'expo-constants'; import { Text, StyleSheet, View } from 'react-native'; const styles = StyleSheet.create({ container: { marginTop: Constants.statusBarHeight, flexGrow: 1, flexShrink: 1, }, }); const Main = () => { return ( <View style={styles.container}> <Text>Rate Repository Application</Text> </View> ); }; export default Main; لنستخدم الآن المكوّن Main ضمن المكوّن App الموجود في الملف "App.js" والذي يقع بدوره في المجلد الجذري للمشروع. استبدل محتويات الملف السابق بالشيفرة التالية: import React from 'react'; import Main from './src/components/Main'; const App = () => { return <Main />; }; export default App; إعادة تحميل التطبيق يدويا رأينا كيف يعيد Expo تحميل التطبيق تلقائيًا عندما نجري تغييرات على الشيفرة. لكن قد تفشل عملية إعادة التحميل تلقائيًا في حالات معينة، وسنضطر إلى إعادة تحميل التطبيق يدويًا. تُنجز هذه العملية من خلال قائمة المطوّر التي يمكن الوصول إليها بهزّ جهازك أو باختيار الرابط "Shake Gesture" ضمن قائمة العتاد الصلب في محاكي iOS. كما يمكن أن تستخدم أيضًا الاختصار "D⌘" عندما يعمل تطبيقك على محاكي iOS، أو الاختصار "M⌘" عندما يعمل التطبيق على مقلّد Android في نظام macOS، والاختصار "Ctrl+M" لمقلد Android في نظامي Linux وWindows. اضغط على "Reload" عندما تفتح قائمة المطوّر لإعادة تحميل التطبيق. لن تحتاج بعد ذلك إلى إعادة تحميل التطبيق، بل ستحدث العملية تلقائيًا. التمرين 10.3 قائمة المستودعات المقيمة سننجز في هذه التمارين النسخة الأولى من تطبيق قائمة المستودعات المُقيَّمة. ينبغي أن تتضمن القائمة الاسم الكامل للمستودع ووصفه ولغته وعدد التشعبات وعدد النجمات ومعدل التقييم وعدد التقييمات. ولحسن الحظ، تؤمن مكوّنًا مفيدًا لعرض قائمة من البيانات وهو FlatList. انجز المكوّنين RepositoryList وRepositoryItem في الملفين "RepositoryList.jsx" و"RepositoryItem.jsx" وضعهما في المجلد "componenets". ينبغي أن يصيّر المكوّن RepositoryList المكوّن البنيوي FlatList وأن يصيّر المكوّن RepositoryItem عنصرًا واحدًا من القائمة استخدم ذلك كأساس للملف "RepositoryItem.jsx": import React from 'react'; import { FlatList, View, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ separator: { height: 10, }, }); const repositories = [ { id: 'jaredpalmer.formik', fullName: 'jaredpalmer/formik', description: 'Build forms in React, without the tears', language: 'TypeScript', forksCount: 1589, stargazersCount: 21553, ratingAverage: 88, reviewCount: 4, ownerAvatarUrl: 'https://avatars2.githubusercontent.com/u/4060187?v=4', }, { id: 'rails.rails', fullName: 'rails/rails', description: 'Ruby on Rails', language: 'Ruby', forksCount: 18349, stargazersCount: 45377, ratingAverage: 100, reviewCount: 2, ownerAvatarUrl: 'https://avatars1.githubusercontent.com/u/4223?v=4', }, { id: 'django.django', fullName: 'django/django', description: 'The Web framework for perfectionists with deadlines.', language: 'Python', forksCount: 21015, stargazersCount: 48496, ratingAverage: 73, reviewCount: 5, ownerAvatarUrl: 'https://avatars2.githubusercontent.com/u/27804?v=4', }, { id: 'reduxjs.redux', fullName: 'reduxjs/redux', description: 'Predictable state container for JavaScript apps', language: 'TypeScript', forksCount: 13902, stargazersCount: 52869, ratingAverage: 0, reviewCount: 0, ownerAvatarUrl: 'https://avatars3.githubusercontent.com/u/13142323?v=4', }, ]; const ItemSeparator = () => <View style={styles.separator} />; const RepositoryList = () => { return ( <FlatList data={repositories} ItemSeparatorComponent={ItemSeparator} // other props /> ); }; export default RepositoryList; لا تبدّل محتويات المتغيّر repositories، فمن المفترض أن يحتوي على كل ما تحتاج إليه لإكمال التمرين. صيّر المكوّن ReositoryList ضمن المكوّن Main الذي أضفناه سابقّا إلى الملف "Main.jsx". ستبدو قائمة الكتب المُقَّيمة مشابهةً للتالي: تنسيق التطبيق بعد أن تعرفنا على آلية عمل المكوّنات، وكيفية استخدامها في بناء واجهة مستخدم بسيطة، لا بد من الالتفات إلى طرق تنسيق مظهر هذه التطبيقات. لقد رأينا في القسم 2 أنه من الممكن في بيئة المتصفح تعريف خصائص لتنسيق المكوّنات باستخدام CSS. كما رأينا أن هناك طريقتان لإنجاز ذلك: إما التنسيق ضمن السياق inline باستخدام الخاصية style، أو عن طريق محددات التنسيق المناسبة selectors المكتوبة في ملف CSS منفصل. ستجد تشابهًا واضحًا في الطريقة التي ترتبط بها خصائص التنسيق بمكونات React Native البنيوية والطريقة التي ترتبط بها هذه الخصائص بعناصر DOM. فمعظم مكوّنات React Native البنيوية تقبل الخاصية style، وتقبل هذه الخاصية بدورها كائنًا يحمل خصائص تنسيقٍ مع قيمها. وتتطابق في أغلب الأحيان خصائص التنسيق هذه مع مقابلاتها في CSS، مع اختلاف بسيط وهو كتابة أسماء هذه الصفات بطريقة سنام الجمل camelCase. أي ستكتب خصائص مثل "padding-top" على الشكل "paddingTop". وسيوضح المثال التالي طريقة استخدام الخاصية style: import React from 'react'; import { Text, View } from 'react-native'; const BigBlueText = () => { return ( <View style={{ padding: 20 }}> <Text style={{ color: 'blue', fontSize: 24, fontWeight: '700' }}> Big blue text </Text> </View> ); }; بالإضافة إلى أسماء الخصائص، ستلاحظ اختلافًا آخر في المثال السابق. إذ ترتبط بخصائص CSS التي تقبل قيمًا عددية واحدات مثل px أو % أو em أو rem. بينما لا تمتلك الخصائص التي تتعلق بالأبعاد مثل width وheight وpadding وكذلك font-size في React Native أية واحدات. تمثل هذه القيم العددية بيكسلات مستقلة عن الكثافة density-independent pixels. ولو تساءلت عن خصائص التنسيق المتاحة لمكوّن بنيوي محدد، ستجد الجواب في أوراق التنسيق الزائفة Styling Cheat Sheet لإطار عمل React Native. وبشكل عام، لا يعتبر تنسيق المكوًنات باستخدام الخاصية style مباشرة فكرة جيدة، لأنها ستجعل شيفرة المكونات غير واضحة. ويفضّل بدلًا عن ذلك، تعريف التنسيق خارج دالة المكوّن باستخدام التابع StyleSheet.create. يقبل هذا التابع وسيطًا واحدًا على شكل كائن يتألف بحد ذاته من كائنات تنسيق مسماة ومحددة القيم، كما ينشئ مرجعًا للمكوّن البنيوي StyleSheet من هذا الكائن. سنستعرض فيما يلي طريقة إعادة كتابة المثال السابق باستخدام التابع StyleSheet.create: import React from 'react'; import { Text, View, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ container: { padding: 20, }, text: { color: 'blue', fontSize: 24, fontWeight: '700', },}); const BigBlueText = () => { return ( <View style={styles.container}> <Text style={styles.text}> Big blue text <Text> </View> ); }; أنشأنا في الشيفرة السابقة كائني تنسيق مسمّيين هما: styles.container وstyles.text. يمكننا الوصول ضمن مكوّن إلى كائن تنسيق محدد بنفس الطريقة التي نصل بها إلى كائن صرف. تقبل الخاصية style مصفوفة من الكائنات بالإضافة إلى الكائنات المفردة. وتتالى كائنات التنسيق في المصفوفة من اليسار إلى اليمين، وبالتالي سيكون للتنسيقات التي تظهر لاحقًا أولوية التنفيذ. كما يمكن تطبيق ذلك بشكل عودي recursive، إذ يمكن على سبيل المثال، أن تحتوي مصفوفة على مصفوفة أخرى لكائنات التنسيق وهكذا. إن كانت إحدى قيم المصفوفة غير صالحة أو كانت "null" أو "undefined"، سيتم تجاهل هذه القيم. وهكذا يصبح من السهل تعريف تنسيقات شرطية كتلك التي تُبنى على أساس قيمة خاصية مثلًا، وإليكم مثالًا عن ذلك: import React from 'react'; import { Text, View, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ text: { color: 'grey', fontSize: 14, }, blueText: { color: 'blue', }, bigText: { fontSize: 24, fontWeight: '700', }, }); const FancyText = ({ isBlue, isBig, children }) => { const textStyles = [ styles.text, isBlue && styles.blueText, isBig && styles.bigText, ]; return <Text style={textStyles}>{children}</Text>; }; const Main = () => { return ( <> <FancyText>Simple text</FancyText> <FancyText isBlue>Blue text</FancyText> <FancyText isBig>Big text</FancyText> <FancyText isBig isBlue> Big blue text </FancyText> </> ); }; استخدمنا في الشيفرة السابقة العامل && في العبارة condition && exprIfTrue. سيعطي تنفيذ هذه العبارة قيمة "experIfTrue" إن كانت قيمة "condition" هي "true"، وإلا سيعطي القيمة "condition" والتي ستأخذ في هذه الحالة القيمة "false". هذه الطريقة المختصرة شائعة جدًا ومفيدة جدًا. كما يمكنك استخدام العامل الشرطي الثلاثي ":?" كالتالي: condition ? exprIfTrue : exprIfFalse سمات بواجهة مستخدم متناسقة لنبقى في مجال تنسيق التطبيقات لكن من منظورٍ أوسع قليلًا. لقد استخدم معظمنا تطبيقات متنوعة، وربما سنتفق أن ما بجعل واجهة المستخدم جيدة هو تناسق العناصر. فمظهر مكوّنات واجهة المستخدم كحجم الخط وعائلته ولونه ستتبع أسلوبًا متناسقًا. ولإنجاز ذلك لابد من ربط قيم خصائص التنسيق المختلفة بمعاملات، وهذا ما يعرف ببناء السمات theming. ربما يكون مصطلح "بناء السمات" مألوفًا لمستخدمي مكتبات بناء واجهات المستخدم المشهورة مثل Bootstrap وMaterial UI. وعلى الرغم من اختلاف طرق بناء السمات، إلا أنّ الفكرة الرئيسية ستبقى دائمًا استخدام المتغيرات مثل colors.primary بدلًا من الأرقام السحرية مثل 0366dd# عند تعريف التنسيق، وهذا ما سيقود إلى زيادة في الاتساق والمرونة. دعونا نلقي نظرة على طريقة التنفيذ العملي لبناء السمات في تطبيقنا. سنستخدم في عملنا العديد من النصوص وبتنسيقات مختلفة مثل حجم الخط ولونه. وطالما أنّ React Native لا تدعم التنسيق العام، لا بد من إنشاء مكوّنات Text خاصة بنا لنبقي المحتوى النصي متسقًا. لنبدأ إذًا بإضافة شيفرة كائن "تهيئة التنسيق" التالية إلى الملف "theme.js" الموجود في المجلد "src": const theme = { colors: { textPrimary: '#24292e', textSecondary: '#586069', primary: '#0366d6', }, fontSizes: { body: 14, subheading: 16, }, fonts: { main: 'System', }, fontWeights: { normal: '400', bold: '700', }, }; export default theme; علينا الآن إنشاء الكائن Text الفعلي الذي يستخدم قواعد تهيئة التنسيق. لننشئ إذًا الملف "Text.jsx" في المجلد "components" الذي يحتوي ملفات بقية المكوّنات، ولنضع فيه الشيفرة التالية: import React from 'react'; import { Text as NativeText, StyleSheet } from 'react-native'; import theme from '../theme'; const styles = StyleSheet.create({ text: { color: theme.colors.textPrimary, fontSize: theme.fontSizes.body, fontFamily: theme.fonts.main, fontWeight: theme.fontWeights.normal, }, colorTextSecondary: { color: theme.colors.textSecondary, }, colorPrimary: { color: theme.colors.primary, }, fontSizeSubheading: { fontSize: theme.fontSizes.subheading, }, fontWeightBold: { fontWeight: theme.fontWeights.bold, }, }); const Text = ({ color, fontSize, fontWeight, style, ...props }) => { const textStyle = [ styles.text, color === 'textSecondary' && styles.colorTextSecondary, color === 'primary' && styles.colorPrimary, fontSize === 'subheading' && styles.fontSizeSubheading, fontWeight === 'bold' && styles.fontWeightBold, style, ]; return <NativeText style={textStyle} {...props} />; }; export default Text; وهكذا نكون قد أنجزنا مكوّن النص الخاص بنا من حيث اللون وحجم الخط وكثافته لنستخدمه في أي مكان من تطبيقنا. يمكننا الحصول على تغييرات مختلفة في هذا المكوّن باستخدام خصائص مختلفة كالتالي: import React from 'react'; import Text from './Text'; const Main = () => { return ( <> <Text>Simple text</Text> <Text style={{ paddingBottom: 10 }}>Text with custom style</Text> <Text fontWeight="bold" fontSize="subheading"> Bold subheading </Text> <Text color="textSecondary">Text with secondary color</Text> </> ); }; export default Main; لك كامل الحرية في توسيع أو تعديل هذا المكوّن إن أردت. وقد يكون إنشاء مكوّنات نصية قابلة لإعادة الاستخدام تستخدم المكوّن Text مثل Subheading فكرة جيدة. حاول أيضًا أن توسع أو تعدل قواعد تهيئة السمة كلما تقدمنا في بناء تطبيقنا. استخدام flexbox في تصميم واجهة التطبيق سنعرّج أخيرًا في عرضنا لمواضيع تنسيق التطبيقات على تصميم الواجهات باستخدام flexbox. يعرف المطورون الذين ألفوا اللغة CSS أنّ flexbox لا يتعلق فقط بإطار العمل React Native، فقد يستعمل في حالات عدة لتطوير صفحات الويب أيضًا. وعمليًا لن يتعلم المطورون الذين يعرفون آلية عمل flexbox الكثير من هذه الفقرة، لكن دعونا على الأقل نراجع أو نتعلم أساسيات flexbox. flexbox هو كيان لتصميم الواجهات يتألف من مكونين منفصلين هما: حاوية flex وضمنها مجموعة من عناصر flex. تمتلك حاوية flex مجموعة من الخصائص التي تتحكم بتوزيع العناصر. ولجعل مكوّن ما حاوية flex، لا بد من أن يمتلك خاصية التنسيق display وقد أسندت لها القيمة "flex" وهي القيمة الافتراضية لهذه الخاصية. تظهر الشيفرة التالية طريقة تعريف حاوية flex: import React from 'react'; import { View, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ flexContainer: { flexDirection: 'row', }, }); const FlexboxExample = () => { return <View style={styles.flexContainer}>{/* ... */}</View>; }; ربما تكون الخصائص التالية أهم خصائص حاوية flex: الخاصية flexDirection: وتتحكم بالاتجاه الذي ستأخذه عناصر flex عند ترتيبها ضمن الحاوية. وتأخذ هذه الخاصية القيم row وrow-reverse وcoulmn وcolumn-reverse. سيرتب row عناصر flex من اليسار إلى اليمين، بينما سيرتبها column من الأعلى للأسفل (القيمة الافتراضية). وستعكس القيم ذات اللاحقة reverse- اتجاه ترتيب العناصر. الخاصية justifyContent: وتتحكم بمحاذاة عناصر flex على طول المحور الرئيسي للترتيب الذي تحدده الخاصية السابقة. تأخذ هذه الخاصية القيم flex-start (القيمة الافتراضية) وflex-end وcenter وspace-between وspace-around وspace-evenly. الخاصية alignItems: وتقوم بوظيفة الخاصية السابقة لكن على المحور الآخر لترتيب العناصر. تأخذ هذه الخاصية القيم flex-start وflex-end وcenter وbaseline و stretch (القيمة الافتراضية). لننتقل الآن إلى عناصر flex والتي أشرنا سابقًا على أنها محتواة ضمن حاوية flex. تمتلك هذه العناصر خصائص تتحكم بتنسيق وسلوك هذه العناصر بالنسبة لبعضها في نفس الحاوية. ولكي يكون المكوّن عنصر flex يكفي أن نجعله ابنا مباشرًا لحاوية flex: import React from 'react'; import { View, Text, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ flexContainer: { display: 'flex', }, flexItemA: { flexGrow: 0, backgroundColor: 'green', }, flexItemB: { flexGrow: 1, backgroundColor: 'blue', }, }); const FlexboxExample = () => { return ( <View style={styles.flexContainer}> <View style={styles.flexItemA}> <Text>Flex item A</Text> </View> <View style={styles.flexItemB}> <Text>Flex item B</Text> </View> </View> ); }; إنّ أكثر خصائص العناصر استخدامًا هي flexGrow. حيث تقبل هذه الخاصية قيمة تحدد قدرة العنصر على النمو إن دعت الحاجة. فلو كانت قيمة هذه الخاصية لكل عناصر flex في حاوية هي 1، ستتقاسم هذه العناصر المساحة المتاحة للحاوية بالتساوي. بينما لو كانت قيمتها 0 لعنصر ما، فسيأخذ المساحة التي يتطلبها محتواه فقط، وسيترك المساحة الباقية لبقية العناصر. ننصحك بدراسة المثال النموذجي Flexbox example، والذي يشكّل مثالًا رئيسًا أكثر تفاعلية لاستخدام flexbox في تنفيذ بطاقة بسيطة لها حاشية علوية وجسم وحاشية سفلية. إقرأ تاليًا المقالة تعرف على CSS Flexbox وأساسيات استعماله لهيكلة صفحات الويب التي تحتوي على أمثلة مصوّرة شاملة عن استخدام flex. ومن الجيد أيضًا أن تجرب خصائص flexbox ضمن أرضية العمل Flexbox Playground لترى كيفية تأثير الخصائص على تصميم الواجهة. وتذكر أنّ أسماء الخصائص في React Native هي نفسها في CSS ما عدا نقطة التسمية بأسلوب سنام الجمل، إلا أن قيم هذه الخاصيات مثل flex-start وflex-end ستبقى نفسها. التمارين 10.4 - 10.5 10.4 شريط تنقل للتطبيق سنحتاج قريبًا إلى التنقل بين الواجهات المختلفة للتطبيق، لذلك سنحتاج إلى شريط تنقل بين هذه الواجهات. أنشئ الملف "AppBar.jsx" وضعه في المجلد "components" ثم ضع الشيفرة التالية ضمنه: import React from 'react'; import { View, StyleSheet } from 'react-native'; import Constants from 'expo-constants'; const styles = StyleSheet.create({ container: { paddingTop: Constants.statusBarHeight, // ... }, // ... }); const AppBar = () => { return <View style={styles.container}>{/* ... */}</View>; }; export default AppBar; وبما أنّ المكوّن AppBar سيمنع شريط الحالة من تغطية المحتوى، يمكن إزالة خاصية التنسيق marginTop التي وضعناها للمكوّنMain في الملف "Main.jsx". ينبغي أن يضم المكوّن AppBar نافذة عنوانها "Repositories". اجعل النافذة مستجيبة للمس touchable باستخدام المكوّن TouchableWithoutFeedback، لكن لا حاجة الآن لتعالج الحدث onPress. أضف المكوّن AppBar إلى المكوّن Main ، بحيث يبقى المكوّن الأعلى في الواجهة. من المفترض أن يبدو المكوّن AppBar شبيهًا للتالي: إن لون خلفية التطبيق كما تظهره الصورة السابقة هو 24292e#، لكن يمكنك استخدام اللون الذي تشاء. يمكنك أيضًا تهيئة لون خلفية التطبيق ضمن قواعد تهيئة السمة، وسيصبح تغيير اللون أمرًا سهلًا إن احتجت لذلك. ومن الجيد أيضًا فصل شيفرة النافذة الموجودة في المكوّن AppBar لتصبح ضمن مكوّن مستقل يمكن أن تسميه AppBarTab، سيسهّل ذلك إضافة نوافذ أخرى مستقبلًا. 10.5 قائمة محسنة المظهر للمستودعات المقيمة تبدو النسخة الحالية من تطبيق قائمة المستودعات المقيَّمة كئيبة المظهر. عدّل المكوّن RepositoryListItem ليعرض أيضًا الصورة التمثيلية لمؤلف المستودع. ويمكنك إنجاز ذلك باستخدام المكوّن Image. ينبغي إظهار القيم العددية كعدد النجمات وعدد التشعبات التي تزيد عن 1000 برقم مرتبة الآلاف وبدقة فاصلة عشرية واحدة فقط تليها اللاحقة "k". فلو كان مثلًا عدد التشعبات هو 8439، ستُعرض على الشاشة القيمة "8.4k". حسِّن أيضًا المظهر الكلي للمكوّن بحيث تبدو قائمة المستودعات مشابهة للتالي: إنّ لون الخلفية للمكوّن Main في الصورة السابقة هو e1e4e8# بينما ستظهر خلفية للمكوّن RepositoryListItem باللون الأبيض. كما يأخذ لون خلفية محدد اللغة القيمة 0366d6 #، وهي نفسها قيمة المتغيّر colors.primary في إعدادات تهيئة السمة. لا تنس الاستفادة من المكوّن Text الذي أنجزناه سابقًا. افصل المكوّن RepositoryListItem -إن دعت الحاجة- إلى مكوّنات أصغر. مسارات التنقل في React Native سرعان ما سنحتاج إلى آلية للانتقال بين مختلف واجهات العرض في التطبيق كواجهة المستودعات وواجهة تسجيل الدخول، وذلك عندما نبدأ بتوسيعه. وكنا قد تعلمنا في القسم 7 استخدام المكتبة React router لتنفيذ مهمة التنقل والتوجه في تطبيقات الويب. تختلف آلية التنقل في React Native قليلًا عن التنقل في تطبيقات الويب. ويظهر الاختلاف الرئيسي في عدم القدرة على الإشارة المرجعية للصفحات من خلال العناوين "URL" التي نكتبها في شريط تنقل المتصفح، ولا يمكننا كذلك التنقل بين الصفحات التالية والسابقة اعتمادًا على الواجهة البرمجية لتاريخ تنقلات المستخدم history API في المتصفح. لكن تبقى هذه الإشكالية مسألة تتعلق بواجهة التنقل التي نستخدمها. يمكننا في ReactNative استخدام متحكمات المسار البنيوية React Router's core بالكامل كالخطافات والمكوّنات. لكن علينا استبدال BrowserRouter الذي يستخدم في بيئة المتصفح بمقابله NativeRouter الملائم لإطار عمل React Native والذي تؤمنه المكتبة react-router-native. لنثبّت إذًا هذه المكتبة: npm install react-router-native سيؤدي استخدام المكتبة react-router-native إلى توقف عرض التطبيق ضمن متصفح ويب المنصة Expo، بينما ستعمل بقية طرق عرض التطبيق بشكل جيد. يمكن إصلاح هذه المشكلة بتوسيع إعدادات تهيئة المجمع webpack للمنصة Expo بحيث ينقل transpile الشيفرة المصدرية للمكتبة باستخدام Babel. ولتوسيع إعدادات التهيئة، لابدّ من تثبيت المكتبة expo/webpack-config@: npm install @expo/webpack-config --save-dev سننشئ بعد ذلك الملف "webpack.config.js" في المجلد الجذري للمشروع وضمنه الشيفرة التالية: const path = require('path'); const createExpoWebpackConfigAsync = require('@expo/webpack-config'); module.exports = async function(env, argv) { const config = await createExpoWebpackConfigAsync(env, argv); config.module.rules.push({ test: /\.js$/, loader: 'babel-loader', include: [path.join(__dirname, 'node_modules/react-router-native')], }); return config; }; أعد تشغيل أدوات تطوير لكي تُطبق إعدادات تهيئة webpack الجديدة، وستجد أن المشكلة قد حُلّت. افتح الآن الملف "App.js" وأضف المكوّن NativeRouter إلى المكوّن App: import React from 'react'; import { NativeRouter } from 'react-router-native'; import Main from './src/components/Main'; const App = () => { return ( <NativeRouter> <Main /> </NativeRouter> ); }; export default App; بعد أن أنشأنا متحكمًا بالمسار، سنضيف أول مسار تنقل (وجهة- Route) في المكوّن Main، وذلك ضمن الملف "Main.jsx": import React from 'react'; import { StyleSheet, View } from 'react-native'; import { Route, Switch, Redirect } from 'react-router-native'; import RepositoryList from './RepositoryList'; import AppBar from './AppBar'; import theme from '../theme'; const styles = StyleSheet.create({ container: { backgroundColor: theme.colors.mainBackground, flexGrow: 1, flexShrink: 1, }, }); const Main = () => { return ( <View style={styles.container}> <AppBar /> <Switch> <Route path="/" exact> <RepositoryList /> </Route> <Redirect to="/" /> </Switch> </View> ); }; export default Main; التمرينان 10.6 - 10.7 10.6 واجهة عرض تسجيل الدخول سننفذ قريبًا نموذجًا لكي يصبح المستخدم قادرًا على تسجيل دخوله إلى التطبيق. لكن علينا قبل ذلك أن ننشئ واجهة عرض يمكن الوصول إليها من شريط التطبيق. أنشئ الملف "Signin.jsx" في المجلد "components"، ثم ضع الشيفرة التالية ضمنه: import React from 'react'; import Text from './Text'; const SignIn = () => { return <Text>The sign in view</Text>; }; export default SignIn; هيئ مسارًا وجهته المكوّن Signin وضعه في المكوًن Main. أضف بعد ذلك نافذة عنوانها النص "Sign in" في أعلى شريط التطبيق إلى جوار النافذة "Repositories". ينبغي أن يكون المستخدم قادرًا على التنقل بين النافذتين السابقتين بالضغط على عنوان كل نافذة 10.7 شريط تطبيق قابل للتمرير scrollable طالما أننا سنضيف نوافذ جديدة إلى شريط التطبيق، فمن الجيد أن يكون قابلًا للتمرير أفقيًا عندما لا تتسع الواجهة لكل النوافذ. سنستخدم في ذلك المكوّن الأنسب لأداء المهمة وهو ScrollView. ضع النوافذ الموجودة في المكوّن AppBar ضمن المكوّن ScrollView: const AppBar = () => { return ( <View style={styles.container}> <ScrollView horizontal>{/* ... */}</ScrollView> </View> ); }; عندما تُسند القيمة "true" إلى الخاصية horizontal العائدة للمكون ScrollView، سيجعله قابلًا للتمرير أفقيًا عندما لا تتسع الشاشة للنوافذ التي يحتويها شريط التطبيق. وانتبه إلى ضرورة إضافة خصائص تنسيق ملائمة للمكوّن ScrollView، بحيث تُرتَّب النوافذ على شكل صف "row" ضمن حاوية flex. للتأكد من إنجاز المطلوب أضف نوافذ جديدة إلى الشريط حتى لا تتسع الشاشة لها جميعًا، ولا تنس إزالة هذه النوافذ التجريبية بعد التحقق من إنجاز المهمة. إدارة حالة النموذج بعد أن حصلنا على حاضنة مناسبة لواجهة عرض تسجيل الدخول، ستكون المهمة التالية إنجاز نموذج لتسجيل الدخول. لكن قبل الشروع في ذلك، سنتحدث قليلًا عن النماذج وبمنظور أوسع. يعتمد تنفيذ النماذج بشدة على إدارة الحالة. فقد يقدم لنا الخطاف useState حلًا جيدًا للنماذج الصغيرة، لكن سيكون التعامل مع الحالة بنفس الأسلوب مرهقًا عندما يغدو النموذج أكثر تعقيدًا. ولحسن الحظ سنجد مكتبات تتعايش مع بيئة React لتسهيل عملية إدارة حالة النماذج ومنها المكتبة Formik. تعتمد المكتبة Formik على مفهومي سياق العمل context والحقل field. يؤمن سياق العمل المكوّن Formik الذي يحتوي على حالة النموذج. وتتكون الحالة من معلومات عن حقول النموذج، وتتضمن هذه المعلومات مثلًا قيم الحقول والأخطاء الناتجة عن تقييم كلٍ منها. يمكن الإشارة إلى حالة الحقول باستخدام اسم الحقل عن طريق الخطاف useField أو المكوّن Field. لنرى كيف سيعمل الأمر من خلال إنشاء نموذج لحساب "مؤشر كتلة الجسم" عند البشر: import React from 'react'; import { Text, TextInput, TouchableWithoutFeedback, View } from 'react-native'; import { Formik, useField } from 'formik'; const initialValues = { mass: '', height: '', }; const getBodyMassIndex = (mass, height) => { return Math.round(mass / Math.pow(height, 2)); }; const BodyMassIndexForm = ({ onSubmit }) => { const [massField, massMeta, massHelpers] = useField('mass'); const [heightField, heightMeta, heightHelpers] = useField('height'); return ( <View> <TextInput placeholder="Weight (kg)" value={massField.value} onChangeText={text => massHelpers.setValue(text)} /> <TextInput placeholder="Height (m)" value={heightField.value} onChangeText={text => heightHelpers.setValue(text)} /> <TouchableWithoutFeedback onPress={onSubmit}> <Text>Calculate</Text> </TouchableWithoutFeedback> </View> ); }; const BodyMassIndexCalculator = () => { const onSubmit = values => { const mass = parseFloat(values.mass); const height = parseFloat(values.height); if (!isNaN(mass) && !isNaN(height) && height !== 0) { console.log(`Your body mass index is: ${getBodyMassIndex(mass, height)}`); } }; return ( <Formik initialValues={initialValues} onSubmit={onSubmit}> {({ handleSubmit }) => <BodyMassIndexForm onSubmit={handleSubmit} />} </Formik> ); }; لا يعتبر هذا المثال جزءًا من تطبيقنا، لذا لا حاجة لإضافة شيفرته إلى التطبيق. لكن يمكنك تجريب الشيفرة في Expo Snack مثلًا، وهو محرر لشيفرة React Native على شبكة الإنترنت على غرار JSFiddle وCodePen، ويمثل منصة مفيدة لتجريب الشيفرات بسرعة. كما يمكنك مشاركة Expo Snacks مع الآخرين بتحديد رابط إلى عملك أو تضمين الشيفرة على شكل مشغّل Snack ضمن أي صفحة ويب. ولربما قد صادفت مسبقًا مشغل Snack في مادة منهاجنا أو أثناء اطلاعك على توثيق React Native. عرّفنا في المثال السابق سياق عمل Formik ضمن المكوّن BodyMassIndexCalculator، وزودناه بالقيم الأولية وباستدعاء لإرسال محتويات النموذج submit callback. كما استخدمنا الخاصية initialValues لتزويد السياق بالقيم الأولية على شكل كائنات مفاتيحها أسماء الحقول وقيمها هي القيم الأولية المقابلة. كما تزودنا الصفة onSubmit باستدعاء إرسال محتويات النموذج، حيث ينفَّذ هذا الاستدعاء عندما تُستدعى الدالة handleSubmit شريطة أن لا تظهر أية أخطاء تقييم لمحتويات الحقول. تُستدعى الدالة التي تمثل ابنا مباشرًا للمكوّن Formik من خلال الخصائص props التي تحتوي معلومات متعلقة بالحالة، كما تحتوي أفعالًا كالدالة handleSubmit. يحتوي المكوّن BodyMassIndexForm ارتباطات الحالة بين السياق وعناصر الإدخال النصيّة. ويُستخدم الخطاف useField للحصول على قيمة حقل أو تعديل قيمته. يقبل useField وسيطًا واحدًا هو اسم الحقل ويعيد مصفوفة من ثلاث كائنات هي [الحقل field, البيانات الوصفية meta، المُساعدات helpers]. يحتوي الكائن field على قيمة الحقل، بينما يحتوي الكائن meta على المعلومات الوصفية للحقل كرسائل الخطأ التي قد يرميها. أما الكائن الأخير helpers، فيحتوي على الأفعال التي تُستخدم لتغيير حالة الحقل كالدالة setValue. وتجدر الإشارة إلى أنّ المكوّن الذي يستخدم الخطاف لابدّ أن يكون ضمن سياق Formik. أي يجب أن يكون هذا المكوّن من أبناء المكوّن Formik. يمكنك الاطلاع على نسخة تفاعلية عن المثال السابق باسم Formik example على موقع Expo Snack. لقد سبب استخدام الخطاف useField مع المكوّن TextInput شيفرة مكررة. لنعزل هذه الشيفرة ونضعها في مكوّن جديد باسم FormikTextInput، ولننشئ مكوّنًا مخصصًا باسم TextInput لإضافة مظهر مرئي أفضل لعنصر الإدخال النصي. لنثبّت أولًا Formik: npm install formik سننشئ الآن الملف "TextInput.jsx" في المجلد "components" ونضع فيه الشيفرة التالية: import React from 'react'; import { TextInput as NativeTextInput, StyleSheet } from 'react-native'; const styles = StyleSheet.create({}); const TextInput = ({ style, error, ...props }) => { const textInputStyle = [style]; return <NativeTextInput style={textInputStyle} {...props} />; }; export default TextInput; لننتقل إلى المكوّن FormikTextInput الذي ينشئ رابطًا لحالة Formik بالمكوّن TextInput. سننشئ الآن الملف "FormikTextInput.jsx" في المجلد "components" ونضع فيه الشيفرة التالية: import React from 'react'; import { StyleSheet } from 'react-native'; import { useField } from 'formik'; import TextInput from './TextInput'; import Text from './Text'; const styles = StyleSheet.create({ errorText: { marginTop: 5, }, }); const FormikTextInput = ({ name, ...props }) => { const [field, meta, helpers] = useField(name); const showError = meta.touched && meta.error; return ( <> <TextInput onChangeText={value => helpers.setValue(value)} onBlur={() => helpers.setTouched(true)} value={field.value} error={showError} {...props} /> {showError && <Text style={styles.errorText}>{meta.error}</Text>} </> ); }; export default FormikTextInput; يمكننا بعد استخدام المكوّن FormikTextInput أن نعيد كتابة المكوّن BodyMassIndexForm في المثال السابق كالتالي: const BodyMassIndexForm = ({ onSubmit }) => { return ( <View> <FormikTextInput name="mass" placeholder="Weight (kg)" /> <FormikTextInput name="height" placeholder="Height (m)" /> <TouchableWithoutFeedback onPress={onSubmit}> <Text>Calculate</Text> </TouchableWithoutFeedback> </View> ); }; وكما نرى سيقلص إنشاء المكوّن FormikTextInput الذي يعالج ارتباطات حالة Formik بالمكوّن TextInput الشيفرة اللازمة. ومن الجيد أن تكرر نفس العملية إن استخدَمَت نماذج Formik التي لديك مكونات إدخال بيانات. التمرين 10.8 10.8 نموذج تسجيل الدخول أنجز نموذجًا لتسجيل الدخوّل يرتبط بالمكوّن SignIn الذي أضفناه سابقًا في الملف"SignIn.jsx". يجب أن يحتوي النموذج مربعي إدخال نصيين، أحدهما لاسم المستخدم والآخر لكلمة السر. كما يجب أن يحتوي زرًا لتسليم بيانات النموذج. لا حاجة لكتابة دالة الاستدعاء onSubmit، بل يكفي إظهار قيم الحقول باستخدام الأمر console.log عند تسليم البيانات: const onSubmit = (values) => { console.log(values); }; تذكّر أن تستخدم المكوّن FormikTextInput الذي أنشأناه سابقّا. كما يمكنك استخدام الخاصية secureTextEntry في المكوّن TextInput لحجب كلمة السر عند كتابتها. سيبدو نموذج تسجيل الدخول مشابهًا للتالي: تقييم النموذج تقدم Formik مقاربتين لتقييم النماذج: دالة تقييم أو تخطيط تقييم. فدالة التقييم: هي دالة تُمرر للمكون Formik كقيمة للخاصية validate. حيث تستقبل الدالة قيم حقول النموذج كوسطاء وتعيد كائنًا يحتوى الأخطاء المحتملة الخاصة بكل حقل. أما تخطيط التقييم فيمرر إلى المكوّن Formik كقيمة للخاصية validationSchema. ويمكن إنشاء هذا التخطيط باستخدام مكتبة تقييم تُدعى Yup. لنثبّت هذه المكتبة إذًا: npm install yup كمثال على ما أوردنا، سننشئ تاليًا تخطيط تقييم لنموذج "مؤشر كتلة الجسم" الذي أنجزناه سابقًا. نريد أن نتحقق أنّ قيمتي كتلة الجسم والطول موجودتان، وأنهما قيم عددية. كذلك نريد أن نتحقق أنّ كتلة الجسم أكبر أو تساوي1، وأنّ الطول أكبر أو يساوي 0.5. تمثل الشيفرة التالية طريقة تنفيذ التخطيط: import React from 'react'; import * as yup from 'yup'; // ... const validationSchema = yup.object().shape({ mass: yup .number() .min(1, 'Weight must be greater or equal to 1') .required('Weight is required'), height: yup .number() .min(0.5, 'Height must be greater or equal to 0.5') .required('Height is required'),}); const BodyMassIndexCalculator = () => { // ... return ( <Formik initialValues={initialValues} onSubmit={onSubmit} validationSchema={validationSchema} > {({ handleSubmit }) => <BodyMassIndexForm onSubmit={handleSubmit} />} </Formik> ); }; تجري عملية التحقق افتراضيًا عند حدوث أي تغيير في قيم حقول النموذج، وكذلك عند استدعاء الدالة handleSubmit. فإن أخفق التحقق، لن تُستدعى الدالة التي تمرر إلى الكائن Formik عبر الخاصية onSubmit يعرض المكوًن FormikTextInput الذي أنشأناه سابقًا رسائل الخطأ المتعلقة بالحقل إن وقع الخطأ الموافق لها ولُمس الحقل. أي في الحالة التي يتلقى فيها الحقل تركيز الدخل ثم يفقده: const FormikTextInput = ({ name, ...props }) => { const [field, meta, helpers] = useField(name); // Check if the field is touched and the error message is present const showError = meta.touched && meta.error; return ( <> <TextInput onChangeText={(value) => helpers.setValue(value)} onBlur={() => helpers.setTouched(true)} value={field.value} error={showError} {...props} /> {/* Show the error message if the value of showError variable is true */} {showError && <Text style={styles.errorText}>{meta.error}</Text>} </> ); }; التمرين 10.9 10.9 تقييم نموذج تسجيل الدخول تحقق أنّ حقلي كلمة السر واسم المستخدم في نموذج تسجيل الدخول إجباريين. وتذكر أن لا تستدعي الدالة onSubmit التي أنجزناها في التمرين السابق إن أخفق تقييم النموذج. سيعرض المكوّن FormikTextInput حاليًا رسالة خطأ إن حدث خطأ في الحقل الذي تم لمسه، عزز مظهر هذه الرسالة بجعل نصها أحمر اللون. بالإضافة إلى رسالة الخطأ، إجعل إطار حقل الإدخال أحمر اللون ليشير ذلك إلى وقوع خطأ ضمنه. وتذكر أن المكوّن FormikTextInput سيعطي القيمة "true" للخاصية error العائدة للمكوّن TextInput عند حدوث أية أخطاء ضمن الحقل. يمكنك استعمال قيمة هذه الخاصية لإضافة تنسيق شرطي للمكوّن TextInput. سيبدو شكل نموذج تسجيل الدخول عند حدوث خطأ في أحد الحقول قريبًا من الشكل التالي: الشيفرة الخاصة بمنصة من أعظم ميزات استخدام React Native هي أننا لن نهتم بالمنصة التي سيعمل عليها التطبيق سواء Android أو iOS. لكن، قد تصادفنا حالات نضطر فيها إلى تنفيذ شيفرة خاصة بمنصة محددة. من هذه الحالات مثلًا، كتابة مكوّنات بطرق مختلفة لتلائم منصات مختلفة. يمكن الوصول إلى المنصة التي يستخدمها المستخدم من خلال الثابت Platform.OS: import { React } from 'react'; import { Platform, Text, StyleSheet } from 'react-native'; const styles = StyleSheet.create({ text: { color: Platform.OS === 'android' ? 'green' : 'blue', }, }); const WhatIsMyPlatform = () => { return <Text style={styles.text}>Your platform is: {Platform.OS}</Text>; }; يأخذ هذا الثابت إحدى القيمتين: android أو ios. يمكن كتابة شيفرة مخصصة لمنصة أيضًا مستفيدين من التابع Platform.select. إذ يمرر للتابع كائن قد تأخذ مفاتيحه إحدى القيم التالية: ios أو android أو native أو default، ويعيد القيمة الأكثر ملائمة للمنصة التي يعمل عليها المستخدم. يمكننا إعادة كتابة المتغير styles في المثال السابق باستعمال التابع Platform.select كالتالي: const styles = StyleSheet.create({ text: { color: Platform.select({ android: 'green', ios: 'blue', default: 'black', }), }, }); وبالإمكان أيضًا استخدام التابع Platform.select لطلب مكوّن خاص بمنصة محددة: const MyComponent = Platform.select({ ios: () => require('./MyIOSComponent'), android: () => require('./MyAndroidComponent'), })(); <MyComponent />; إنّ الطريقة الأكثر تقدّمًا لكتابة وإدراج مكوّنات خاصة بمنصة محددة أو أية أجزاء من الشيفرة، هي استخدام ملفات لها إحدى اللاحقتين "ios.jsx." أو "android.jsx.". وانتبه إلى إمكانية استخدام أية لاحقة يمكن للمجمع أن يميزها مثل "js.". إذ يمكن مثلًا أن ننشئ ملفات باسم "Button.ios.jsx" أو باسم "Button.android.jsx" بحيث نستطيع إدراجها في شيفرتنا كالتالي: import React from 'react'; import Button from './Button'; const PlatformSpecificButton = () => { return <Button />; }; وهكذا ستضم حزمة Android للتطبيق المكوّن المعرّف في الملف "Button.android.jsx"، بينما تضم حزمة iOS المكوّن المعرّف في الملف "Button.ios.jsx". التمرين 10.10 10.10 خط خاص بمنصة محددة حُددت عائلة الخط الذي نستخدمه حاليًا في تطبيقنا بأنها "System" ضمن إعدادات تهيئة السمة الموجودة في الملف "theme.js". استخدم بدلًا من الخط "system" خطًا من خطوط العائلة Sans-serif ليكون خطًا خاصًا بكل منصة. استخدم الخط "Roboto" في منصة Android والخط Arial في منصة iOS. يمكن أن تُبقي "System" مثل خط افتراضي. هذا هو التمرين الأخير في هذا الفصل، وقد حان الوقت لتسليم التمارين إلى GitHub والإشارة إلى أنك أكملتها في منظومة تسليم التمارين. انتبه إلى وضع الحلول في القسم 2 ضمن المنظومة. ترجمة -وبتصرف- للفصل React Native basics من سلسلة Deep Dive Into Modern Web Development. اقرأ أيضًا المقال السابق: مدخل إلى React Native مدخل إلى التحريك في React Native
  21. تتطلب كتابة تطبيقات أصليّة Native لنظامي التشغيل iOS وAndroid تقليديًا استخدام لغات برمجة وبيئات تطوير خاصة بكل منصة. فقد استخدمت لغات مثل Objective C وSwift لتطوير تطبيقات iOS، ولغات مبنية على JVM مثل Java وScala وKotlin لتطوير تطبيقات موجّهة لنظام Android. وبالتالي وجب من الناحية التقنية كتابة تطبيقين منفصلين وبلغتي برمجة مختلفتين من أجل العمل على المنصتين السابقتين، وهذا ما تطلب الكثير من الموارد. لقد كان استخدام المتصفح كمحرك لتصيير التطبيقات إحدى المقاربات الأكثر شعبية في توحيد تطوير التطبيقات للمنصات المختلفة. وكانت المنصة Cordova من أشهر المنصات التي استخدمت في تطوير تطبيقات تعمل على منصات مختلفة. إذ تسمح هذه المنصة بتطوير تطبيقات قادرة على العمل على منصات مختلفة اعتمادًا على تقنيات الويب المعيارية مثل HTML5 وCSS3 وJavaScript. تعمل تطبيقات Cordova ضمن المتصفح المدمج في جهاز المستخدم، وبالتالي لن تحقق الأداء ولا المظهر الذي يمنحه استخدام التطبيقات الأصلية التي تستخدم مكوّنات واجهة المستخدم الأصلية. تقدم React Native إطار عمل لتطوير تطبيقات أصلية لمنصتي iOS وAndroid باستخدام JavaScript وReact. حيث تؤمن مجموعة من المكوّنات التي تعمل على منصات مختلفة وتستخدم خلف الستار المكوّنات الأصلية بالمنصة التي يعمل عليها التطبيق. وباستخدام React Native يمكننا استحضار كل الميزات المألوفة التي تقدمها React مثل JSX والمكوّنات components والخصائص props والحالة state والخطافات hooks، لتطوير تطبيقات أصلية لمنصة. وفوق كل ذلك، سنتمكن من استخدام مكتبات مألوفة بالنسبة لنا ضمن بيئة عمل React مثل react-redux وreact-apollo وreact-router وغيرها الكثير. تعتبر سرعة التطوير ومنحى التعلم المتصاعد للمطورين الذين ألفوا React، من أبرز الفوائد التي تمنحها React Native. وإليكم اقتباسًا تشجيعيًا ورد في مقال لمدونة Coinbase بعنوان Onboarding thousands of users with React Native، أو بالعربية "ضم آلاف المستخدمين إلى React Native"، حول فوائد React Native. حول هذا القسم سنتعلم خلال هذا القسم بناء تطبيقات React Native فعلية من البداية إلى النهاية. وسنتعلم مفاهيم مثل مكونات React Native البنيوية core components، وكيفية بناء واجهات مستخدم جميلة، وكيف سنتصل مع الخادم، كما سنتعلم كيفية اختبار تطبيقات React Native. سنعمل على تطوير تطبيق لتقييم مستودعات GitHub. وسيمتلك التطبيق ميزاتٍ كفرز وانتقاء المستودعات التي نقيّمها، وكذلك ميزة تسجيل مستخدم جديد، وتسجيل دخول مستخدم، وإنشاء مذكرة تقييم جديدة. سنستخدم واجهة خلفية جاهزة لكي ينصب تركيزنا على تطوير التطبيق باستخدام React Native فقط. ينبغي أن تسلم كل تمارين هذا القسم إلى مستودع GitHub واحد، سيضم مع الوقت الشيفرة الكلية للتطبيق. ستجد أيضًا حلًا نموذجيًا لكل مقال من هذا الجزء يمكنك استخدامه لإكمال التمارين الناقصة عند تسليمها. تعتمد هيكلية القسم على فكرة تطوير التطبيق تدريجيًا مع تقدمك في قراءة مادة القسم العلمية. فلا تنتظر حتى تصل إلى التمارين لتبدأ بتطوير تطبيقك، بل طوّره بما يتناسب مع المرحلة التي وصلت إليها في قراءة مادة القسم. سيعتمد هذا القسم بشدة على مفاهيم ناقشناها في أقسام سابقة. لذا وقبل أن تبدأ رحلتك هنا، عليك امتلاك بعض المعارف الأساسية في JavaScript وReact وGraphQL. لن تحتاج إلى معرفة عميقة في تطوير التطبيقات من ناحية الخادم، فكل الشيفرة التي تحتاجها للواجهة الخلفية محضرة مسبقًا. مع ذلك، سننفذ طلبات اتصال شبكية من خلال تطبيقات React Native مثل استعلامات GraphQL. ننصحك بإكمال الأقسام Part 1 وPart 2 وPart 5 وPart 7 وPart 8 قبل البدء بهذا القسم. تسليم التمرينات والحصول على الساعات المعتمدة ستُسلّم التمارين إلى منظومة استلام التمارين كما هو الحال في الأقسام السابقة. لكن انتبه إلى أنّ تمارين هذا المقال ستُسلّم إلى نسخةً أخرى من المنهاج تختلف عن النسخة التي سلمنا فيها تمارين الأقسام السابقة. فالأقسام من 1 إلى 4 في منظومة التسليم في هذه النسخة تشير إلى الفصول من a إلى d من هذا القسم. ويعني هذا أن عليك تسليم تمارين كل مقال على حدى ابتداءً من هذا المقال "مدخل إلى React Native" والذي سيحمل الاسم "Part 1" في منظومة تسليم التمارين. ستحصل في هذا القسم على ساعات معتمدة بناء على عدد التمارين التي تُكملها. فستحصل على ساعة معتمدة عند إكمالك 19 تمرينًا على الأقل، وعلى ساعتين معتمدتين عند إكمالك 26 تمرينُا على الأقل. وإن أردت الحصول على الساعات عند إكمالك للتمارين، أعلمنا من خلال منظومة التسليم بأنك أكملت المنهاج. وتشير الملاحظة "exam done in Moodle" إلى امتحان منهاج التطوير الشامل لتطبيقات الويب، والذي عليك اجتيازه حتى تحصل على ساعاتك المعتمدة المخصصة لهذا القسم. يمكنك الحصول على شهادة إكمال هذا القسم بالنقر على إحدى أيقونات "العلم". حيث ترتبط أيقونات الأعلام باللغة التي نُظِّمت بها الشهادة. وانتبه إلى ضرورة إكمال ما يعادل ساعة معتمدة من تمارين هذا القسم لتحصل على الشهادة. تهيئة التطبيق علينا تهيئة بيئة التطوير حتى نبدأ بالعمل مع تطبيقنا. وكنا قد تعاملنا في الأقسام السابقة مع أدوات مفيدة في ضبط وتهيئة تطبيقات React بسرعة مثل برنامج "create react app". تمتلك React Native لحسن الحظ أدواتٍ مشابهة أيضًا. سنستخدم لتطوير تطبيقنا منصة Expo، وهي منصة تبسط إعداد وتطوير وبناء ونشر تطبيقات React Native. لنبدأ إذًا بتثبيت واجهة سطر الأوامر الخاصة بهذه المنصة: npm install --global expo-cli يمكننا الآن تهيئة مشروعنا داخل المجلد "rate-repository-app" بتنفيذ الأمر التالي: expo init rate-repository-app --template expo-template-blank@sdk-38 يضبط الوسيط "sdk-38@" نسخة "Expo SDK" على 38 وهي النسخة التي تدعم النسخة 0.62 من React Native. وقد يتسبب لك استخدام نسخة مختلفة من "Expo SDK" مشاكل عند تتبع المادة التعليمية في هذا القسم. وتجدر الإشارة إلى محدودية Expo في بعض النقاط موازنةً بواجهة اللغة المشتركة CLI التي تستخدمها React Native، لكن لن تؤثر هذه المحدودية على التطبيق الذي سننفذه في هذا القسم. افتح المجلد "rate-repository-app" الذي أنشأناه عند تهيئة التطبيق باستخدام محرر شيفرة مثل Visual Studio Code. قد ترى بعض الملفات والمجلدات المألوفة ضمنه مثل "package.json" و"node_modules"، كما سترى أيضًا الملفات الأكثر ارتباطًا بالتطبيق مثل "app.json" الذي يحتوي على أوامر التهيئة الخاصة بالمنصة Expo، والملف "App.js" الذي يمثل المكوِّن الجذري لتطبيقنا. لا تغير اسم الملف "App.js" ولا تنقله إلى مجلد آخر، لأن Expo سيدرجه لتسجيل المكوّن الجذري. لنلق نظرة على قسم السكربتات في الملف "package.json": { // ... "scripts": { "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", "eject": "expo eject" }, // ... } سيشغّل تنفيذ الأمر npm start المجمّع Metro وهو مُجمّع JavaScript أي JavaScript bundler يستخدم مع React Native، ويمكن أن نعده مشابهًا لبرنامج Webpack لكنه خاص ببيئة React Native. كما ينبغي أن تُشغَّل أدوات تطوير Expo على العنوان http://localhost:19002 من خلال المتصفح. وهذه الأدوات مفيدةً جدًا في عرض سجلات تنفيذ التطبيق، بالإضافة إلى تشغيل التطبيق من خلال المقلّد emulator، أو من خلال تطبيق جوّال Expo. سنعود إلى هذين الأخيرين لاحقًا، إذا علينا أولًا تشغيل التطبيق في المتصفح بالنقر على الزر "Run": ستجد بعد النقر على الرابط أن النص المكتوب في الملف "App.js" سيظهر على نافذة المتصفح. افتح هذا الملف باستخدام أي محرر مناسب واجر تغييرًا بسيطًا على النص الموجود في المكوّن Text. يجب أن تظهر التعديلات التي أجريتها مباشرة على شاشة المتصفح بعد أن تحفظ التغيرات. إعداد بيئة التطوير لقد ألقينا نظرة أولى على تطبيقنا من خلال متصفح Expo. وعلى الرغم من فائدة هذا المتصفح، إلا أنه يقدم محاكاة بدائية للبيئة الأصلية. سنتعرف تاليًا على بعض البدائل المتوفرة لدينا بما يتعلق ببيئة التطوير. يمكن تقليد أجهزة iOS وAndroid كالأجهزة اللوحية والهواتف عن طريق الحاسوب وباستخدام مقلّدات Emulators خاصة. وهذا أمر مفيد جدًا عند تطوير التطبيقات باستخدام React Native. يمكن لمستخدمي نظام التشغيل macOS استخدام مقلدات iOS وAndroid على حواسيبهم، أما مستخدمي أنظمة التشغيل الأخرى مثل Linux وWindows، فليس بوسعهم سوى العمل مع مقلدات Android. اتبع التعليمات التالية حول إعداد المقلّد، بناء على نظام التشغيل الذي يستخدمه حاسوبك: Set up Android emulator with Android Studio لأي نظام تشغيل Set up iOS simulator with Xcode لنظام التشغيل macOS بعد تثبيت المقلّد واختباره، شغّل أدوات تطوير Expo كما فعلنا سابقًا عن طريق الأمر npm start. وبناء على المقلد الذي تستخدمه، إنقر الزر "Run" على مقلد أجهزة Android، أو "Run" على رابط محاكي iOS. ينبغي أن يتصل Expo بالمقلد بعد النقر على الرابط، وسترى التطبيق عليه. كن صبورًا، فقد يستغرق الأمر وقتًا. بالإضافة إلى المقلدات، ستجد أنّ هناك طريقة غاية في الأهمية لتطوير تطبيقات React Native باستخدام Expo، وهي تطبيق جوّال Expo. فباستخدام هذا التطبيق، ستتمكن من استعراض تطبيقك عبر جهازك النقّال الفعلي، وهذا ما يمنحك خبرة أكثر موازنةً بالمقلدات. ثبّت أولًا تطبيق جوّال Expo باتباع التعليمات الموجودة في توثيق Expo. وانتبه إلى أنّ تطبيق جوّال Expo لن يعمل إن لم يكن جهازك النقًال متصلًا بنفس الشبكة المحلية (نفس الشبكة اللاسلكية على سبيل المثال) التي يتصل بها حاسوب التطوير الذي تعمل عليه. شغل تطبيق جوّال Expo عندما تنتهي من تثبيته. إن لم تكن قد شغّلت أدوات تطوير Expo بعد، فافعل ذلك مستخدمًا الأمر npm start. سترى في الزاوية اليسارية السفلى من نافذة أدوات التطوير شيفرة QR. امسح هذه الشيفرة باستخدام تطبيق جوّال Expo عن طريق الضغط على الرابط "Scan QR Code". ينبغي أن يبدأ تطبيق Expo ببناء مجمّع JavaScript، ومن المفترض أن ترى تطبيقك وهو يعمل بعد انتهاء عملية التجميع. وهكذا ستتمكن في كل مرة تفتح فيها تطبيقك باستخدام تطبيق جوّال Expo من الوصول إليه دون الحاجة إلى مسح شيفرة QR مجددًا، وذلك بالضغط عليه في قائمة التطبيقات التي شُغّلت مؤخرًا، والتي تظهر في نافذة العرض "Project". التمرين 10.1 10.1 تهيئة التطبيق استخدم واجهة سطر أوامر Expo لتهيئة تطبيقك، وجهّز بيئة التطوير باستخدام المقلد أو تطبيق جوّال Expo. ننصحك بتجريب الأسلوبين السابقين لتحدد البيئة الأنسب بالنسبة لك. لا تهتم كثيرًا لاسم التطبيق، فيمكنك أن تستخدم الاسم "rate-repository-app". عليك إنشاء مستودع GitHub جديد لتسليم هذا التمرين وغيره من التمارين اللاحقة، ويمكنك تسمية هذا المستودع باسم التطبيق الذي قمت بتهيئته عند تنفيذك الأمر expo init. إن قررت إنشاء مستودع خاص، أضف المستخدم Kaltsoon كمشارك لك collaborator في هذا المستودع، وذلك للتحقق من التمارين التي أنجزتها وسلمتها. نفذ بعد إنشائك المستودع الأمر git init ضمن المجلد الجذري لمشروعك للتأكد من تهيئة المستودع كمستودع Git. ولإضافة المستودع الجديد إلى قائمة العمل عن بعد نفذ الأمر التالي: git remote add origin git@github.com:<YOUR\*GITHUB\*USERNAME>/<NAME\*OF\*YOUR_REPOSITORY>.git وتذكر أن تستبدل القيم داخل الأقواس بما يناسب عندك كتابتك لسطر الأوامر. ادفع أخيرًا بالتغييرات التي أجريتها إلى المستودع وسيكون كل شيء على ما يرام. المدقق ESLint الآن، وقد ألفنا بيئة التطوير سنعمل على تحسين مهاراتنا في التطوير وذلك بتهيئة مدقق للشيفرة. سنستخدم المدقق ESLint الذي تعاملنا معه سابقًا، وكالعادة سنبدأ بتثبيت الاعتماديات اللازمة: npm install --save-dev eslint babel-eslint eslint-plugin-react سنضع القواعد التالية للمدقق داخل الملف ذو اللاحقة "eslintrc." والموجود في المجلد "rate-repository-app": { "plugins": ["react"], "settings": { "react": { "version": "detect" } }, "extends": ["eslint:recommended", "plugin:react/recommended"], "parser": "babel-eslint", "env": { "browser": true }, "rules": { "react/prop-types": "off", "semi": "error" } } ومن ثم سنضيف السكربت "lint" إلى محتويات الملف "package.json"، وذلك للتحقق من تطبيق قواعد المدقق في الملفات المحددة: { // ... "scripts": { "start": "expo start", "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", "eject": "expo eject", "lint": "eslint ./src/**/*.{js,jsx} App.js --no-error-on-unmatched-pattern" }, // ... } لاحظ أننا استخدمنا الفواصل لإنهاء سطر الشيفرة على نقيض ما فعلنا في الأقسام 1-8، لذلك أضفنا القاعدة semi للتأكد من ذلك. نستطيع الآن التأكد من خضوع ملفات JavaScript الموجودة في المجلد "src" وكذلك الملف "App.js" لقواعد المدقق، وذلك بتنفيذ الأمر npm run lint. سنضع ملفات الشيفرة لاحقًا في المجلد "src"، لكن طالما أننا لم نضف أية ملفات ضمنه حتى اللحظة، سنضطر إلى تفعيل الراية "no-error-on-unmatched-pattern". حاول إن استطعت أيضًا دمج المدقق ESlint مع محرر الشيفرة الذي تعمل عليه. فإن كنت تعمل على Visual Studio Code، يمكنك فعل ذلك بتفقد قسم الموّسعات extensions والتأكد من تثبيت وتفعيل موسِّع ESlint. تمثل قواعد تهيئة المدقق السابقة، القواعد الأساسية للتهيئة. يمكنك أن تعدّل بحرية على هذه القواعد وأن تضيف ملحقات جديدة plugins إن شعرت بالحاجة إلى ذلك. التمرين 10.2 10.2 إعداد وضبط المدقق ESLint هيئ المدقق في مشروعك حتى تتمكن من تدقيق ملفات الشيفرة عند تنفيذك الأمر. ويفضّل عند استخدامك المدقق أن تدمجه مع محررك لكي تستفيد من كامل إمكانياته. هذا هو التمرين الأخير في هذا المقال، وقد حان الوقت لتسليم التمارين إلى GitHub والإشارة إلى أنك أكملتها في منظومة تسليم التمارين. انتبه إلى وضع الحلول في القسم 1 ضمن المنظومة. عرض سجلات العمل يمكن الاستفادة من بيئة تطوير Expo في عرض سجلات العمل للتطبيق، كما يمكن متابعة رسائل الخطأ والتحذير ضمن المقلّد أو ضمن واجهة تطبيق الجوّال. ستظهر رسالة الخطأ باللون الأحمر فوق مكان الخطأ، بينما يمكن عرض رسائل التحذير بالضغط على رسالة التحذير في أسفل الشاشة. كما يمكنك استخدام الأمر console.log لطباعة رسائل محددة، وذلك لغايات التنقيح وكشف الأخطاء. لتجريب ذلك عمليًا، شغّل أدوات تطوير Expo بتنفيذ الأمر npm start، ثم شغّل التطبيق باستخدام المقلّد أو تطبيق الجوّال. ستتمكن عندها من رؤية الأجهزة المتصلة بحاسوبك تحت العنوان "Metro Bundler" في الزاوية العليا اليسارية من نافذة أدوات التطوير: انقر على الجهاز لفتح سجلات العمل عليه. افتح بعد ذلك الملف "App.js" وأضف رسالةً ما باستخدام الأمر console.log إلى المكوِّن App. سترى رسالتك ضمن السجلات بمجرد أن تحفظ التغييرات التي أجريتها على الملف. استخدام منقح الأخطاء Debugger قد يكون فحص الرسائل التي كُتبت باستخدام الأسلوب console.log مفيدًا، لكن سيتطلب الأمر فهمًا أوسع إن كنت تحاول أن تجد ثغرةً ما أو أن تفهم سير عمل التطبيق. فربما سنهتم -على سبيل المثال- بخصائص أو بحالة مكوِّن محدد، أو بالاستجابة لطلبٍ محدد على الشبكة، لذلك استخدمنا أدوات المطوّر التي يؤمنها المتصفح لإنجاز هذا العمل سابقًا. بالمقابل سنجد أنّ المنقّح React Native Debugger سيزوّدك بنفس ميزات التنقيح، لكن لتطبيقات React Native. لنبدأ العمل بتثبيت منقِّح React Native بمساعدة تعليمات التثبيت. شغّل المنقّح عند انتهاء التثبيت، وافتح نافذة تنقيح جديدة (يمكنك استخدام الاختصارات التالية: command+T على macOS وCtrl+T على Windows/Linux)، ثم اضبط رقم منفذ محزِّم React Native على 19001. علينا الآن تشغيل تطبيقنا وربطه مع المنقّح. لننفذ إذًا الأمر npm start أولًا، ثم سنفتح التطبيق ضمن المقلّد أو تطبيق جوّال Expo. افتح قائمة المطوّرين داخل المقلّد أو التطبيق باتباعك الإرشادات الموجودة في توثيق Expo. اختر من قائمة المطوّرين العنصر "Debug remote JS" لتتصل مع المنقّح. إن جرى كل شيء على مايرام سترى شجرة مكونات التطبيق ضمن المنقّح. يمكنك استخدام المنقّح للتحقق من حالة المكوّنات وخصائصها وتغييرها إن أردت. حاول أن تعثر مثلًا على المكوّن Text الذي يصيره المكوّن App باستخدام خيار البحث أو عبر شجرة المكوّنات. انقر المكوّن text وغّير قيمة الخاصية children. من المفترض أن يَظهر التغيير تلقائيًا في نافذة عرض التطبيق. للاطلاع على أدوات تنقيح أكثر فائدة لتطبيقات Expo، اطلع على توثيق عملية التنقيح في Expo. ترجمة -وبتصرف- للفصل Introduction to React Native من سلسلة Deep Dive Into Modern Web Development. اقرأ أيضًا المقال السابق: استخدام TypeScript والأنواع التي توفرها في تطبيقات React. مدخل إلى التحريك في React Native.
  22. علينا أولًا وقبل أن ندخل في موضوع استخدام TypeScript مع React تحديد ما يلزمنا وما الذي نهدف لتحقيقه. فعندما يعمل كل شيء كما ينبغي، ستساعدنا TS في التقاط الأخطاء التالية: محاولة تمرير خاصيات Props زائدة أو غير مطلوبة لمكوِّن نسيان تمرير خاصية يحتاجها المكوَّن تمرير خاصية من النوع الخاطئ إلى مكوِّن ستساعدنا TS على التقاط أيًا من الأخطاء السابقة ضمن المحرر مباشرة. وفي حال لم نستخدم TS، سنضطر إلى التقاط هذه الأخطاء لاحقًا عن طريق الاختبارات، وربما سنقضي وقتًا مرهقًا في إيجاد مسبباتها. هذه الأسباب كافية حتى الآن لنبدأ إذًا! إنشاء تطبيق React باستخدام TypeScript يمكننا استخدام create-react-app لإنشاء تطبيق React باستخدام TS بإضافة الوسيط template إلى سكربت التهيئة الأولية. لذا نفّذ الأمر التالي لإنشاء تطبيق create-react-app باستخدام TS: npx create-react-app my-app --template typescript ينبغي بعد تنفيذ الأمر، أن تحصل على تطبيق React مكتمل يستخدم TS. يمكنك أن تشغل التطبيق باستخدام الأمر npm start في جذر المشروع. لو ألقينا نظرة على الملفات والمجلدات، ستجد أن التطبيق لا يختلف كثيرًا عن التطبيقات التي تستخدم JavaScript صرفة. إذ تقتصر الاختلافات على تحول الملفات التي تحمل إحدى اللاحقتين js. و jsx. إلى ملفات باللاحقتين ts.وtsx. وستحتوي هذه الملفات على مسجلات للأنواع، كما سيحتوي المجلد الجذري على الملف tsconfig.json. لنلق نظرة الآن على الملف tsconfig.json الذي أُنشئ نيابة عنا. ينبغي أن تكون قواعد التهيئة ضمنه مناسبة إلى حد ما، إلا أنّ هذه القواعد ستسمح بتصريف ملفات JavaScript، لأن القاعدة allowJs تأخذ القيمة "true". لا بأس بذلك إن كنت ستمزج بين TS و JavaScript (في الحالة التي تعمل فيها مثلًا على تحويل شيفرة JS إلى TS أو ما شابه)، لكننا نريد هنا إنشاء تطبيق TS صرف، لذا سنغير قيمة القاعدة allowJs إلى false. استخدمنا في المشروع السابق المدقق لمساعدتنا في فرض أسلوب برمجة معين، سنفعل ذلك أيضًا في هذا التطبيق. لا حاجة لتثبيت أية اعتماديات لأن create-react-app قد اهتم بكل الترتيبات. يحمل الملف ذو اللاحقة "eslintrc." قواعد eslint التالية: { "env": { "browser": true, "es6": true, "jest": true }, "extends": [ "eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended" ], "plugins": ["react", "@typescript-eslint"], "settings": { "react": { "pragma": "React", "version": "detect" } }, "rules": { "@typescript-eslint/explicit-function-return-type": 0 } } طالما أنّ النوع الذي تعيده جميع مكوّنات React هي عناصر JSX أو القيمة null، سنغير قواعد المدقق قليلًا بإلغاء تفعيل القاعدة explicit-function-return-type. وهكذا لن نحتاج إلى التصريح عن نوع القيمة التي تعيدها الدالة في كل مكان. علينا أيضًا أن نجعل المدقق قادرًا على فهم ملفات "tsx."، وهي المقابل في TS لملفات "jsx." في React. سننفذ ذلك بتغيير السكربت lint في الملف package.json على النحو التالي: { // ... "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", "lint": "eslint './src/**/*.{ts,tsx}'" }, // ... } قد تحتاج إلى استخدام علامة التنصيص المزدوجة عند كتابة مسار المدقق: {lint": "eslint './src/**/*.{ts,tsx} وذلك إن كنت تعمل على نظام Windows. لو نفذنا الآن الأمر npm run lint سنحصل على رسالة خطأ من المدقق eslint مجددًا: لماذا يحدث ذلك؟ تخبرنا رسالة الخطأ أنّ الملف serviceWorker.ts لا يتقيد بقواعد المدقق. والسبب في ذلك، أنّ الدالة register ستستخدم دوال أخرى عُرِّفت لاحقًا في الملف نفسه وهذا لا يتوافق مع القاعدةtypescript-eslint/no-use-before-define@ . ولإصلاح المشكلة لا بدّ من نقل الدالة register إلى آخر الملف. لا ينبغي أن تظهر أية أخطاء بعد الآن. لا يشكل الخطأ السابق عائقًا في واقع الأمر، لأننا لن نحتاج إلى الملف serviceWorker.ts. لذا من الأفضل حذفه. مكونات React مع TypeScript لنتأمل المثال التالي لتطبيق React كُتب باستخدام JavaScript: import React from "react"; import ReactDOM from 'react-dom'; import PropTypes from "prop-types"; const Welcome = props => { return <h1>Hello, {props.name}</h1>; }; Welcome.propTypes = { name: PropTypes.string }; const element = <Welcome name="Sara" />; ReactDOM.render(element, document.getElementById("root")); لدينا في هذا المثال المكوًن Welcome الذي نمرر إليه اسمًا كخاصية ليقوم بتصييره على الشاشة. ينبغي أن يكون الاسم من النوع string وقد استخدمنا الحزمة prop-types التي تعرفنا عليها في القسم 5، لكي نحصل على تلميحات حول الأنواع المطلوبة لخصائص المكوّنات وتحذيرات عند استخدام خصائص من النوع الخاطئ. لن نحتاج إلى الحزمة prop-types أبدًا عند استخدام TS. إذ يمكننا تعريف الأنواع بمساعدة TS، وذلك باستخدام واجهة النوع "FunctionComponent" أو باستخدام اسمها المستعار FC. عندما نستخدم TS مع المكوّنات، ستبدو مسجلات الأنواع مختلفة قليلًا عن شيفرات TS الأخرى. حيث نضيف النوع إلى المتغيًر الذي يُسند إليه المكوّن بدلًا من الدالة وخصائصها. يدعى النوع الناتج عن "FunctionComponent" بالنوع المُعمَّم generic. بحيث يمكن أن نمرر لهذه الواجهة نوعًا كمعامل، ثم تستخدمه على أنه النوع الخاص بها. تبدو تصريحات React.FC وReact.FunctionComponent على النحو التالي: type FC<P = {}> = FunctionComponent<P>; interface FunctionComponent<P = {}> { (props: PropsWithChildren<P>, context?: any): ReactElement | null; propTypes?: WeakValidationMap<P>; contextTypes?: ValidationMap<any>; defaultProps?: Partial<P>; displayName?: string; } سترى أولًا أن FC ببساطة هو اسم مستعار لواجهة النوع FunctionComponent، وكلاهما من النوع المعمم الذي يمكن تمييزه بسهولة من قوسي الزاوية "<>" بعد اسم النوع. سترى داخل قوسي الزاوية هذه الشيفرة {}=P. وتعني أنه بالإمكان تمرير نوع كمعامل. سيُعرَف النوع الذي سيُمرَّر بالاسم P، وهو افتراضيًا كائن فارغ {}. لنلق نظرة على السطر الأول من شيفرة FunctionComponent: (props: PropsWithChildren<P>, context?: any): ReactElement | null; تحمل الخصائص النوع PropsWithChildren، وهو أيضًا نوع معمم يُمرَّر إليه النوع P. يمثل النوع "PropsWithChildren" بدوره تقاطعًا intersection بين النوعين بالشكل التالي: type PropsWithChildren<P> = P | { children?: ReactNode }; كل ما نحتاج إليه الآن من هذا الشرح الذي قد يبدو معقدًا بأنه من الممكن تعريف النوع وتمريره إلى واجهة النوع FunctionComponent، حيث ستحمل خصائصها بعد ذلك النوع الذي عرًفناه بالإضافة إلى المكوّنات الأبناء. لنعد إلى مثالنا ونحاول أن نعرًف نوعًا لخصائص المكوًن Welcome باستخدام TS: interface WelcomeProps { name: string; } const Welcome: React.FC<WelcomeProps> = (props) => { return <h1>Hello, {props.name}</h1>; }; const element = <Welcome name="Sara" />; ReactDOM.render(element, document.getElementById("root")); عرًفنا في الشيفرة السابقة واجهة النوع WelcomeProps ومررناها إلى المكوّن Welcome عندما صرّحنا عن نوعه: const Welcome: React.FC<WelcomeProps>; يمكننا كتابة الشيفرة بأسلوب أقصر: const Welcome: React.FC<{ name: string }> = ({ name }) => ( <h1>Hello, {name}</h1> ); سيعرف المحرر الآن أنّ الخاصية من النوع string. لكن المدقق لن يقتنع تمامًا بما فعلنا، وسيعترض بأن الخاصية name غير موجودة عند تقييم الخصائص. ويحدث هذا لأنّ قاعدة المدقق ستتوقع منا أن نعرِّف أنواعًا لجميع الخصائص. فلن يدرك المدقق أننا نستخدم TS في تعريف الأنواع لخصائصنا. لإصلاح الخلل، لا بدّ من إضافة قاعدة تدقيق جديدة إلى الملف "eslintrc.": { // ... "rules": { "react/prop-types": 0, }, // ... } التمرين 9.14 9.14 أنشئ تطبيق create-react-app مستخدمًا TS وهيئ المدقق لمشروعك بالطريقة التي تعلمناها. يشبه هذا التمرين تمرينًا نفَّذناه في القسم 1 من المنهاج لكن باستخدام TS هذه المرة بالإضافة إلى بعض التعديلات. إبدأ بتعديل محتويات الملف "index.tsx" لتصبح على النحو التالي: import React from "react"; import ReactDOM from "react-dom"; const App: React.FC = () => { const courseName = "Half Stack application development"; const courseParts = [ { name: "Fundamentals", exerciseCount: 10 }, { name: "Using props to pass data", exerciseCount: 7 }, { name: "Deeper type usage", exerciseCount: 14 } ]; return ( <div> <h1>{courseName}</h1> <p> {courseParts[0].name} {courseParts[0].exerciseCount} </p> <p> {courseParts[1].name} {courseParts[1].exerciseCount} </p> <p> {courseParts[2].name} {courseParts[2].exerciseCount} </p> <p> Number of exercises{" "} {courseParts.reduce((carry, part) => carry + part.exerciseCount, 0)} </p> </div> ); }; ReactDOM.render(<App />, document.getElementById("root")); واحذف الملفات غير الضرورية. إنّ التطبيق ككل موجود في مكوّن واحد، وهذا ما لا نريده. أعد كتابة الشيفرة لتتوزع على ثلاثة مكوّنات: Header وContent وTotal. ابق على جميع البيانات ضمن المكوّن App الذي سيمرر كل البيانات اللازمة لمكوّن كخصائص. واحرص على تعريف نوع لكل خاصية من خصائص المكوُنات. سيتحمل المكوًن Header مسؤولية تصيير اسم المنهاج، وسيصيّر المكوّن Content أسماء الأقسام المختلفة وعدد التمارين في كل قسم، أما المكوّن Total فسيصيّر مجموع التمارين في كل الأقسام. سيبدو المكوًن App بالشكل التالي تقريبًا: const App = () => { // const-declarations return ( <div> <Header name={courseName} /> <Content ... /> <Total ... /> </div> ) }; استخدام أدق للأنواع يتضمن المثال السابق منهاجًا من ثلاثة أقسام، ويمتلك كل قسم نفس الصفتين name وexcerciseCount. لكن ماذا لو احتجنا إلى صفات أخرى، وتطلّب كل قسم صفات مختلفة عن الآخر؟ كيف ستبدو شيفرة التطبيق؟ لنتأمل المثال التالي: const courseParts = [ { name: "Fundamentals", exerciseCount: 10, description: "This is an awesome course part" }, { name: "Using props to pass data", exerciseCount: 7, groupProjectCount: 3 }, { name: "Deeper type usage", exerciseCount: 14, description: "Confusing description", exerciseSubmissionLink: "https://fake-exercise-submit.made-up-url.dev" } ]; أضفنا في الشيفرة السابقة صفات أخرى إلى كل قسم. يمتلك الآن كل قسم الصفتين name وexerciseCount، ويمتلك القسمان الأول والثالث الصفة description، كما يمتلك القسمان الثاني والثالث بعض الصفات الإضافية الخاصة. لنتخيل أنّ تطبيقنا سيستمر في النمو، وأننا سنضطر إلى تمرير الأقسام المختلفة ضمن الشيفرة. وقد تّضاف أيضًا خصائص أخرى أو أقسام أخرى. كيف سنضمن أن الشيفرة قادرة على التعامل مع كل الأنواع المختلفة للبيانات بشكل صحيح، وأننا لن ننس تصيير أحد أقسام المنهاج على صفحة ما؟ هنا ستظهر فائدة اللغة TS! لنبدأ بتعريف أنواع لأقسام المنهاج المختلفة: interface CoursePartOne { name: "Fundamentals"; exerciseCount: number; description: string; } interface CoursePartTwo { name: "Using props to pass data"; exerciseCount: number; groupProjectCount: number; } interface CoursePartThree { name: "Deeper type usage"; exerciseCount: number; description: string; exerciseSubmissionLink: string; } ستُنشئ الشيفرة التالية نوعًا موّحَدًا من كل الأنواع. وبالتالي يمكننا استخدامه من أجل مصفوفتنا التي يُفترض بها أن تقبل أي نوع من الأنواع التي تحملها أقسام المنهاج: type CoursePart = CoursePartOne | CoursePartTwo | CoursePartThree; يمكننا الأن تحديد نوع المتغيّر Coursepart، وسيحذرنا المحرر تلقائيًا إن استخدمنا النوع الخاطئ لإحدى الصفات، أو استخدمنا صفة زائدة، أو نسينا أن نحدد صفة متوقعة. اختبر ذلك بوضع علامة تعليق قبل أي صفة لأي قسم. وبفضل القيمة النصية الحرفية التي تحملها السمة name، يمكن أن تحدد TS أي قسم سيحتاج إلى أية صفات إضافية، حتى لو عرفنا المتغير على أنه من النوع الموّحد. لم نصل درجة القناعة بتطبيقنا بعد، فلا زال هناك تكرار كثير للأنواع، ونريد أن نتجنب ذلك. لهذا سنبدأ بتعريف الصفات المشتركة بين جميع الأقسام، ثم سنعرِّف نوعًا أساسيًا (Base Type) يحتويها. سنوسع بعد ذلك النوع الأساسي لإنشاء الأنواع الخاصة بكل قسم: interface CoursePartBase { name: string; exerciseCount: number; } interface CoursePartOne extends CoursePartBase { name: "Fundamentals"; description: string; } interface CoursePartTwo extends CoursePartBase { name: "Using props to pass data"; groupProjectCount: number; } interface CoursePartThree extends CoursePartBase { name: "Deeper type usage"; description: string; exerciseSubmissionLink: string; } كيف سنستخدم هذه الأنواع الآن في مكوًناتنا؟ من الطرق المفيدة في استخدام هذه الأنواع هي في عبارة switch case. فعندما تصرُح علنًا عن نوع المتغير أنه من النوع الموّحد أو استدلت TS على ذلك واحتوى كل نوع موجود ضمن النوع الموًحد صفة معينة، يمكن استخدام ذلك كمعرّفات للأنواع. يمكن بعد ذلك استخدام البنية switch case للتنقل بين الصفات وستحدد TS الصفات الموجودة في كل حالة من حالات البنية switch. ستميّز TS في المثال السابق أن المتغير coursePart من النوع CoursePart. ويمكنها عندها أن تستدل أن المتغير part من أحد الأنواع التالية CoursePartOne أو CoursePartTwo أو CoursePartThree. أما الصفة name فهي مختلفة ومميزة لكل نوع، لذلك من الممكن استخدامها لتمييز الأنواع وستكون TS قادرة على تحديد الصفات الموجودة في كل حالة case من حالات البنية switch case. وهكذا ستعطي خطأً إن حاولت على سبيل المثال أن تستخدم الصفة part.descriptionضمن كتلة الحالة Using props to pass data. كيف سنضيف نوعًا جديدًا؟ من الجيد أن نعرف إن كنا قد أنجزنا مسبقًا آليةً للتعامل مع هذا النوع في شيفرتنا، عند إضافة قسم جديد للمنهاج. فإضافة أي نوع جديد في المثال السابق ستجعله موجودًا في الكتلة default من البنية switch case وبالتالي لن يطبع شيء عن هذا النوع. وقد يكون هذا الأمر مقبولًا في بعض الحالات، كالحالة التي نريد فيها التعامل مع نوع واحد محدد من النوع الموّحد، لكن في أغلب الحالات عليك التعامل مع كل الحالات وبشكل منفصل. يمكن في TS أن نستخدم طريقة تدعى "التحقق الشامل من الأنواع". ومبدأ هذه الطريقة: أنه في حال واجهتنا قيمة غير معروفة النوع، نستدعي دالة تقبل قيمة من النوع never وتعيد قيمة من النوع نفسه. تمثل الشيفرة التالية تطبيقًا مباشرًا لهذا المبدأ: /** * Helper function for exhaustive type checking */ const assertNever = (value: never): never => { throw new Error( `Unhandled discriminated union member: ${JSON.stringify(value)}` ); }; لكن لو أردنا استبدال محتويات الكتلة كالتالي: default: return assertNever(part); ووضع علامة تعليق قبل الكتلة Deeper type usage case block سنرى الخطأ التالي: تنص الرسالة أن معاملًا من النوع CoursePartThree لم يُسند إلى معامل من النوع never. أي أننا نستخدم متغيرًا في مكان ما يفترض به أن يكون من النوع never. وهذا ما يدلنا على وجود مشكلة. لكن بمجرد أن نزيل علامة التعليق التي وضعناها على الكتلة Deeper type usage case block سيختفي الخطأ. التمرين 9.15 9.15 أضف في البداية النوع information إلى الملف index.tsx واستبدل المتغير courseParts بالمتغير الموجود في المثال التالي: // new types interface CoursePartBase { name: string; exerciseCount: number; } interface CoursePartOne extends CoursePartBase { name: "Fundamentals"; description: string; } interface CoursePartTwo extends CoursePartBase { name: "Using props to pass data"; groupProjectCount: number; } interface CoursePartThree extends CoursePartBase { name: "Deeper type usage"; description: string; exerciseSubmissionLink: string; } type CoursePart = CoursePartOne | CoursePartTwo | CoursePartThree; // this is the new coursePart variable const courseParts: CoursePart[] = [ { name: "Fundamentals", exerciseCount: 10, description: "This is an awesome course part" }, { name: "Using props to pass data", exerciseCount: 7, groupProjectCount: 3 }, { name: "Deeper type usage", exerciseCount: 14, description: "Confusing description", exerciseSubmissionLink: "https://fake-exercise-submit.made-up-url.dev" } ]; نعلم الآن أن واجهتي النوع CoursePartThree وCoursePartOne يتقاسمان صفة تدعى description بالإضافة إلى الصفات الأساسية (الموجودة في واجهة النوع الأساسية)، وهذه الصفة من النوع string في كلتا الواجهتين. تقتضي مهمتك الأولى أن تصرِّح عن واجهة نوع جديد تتضمن الصفة description وتوسِّع واجهة النوع CoursePartThree. عدّل الشيفرة بعد ذلك بحيث تصبح قادرًا على إزالة الصفة description من الواجهتين دون حدوث أية أخطاء. أنشئ بعد ذلك المكوّن Part الذي يصيّر كل الصفات من كل نوع ضمن أقسام المنهاج. استخدم آلية تحقق شاملة من الأنواع معتمدًا على بنية switch case. استخدم المكوِّن الجديد ضمن المكوّن Content. أضف في النهاية واجهة نوع لقسم جديد يحوي على الأقل الصفات التالية: name وexerciseCount وdescription. ثم أضف واجهة النوع هذه إلى النوع الموّحد CoursePart وأضف البيانات المتعلقة بالقسم إلى المتغير CourseParts. إن لم تكن قد عدّلت المكوّن Content بشكل صحيح، ستحصل على رسالة خطأ، لأنك لم تضف ما يدعم النوع الخاص بالقسم الرابع. أجر التعديلات المناسبة على المكوّن Content لكي تُصيّر كل صفات القسم الجديد دون أخطاء. ملاحظة حول تعريف أنواع للكائنات لقد استخدمنا واجهات النوع لتعريف أنواع للكائنات مثل DiaryEntry من الفقرة السابقة: interface DiaryEntry { id: number; date: string; weather: Weather; visibility: Visibility; comment?: string; } وCoursePart من هذه الفقرة. interface CoursePartBase { name: string; exerciseCount: number; } لقد كان بمقدورنا تنفيذ ذلك باستخدام نوع بديل type alias. type DiaryEntry = { id: number; date: string; weather: Weather; visibility: Visibility; comment?: string; } يمكنك استخدام الأسلوبين Interface وType لإنشاء نوع في معظم الحالات. لكن هنالك بعض النقاط التي ينبغي الانتباه لها. فلو عرَّفت عدة واجهات نوع لها نفس الاسم ستحصل على واجهة نوع مختلطة، بينما لو عرّفت عدة أنواع لها الاسم ذاته ستحصل على رسالة خطأ مفادها أنّ نوعًا يحمل نفس الاسم قد جرى التصريح عنه مسبقًا. ينصحك توثيق TS باستخدام الواجهات في معظم الحالات. العمل مع شيفرة جاهزة من الأفضل عندما تبدأ العمل على شيفرة جاهزة للمرة الأولى أن تلقي نظرة شاملة على هيكلية المشروع وأسلوب العمل. يمكنك البدء بقراءة الملف README.md الموجود في جذر المستودع. يحتوي هذا الملف عادة على وصف موجز للتطبيق ومتطلباته، وكيف سنجعله يعمل لبدء عملية التطوير. إن لم يكن الملف README متاحًا أو أنّ أحدهم فضّل اختصار الوقت ووضعه كمرجع احتياطي، سيكون من الجيد الاطلاع على الملف package.json. ومن الجيد دائمًا تشغيل التطبيق وتجريبه والتحقق من وظائفه. يمكنك أيضًا تصفح هيكيلة مجلد المشروع لتطلع على وظائفه أو/والمعمارية المستخدمة. لكن هذا الأسلوب لن يفيدك دائمًا، فلربما اختار المطوّر أسلوبًا لم تعهده. ركّزنا في تنظيم المشروع التدريبي الذي سنستخدمه في ما تبقى من هذا القسم على الميزات التي يقدمها. حيث يمكنك الاطلاع على الصفحات التي يعرضها التطبيق، وبعض المكوّنات العامة كالوحدات وحالة التطبيق، وتذكر أن للميزات مجالات مختلفة. فالوحدات هي مكونات مرئية على مستوى واجهة المستخدم بينما تحافظ حالة التطبيق على جميع البيانات تحت الستار لكي تستخدمها بقية المكوّنات. تزوّدك TS بالأنواع التي تخبرك عن شكل بنى البيانات والدوال والمكوّنات والحالة التي ستحصل عليها. يمكنك إلقاء نظرة على الملف types.ts أو أي ملف مشابه كبداية. كما سيساعدك VSCode كثيرًا فمجرد توضيحه للمتغيرات والمعاملات سيمنحك رؤية أفضل للشيفرة. ويعتمد هذا كله بالطبع على طريقة استخدام الأنواع في هذا المشروع. إن احتوى المشروع اختبارات أجزاء أو اختبارت تكامل فسيكون الاطلاع عليها مفيدًا بالتأكيد. فالاختبارات أداة مفيدة جدًا عندما تعيد كتابة الشيفرة أو عند كتابة ميزات جديدة للتطبيق. وتأكد من عدم تخريب أية ميزة موجودة عندما تبدأ بالتلاعب بالشيفرة. سترشدك TS أيضًا بما يتعلق بالمعاملات والقيم المعادة عندما تتغير الشيفرة. إن قراءة الشيفرة مهارة بحد ذاتها. لا تقلق إن لم تستطع فهم الشيفرة في البداية، فقد تحتوي الشيفرات على بعض الحالات غير الواضحة أو قد تحتوي أجزاءً أضيفت هنا وهناك خلال عملية التطوير. فلا يمكننا تصور المشاكل التي عانى منها المطوّر السابق لهذه الشيفرة. ويتطلب فهم تفاصيل الشيفرة بشكل كامل الغوص إلى أعماقها وفهم متطلبات المجال الذي تعمل عليه. فكلما قرأت شيفرات أكثر ستصبح أفضل في فهمها واستخدامها. إقرأ شيفرات أكثر مما تكتبه. الواجهة الأمامية لتطبيق إدارة المرضى لقد حان الوقت لإكمال الواجهة الأمامية للواجهة الخلفية التي بنيناها في التمارين السابقة وقبل أن نتعمق في كتابة الشيفرة، سنشغل الواجهتين معًا. إن سار كل شيء على ما يرام، سترى صفحة ويب تضم قائمة بالمرضى. تحضر الصفحة قائمة المرضى من الواجهة الخلفية وتصيّرها على الشاشة ضمن جدول بسيط. ستجد أيضًا زرًا لإضافة مريض جديد إلى الواجهة الخلفية. وطالما أننا نستخدم بيانات وهمية بدلًا من قواعد البيانات، فلن تخزّن البيانات التي أضفناها عند إغلاق الواجهة الخلفية. وطبعًا تقييم تصميم واجهة المستخدم UI ليس في صالح المصمم، لذلك سنهمل موضوع واجهة المستخدم حاليًا. بعد التأكد من كل الأمور، يمكننا الشروع في دراسة الشيفرة. سنجد معظم النقاط المهمة في المجلد /‎src. ولمساعدتك هنالك أيضًا ملف جاهز يصف الأنواع الرئيسية التي يستخدمها التطبيق، والتي علينا ان نوسِّعها أو نعيد كتابتها خلال التمارين. يمكننا من حيث المبدأ استخدام الأنواع نفسها في الواجهتين الأمامية والخلفية، لكن الواجهة الأمامية ستحتوي على بنى بيانات مختلفة وستستخدمها في حالات مختلفة، مما يجعل الأنواع ضمنها مختلفة. فللواجهة الأمامية على سبيل المثال حالة للتطبيق، ولربما أردت تخزين البيانات ضمن كائنات، بينما تستخدم الواجهة الخلفية مصفوفة. وقد لا تحتاج الواجهة الأمامية إلى كل حقول كائن البيانات المخزّن في الواجهة الخلفية، كما قد تضيف حقولًا جديدة لاستخدامها في التصيير. ستبدو هيكلية المجلد على النحو التالي تحتوي الواجهة الخلفية حاليًا مكوّنين هما: AddPatientModal وPatientListPage. يحتوي المجلد state على الشيفرة التي تتعامل مع حالة الواجهة الأمامية. وتقتصر وظيفة الشيفرة في هذا المجلد على إبقاء البيانات في مكان واحد وتزويد الواجهة بأفعال بسيطة لتبديل حالة التطبيق. التعامل مع حالة التطبيق لندرس الطرق المتبعة في التعامل مع حالة التطبيق. إذ يبدو أنّ الكثير من الأشياء تجري خلف الستار، وهي مختلفة قليلًا عن الطرق التي استخدمت سابقًا في المنهاج. بُني أسلوب إدارة الحالة باستخدام الخطافين useContext وuseReducer للمكتبة React. وهذا قرار صائب لأنّ التطبيق صغير نوعًا ما ولن نحتاج إلى Redux أو إلى أية مكتبات أخرى لإدارة الحالة. ستجد على الانترنت مواد تعليمة مفيدة عن استخدام هذه المقاربة. تستخدم هذه المقاربة أيضًا الواجهة البرمجية context العائدة للمكتبة React، إذ ينص توثيق الواجهة أنها: فالبيانات المشتركة في حالتنا هي حالة التطبيق ودالة الإيفاد التي تستخدم لإجراء التعديلات على البيانات. ستعمل شيفرتنا بأسلوب يشابه كثيرًا أسلوب Redux في إدارة الحالة والذي خبرناه في القسم 6، لكنها شيفرة أسرع لأنها لا تحتاج أية مكتبات خارجية. يفترض هذا القسم أنك مطلع على طريقة عمل Redux على الأقل، وبعبارة أخرى من المفترض أنك اطلعت على الفصل الأول من القسم 6. تتضمن الواجهة البرمجية context قناة تضم حالة التطبيق ودالة إيفاد لتغيير الحالة. وقد حُدِّد نوع للحالة على النحو التالي: export type State = { patients: { [id: string]: Patient }; }; فالحالة كما تُظهر الشيفرة هي كائن بمفتاح واحد يدعى Patients يمتلك قاموسًا، أو بعبارة أخرى، يقبل كائنًا له مفاتيح من النوع "string" مع كائن Patient كقيمة له. يمكن لقرينة الفهرسة أن تكون من أحد النوعين "string" أو"number" إذ يمكننا الوصول إلى قيم الكائن باستخدام هذين النوعين. وبهذا نجبر الحالة أن تتقيد بالصيغة التي نريد، وتمنع المطورين من استخدامها بشكل غير صحيح. لكن انتبه إلى نقطة مهمة! عندما يُصرّح عن نوع بالطريقة التي اتبعناها مع patients، فلن تمتلك TS أية طريقة لمعرفة إن كان المفتاح الذي تحاول الوصول إليه موجودًا أم لا. فلو حاولنا الوصول إلى بيانات مريض بمعرِّف غير موجود، سيعتقد المصرّف أن القيمة المعادة من النوع Patient، ولن تلق أية أخطاء عند محاولة الوصول إلى خصائص هذا الكائن: const myPatient = state.patients['non-existing-id']; console.log(myPatient.name); // لا أخطاء إذ يعتقد المصرّف أن القيمة المعادة //patient من النوع لإصلاح الخلل، يمكننا أن نعرّف نوع القيم التي تحمل بيانات المريض على أنها من نوعٍ موحّد بين Patient وundefined كالتالي: export type State = { patients: { [id: string]: Patient | undefined }; }; وجراء هذا الحل، سيحذرنا المصرّف بالرسالة التالية: const myPatient = state.patients['non-existing-id']; console.log(myPatient.name); // error, Object is possibly 'undefined' من الجيد دائمًا إنشاء هذا النوع مع بعض الإضافات الأمنية عندما تستخدم مثلًا بيانات من مصادر خارجية أو قيمة أدخلها المستخدم للوصول إلى بيانات ضمن شيفرتك. لكن إن كنت متأكدًا من أن شيفرتك قادرة على التعامل مع البيانات غير الموجودة، فلا مانع أبدًا من استخدام أول حلٍ قدمناه. وعلى الرغم من عدم استخدام هذا الأسلوب في هذا القسم، ينبغي الإشارة إلى أن استخدام كائنات Map سيؤمن لك طريقة أكثر تشددًا في استخدام الأنواع. حيث يمكنك أن تُصرح عن نوع لكلٍ من المفتاح ومحتواه. تعيد دالة الوصول ()getإلى كائنات Map نوعًا موحّدًا يجمع بين النوع المصرّح عنه وundefined، وبالتالي ستطلب TS تلقائيُا إجراء تحقق من البيانات المستخلصة من كائن Map: interface State { patients: Map<string, Patient>; } ... const myPatient = state.patients.get('non-existing-id'); // type for myPatient is now Patient | undefined console.log(myPatient.name); // error, Object is possibly 'undefined' console.log(myPatient?.name); // valid code, but will log 'undefined' تُنفذ التعديلات على الحالة باستخدام دوال الاختزال، تمامًا كما في Redux. عُرِّفت هذه الدوال في الملف reducer.ts بالإضافة إلى النوع Action الذي يبدو على النحو التالي: export type Action = | { type: "SET_PATIENT_LIST"; payload: Patient[]; } | { type: "ADD_PATIENT"; payload: Patient; }; تبدو دالة الاختزال مشابه تمامًا للدوال التي أنشأناها في القسم 6. حيث تغيّر حالة كل نوع من الأفعال: export const reducer = (state: State, action: Action): State => { switch (action.type) { case "SET_PATIENT_LIST": return { ...state, patients: { ...action.payload.reduce( (memo, patient) => ({ ...memo, [patient.id]: patient }), {} ), ...state.patients } }; case "ADD_PATIENT": return { ...state, patients: { ...state.patients, [action.payload.id]: action.payload } }; default: return state; } }; ينحصر الفرق في أنّ الحالة الآن على شكل قاموس (أو كائن) بدلًا من المصفوفة التي استخدمناها في القسم 6. تجري الكثير من الأمور في الملف state.ts والتي تهيئ سياق العمل. ويعتبر الخطاف useReducer الذي يستخدم لإنشاء الحالة، ودالة الإيفاد المكونان الرئيسيان لإنجاز التغييرات على حالة التطبيق، حيث يمرران إلى التابع context.povider: export const StateProvider: React.FC<StateProviderProps> = ({ reducer, children }: StateProviderProps) => { const [state, dispatch] = useReducer(reducer, initialState); return ( <StateContext.Provider value={[state, dispatch]}> {children} </StateContext.Provider> ); }; يستخدم التابع السابق في تزويد كل المكوّنات بالحالة ودالة الإيفاد، وذلك بفضل الإعدادات الموجودة في الملف index.ts: import { reducer, StateProvider } from "./state"; ReactDOM.render( <StateProvider reducer={reducer}> <App /> </StateProvider>, document.getElementById('root') ); كما يُعرّف أيضًا الخطاف useStateValue: export const useStateValue = () => useContext(StateContext); وتستعمله أيضًا المكوّنات التي تحتاج إلى الحالة أو دالة الإيفاد لتخزينهما: import { useStateValue } from "../state"; // ... const PatientListPage: React.FC = () => { const [{ patients }, dispatch] = useStateValue(); // ... } لا تقلق إن بدا الأمر مربكًا قليلًا، فبالطبع سيبقى كذلك حتى تدرس توثيق context وطريقة استخدامها في إدارة الحالة. لكن ليس عليك فهم كل ذلك بشكل كامل حتى تحل التمارين. من الشائع جدًا أن لا تفهم بشكل كامل ما تفعله الشيفرة خلف الستار، عندما تبدأ العمل على مشروع جاهز. فإن كان المشروع مبني بهيكلية جيدة يمكنك أن تثق أن أية تعديلات مدروسة جيدًا لن تؤثر على عمل التطبيق، على الرغم من عدم إدراكك لكامل آليات العمل التي تجري داخله. مع الوقت ستفهم الأجزاء التي لم تكن مألوفة بالنسبة لك، لكن ذلك لن يحدث بين يوم وليلة وخاصة عندما تعمل على مشروع ذو شيفرة ضخمة. صفحة قائمة المرضى لنتوجه إلى الملف index.ts الموجود في المجلد PateintListPage لنأخذ بعض الأفكار التي تساعدنا على إحضار البيانات من الواجهة الخلفية وتحديث حالة التطبيق. تستخدم الصفحة خطافًا أنشئ خصيصًا لوضع البيانات في الحالة ودالة الإيفاد لتحديث محتوياتها. فعندما ننشئ قائمة بالمرضى لا نحتاج سوى تفكيك خصائص الكائن patients الموجود في حالة التطبيق: import { useStateValue } from "../state"; const PatientListPage: React.FC = () => { const [{ patients }, dispatch] = useStateValue(); // ... } ونستخدم أيضًا الحالة app التي أنشئت باستخدام الخطاف useState لإدارة حالات إظهار الوحدة أو إخفائها، وكذلك معالجة الأخطاء: const [modalOpen, setModalOpen] = React.useState<boolean>(false); const [error, setError] = React.useState<string | undefined>(); نمرر للخطاف useState نوعًا كمعامل، ثم يُطبَّق على الحالة الفعلية. فالمعامل modalOpen من النوع Boolean والمعامل error من النوع string | undefined. وكلا دالتي الضبط اللتان يعيدهما الخطاف useState سيقبلان معاملات من نوع يماثل النوع الذي يمرر إليها من خلال معامل (يمتلك نوعًا). فالنوع الفعلي للدالة setModalOpen هو ما يلي: <<React.Dispatch<React.SetStateAction<boolean. كما نستخدم الدالتين المساعدتين closeModal وopenModal لقراءة البيانات بشكل أفضل وأكثر ملاءمة: const openModal = (): void => setModalOpen(true); const closeModal = (): void => { setModalOpen(false); setError(undefined); }; تتعلق الأنواع في الواجهة الأمامية بما قمت بتطويره بإنشائه عند تطوير الواجهة الخلفية في الفصل السابق. سيحضر المكوّن App عندما يُثبَّت المرضى مستخدمًا المكتبة axios. ويجدر الانتباه إلى أننا مررنا نوعًا على شكل معامل إلى الدالة axios.get لكي نحدد نوع البيانات التي سنحصل عليها من الاستجابة: React.useEffect(() => { axios.get<void>(`${apiBaseUrl}/ping`); const fetchPatientList = async () => { try { const { data: patients } = await axios.get<Patient[]>( `${apiBaseUrl}/patients` ); dispatch({ type: "SET_PATIENT_LIST", payload: patients }); } catch (e) { console.error(e); } }; fetchPatientList(); }, [dispatch]); تحذير! لن تُقيَّم البيانات عندما نمرر النوع كمعامل إلى المكتبة axios. وهو خطر تمامًا وخاصة إن كنت تستخدم واجهة برمجية خارجية. لتفادي ذلك يمكنك إنشاء دوال تقييم مخصصة تتحمل عبء التقييم وتعيد النوع الصحيح، أو يمكنك استخدام "حاميات النوع". ستجد أيضًا عدة مكتبات تزوّدك بأساليب لتقييم البيانات ضمن أنواع مختلفة من التخطيطات، مثل المكتبة io-ts. لكننا وتوخيًا للبساطة سنثق أننا سنحصل على البيانات بشكلها الصحيح من الواجهة الخلفية. وطالما أن تطبيقنا صغير، سنحدِّث الحالة باستدعاء دالة الإيفاد التي يؤمنها لنا الخطاف useStateValue. وسيساعدنا المصرّف بالتأكد من أننا أوفدنا الأفعال بما يوافق النوع Action الذي يضم قيمة محددة مسبقًا من النوع string وحمولة من البيانات payload. dispatch({ type: "SET_PATIENT_LIST", payload: patients }); التمارين 9.16 - 9.18 سنضيف قريبًا النوع Entry إلى التطبيق والذي يمثّل مُدخلًا على شكل ملخّص بسيط عن المريض. يتكون الملخص من نص وصفي، وتاريخ الإنشاء، ومعلومات تتعلق بالاختصاصي الذي أنشأ الملخص ورمز التشخيص المحتمل. ترتبط شيفرة التشخيص بالرموز ICD-10 التي تُعيدها وصلة التخديم api/diagnosis/. سيكون ما ننفذه بسيطًا، إذ يعطي لكل مريض مصفوفة من المُدخلات. سنُجري بعض التحضيرات، قبل الشروع في العمل. 9.16 تطبيق إدارة المرضى: الخطوة 1 أنشئ وصلة تخديم على العنوان api/patients/:id/ تعيد كل المعلومات عن مريض، بما فيها مصفوفة المُدخلات الخاصة به والتي ستبقى فارغة لجميع المرضى. وسّع حاليًا الأنواع في الواجهة الخلفية على النحو التالي: // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface Entry { } export interface Patient { id: string; name: string; ssn: string; occupation: string; gender: Gender; dateOfBirth: string; entries: Entry[]} export type PublicPatient = Omit<Patient, 'ssn' | 'entries' > ينبغي أن تبدو الاستجابة كالتالي: 9.17 تطبيق إدارة المرضى: الخطوة 2. أنشئ صفحة لإظهار المعلومات الكاملة عن مريض ضمن الواجهة الأمامية. ينبغي أن يكون المستخدم قادرًا على الوصول إلى معلومات المريض بالنقر على اسم المريض مثلًا. أحضر البيانات من وصلة التخديم التي أنشأتها في التمرين السابق. أضف المعلومات التي ستحضرها عن المريض إلى حالة التطبيق. لا تحضر المعلومات إن كانت موجودة مسبقًا في حالة التطبيق، أي في الحالة التي يعرض فيها المستخدم معلومات مريض أكثر من مرة. طالما أننا سنستخدم حالة التطبيق ضمن سياق العمل، عليك أن تُعرّف فعلًا جديدًا لتحديث بيانات مريض محدد. يستخدم التطبيق المكتبة Semantic UI React لإضافة التنسيقات إليه. وهي مكتبة مشابهة كثيرًا للمكتبتين React Bootstrap وMaterialUI اللتان تعاملنا معهما في القسم 7. يمكنك استخدامهما لتنسيق المكوّن الجديد، وهذا أمر يعود إليك، فاهتمامنا ينصب الآن على TS. ويستخدم التطبيق أيضًا المكتبة react router للتحكم بإظهار واجهات العرض على الشاشة. عليك إلقاء نظرة على القسم 7 إن لم تكن متمكنًا من فهم آلية عمل الموجّهات routers. ستبدو النتيجة كالتالي: يظهر جنس المريض باستخدام المكون Icon من المكتبة Semantic UI React. ملاحظة: لتصل إلى المُعرِّف بكتابة عنوان المورد، عليك إعطاء الخطاف useParams معامل من نوع مناسب: const { id } = useParams<{ id: string }>(); 9.18 تطبيق إدارة المرضى: الخطوة 3. سننشئ حاليًا كائنات أفعال action في أي مكان نوفد إليه هذه الأفعال. إذ يمتلك المكوّن App مثلًا دالة الإيفاد التالية: dispatch({ type: "SET_PATIENT_LIST", payload: patientListFromApi }); أعد كتابة الشيفرة مستخدمًا دوال توليد الأفعال المُعرّفة ضمن الملف "reducer.tsx". سيتغير المكوِّن App مثلًا على النحو التالي: import { useStateValue, setPatientList } from "./state"; // ... dispatch(setPatientList(patientListFromApi)); مدخلات كاملة أنجزنا في التمرين 9.12 وصلة تخديم لإحضار تشخيص مريض، لكننا لم نستخدمها بعد. من الجيد توسيع بياناتنا قليلُا بعد أن أصبح لدينا صفحة لعرض معلومات المرضى. لنضف إذًا حقلًا من النوع Entry إلى بيانات المريض، تتضمن مدخلاته الدوائية مع التشخيص المحتمل. لنفصل بنية بيانات المرضى السابقة عن الواجهة الخلفية ونستخدم الشكل الجديد الموسَّع. ملاحظة: إن تنسيق البيانات هذه المرة بالصيغة "ts." وليس بالصيغة ".json". كما أن النوعين Gender وPatient جاهزين مسبقًا، لذلك كل ما عليك الآن هو تصحيح مسار إدراجهما إن اقتضت الحاجة. لننشئ النوع Entry بشكل ملائم بناء على البيانات المتوفرة. لو ألقينا الآن نظرة أقرب على البيانات المتوفرة، سنجد أن المُدخلات مختلفة عن بعضها قليلًا. لاحظ الاختلافات بين أول مدخلين على سبيل المثال: { id: 'd811e46d-70b3-4d90-b090-4535c7cf8fb1', date: '2015-01-02', type: 'Hospital', specialist: 'MD House', diagnosisCodes: ['S62.5'], description: "Healing time appr. 2 weeks. patient doesn't remember how he got the injury.", discharge: { date: '2015-01-16', criteria: 'Thumb has healed.', } } ... { id: 'fcd59fa6-c4b4-4fec-ac4d-df4fe1f85f62', date: '2019-08-05', type: 'OccupationalHealthcare', specialist: 'MD House', employerName: 'HyPD', diagnosisCodes: ['Z57.1', 'Z74.3', 'M51.2'], description: 'Patient mistakenly found himself in a nuclear plant waste site without protection gear. Very minor radiation poisoning. ', sickLeave: { startDate: '2019-08-05', endDate: '2019-08-28' } } سنرى أن بعض الحقول متطابقة لكن المُدخل الأول يمتلك الحقل discharge والثاني يمتلك الحقلين employerName وsickLeave. وبشكل عام تحتوي جميع المدخلات حقول مشتركة وحقول خاصة. وبالنظر إلى الحقل type، سنجد ثلاثة أشكال للمدخلات تشير إلى حاجتنا إلى ثلاثة أنواع منفصلة: OccupationalHealthcare Hospital HealthCheck. تشترك المدخلات بعدة حقول، وبالتالي من المناسب إنشاء واجهة نوع أساسي تدعى "Entry" قابلة للتوسّع بإضافة حقول خاصة لكل نوع. تُعَدّ الحقول الآتية مشتركةً بين كل المدخلات: id description date specialist ويبدو أن الحقل diagnosesCodes موجود فقط في المدخلات من النوعين OccupationalHealthcare وHospital. وطالما أنه لا يستخدم دائمًا حتى في هذين النوعين، فسنفترض أنه حقل اختياري. ويمكن إضافته إلى النوع HealthCheck أيضًا، إذ ليس من الضرورة استخدامه في أي من المدخلات الثلاث. ستبدو واجهة النوع الأساسي كالتالي: interface BaseEntry { id: string; description: string; date: string; specialist: string; diagnosisCodes?: string[]; } لو أردنا تهيئة الواجهة بشكل أفضل، ولعلمنا أن النوع Diagnoses قد عُرِّف مسبقًا في الواجهة الخلفية، من الممكن إذًا الإشارة مباشرة إلى حقل الشيفرة للنوع Diagnoses في حال تغيّر نوعه لسبب ما. سننفذ ذلك على النحو التالي: interface BaseEntry { id: string; description: string; date: string; specialist: string; diagnosisCodes?: Array<Diagnosis['code']>; } وتذكّر أن <Array<Type هي صيغة بديلة للتعليمة []Type. ومن الأفضل والأوضح في حالات كهذه أن نستخدم المصفوفة، لأن استخدام الخيار الآخر سيدفعنا إلى تعريف النوع بالعبارة DiagnosisCode والتي تبدو غريبة بعض الشيء. يمكننا الآن وبعد تعريف النوع الأساسي Entry، أن ننشئ الأنواع Entry الموسّعة التي سنستخدمها فعليًا. وسنبدأ بإنشاء النوع HealthCheckEntry. تحتوي المدخلات من النوع HealthCheck على الحقل HealthCheckRating الذي يأخذ قيمًا صحيحة بين 0 و 3. تعني القيمة 0 أن المريض بصحة جيدة، أما القيمة 3 فتعني أن المريض بحالة حرجة. وهذا ما يجعل استخدام التعداد مثاليًا. وبناء على المعطيات السابقة يمكن تعريف النوع HealthCheckEntry على النحو التالي: export enum HealthCheckRating { "Healthy" = 0, "LowRisk" = 1, "HighRisk" = 2, "CriticalRisk" = 3 } interface HealthCheckEntry extends BaseEntry { type: "HealthCheck"; healthCheckRating: HealthCheckRating; } يبقى علينا الآن إنشاء النوعين OccupationalHealthcareEntry وHospitalEntry ومن ثم نضم الأنواع الثلاثة ونصدّرها كنوع موحّد Entry كالتالي: export type Entry = | HospitalEntry | OccupationalHealthcareEntry | HealthCheckEntry; التمارين 9.19 - 9.22 9.19 تطبيق إدارة المرضى: الخطوة 4 عّرف النوعين بما يتلائم مع البيانات الموجودة. تأكد أن الواجهة الخلفية ستعيد المُدخل المناسب عندما تتوجه لإحضار معلومات مريض محدد. استخدم الأنواع بشكل صحيح في الواجهة الخلفية. لا حاجة الآن لتقييم بيانات كل الحقول في الواجهة الخلفية، ويكفي التحقق أن الحقل type سيحتوي قيمة من النوع الصحيح. 9.20 تطبيق إدارة المرضى: الخطوة 5 وسّع صفحة المريض في الواجهة الأمامية لإظهار قائمة تضم تاريخ ووصف ورمز التشخيص لكل مدخلات المريض. يمكنك استخدام نفس النوع Entry الذي عرّفناه الآن ضمن الواجهة الأمامية. ويكفي في هذه التمارين أن ننقل تعريف الأنواع كما هو من الواجهة الخلفية إلى الأمامية (نسخ/لصق). قد يبدو حلك قريبًا من التالي: 9.21 تطبيق إدارة المرضى: الخطوة 6 أحضر التشخيص ثم أضفه إلى حالة التطبيق من وصلة التخديم api/diagnosis/. استخدم بيانات التشخيص الجديدة لإظهار توصيف رموز التشخيصات للمريض. 9.22 تطبيق إدارة المرضى: الخطوة 7 وسّع قائمة المدخلات في صفحة المريض لتتضمن تفاصيل أكثر باستخدام مكوّن جديد يُظهِر بقية المعلومات الموجودة ضمن مدخلات المريض ومميزًا الأنواع عن بعضها. يمكنك أن تستخدمعلى سبيل المثال المكوّن Icon أو أي مكوًن آخر من مكوّنات المكتبة SemanticUI للحصول على مظهر مناسب لقائمتك. ينبغي تنفيذ عملية تصيير شرطية باستخدام البنية switch case وآلية التحقق الشاملة من الأنواع لكي لا تنس أية حالة. تأمل الشكل التالي: ستبدو قائمة المدخلات قريبة من الشكل التالي: نموذج لإضافة مريض قد يكون التعامل مع النماذج مزعجًا أحيانًا في ، لذلك قررنا استخدام الحزمة Formik لإنشاء نموذج لإضافة مريض في تطبيقنا. فيما سيأتي تقديم بسيط اجتزأناه من توثيق : ستنظم Formik الأشياء بتجميع العمليات السابقة في مكان واحد، وستغدو الاختبارات و إعادة كتابة الشيفرة وتقييم النماذج أمرًا يسيرًا. يمكنك إيجاد شيفرة النموذج في الملف src/AddPatientModal/AddPatientForm.tsx ،كما يمكنك إيجاد الدوال المساعدة لهذا النموذج في الملف src/AddPatientModal/FormField.tsx. لو نظرت إلى الملف AddPatientForm.tsx لوجدت أننا أنشأنا نوعًا لقيم النموذج يدعى PatientFormValues. يمثل هذا النوع نسخة معدلة عن النوع Patient بعد حذف الخاصيتين id و entries. فلا نريد أن يرسل المستخدم المعلومات الموجودة ضمن هاتين الخاصيتين عند إنشاء مريض جديد. فالمعرِّف id ستنشئه الواجهة الخلفية والمدخلات entries لا يمكن إضافتها إلا لمريض موجود مسبقًا. export type PatientFormValues = Omit<Patient, "id" | "entries">; سنصرّح تاليًا عن خصائص المكوّن الخاص بالنموذج: interface Props { onSubmit: (values: PatientFormValues) => void; onCancel: () => void; } يتطلب المكوّن وجود خاصيتين: onSubmit وonCancel. وكلاهما دالة استدعاء لا تعيد قيمًا. ينبغي أن تتلقى الدالة كائنًا من النمط "PatientFormValues" كمعامل لكي تتمكن من معالجة قيم النموذج. بالنظر إلى مكوّن الدالة AddPatientForm، ستجد أننا ربطنا به خصائصه، ومن ثم فككنا onSubmit وonCancel من هذه الخصائص. export const AddPatientForm: React.FC<Props> = ({ onSubmit, onCancel }) => { // ... } قبل أن نكمل، لنلق نظرة على الدوال المساعدة في نموذجنا والموجودة في الملف FormField.tsx. لو تحققت من الأشياء التي يصدرها الملف، ستجد النوع GenderOption ومكوني الدوال SelectField وTextField. لنلق نظرة على مكوِّن الدالة SelectField والأنواع المتعلقة به. أنشأنا أولًا نوعًا معممًا لكل كائن خيارات يتضمن قيمة وتسمية. هذه هي أنواع كائنات الخيارات التي سنسمح بها في نموذجنا ضمن حقل الخيارات. وطالما أن الخيار الوحيد الذي سنسمح به هو جنس المريض سنجعل القيمة value من النوع Gender. export type GenderOption = { value: Gender; label: string; }; أعطينا المتغير genderOptions في الملف AddPatientForm.tsx النوع GenderOption وصرحنا أنه مصفوفة تحتوي على كائنات من النوع GenderOption. const genderOptions: GenderOption[] = [ { value: Gender.Male, label: "Male" }, { value: Gender.Female, label: "Female" }, { value: Gender.Other, label: "Other" } ]; لاحظ إيضًا النوع SelectFieldProps. إذ يقوم بتعريف نوع لخصائص مكوِّن الدالة SelectField. سترى أن الخيارات عبارة عن مصفوفة من النوع GenderOption. type SelectFieldProps = { name: string; label: string; options: GenderOption[]; }; إن عمل مكوِّن الدالة SelectField واضح تمامًا. فهو يصيّر التسمية ويختار عنصرًا، ومن ثم يعطي كل الخيارات المتاحة لهذا العنصر ( قيم هذه الخيارات وتسمياتها). export const SelectField: React.FC<SelectFieldProps> = ({ name, label, options }: SelectFieldProps) => ( <Form.Field> <label>{label}</label> <Field as="select" name={name} className="ui dropdown"> {options.map(option => ( <option key={option.value} value={option.value}> {option.label || option.value} </option> ))} </Field> </Form.Field> ); لننتقل الآن إلى مكوِّن الدالة TextField. يُصيِّر هذا المكوّن الكائن Form.Field من المكتبة SemanticUI إلى تسمية ومكوّن Field من المكتبة Formik. يتلقى هذا المكوًن القيمة name والقيمة placeholder كخصائص. interface TextProps extends FieldProps { label: string; placeholder: string; } export const TextField: React.FC<TextProps> = ({ field, label, placeholder }) => ( <Form.Field> <label>{label}</label> <Field placeholder={placeholder} {...field} /> <div style={{ color:'red' }}> <ErrorMessage name={field.name} /> </div> </Form.Field> ); لاحظ أننا استخدمنا المكوّن ErrorMessage من المكتبة Formik لتصيير رسالة خطأ تتعلق بالقيمة المدخلة إن اقتضى الأمر. ينفذ المكوّن كل ما يلزم خلف الستار، فلا حاجة لكتابة شيفرة خاصة لذلك. من الممكن أيضًا الاحتفاظ برسالة الخطأ ضمن المكوّن باستخدام الخاصيّة form. export const TextField: React.FC<TextProps> = ({ field, label, placeholder, form }) => { console.log(form.errors); // ... } سنعود الآن إلى مكوّن النموذج الفعلي AddPatientForm.tsx. سيصيّر مكون الدالة AddPatientForm مكوّن Formik. سيغلّف هذا المكون بقية المكوّنات ويتطلب خاصيتين initialValues وonSubmit. شيفرة دالة الخصائص واضحة تمامًا. سيتعقب مكوِّن Formik حالة النموذج وسيظهر هذه الحالة بالإضافة إلى عدة توابع قابلة لإعادة الاستخدام ومعالجات الأحداث ضمن النموذج عن طريق الخصائص. نستخدم أيضًا الخاصية الاختيارية valiate التي تقبل دالة تقييم وتعيد كائنًا يتضمن الأخطاء المحتملة. نتحقق في نموذجنا أن الحقول النصية ليست فارغة، لكن بالإمكان أن نضيف بعض المعايير الأخرى للتقييم كالتأكد من صحة رقم الضمان الاجتماعي على سبيل المثال. يمكن لرسائل الخطأ التي تحددها دالة التقييم أن تُعرض ضمن مكوّن ErrorMessage الخاص بكل حقل. لنلق نظرة أولًا على المكوّن بشكله الكامل. ثم سنناقش بعدها أقسامه المختلفة بشيء من التفصيل. interface Props { onSubmit: (values: PatientFormValues) => void; onCancel: () => void; } export const AddPatientForm: React.FC<Props> = ({ onSubmit, onCancel }) => { return ( <Formik initialValues={{ name: "", ssn: "", dateOfBirth: "", occupation: "", gender: Gender.Other }} onSubmit={onSubmit} validate={values => { const requiredError = "Field is required"; const errors: { [field: string]: string } = {}; if (!values.name) { errors.name = requiredError; } if (!values.ssn) { errors.ssn = requiredError; } if (!values.dateOfBirth) { errors.dateOfBirth = requiredError; } if (!values.occupation) { errors.occupation = requiredError; } return errors; }} > {({ isValid, dirty }) => { return ( <Form className="form ui"> <Field label="Name" placeholder="Name" name="name" component={TextField} /> <Field label="Social Security Number" placeholder="SSN" name="ssn" component={TextField} /> <Field label="Date Of Birth" placeholder="YYYY-MM-DD" name="dateOfBirth" component={TextField} /> <Field label="Occupation" placeholder="Occupation" name="occupation" component={TextField} /> <SelectField label="Gender" name="gender" options={genderOptions} /> <Grid> <Grid.Column floated="left" width={5}> <Button type="button" onClick={onCancel} color="red"> Cancel </Button> </Grid.Column> <Grid.Column floated="right" width={5}> <Button type="submit" floated="right" color="green" disabled={!dirty || !isValid} > Add </Button> </Grid.Column> </Grid> </Form> ); }} </Formik> ); }; export default AddPatientForm; يمتلك مكوّن التغليف دالة تعيد محتويات النموذج كمكوِّن ابن child component. ونستخدم العنصر Form من المكتبة Formik لتصيير العناصر الفعلية للنموذج. سنضع داخل هذا العنصر مكونات الدوال TextField وSelectField التي أنشأناها في الملف FormField.tsx. سننشئ في النهاية زرين: الأول لإلغاء إرسال معلومات النموذج، وآخر لإرسالها. يستدعي الزر الأول الدالة onCancel مباشرة، ويحرّض الزر الثاني الحدث onSubmitt للمكتبة Formik الذي يستدعي بدوره الدالة onSubmitt من خصائص المكوّن. سيُفعَّل زر الإرسال إن كان النموذج صالحًا (يعيد القيمة valid) و مستعملًا (يعيد القيمة dirty). أي عندما يضيف المستخدم معلومات ضمن بعض الحقول. نعالج عملية إرسال بيانات النموذج باستخدام المكتبة لأنها تسمح لنا باستدعاء دالة التقييم قبل الشروع فعليًا بإرسال البيانات. فإن أعادة دالة التقييم أية أخطاء، سيلغى الإرسال. وُضع الزرين داخل العنصر Grid من المكتبة Formik لضبطهما متجاورين بطريقة سهلة. <Grid> <Grid.Column floated="left" width={5}> <Button type="button" onClick={onCancel} color="red"> Cancel </Button> </Grid.Column> <Grid.Column floated="right" width={5}> <Button type="submit" floated="right" color="green"> Add </Button> </Grid.Column> </Grid> يُمرر الاستدعاء onSubmit من صفحة قائمة المرضى نزولًا إلى نموذج إضافة مريض، ليرسل طلب HTTP-POST إلى الواجهة الخلفية، ويضيف المريض الذي تعيده الواجهة الخلفية إلى حالة التطبيق ويغلق النموذج. إن أعادت الواجهة الخلفية خطأً، سيُعرض الخطأ على النموذج. تمثل الشيفرة التالية دالة الاستدعاء: const submitNewPatient = async (values: FormValues) => { try { const { data: newPatient } = await axios.post<Patient>( `${apiBaseUrl}/patients`, values ); dispatch({ type: "ADD_PATIENT", payload: newPatient }); closeModal(); } catch (e) { console.error(e.response.data); setError(e.response.data.error); } }; ينبغي أن تكون قادرًا على إكمال بقية التمارين في هذا القسم بناء على المعلومات التي قدمناها. وإن راودتك أية شكوك، أعد قراءة الشيفرة الموجودة، فقد تعطيك تلميحًا لكيفية المتابعة. التمارين 9.23 - 9.27 9.23 تطبيق إدارة المرضى: الخطوة 8 صممنا التطبيق بحيث يمكن أن تجد عدة مدخلات لنفس المريض. لا توجد حتى الآن آلية في تطبيقنا لإضافة مُدخلات. عليك الآن أن تضيف وصلة تخديم على العنوان api/patients/:id/entries/ في الواجهة الخلفية، لتتمكن من إضافة مُدخل لمريض. تذكر أن هناك أنواع مختلفة من المدخلات في تطبيقنا، ويجب أن تدعم الواجهة الخلفية كل هذه الأنواع، وتحقق من وجود كل الحقول المطلوبة لكل نوع. 9.24 تطبيق إدارة المرضى: الخطوة 9 ستدعم الواجهة الخلفية الآن إضافة المُدخلات، ونريد أن ننفذ الوظيفة نفسها في الواجهة الأمامية. عليك إذًا إنشاء نموذج لإضافة مُدخل إلى سجل مريض. وتعتبر صفحة المريض مكانًا مناسبًا للوصول إلى هذا النموذج. يكفي في هذا التمرين أن تدعم نوعًا واحدًا من المدخلات، وليس مطلوبًا معالجة كافة الأخطاء الآن. إذ يكفي أن تنشئ بنجاح مدخلًا جديدًا عندما تُملأ حقول النموذج ببيانات صحيحة. ينبغي أن يُضاف مُدخل جديد إلى سجل المريض المحدد بعد نجاح عملية إرسال بيانات النموذج. كما ينبغي تحديث صفحة المريض ليظهر المُدخل الجديد. يمكنك إن أردت استخدام جزء من شيفرة نموذج إضافة مريض، لكن ذلك غير ضروري. انتبه إلى وجود المكوّن الجاهز GiagnosesSelection في الملف FormField.tsx حيث يمكنك استخدامه لضبط الحقل diagnoses بالشكل التالي: const AddEntryForm: React.FC<Props> = ({ onSubmit, onCancel }) => { const [{ diagnoses }] = useStateValue() return ( <Formik initialValues={{ /// ... }} onSubmit={onSubmit} validate={values => { /// ... }} > {({ isValid, dirty, setFieldValue, setFieldTouched }) => { return ( <Form className="form ui"> // ... <DiagnosisSelection setFieldValue={setFieldValue} setFieldTouched={setFieldTouched} diagnoses={Object.values(diagnoses)} /> // ... </Form> ); }} </Formik> ); }; كما يوجد أيضًا مكوّن جاهز يدعى NumberField للقيم العددية ضمن مجال محدد: <Field label="healthCheckRating" name="healthCheckRating" component={NumberField} min={0} max={3} /> 9.25 تطبيق إدارة المرضى: الخطوة 10 وسّع التطبيق ليُظهر رسالة خطأ إن كانت إحدى القيم المطلوبة مفقودة أو بتنسيق غير صحيح. 9.26 تطبيق إدارة المرضى: الخطوة 11 وسّع تطبيقك ليدعم نوعين من المُدخلات، ويُظهر رسالة خطأ إن كانت إحدى القيم المطلوبة مفقودة أو بتنسيق غير صحيح. لا تهتم بالأخطاء المحتملة التي قد تحملها استجابة الخادم. تقتضي الطريقة الأسهل -وبالطبع ليست الأكثر أناقة- لتنفيذ التمرين أن تنشئ نموذجًا منفصلًا لكل مدخل، فقد تواجهك بعض الصعوبات في التعامل مع الأنواع إن استخدمت نمودجًا واحدًا. 9.27 تطبيق إدارة المرضى: الخطوة 12 وسّع التطبيق ليدعم كل أنواع المُدخلات، ويُظهر رسالة خطأ إن كانت إحدى القيم المطلوبة مفقودة أو بتنسيق غير صحيح. لا تهتم بالأخطاء المحتملة التي قد تحملها استجابة الخادم. وهكذا نكون قد وصلنا إلى التمرين الأخير في هذا الفصل وحان الوقت لتسليم الحلول إلى GitHub، والإشارة إلى التمارين التي أنجزتها في منظومة تسليم التمارين. ترجمة -وبتصرف- للفصل React with Types من سلسلة Deep Dive Into Modern Web Development
  23. لقد امتلكنا الآن بعض المفاهيم الأساسية عن عمل TypeScript وعن كيفية استخدامها في إنشاء المشاريع الصغيرة، وأصبحنا قادرين على تنفيذ أشياء مفيدة فعلًا. سنبدأ إذًا بإنشاء مشروع جديد ذو استخدامات أكثر واقعية. سيكون التغيير الرئيسي في هذا القسم عدم استخدام ts-node. وعلى الرغم من أنها أداة مفيدة وتساعدك في الانطلاق، إلا أن استخدام المصرِّف الرسمي للغة هو الخيار المفضل، وخاصة على المدى الطويل لأعمالك. يأتي هذا المصرِّف مع الحزمة typescript، حيث يولّد ويحزم ملفات JavaScript انطلاقًا من ملفات "ts." وبذلك لن تحتوي نسخة الإنتاج بعد ذلك شيفرات TypeScript. وهذا ما نحتاجه فعلًا لأن TypeScript غير قابلة للتنفيذ على المتصفح أو على Node. إعداد المشروع سننشئ مشروعًا من أجل Ilari الذي يحب وضع خطط لرحلات جوية قصيرة، لكنه يعاني من ذكريات مزعجة متعلقة بهذه الرحلات. سيكون هو نفسه من سيكتب شيفرة التطبيق فلن يحتاج إلى واجهة مستخدم، لكنه يرغب باستعمال طلبات HTTP ويبقى خياراته مفتوحة في إضافة واجهة مستخدم مبنية على تقنيات الويب لاحقًا. لنبدأ بإنشاء أول مشروع واقعي لنا بعنوان Ilari flight diaries. وسنثبت كالعادة حزمة typescript باستخدام الأمر npm init ومن ثم npm install typescript. يساعدنا مصرِّف TypeScript الأصلي tsc في تهيئة المشروع من خلال الأمر tsc --init. لكن علينا أولًا أن نضيف الأمر tsc إلى قائمة السكربتات القابلة للتنفيذ داخل الملف package.json (إلا إن كنت قد ثبتَّ TypeScript لكافة المشاريع). وحتى لو ثبتّها لكافة المشاريع لابد من إدراجها كاعتمادية تطوير في مشروعك. سيكون سكربت npm الذي سينفذ الأمر tsc كالتالي: { // .. "scripts": { "tsc": "tsc", }, // .. } يضاف الأمر tsc غالبًا لاستخدامه في سكربتات تنفذ سكربتات أخرى، وبالتالي من الشائع رؤية إعداداته ضمن المشروع بهذا الشكل. سنهيئ الآن إعدادات الملف tsconfig.json بتنفيذ الأمر: npm run tsc -- --init لاحظ وجود المحرفين "--" قبل الوسيط الفعلي init. يُفسَّر الوسيط الذي يقع قبل "--" كأوامر npm والذي يقع بعدها كأوامر تُنفَّذ من قبل السكربت. ينتج عن تنفيذ الأمر السابق الملف tsconfig.ts الذي يضم قائمة طويلة بكل أوامر التهيئة التي يمكن ضبطها. وسترى أن قلة منها فقط لم تُسبق بعلامة التعليق. ستفيدك دراسة هذا الملف في إيجاد بعض تعليمات التهيئة التي قد تحتاجها. يمكنك أيضًا ترك الأسطر التي تبدأ بعلامة التعليق في الملف، فلربما قررت لاحقًا توسيع إعدادات التهيئة الخاصة بمشروعك. إن كل ما نحتاج إليه حاليًا من إعدادات هي: { "compilerOptions": { "target": "ES6", "outDir": "./build/", "module": "commonjs", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true } } لنعرّج على الإعدادات واحدًا تلو الآخر: target: سيخبر المصرِّف عن نسخة ECMAScript التي سيستعملها لتوليد ملفات JavaScript. ولاحظ أن القيمة التي يأخذها هي ES6 التي تدعمها معظم المتصفحات، وهي خيار جيد وآمن. outDir: يحدد المجلد الذي سيحوي الشيفرة المصرِّفة. modules: يخبر المصرِّف أننا سنستخدم وحدات commonjs في الشيفرة المصرِّفة. وهذا يعني أننا نستطيع استخدام require بدلًا من import وهذا الأمر غير مدعوم في النسخ الأقدم من Node مثل النسخة 10. strict: وهو في الواقع اختصار لعدة خيارات منفصلة توجه استخدام ميزات TypeScript بطريقة أكثر تشددًا (يمكنك أن تجد تفاصيل بقية الخيارات في توثيق tsconfig، كما ينصح التوثيق باستخدام strict دائمًا) هي: noImplicitAny: يكون الخيار المألوف بالنسبة لنا هو الأكثر أهمية. ويمنع هذا الخيار تضمين النوع any (إعطاء قيمة ما النوع any)، والذي قد يحدث إن لم تحدد نوعًا لمعاملات دالة على سبيل المثال. noImplicitThis alwayesStrict strictBindCallApply strictNullChecks strictFunctionTypes strictpropertyIntialization noUnUsedLocals: يمنع وجود متغيرات محلية غير مستعملة. nofallThroughCasesInSwitch: ترمي خطأً إن احتوت الدالة على معامل لم يستعمل. esModuleInterop: تتأكد من وجود إحدى التعليمتين return أو break في نهاية الكتلة case، عند استخدام البنية switch case. ModuleInterop: تسمح بالتصريف بين وحدات commonjs و ES. يمكنك الاطلاع أكثر على هذا الإعداد في توثيق tsconfig وبما أننا أنهينا ضبط إعدادات التهيئة التي نريد، سنتابع العمل بتثبيت المكتبة express وتثبيت تعريفات الأنواع الموجودة ضمنها مستخدمين "types/express@". وبما أن المشروع واقعي، أي أنه سينمو مع الوقت، سنستخدم المدقق eslint من البداية: npm install express npm install --save-dev eslint @types/express @typescript-eslint/eslint-plugin @typescript-eslint/parser ستبدو محتويات الملف package.json مشابهة للتالي: { "name": "ilaris-flight-diaries", "version": "1.0.0", "description": "", "main": "index.ts", "scripts": { "tsc": "tsc", "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "ISC", "dependencies": { "express": "^4.17.1" }, "devDependencies": { "@types/express": "^4.17.2", "@typescript-eslint/eslint-plugin": "^2.17.0", "@typescript-eslint/parser": "^2.17.0", "eslint": "^6.8.0", "typescript": "^3.7.5" } } كما سننشئ ملف تهيئة المدقق ذو اللاحقة eslintrc ونزوده بالمحتوى التالي: { "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking" ], "plugins": ["@typescript-eslint"], "env": { "browser": true, "es6": true }, "rules": { "@typescript-eslint/semi": ["error"], "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/no-unused-vars": [ "error", { "argsIgnorePattern": "^_" } ], "@typescript-eslint/no-explicit-any": 1, "no-case-declarations": 0 }, "parser": "@typescript-eslint/parser", "parserOptions": { "project": "./tsconfig.json" } } سنتابع الآن العمل بإعداد بيئة التطوير، وسنصبح بعد ذلك جاهزين لكتابة الشيفرة الفعلية. هناك خيارات عدة لإجراء ذلك. فقد نستخدم المكتبة nodemon مع ts-node، لكن وكما رأينا سابقًا فالمكتبة ts-node-dev تؤمن الوظائف نفسها، لذا سنستمر في استخدامها: npm install --save-dev ts-node-dev سنعرّف بعض سكربتات npm: { // ... "scripts": { "tsc": "tsc", "dev": "ts-node-dev index.ts", "lint": "eslint --ext .ts ." }, // ... } هنالك الكثير لتفعله قبل البدء بكتابة الشيفرة. فعندما تعمل على مشروع حقيقي، سيفيدك الإعداد المتأني لبيئة التطوير إلى حد كبير. خذ وقتك في ضبط إعدادات بيئة العمل لك ولفريقك، وسترى أن كل شيء سيسير بسلاسة على المدى الطويل. لنبدأ بكتابة الشيفرة سنبدأ كتابة الشيفرة بإنشاء وصلة تخديم endpoint لتفقد الخادم، لكي نضمن أن كل شيء يعمل بالشكل الصحيح. فيما يلي محتوى الملف index.ts: import express from 'express'; const app = express(); app.use(express.json()); const PORT = 3000; app.get('/ping', (_req, res) => { console.log('someone pinged here'); res.send('pong'); }); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); لو نفذنا الآن الأمر npm run dev سنرى أن الطلب إلى العنوان http://localhost:3000/ping، سيعيد الاستجابة pong، وبالتالي فتهيئة المشروع قد أنجزت بالشكل الصحيح. سيعمل التطبيق بعد تنفيذ الأمر السابق في وضع التطوير. وهذا الوضع لن يكون مناسبًا أبدًا عند الانتقال لاحقًا إلى وضع الإنتاج. لنحاول إنجاز نسخة إنتاج بتشغيل مصرِّف TypeScript. وبما أننا حددنا قيمةً للإعداد outDir في الملف package.json، فلا شيء بقي لنفعله سوى تنفيذ السكربت باستخدام الأمر npm run tsc. ستكون النتيجة نسخة إنتاج مكتوبة بلغة JavaScript صرفة، لواجهة express الخلفية ضمن المجلد build. سيفسر المدقق eslint حاليًًا الملفات ويضعها ضمن المجلد build نفسه. ولا نرغب بالطبع أن يحدث هذا، فمن المفترض أن تكون الشيفرة الموجودة في هذا المجلد ناتجة عن المصرِّف فقط. يمكن منع ذلك بإنشاء ملف تجاهل لاحقته "eslintignore." يضم قائمة بالمحتوى الذي نريد من المدقق أن يتجاهله، كما هي الحال مع git وgitignore. لنضف سكربت npm لتشغيل التطبيق في وضع الإنتاج: { // ... "scripts": { "tsc": "tsc", "dev": "ts-node-dev index.ts", "lint": "eslint --ext .ts .", "start": "node build/index.js" }, // ... } وبتشغيل التطبيق باستخدام الأمر npm start، نتأكد أن نسخة الانتاج ستعمل أيضًا بشكل صحيح. لدينا الآن بيئة تطوير تعمل بالحد الأدنى. وبمساعدة المصرِّف والمدقق eslint سنضمن أن تبقى الشيفرة بمستوًى جيد. وبناء على ما سبق سنتمكن من إنشاء تطبيق قابل للنشر لاحقًا كنسخ إنتاج. التمرينان 9.8 - 9.9 قبل أن تبدأ بالحل ستطوّر خلال مجموعة التمارين هذه واجهة خلفية لمشروع جاهز يدعى Patientor. والمشروع عبارة عن تطبيق سجلات طبية بسيط يستخدمه الأطباء الذين يشخصون الأمراض ويسجلون المعلومات الصحية لمرضاهم. بُنيت الواجهة الأمامية مسبقًا من قبل خبراء خارجيين، ومهمتك أن تطور واجهة خلفية تدعم الشيفرة الموجودة. 9.8 الواجهة الخلفية لتطبيق إدارة المرضى: الخطوة 1 هيئ المشروع الذي ستستخدمه الواجهة الأمامية. ثم هيئ المدقق eslint والملف tsconfig بنفس إعدادات التهيئة التي استخدمناها سابقًا. عرِّف وصلة تخديم تستجيب لطلبات HTTP-GET المرسلة إلى الوجهة ping/. ينبغي أن يعمل المشروع باستخدام سكربتات npm في وضع التطوير، وكشيفرة مصرّفة في وضع الإنتاج. 9.9 الواجهة الخلفية لتطبيق إدارة المرضى: الخطوة 2 انسخ المشروع patientor. شغل بعد ذلك التطبيق مستعينًا بالملف README.md. يجب أن تعمل الواجهة الأمامية دون حاجة لوجود واجهة خلفية وظيفية. تأكد من استجابة الواجهة الخلفية إلى طلب التحقق من الخادم ping الذي ترسله الواجهة الأمامية. وتحقق من أداة التطوير للتأكد من سلامة عملها. من الجيد أن تلقي نظرة على النافذة console في طرفية التطوير. وإن أخفق شيء ما، استعن بمعلومات القسم الثالث المتعلقة بحل هذه المشكلة. كتابة شيفرة وظائف الواجهة الخلفية لنبدأ بالأساسيات. يريد Ilari أن يكون قادرًا على مراجعة ما اختبره خلال رحلاته الجوية. إذ يريد تخزين مدخلات في مذكراته تتضمن المعلومات التالية: تاريخ المُدخل حالة الطقس (جيد، رياح، ماطر، عاصف) مدى الرؤية (جيد، ضعيف) نص يفصِّل تجربته ضمن الرحلة. حصلنا على عينة من المعلومات التي سنستخدمها كأساس نبني عليه. خُزّنت المعلومات بصيغة JSON ويمكن الحصول عليها من GitHub. تبدو البيانات على النحو التالي: [ { "id": 1, "date": "2017-01-01", "weather": "rainy", "visibility": "poor", "comment": "Pretty scary flight, I'm glad I'm alive" }, { "id": 2, "date": "2017-04-01", "weather": "sunny", "visibility": "good", "comment": "Everything went better than expected, I'm learning much" }, // ... ] لننشئ وصلة تخديم تعيد كل مدخلات مذكرة الرحلات. علينا في البداية إتخاذ بعض القرارات المتعلقة بطريقة هيكلة الشيفرة المصدرية. فمن الأفضل في حالتنا وضع الشيفرة بأكملها في مجلد واحد باسم src، وهكذا لن تختلط ملفات الشيفرة مع ملفات التهيئة. إذًا سننقل الملف إلى هذا المجلد ونجري التعديلات اللازمة على سكربت npm. سنضع أيضًا وحدات المتحكمات بالمسار routers المسؤولة عن التعامل مع موارد محددة كمدخلات المذكرات، ضمن المجلد src/routes. يختلف ما فعلناه قليلًا عن المقاربة التي اتبعناها في القسم 4 حيث وضعنا المتحكمات في المجلد src/controllers. ستجد شيفرة المتحكم الذي يتعامل مع جميع وصلات تخديم المرتبطة بالمذكّرات في الملف "src/routes/diaries.ts" الذي يحتوي الشيفرة التالية: import express from 'express'; const router = express.Router(); router.get('/', (_req, res) => { res.send('Fetching all diaries!'); }) router.post('/', (_req, res) => { res.send('Saving a diary!'); }) export default router; سنوجّه كل الطلبات التي تبدأ بالعنوان api/diaries/ إلى متحكم المسار المحدد في الملف index.ts import express from 'express'; import diaryRouter from './routes/diaries'; const app = express(); app.use(express.json()); const PORT = 3000; app.get('/ping', (_req, res) => { console.log('someone pinged here'); res.send('pong'); }); app.use('/api/diaries', diaryRouter); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); وهكذا لو أرسلنا طلبًا إلى العنوان http://localhost:3000/api/diaries فمن المفترض أن نرى الرسالة Fetching all diaries. علينا تاليًا البدء بتقديم البيانات الأساسية (عينة المذكّرات) انطلاقًا من التطبيق. إذ سنحضر البيانات ونخزنها في الملف data/diaries.json. لن نكتب الشيفرة التي تغير البيانات الفعلية في ملف المتحكم بالمسار، بل سننشئ خدمة تهتم بهذا الأمر. ويعتبر فصل "منطق العمل" عن شيفرة متحكمات المسار ضمن وحدات منفصلة أمرًا شائعًا، وتدعى في أغلب الأحيان "خدمات". ويعود أصل هذه التسمية إلى مفهوم التصميم المقاد بالمجال Domain driven design، ومن ثم جعله إطار العمل Spring أكثر شعبية. لننشئ مجلدًا للخدمات يدعى src/services، ونضع الملف diaryService.ts فيه. يحتوي الملف على دالتين لإحضار وتخزين مُدخلات المذكرة: import diaryData from '../../data/diaries.json' const getEntries = () => { return diaryData; }; const addEntry = () => { return null; }; export default { getEntries, addEntry }; لكن سنلاحظ خللًا ما: يخبرنا المحرر أننا قد نحتاج إلى استعمال الإعداد resolveJsonModule في ملف التهيئة tsconfig. سنضيفه إذًا إلى قائمة الإعدادات: { "compilerOptions": { "target": "ES6", "outDir": "./build/", "module": "commonjs", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "resolveJsonModule": true } } وهكذا ستختفي المشكلة: وجدنا في وقت سابق كيف يمكن للمصرِّف أن يقرر نوع المتغير بناء على القيمة التي أسندت إليه. وبالمثل يمكن للمصرِّف تفسير مجموعات كبيرة من البيانات تتضمن كائنات ومصفوفات. وبناء على ذلك يمكن للمصرِّف أن يحذرنا عندما نحاول أن نفعل شيئًا قد يعتبره مريبًا باستخدام بيانات JSON التي نعالجها. فلو كنا نتعامل على سبيل المثال مع مصفوفة من كائنات محددة النوع، وحاولنا إضافة كائن لا يمتلك كل حقول كائنات المصفوفة أو خللًا في نوع الحقل (number بدل string مثلًا)، سيحذرنا المصرِّف مباشرة. وعلى الرغم من فائدة المصرِّف في التحقق من أننا لم نفعل شيئًا خاطئًا، فتحديد أنواع البيانات بأنفسنا هو الخيار الأكثر أمانًا. ما لدينا حاليًا، هو تطبيق express باستخدام TypeScript يعمل بشكل محدود، لكن لا وجود لأية أنواع في شيفرته. وطالما أننا على دراية بأنواع البيانات التي سنستقبلها في حقلي weather، وvisibility، فلا سبب سيدفعنا لإدراج نوعيهما في الشيفرة. لننشئ ملفًا للأنواع يدعى types.ts، وسنعرّف ضمنه كل الأنواع التي سنستخدمها في المشروع. أولًا سنعرف نوعين لقيم weather، وvisibility باستخدام نوع موّحد union type للقيم النصية التي يمكن أن يأخذها الحقلين: export type Weather = 'sunny' | 'rainy' | 'cloudy' | 'windy' | 'stormy'; export type Visibility = 'great' | 'good' | 'ok' | 'poor'; سنتابع بإنشاء واجهة نوع interface باسم DiaryEntry: export interface DiaryEntry { id: number; date: string; weather: Weather; visibility: Visibility; comment: string; } وسنحاول تحديد نوع لبيانات JSON المُدرجة: import diaryData from '../../data/diaries.json'; import { DiaryEntry } from '../types'; const diaries: Array<DiaryEntry> = diaryData; const getEntries = (): Array<DiaryEntry> => { return diaries;}; const addEntry = () => { return null; }; export default { getEntries, addEntry }; ولأننا صرحنا مسبقا عن القيم، فإسناد نوع لها سيوّلد خطأً: تُظهر نهاية رسالة الخطأ المشكلة: الحقل غير ملائم. فقد حددنا نوعه في واجهة النوع على أنه DiaryEntry، لكن المصرِّف استدل من القيمة المسندة إليه أنه من النوع string. يمكن حل المشكلة بتأكيد النوع type assertion. ولا ينبغي فعل ذلك إلا عندما نعلم قطعًا مالذي نفعله. فلو أكدنا أن نوع المتغير diaryData هو DiaryEntry باستخدام التعليمة as سيعمل كل شيء على مايرام. import diaryData from '../../data/entries.json' import { Weather, Visibility, DiaryEntry } from '../types' const diaries: Array<DiaryEntry> = diaryData as Array<DiaryEntry>; const getEntries = (): Array<DiaryEntry> => { return diaries; } const addEntry = () => { return null } export default { getEntries, addEntry }; لا تستخدم أسلوب تأكيد النوع إلا في غياب أية طرق أخرى للمتابعة. فتأكيد نوع غير مناسب سيسبب أخطاءً مزعجة في زمن التشغيل. على الرغم من أن المصرِّف سيثق بك عندما تستخدم as، لكن بعملك هذا لن تكون قد سخرت الإمكانيات الكاملة للغة TypeScript بل اعتمدت على المبرمج لضمان عمل الشيفرة. من الممكن في حالتنا تغيير الطريقة التي نصدّر بها البيانات، وذلك بإعطائها نوعًا. وبما أننا لا نستطيع استخدام الأنواع داخل ملف JSON لابد من تحويله إلى ملف TS الذي يصدِّر البيانات على النحو التالي: import { DiaryEntry } from "../src/types"; const diaryEntries: Array<DiaryEntry> = [ { "id": 1, "date": "2017-01-01", "weather": "rainy", "visibility": "poor", "comment": "Pretty scary flight, I'm glad I'm alive" }, // ... ]; export default diaryEntries; وهكذا سيفسر المصرِّف المصفوفة عند إدراجها بشكل صحيح، وسيفهم نوع الحقلين weather وvisibility: import diaries from '../../data/diaries'; import { DiaryEntry } from '../types'; const getEntries = (): Array<DiaryEntry> => { return diaries; } const addEntry = () => { return null; } export default { getEntries, addEntry }; لو أردت أن تُخزّن مُدخلًا ما باستثناء حقل محدد، يمكنك ضبط نوع هذا الحقل كحقل اختياري بإضافة "؟" إلى تعريف النوع: export interface DiaryEntry { id: number; date: string; weather: Weather; visibility: Visibility; comment?: string; } وحدات Node وJSON من المهم أن تنتبه إلى مشكلة قد تظهر عند استخدام الخيار resolveJsonModule في الملف tsconfig: { "compilerOptions": { // ... "resolveJsonModule": true } } فبناء على توثيق node المتعلق بملفات الوحدات، ستحاول node أن تنفّذ الوحدات وفق ترتيب اللاحقات كالتالي: ["js", "json", "node"] وبالإضافة إلى ذلك ستوسِّع المكتبتان ts-node وts-node-dev قائمة اللاحقات لتصبح كالتالي: ["js", "json", "node", "ts", "tsx"] لنتأمل الهيكيلة التالية لمجلد يحتوي على ملفات: ├── myModule.json └── myModule.ts إن ضبطنا الخيار resolveJsonModule على القيمة true في TypeScript، سيصبح الملف myModule.json وحدة node صالحة للاستخدام. لنتخيل الآن السيناريو الذي نرغب فيه باستخدام الملف myModule.ts: import myModule from "./myModule"; بالنظر إلى ترتيب لاحقات الوحدات في node: ["js", "json", "node", "ts", "tsx"] سنلاحظ أن الأولوية ستكون للملف ذو اللاحقة "json." على الملف ذو اللاحقة "ts."، وبالتالي سيُدرج الملف myModule.json بدلًا من الملف myModule.ts. ولكي نتجنب هذه الثغرة التي ستضيع الكثير من الوقت، يفضل أن نسمي الملفات التي تحمل لاحقة تفهمها node بأسماء مختلفة. الأنواع الخدمية قد نحتاج أحيانًا إلى إجراء تعديلات محددة على نوع. فلنفترض مثلًا وجود صفحة لعرض قائمة من البيانات التي يعتبر بعضها بيانات حساسة وأخرى غير حساسة. ونريد أن نضمن عدم عرض البيانات الحساسة. يمكننا اختيار الحقول التي نريدها من نوع محدد، عن طريق استخدام أحد الأنواع الخدمية Utility type ويدعى Pick. ينبغي علينا في مشروعنا أن نفترض أن Ilari يريد إظهار قائمة بالمذكرات دون أن يُظهر حقل التعليقات، فقد يكتب شيئًا لايريد إظهاره لأحد، وخاصة في الرحلات التي أفزعته بشدة. يسمح النوع الخدمي Pick أن نختار ما نريد استخدامه من حقول نوع محدد. ويستخدم هذا النوع لإنشاء نوع جديد تمامًا أو لإعلام الدالة ما يجب أن تعيده في زمن التشغيل. فالأنواع الخدمية هي صنف خاص من أدوات إنشاء الأنواع، لكن يمكن استخدامها كما نستخدم أي نوع آخر. ولكي ننشئ نسخة خاضعة للرقابة من النوع DiaryEntry، سنستخدم Pick عند تعريف الدالة: const getNonSensitiveEntries = (): Array<Pick<DiaryEntry, 'id' | 'date' | 'weather' | 'visibility'>> => { // ... } سيتوقع المصرِّف أن تعيد الدالة مصفوفة قيم من النوع DiaryEntry المعدّل والذي يتضمن الحقول الأربعة المختارة فقط. وطالما أن النوع Pick سيتطلب أن يكون النوع الذي سيعدّله معطًى كنوع متغير، كما هو حال المصفوفة، سيكون لدينا نوعين متغيرين ومتداخلين، وستبدو العبارة غريبة بعض الشيء. يمكن تحسين القدرة على قراءة الشيفرة باستخدام العبارة البديلة للمصفوفة: const getNonSensitiveEntries = (): Pick<DiaryEntry, 'id' | 'date' | 'weather' | 'visibility'>[] => { // ... } سنحتاج في حالتنا إلى استثناء حقل واحد، لذلك من الأفضل أن نستخدم النوع الخدمي Omit والذي يحدد الحقول التي يجب استبعادها من نوع: const getNonSensitiveEntries = (): Omit<DiaryEntry, 'comment'>[] => { // ... } وكطريقة أخرى، يمكن التصريح عن نوع جديد NonSensitiveDiaryEntry كالتالي: export type NonSensitiveDiaryEntry = Omit<DiaryEntry, 'comment'>; ستصبح الشيفرة الآن على النحو: import diaries from '../../data/diaries'; import { NonSensitiveDiaryEntry, DiaryEntry } from '../types'; const getEntries = (): DiaryEntry[] => { return diaries; }; const getNonSensitiveEntries = (): NonSensitiveDiaryEntry[] => { return diaries; }; const addEntry = () => { return null; }; export default { getEntries, addEntry, getNonSensitiveEntries}; هنالك شيء واحد سيسبب القلق في تطبيقنا. ستعيد الدالة getNonSensitiveEntries كامل حقول المُدخلات عند تنفيذها، ولن يعطينا المصرِّف أية أخطاء على الرغم من تحديد نوع المعطيات المعادة. يحدث هذا لسبب بسيط هو أن TypeScript ستتحقق فقط من وجود كل الحقول المطلوبة، ولن تمنع وجود حقول زائدة. فلن تمنع في حالتنا إعادة كائن من النوع [ ]DiaryEntry، لكن لو حاولنا الوصول إلى الحقل comment فلن نتمكن من ذلك، لأن TypeScript لا تراه على الرغم من وجوده. سيقود ذلك إلى سلوك غير مرغوب إن لم تكن مدركًا ما تفعله. فلو كنا سنعيد كل المُدخلات عند تنفيذ الدالة getNonSensitiveEntries إلى الواجهة الأمامية، ستكون النتيجة وصول قيمٍ لحقول لانريدها إلى المتصفح الذي أرسل الطلب، وهذا مخالف لتعريف نوع القيم المعادة. علينا إذًا استثناء تلك الحقول بأنفسنا، طالما أن TypeScript لا تعدّل البيانات الفعلية المعادة بل نوعها فقط: import diaries from '../../data/entries.js' import { NonSensitiveDiaryEntry, DiaryEntry } from '../types' const getEntries = () : DiaryEntry[] => { return diaries } const getNonSensitiveEntries = (): NonSensitiveDiaryEntry [] => { return diaries.map(({ id, date, weather, visibility }) => ({ id, date, weather, visibility, }));}; const addDiary = () => { return [] } export default { getEntries, getNonSensitiveEntries, addDiary } فلو أردنا الآن إعادة هذه البيانات على أنها من النوع "DiaryEntry" كالتالي: const getNonSensitiveEntries = () : DiaryEntry[] => { سنحصل على الخطأ التالي: سيقدم لنا السطر الأخير من الرسالة هذه المرة أيضًا فكرة الحل. لنتراجع عن هذا التعديل. تتضمن الأنواع الخدمية العديد من الوسائل المفيدة، وتستحق أن نقف عندها لبعض الوقت ونطلع على توثيقها. وأخيرًا يمكننا إكمال المسار الذي يعيد كل المُدخلات: import express from 'express'; import diaryService from '../services/diaryService'; const router = express.Router(); router.get('/', (_req, res) => { res.send(diaryService.getNonSensitiveEntries());}); router.post('/', (_req, res) => { res.send('Saving a diary!'); }); export default router; وستكون الاستجابة كما نريدها: التمرينان 9.10 -9.11 لن نستخدم في هذه التمارين قاعدة بيانات حقيقية في تطبيقنا، بل بيانات محضرة مسبقًا موجودة في الملفين diagnoses.json وpatients.json. نزِّل الملفين وخزنهما في مجلد يدعى data في مشروعك. ستجري التعديلات على البيانات في ذاكرة التشغيل فقط، فلا حاجة للكتابة على الملفين في هذا القسم. 9.10 واجهة خلفية لتطبيق إدارة المرضى: الخطوة 3 أنشئ النوع daiagnoses واستخدمه لإنشاء وصلة التخديم لإحضار كل تشخيصات المريض من خلال الطلب HTTP -GET. اختر هيكيلة مناسبة لشيفرتك عبر اختيار أسماء ملائمة للملفات والمجلدات. ملاحظة: قد يحتوي النوع daiagnoses على الحقل latin وقد لا يحتويه. ربما ستحتاج إلى استخدام الخصائص الاختيارية عند تعريف النوع. 9.11 واجهة خلفية لتطبيق إدارة المرضى: الخطوة 4 أنشئ نوعًا للبيانات باسم Patient ثم أنشئ وصلة تخديم لطلبات GET على العنوان api/patients/ لتعيد بيانات كل المرضى إلى الواجهة الأمامية لكن دون الحقل ssn. استخدم الأنواع الخدمية للتأكد من أنك أعدت الحقول المطلوبة فقط. يمكنك في هذا التمرين افتراض نوع الحقل gender على أنه string. جرب وصلة التخديم من خلال المتصفح، وتأكد أن الحقل ssn غير موجود في بيانات الاستجابة: تأكد من عرض الواجهة الأمامية لقائمة المرضى بعد إنشائك لوصلة التخديم: منع الحصول على نتيجة غير محددة عرضيا سنوسع الواجهة الخلفية لدعم وظيفة الحصول على مُدخل محدد من خلال الطلبGET إلى العنوان api/diaries/:id. سنوسّع الخدمة DiaryService بإنشاء الدالة findById: // ... const findById = (id: number): DiaryEntry => { const entry = diaries.find(d => d.id === id); return entry;}; export default { getEntries, getNonSensitiveEntries, addDiary, findById} مرة أخرى سيواجهنا الخطأ التالي: تظهر المشكلة لأن وجود مُدخل بمعرِّف id محدد غير مضمون. من الجيد أننا أدركنا هذه المشكلة في مرحلة التصريف. فلو لم نستخدم TypeScript لما جرى تحذيرنا من قبل المصرِّف، وقد نعيد في السيناريو الأسوء كائنًا غير محدد بدلًا من تنبيه المستخدم أنه لاتوجد مُدخلات بهذا المعرِّف. نحتاج قبل كل شيء في حالات كهذه إلى اتخاذ قرار بشأن القيمة المعادة إن لم نجد الكائن المطلوب، وكيفية معالجة هذه الحالة. يعيد تابع المصفوفات find كائنًا غير محدد إن لم يجد الكائن المطلوب، ولا مشكلة في ذلك. إذ يمكن حل مشكلتنا بتحدبد نوع للقيمة المعادة على النحو التالي: const findById = (id: number): DiaryEntry | undefined => { const entry = diaries.find(d => d.id === id); return entry; } وسنستخدم معالج المسار التالي: import express from 'express'; import diaryService from '../services/diaryService' router.get('/:id', (req, res) => { const diary = diaryService.findById(Number(req.params.id)); if (diary) { res.send(diary); } else { res.sendStatus(404); } }) // ... export default router; إضافة مذكرة جديدة لنبدأ بإنشاء وصلة تخديم لطلبات HTTP-POST وذلك لإضافة مّدخلات جديدة إلى المذكرات. ينبغي أن تحمل المدخلات النوع ذاته للبيانات المطلوبة. تعالج الشيفرة التالية بيانات الاستجابة: router.post('/', (req, res) => { const { date, weather, visibility, comment } = req.body; const newDiaryEntry = diaryService.addDiary( date, weather, visibility, comment, ); res.json(newDiaryEntry); }); وسيكون التابع الموافق في شيفرة الخدمة DiaryService كالتالي: import { NonSensitiveDiaryEntry, DiaryEntry, Visibility, Weather} from '../types'; const addDiary = ( date: string, weather: Weather, visibility: Visibility, comment: string ): DiaryEntry => { const newDiaryEntry = { id: Math.max(...diaries.map(d => d.id)) + 1, date, weather, visibility, comment, } diaries.push(newDiaryEntry); return newDiaryEntry; }; وكما ترى، سيغدو من الصعب قراءة الدالة addDiary نظرًا لكتابة كل الحقول كمعاملات منفصلة. وقد يكون من الأفضل لو أرسلنا البيانات ضمن كائن واحد إلى الدالة: router.post('/', (req, res) => { const { date, weather, visibility, comment } = req.body; const newDiaryEntry = diaryService.addDiary({ date, weather, visibility, comment, }); res.json(newDiaryEntry); }) لكن ما نوع هذا الكائن؟ فهو ليس من النوع DiaryEntry تمامًا كونه يفتقد الحقل id. من الأفضل هنا لو أنشأنا نوعًا جديدًا باسم NewDiaryEntry للمُدخلات التي لم تُخزَّن بعد. لنعرّف هذا النوع في الملف types.ts مستخدمين النوع DiaryEntry مع النوع الخدمي Omit. export type NewDiaryEntry = Omit<DiaryEntry, 'id'>; يمكننا الآن استخدام النوع الجديد في الخدمة DiaryService، بحيث يُفكَّك الكائن الجديد عند إنشاء مُدخل جديد من أجل تخزينه: import { NewDiaryEntry, NonSensitiveDiaryEntry, DiaryEntry } from '../types'; // ... const addDiary = ( entry: NewDiaryEntry ): DiaryEntry => { const newDiaryEntry = { id: Math.max(...diaries.map(d => d.id)) + 1, ...entry }; diaries.push(newDiaryEntry); return newDiaryEntry; }; ستبدو الشيفرة أوضح الآن! ولتفسير البيانات المستقبلة، علينا تهيئة أداة JSON الوسطية: import express from 'express'; import diaryRouter from './routes/diaries'; const app = express(); app.use(express.json()); const PORT = 3000; app.use('/api/diaries', diaryRouter); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); أصبح التطبيق الآن جاهزًا لاستقبال طلبات HTTP-POST لإنشاء مُدخلات جديدة بالنوع الصحيح. التأكد من الطلب هناك الكثير من الأمور التي قد تسير بشكل خاطئ عند استقبال بيانات من مصدر خارجي. فلا يمكن أن تعمل التطبيقات بنفسها إلا نادرًا، وعلينا أن نقبل حقيقة أن البيانات التي نحصل عليها من خارج منظومة التطبيق غير موثوقة بشكل كامل. فلا يمكن أن تكون البيانات عند استقبالها من مصدر خارجي محددة النوع مسبقًا. وعلينا أن نقرر الأسلوب الذي سنتعامل به مع ضبابية هذه الحالة. تعالج express الموضوع بتأكيد النوع any لكل حقول جسم الطلب. لن يكون هذا السلوك واضحًا للمصرِّف في حالتنا، لكن لو حاولنا إلقاء نظرة أقرب إلى المتغيرات، ومررنا مؤشر الفأرة فوق كل منها، سنجد أن نوع كل منها هو بالفعل any. وبالتالي لن يعترض المحرر أبدًا عندما نمرر هذه البيانات إلى الدالة addDiary كمعاملات. يمكن إسناد القيم من النوع any إلى متغيرات من أي نوع، فلربما تكون هي القيمة المطلوبة. وهذا الأسلوب يفتقر بالتأكيد إلى عامل الأمان، لذا علينا التحقق من القيم المستقبلة سواءً استخدمنا TypeScript أو لا. بالإمكان إضافة آليتي تحقق بسيطتين exist و is_value_valid ضمن الدالة التي تعرّف الوجهة route، لكن من الأفضل كتابة منطق تفسير البيانات وتقييمها في ملف منفصل "utils.ts". لابد من تعريف الدالة toNewDiaryEntry التي تستقبل جسم الطلب كمعامل وتعيد كائن NewDiaryEntry بنوع مناسب. يستخدم تعريف الوجهة هذه الدالة على النحو التالي: import toNewDiaryEntry from '../utils'; // ... router.post('/', (req, res) => { try { const newDiaryEntry = toNewDiaryEntry(req.body); const addedEntry = diaryService.addDiary(newDiaryEntry); res.json(addedEntry); } catch (e) { res.status(400).send(e.message); } }) طالما أننا نحاول الآن كتابة شيفرة آمنة، وأن نتأكد من تلقي البيانات التي نريدها تمامًا من الطلبات، علينا أن نتحقق من ترجمة وتقييم بيانات كل حقل نتوقع أن نستقبله. سيبدو هيكل الدالة toNewDiaryEntry على النحو التالي: import { NewDiaryEntry } from './types'; const toNewDiaryEntry = (object): NewDiaryEntry => { const newEntry: NewDiaryEntry = { // ... } return newEntry; } export default toNewDiaryEntry; على الدالة ترجمة كل حقل والتأكد أن نوع كل حقل من حقول القيمة المعادة هو NewDiaryEntry. ويعني هذا التحقق من كل حقل على حدى. ستواجهنا أيضًا في هذه المرحلة مشاكل تتعلق بالأنواع: ماهو نوع الكائن المعاد، طالما أنه جسم الطلب في الحقيقة من النوع any؟ إن فكرة هذه الدالة، هي الربط بين الحقول مجهولة النوع بحقول من النوع الصحيح والتحقق من أنها معرّفة كحقول متوقعة أولا. وربما تكون هذه الحالة هي الحالة النادرة التي سنسمح فيها باستخدام النوع any. سيعترض المدقق eslint على استخدام النوع any: وسبب ذلك، هو تفعيل القاعدة no-explicit-any التي تمنع التصريح علنًا عن النوع any. وهي في الواقع قاعدة جيدة لكنها غير مطلوبة في هذا الجزء من الملف. يمككنا أن نتجاوز هذه القاعدة بإلغاء تفعيلها عن طريق وضع السطر التالي في ملف الشيفرة: /* eslint-disable @typescript-eslint/no-explicit-any */ سنبدأ الآن بإنشاء المفسّرات لكل حقل من حقول الكائن المُعاد. لتقييم الحقل comment لابد من التحقق أنه موجود وأنه يحمل النوع string. ستبدو الدالة على نحو مماثل لما يلي: const parseComment = (comment: any): string => { if (!comment || !isString(comment)) { throw new Error('Incorrect or missing comment: ' + comment); } return comment; } تتلقى الدالة معاملُا من النوع any وتعيده من النوع string إن كان موجودًا ومن النوع الصحيح. ستبدو دالة تقييم النوع string كالتالي: const isString = (text: any): text is string => { return typeof text === 'string' || text instanceof String; }; تدعى هذه الدالة أيضًا بالدالة حامية النوع type guard. وتعرَّف بأنها دالة تعيد قيمة منطقية ونوعًا إسناديُا (type predicate -يسند نوعا صريحًا لقيمة معادة-). سيكون شكل النوع الإسنادي في حالتنا هو التالي: text is string إن الشكل العام للنوع الإسنادي هو parameterName is Type حيث يمثل parameterName معامل الدالة ويمثل "Type" النوع الذي سيُعاد. فإن أعادت الدالة حامية النوع القيمة true، سيعرف مصرِّف TypeScript أن للمتغير المُختَبَر النوع ذاته الذي حدده النوع الإسنادي. قبل أن تُستدعى الدالة حامية للنوع سيكون النوع الفعلي للمتغير comment مجهولًا. لكن بعد استدعائها سيعلم المصرِّف -في حال أعادت حامية النوع القيمة true، أن المتغير من النوع string. لماذا نرى شرطين في حامية النوع string؟ const isString = (text: any): text is string => { return typeof text === 'string' || text instanceof String;} أليس كافيًا أن نكتب شيفرة الحامية على الشكل: const isString = (text: any): text is string => { return typeof text === 'string'; } إن الشكل الأبسط في أغلب الأحيان كافٍ للتطبيق العملي. لكن إن أردنا أن نكون متأكدين تمامًا لابد من وجود الشرطين. فهنالك طريقتان لإنشاء كائن من النوع string في JavaScript وكلاهما يعمل بطريقة تختلف قليلًا عن الآخر بما يتعلق باستخدام العاملين typeof و instanceof. const a = "I'm a string primitive"; const b = new String("I'm a String Object"); typeof a; --> returns 'string' typeof b; --> returns 'object' a instanceof String; --> returns false b instanceof String; --> returns true لن يفكر في الغالب أحد باستخدام الدالة البانية لإنشاء قيمة نصية، وبالتالي ستكون النسخة الأبسط للدالة الحامية مناسبة. لنتأمل الآن الحقل date. سيُترجم ويُقيَّم هذا الحقل بطريقة مشابه كثيرًا لطريقة تقييم الحقل comment. وطالما أن TypeScript لا تعرف نوعًا للتاريخ، سنضطر للتعامل معه كنص string. ولابد كذلك من التحقق على مستوى JavaScript أن تنسيق التاريخ مقبول. سنضيف الدوال التالية: const isDate = (date: string): boolean => { return Boolean(Date.parse(date)); }; const parseDate = (date: any): string => { if (!date || !isString(date) || !isDate(date)) { throw new Error('Incorrect or missing date: ' + date); } return date; }; لا يوجد شيء مميز في هذه الشيفرة، لكن يجدر الإشارة أنه لا يمكن استعمال الدالة حامية النوع لأن التاريخ هنا سيعتبر من النوع string ليس إلا. ولاحظ على الرغم من أن الدالة parseDate تقبل المتغير date على أنه من النوع any، فسيتغير نوعه إلى string بعد التحقق من نوعه باستخدام isString. وهذا مايفسر الحاجة لقيمة نصية صحيحة التنسيق، عند تمرير المتغير إلى الدالة isDate. سننتقل الآن إلى النوعين الأخيرين Weather و Visibility. نريد أن تجري عمليتي الترجمة والتقييم كالتالي: const parseWeather = (weather: any): Weather => { if (!weather || !isString(weather) || !isWeather(weather)) { throw new Error('Incorrect or missing weather: ' + weather) } return weather; }; لكن كيف يمكننا تقييم نص ما على أنه من تنسيق أو شكل محدد؟ تقتضي أحدى الطرق المتبعة، كتابة دالة حامية للنوع بالشكل التالي: const isWeather = (str: string): str is Weather => { return ['sunny', 'rainy', 'cloudy', 'stormy' ].includes(str); }; سيعمل الحل السابق بشكل جيد، لكن المشكلة أن قائمة الأحوال الجوية المحتملة قد لاتبقى متزامنة مع تعريفات النوع إن حدث أي تبديل فيها (خطأ في الكتابة مثلًا). وهذا بالطبع ليس جيدًا، لأننا نرغب بوجود مصدر وحيد لكل أنواع الطقس الممكنة. من الأفضل في حالتنا أن نحسَّن تعريف أنواع الطقس. فبدلًا من النوع البديل لابد من استخدام تعداد TypeScript الذي يسمح لنا باستخدام القيمة الفعلية في الشيفرة في زمن التشغيل، وليس فقط في مرحلة التصريف. لنعد تعريف النوع Weather ليصبح كالتالي: export enum Weather { Sunny = 'sunny', Rainy = 'rainy', Cloudy = 'cloudy', Stormy = 'stormy', Windy = 'windy', } يمكننا التحقق الآن أن القيمة النصية المستقبلة هي إحدى أنواع الطقس الممكنة، وستكتب الدالة الحامية للنوع كالتالي: const isWeather = (param: any): param is Weather => { return Object.values(Weather).includes(param); }; لاحظ كيف غيرنا نوع المعامل إلى any فلو كان من النوع string، لن يُترجَم التابع includes. وهذا منطقي وخاصة إذا أخذنا في الحسبان إعادة استخدام الدالة لاحقًا. بهذه الطريقة سنكون متأكدين تمامًا أنه أيًا كانت القيمة التي ستمرر إلى الدالة، فستخبرنا إن كانت القيمة مناسبة للطقس أو لا. يمكن تبسيط الدالة قليلًا كما يلي: const parseWeather = (weather: any): Weather => { if (!weather || !isWeather(weather)) { throw new Error('Incorrect or missing weather: ' + weather); } return weather; }; ستظهر مشكلة بعد هذه التغييرات. لن تلائم البيانات الأنواع التي عرّفناه بعد الآن. والسبب أننا لا نستطيع ببساطة افتراض أن القيمة string على أنها تعداد enum. يمكننا حل المشكلة بربط عناصر البيانات الأولية (المحضرة مسبقًا) بالنوع DiaryEntry مستخدمين الدالة toNewDiaryEntry: import { DiaryEntry } from "../src/types"; import toNewDiaryEntry from "../src/utils"; const data = [ { "id": 1, "date": "2017-01-01", "weather": "rainy", "visibility": "poor", "comment": "Pretty scary flight, I'm glad I'm alive" }, // ... ] const diaryEntries: DiaryEntry [] = data.map(obj => { const object = toNewDiaryEntry(obj) as DiaryEntry object.id = obj.id return object }) export default diaryEntries وطالما أن الدالة ستعيد كائنًا من النوع NewDiaryEntry، فعلينا التأكيد على أنه من النوع DiaryEntry باستخدام العامل as. يستخدم التعداد عادة عندما نكون أمام مجموعة من القيم المحددة مسبقًا ولا نتوقع تغييرها في المستقبل، خاصة القيم التي يصعب تغييرها مثل أيام الأسبوع وأسماء الأشهر وغيرها. لكن طالما أنها تقدم أسلوبًا جيدًا في تقييم القيم الواردة فمن المفيد استخدامها في حالتنا. وكذلك الأمر لابد من التعامل مع النوع visibility بنفس الأسلوب. سيبدو التعداد الخاص به كالتالي: export enum Visibility { Great = 'great', Good = 'good', Ok = 'ok', Poor = 'poor', } وستمثل الشيفرة التالية الدالة الحامية للنوع: const isVisibility = (param: any): param is Visibility => { return Object.values(Visibility).includes(param); }; const parseVisibility = (visibility: any): Visibility => { if (!visibility || !isVisibility(visibility)) { throw new Error('Incorrect or missing visibility: ' + visibility); } return visibility; }; وهكذا نكون قد انتهينا من إنشاء الدالة toNewDiaryEntry التي تتحمل مسؤولية تقييم وتفسير حقول بيانات الطلب: const toNewDiaryEntry = (object: any): NewDiaryEntry => { return { date: parseDate(object.date), comment: parseComment(object.comment), weather: parseWeather(object.weather), visibility: parseVisibility(object.visibility) }; }; وبهذا ننهي النسخة الأولى من تطبيق مذكرات الرحلات الجوية! وفي حال أردنا إدخال مذكرة جديدة بحقول مفقودة أو غير صالحة سنحصل على رسالة الخطأ التالية: التمرينان 9.12 - 9.13 9.12 واجهة خلفية لتطبيق إدارة المرضى: الخطوة 5 أنشأ وصلة تخديم للطلب POST على العنوان api/patients/ بغرض إضافة مرضى جدد. وتأكد من إمكانية إضافة مرضى عبر الواجهة الأمامية أيضًا. 9.13 واجهة خلفية لتطبيق إدارة المرضى: الخطوة 6 جد آلية أمنة لتفسير وتقييم وحماية الأنواع لطلبات HTTP-POST إلى العنوان api/patients/ أعد كتابة الحقل Gender مستخدمًا التعداد (enum). تصريف -وبتصرف- للفصل Typing the express app من سلسلة Deep Dive Into Modern Web Development
  24. بعد المقدمة الموجزة التي تحدثنا فيها عن أساسيات اللغة TypeScript في المقال السابق، سنكون جاهزين لبدء رحلتنا لنصبح مطورين شاملين لتطبيقات الويب باستخدام TypeScript. سنركز في هذا القسم على المشاكل التي قد تنشأ عن استخدام اللغة لتطوير تطبيقات express للواجهة الخلفية وReact للواجهة الأمامية، بدلًا من تقديم مدخل موّسع عن كل جوانب اللغة. وسنركز أيضًا على استخدام أدوات التطوير المتاحة بالإضافة إلى الميزات التي تقدمها اللغة. تجهيز كل شيء لبدء العمل ثَبّت ما تحتاجه لاستخدام TypeScript على المحرر الذي تختاره، ستحتاج إلى تثبيت الموسِّع typescript hero إن كنت ستعمل على Visual Studio Code. لا يمكن تنفيذ شيفرة TypeScript مباشرة كما ذكرنا سابقًا، بل علينا تصريفها إلى شيفرة JavaScript قابلة للتنفيذ. عندما تصرّف شيفرة اللغة إلى JavaScript ستصبح عرضًة لإزالة الأنواع. أي ستزال مسجلات النوع والاستدلالات والأسماء المستعارة للأنواع وكل معطيات نظام تحديد النوع الأخرى، وستكون النتيجة شيفرة JavaScript صرفة جاهزة للتنفيذ. نقصد أحيانًا بالحاجة إلى التصريف في بيئات الإنتاج، أن نوجد "خطوة لبناء التطبيق". حيث تُصرّف كل شيفرة TypeScript خلال خطوة بناء التطبيق إلى شيفرة JavaScript ضمن مجلد منفصل، ومن ثم تُنفِّذ بيئة الإنتاج الشيفرة الموجودة ضمن هذا المجلد. يُفضَّل في بيئات الإنتاج أن نستخدم التصريف في الزمن الحقيقي، وإعادة التحميل التلقائي لنتمكن من متابعة النتائج بشكل أسرع. لنبدأ بكتابة أول تطبيق لنا باستخدام TypeScript. ولتبسيط الأمر، سنستخدم حزمة npm تُدعى ts-node. حيث تصرّف هذه المكتبة ملف TypeScript وتنفذه مباشرة. وبالتالي لا حاجة لخطوة تصريف مستقلة. يمكن تثبيت المكتبة ts-node والحزمة الرسمية للغة TypeScript لكافة المشاريع globally كالتالي: npm install -g ts-node typescript إن لم تستطع أو لم ترد تثبيت حزم شاملة، يمكنك إنشاء مشروع npm وتثبت فيه الاعتماديات اللازمة فقط ومن ثم تُنفِّذ شيفرتك ضمنه. كما سنستخدم المقاربة التي اتبعناها في القسم 3، حيث نهيئ مشروع npm بتنفيذ الأمر npm init في مجلد فارغ، ونثبّت بعدها الاعتماديات بتنفيذ الأمر: npm install --save-dev ts-node typescript كما يجب أن نهيئ تعليمة scripts ضمن الملف package.json كالتالي: { // .. "scripts": { "ts-node": "ts-node" }, // .. } يمكنك استخدام ts-node ضمن المجلد بتنفيذ الأمر npm run ts-node. وتجدر الملاحظة أن استخدام ts-node من خلال الملف package.json يقتضي أن تبدأ كل أسطر الأوامر بالرمز "--". لذا سيكون عليك تنفيذ الأمر التالي إن أردت مثلًا تنفيذ الملف file.ts باستخدام ts-node: npm run ts-node -- file.ts نشير أيضًا إلى وجود أرضية عمل خاصة باللغة TypeScript على الإنترنت، حيث يمكنك تجريب شيفرتك والحصول على شيفرة JavaScript الناتجة وأخطاء التصريف المحتملة بسرعة. ملاحظة: يمكن أن تتضمن أرضية العمل قواعد تهيئة مختلفة (وهذا ما سنوضحه لاحقًا) عما هو موجود في بيئة التطوير الخاصة بك، لهذا قد تجد تحذيرات مختلفة. يمكن تعديل قواعد التهيئة لأرضية العمل هذه من خلال القائمة المنسدلة config. ملاحظة عن أسلوب كتابة الشيفرة إن لغة JavaScript بحد ذاتها مريحة الاستخدام، ويمكنك أن تنفذ ما تريده بطرق مختلفة في أغلب الأحيان. فعلى سبيل المثال يمكنك استخدام الدوال named، أو anynamous، وقد تستخدم const، أو var، أو let للتصريح عن متغير، وكذلك الاستخدامات المختلفة للفاصلة المنقوطة. سيختلف هذا القسم عن البقية بطريقة استخدام الفاصلة المنقوطة. ولا يمثل هذا الاختلاف طريقة خاصة باللغة لكنه أكثر ما يكون أسلوبًا لكتابة شيفرة أي نوع من JavaScript. واستخدام هذا التنسيق أو عدمه سيعود كليًا للمبرمج. لكن من المتوقع أن يغير أسلوبه في كتابة الشيفرة لتتماشى مع الأسلوب المتبع في هذا القسم، إذ يجب إنجاز تمارين القسم مستخدمًا الفواصل المنقوطة ومراعيًا أسلوب كتابة الشيفرة المستخدم فيه. كما ستجد اختلافًا في تنسيق الشيفرة في هذا القسم بالمقارنة مع التنسيق الذي اتبعناه في بقية أقسام المنهاج كتسمية المجلدات مثلًا. لنبدأ بإنشاء دالة بسيطة لتنفيذ عملية الضرب. ستبدو تمامًا كما لو أنها مكتوبة بلغة JavaScript: const multiplicator = (a, b, printText) => { console.log(printText, a * b); } multiplicator(2, 4, 'Multiplied numbers 2 and 4, the result is:'); وكما نرى، فالدالة مكتوبة بشيفرة JavaScript عادية دون استخدام ميزات TS. ستصرّف وتنفذ الشيفرة بلا مشاكل باستخدام الأمر npm run ts-node -- multiplier. لكن ما الذي سيحدث إن مررنا نوعًا خاطئًا من المعاملات إلى الدالة؟ لنجرب ذلك! const multiplicator = (a, b, printText) => { console.log(printText, a * b); } multiplicator('how about a string?', 4, 'Multiplied a string and 4, the result is:'); عند تنفيذ الشيفرة، سيكون الخرج: "Multiplied a string and 4, the result is: NaN". أليس من الجيد أن تمنعنا اللغة من ارتكاب هذا الخطأ؟ سترينا هذه المشكلة أولى مزايا TS. لنعرف أنواعًا للمعاملات ونرى ما الذي سيتغير. تدعم اللغة TS أنواعًا مختلفة، مثل: number، وstring، وArray. يمكنك إيجاد قائمة بالأنواع في توثيق TS على الانترنت. كما يمكن إنشاء أنواع مخصصة أكثر تعقيدًا. إنّ أول معاملين لدالة الضرب من النوع number والثالث من النوع string. const multiplicator = (a: number, b: number, printText: string) => { console.log(printText, a * b); } multiplicator('how about a string?', 4, 'Multiplied a string and 4, the result is:'); لم تعد الشيفرة السابقة شيفرة JavaScript صالحة للتنفيذ. فلو حاولنا تنفيذها، لن تُصرَّف: إنّ إحدى أفضل ميزات محرر TS، أنك لن تحتاج إلى تنفيذ الشيفرة حتى ترى المشكلة. فالإضافة VSCode فعالة جدًا، حيث ستبلغك مباشرة إن حاولت استخدام النوع الخاطئ. إنشاء أول نوع خاص بك لنطور دالة الضرب إلى آلة حاسبة تدعم أيضًا الجمع والقسمة. ينبغي أن تقبل الآلة الحاسبة ثلاث معاملات: عددين وعملية قد تكون الضرب multiply، أو الجمع add، أو القسمة divide. تحتاج الشيفرة في JavaScript إلى تقييم إضافي للمعامل الثالث لتتأكد من كونه قيمة نصية. بينما تقدم TS طريقة لتعريف أنواع محددة من الدخل، بحيث تصف تمامًا الأنواع المقبولة. وعلاوة على ذلك، ستظهر لك TS معلومات عن القيم الصالحة للاستخدام ضمن المحرر. يمكن إنشاء نوع باستخدام التعليمة الداخلية type في TS. سنصف فيما سيأتي النوع Operation الذي عرًفناه: type Operation = 'multiply' | 'add' | 'divide'; يقبل النوع Op ثلاثة أشكال من الدخل وهي تمامًا القيم النصية الثلاثة التي نريد. يمكننا باستخدام العامل OR ('|') تعريف متغير يقبل قيمًا متعددة وذلك بإنشاء نوع موحَّد union type.لقد استخدمنا في الحالة السابقة القيمة النصية بحرفيتها (وهذا ما يدعى التعريف المختصر للنوع النصي string literal types)، لكن باستخدام الأنواع الموحَّدة، ستجعل المصرِّف قادرًا على قبول قيمة نصية أو عددية: string | number. تُعرّف التعليمة type اسمًا جديدًا للنوع أو ما يسمى اسمًا مستعارًا للنوع. وطالما أن النوع المُعرَّف هو اتحاد بين ثلاث قيم مقبولة، فمن المفيد إعطاء النوع اسمًا مستعارًا يصفه بشكل جيد. لنلق نظرة على الآلة الحاسبة: type Operation = 'multiply' | 'add' | 'divide'; const calculator = (a: number, b: number, op : Operation) => { if (op === 'multiply') { return a * b; } else if (op === 'add') { return a + b; } else if (op === 'divide') { if (b === 0) return 'can\'t divide by 0!'; return a / b; } } ستلاحظ الآن أنك لو مررت الفأرة فوق النوع Operation في المحرر، ستظهر لك مباشرة اقتراحات عن طريقة استخدامه: ولو أردنا استخدام قيمة غير موجودة ضمن النوع Operation، ستظهر لك مباشرة إشارة التحذير الحمراء ومعلومات إضافية ضمن المحرر: لقد أبلينا جيدًا حتى اللحظة، لكننا لم نطبع بعد النتيجة التي تعيدها دالة الآلة الحاسبة. غالبًا ما نريد معرفة النتيجة التي تعيدها الدالة، ومن الأفضل أن نضمن أن الدالة ستعيد القيمة التي يفترض أن تعيدها. لنضف إذًا القيمة المعادة number إلى الدالة: type Operation = 'multiply' | 'add' | 'divide'; const calculator = (a: number, b: number, op: Operation): number => { if (op === 'multiply') { return a * b; } else if (op === 'add') { return a + b; } else if (op === 'divide') { if (b === 0) return 'this cannot be done'; return a / b; } } سيعترض المصرِّف مباشرة على فعلتنا، ذلك أن الدالة ستعيد في إحدى الحالات (القسمة على صفر) قيمة نصية. هناك عدة طرق لحل المشكلة. إذ يمكن إعادة تعريف النوع المعاد ليتضمن القيم النصية string على النحو التالي: const calculator = (a: number, b: number, op: Operation): number | string => { // ... } أو قد ننشئ نوعًا جديدًا للقيم المعادة يتضمن النوعين اللذين تعيدهما الدالة تمامًا مثل النوع Operation. type Result = string | number const calculator = (a: number, b: number, op: Operation): Result => { // ... } لكن السؤال الذي يطرح نفسه هو: هل من الطبيعي لدالة أن تعيد قيمة نصة؟ عندما تصل أثناء كتابة الشيفرة إلى حالة تضطر فيها إلى التقسيم على صفر، فإن خطأً جسيمًا قد وقع في مرحلة ما، وينبغي أن يلقي التطبيق خطأً ثم يعالج عند استدعاء الدالة. عندما تحاول أن تعيد قيمًا لم تكن تتوقعها أصلًا، ستمنعك التحذيرات التي تراها في محرر TSمن اتخاذ قرارات متسرعة، وتضمن لك عمل شيفرتك كما أردتها. وعليك أن تدرك أمرًا آخر، فحتى لو عرّفنا أنواعًا لمعاملاتنا، فإن شيفرة JavaScript المتولدة عند التصريف لن تكون قادرة على التحقق من هذه الأنواع أثناء التنفيذ. وإن كانت قيمة المعامل الذي يحدد العملية قادمةً من مكتبة خارجية على سبيل المثال، فلن تضمن أنها واحدة من القيم المسموحة. لذلك من الأفضل أن تنشئ معالجات للأخطاء، وأن تكون منتبهًا لحدوث ما لا تتوقعه. وفي الحالة التي تصادفك فيها عدة قيم مقبولة بينما ينبغي رفض بقية القيم وإلقاء خطأ، ستجد أن كتلة switch…case أكثر ملائمة من كتلة "if….else". ستبدو شيفرة الآلة الحاسبة التي طورناها على النحو التالي: type Operation = 'multiply' | 'add' | 'divide'; type Result = number; const calculator = (a: number, b: number, op : Operation) : Result => { switch(op) { case 'multiply': return a * b; case 'divide': if( b === 0) throw new Error('Can\'t divide by 0!'); return a / b; case 'add': return a + b; default: throw new Error('Operation is not multiply, add or divide!'); } } try { console.log(calculator(1, 5 , 'divide')) } catch (e) { console.log('Something went wrong, error message: ', e.message); } لا بأس بما فعلنا حتى الآن، لكن من الأفضل لو استخدمنا سطر الأوامر لإدخال القيم بدلًا من تغييرها ضمن الشيفرة في كل مرة. سنجرب ذلك كما لو كنا ننفذ تطبيق Node نظامي، باستخدام process.argv. لكن مشكلة ستقع: الحصول على الأنواع في حزم npm من خلال Type@ لنعد إلى الفكرة الأساسية للغة TS. تتوقع اللغة أن تكون كل الشيفرة المستخدمة قادرة على تمييز الأنواع، كما تتوقع ذلك من شيفرة مشروعك الخاص الذي هيِّئ بشكل مناسب. ولا تتضمن المكتبة TS بذاتها سوى الأنواع الموجودة في الحزمة TS. من الممكن كتابة أنواع خاصة بك لمكتبة، لكن ذلك غير ضروري إطلاقًا! فمجتمع TS قد فعل ذلك بالنيابة عنّا. وكما هو حال npm، يرحب عالم TS بالشيفرة مفتوحة المصدر. ومجتمع TS مجتمع نشط ويتفاعل دومًا مع آخر التحديثات والتغييرات في حزم npm المستخدمة أكثر. ستجد غالبًا كل ماتحتاجه من أنواع حزم npm، فلا حاجة لإنشاء هذه الأنواع بنفسك لكل اعتمادية قد تستخدمها. عادة ما نحصل على الأنواع المعرّفة في الحزم الموجودة من خلال منظمة Types@ وباستخدام npm. إذ يمكنك إضافة الأنواع الموجودة في حزمة إلى مشروعك بتثبيت حزمة npm لها نفس اسم حزمتك لكنه مسبوق بالبادئة Types@. فعلى سبيل المثال ستُثبَّت الأنواع التي توفرها المكتبة express بتنفيذ الأمر npm install --save-dev @types/express. تجري صيانة وتطوير Types@ من قبل Definitely typed، وهو مشروع مجتمعي يهدف إلى صيانة أنواع كل ما هو موجود في مكان واحد. قد تحتوي حزمة npm في بعض الأحيان الأنواع الخاصة بها ضمن الشيفرة، فلا حاجة في هذه الحالة إلى تثبيت تلك الأنواع بالطريقة التي ذكرنا سابقًا. طالما أن المتغير العام process قد عُرِّف من قبل Node، سنحصل على الأنواع التي يؤمنها بتثبيت الحزمة types/node@: npm install --save-dev @types/node لن يعترض المصرِّف على المتغير process بعد تثبيت الأنواع. ولاحظ أنه لا حاجة لطلب الأنواع ضمن الشيفرة، إذ يكفي تثبيت الحزمة التي تُعرِّفها. تحسينات على المشروع سنضيف تاليًا سيكربت npm لتشغيل كلا من برنامج دالة الضرب، وبرنامج الآلة الحاسبة: { "name": "part1", "version": "1.0.0", "description": "", "main": "index.js", "scripts": { "ts-node": "ts-node", "multiply": "ts-node multiplier.ts", "calculate": "ts-node calculator.ts" }, "author": "", "license": "ISC", "devDependencies": { "ts-node": "^8.6.2", "typescript": "^3.8.2" } } سنجعل دالة الضرب تعمل من خلال سطر الأوامر عند إجراء التعديلات التالية: const multiplicator = (a: number, b: number, printText: string) => { console.log(printText, a * b); } const a: number = Number(process.argv[2]) const b: number = Number(process.argv[3]) multiplicator(a, b, `Multiplied ${a} and ${b}, the result is:`); وسننفذ البرنامج كالتالي: npm run multiply 5 2 لو نفذنا البرنامج بمعاملات ليست من النوع الصحيح، كما تُظهر الشيفرة التالية: npm run multiply 5 lol سيعمل البرنامج أيضًا معطيًا الخرج التالي: Multiplied 5 and NaN, the result is: NaN والسبب في ذلك أن تنفيذ الأمر ('Number('lol سيعيد القيمة NaN وهذه القيمة من النوع number. لهذا لا يمكن أن تنقذنا TS من مأزق كهذا. لا بدّ في هذه الحالات من تقييم البيانات التي نحصل عليها من سطر الأوامر لكي نمنع سلوكًا كهذا. ستبدو النسخة المحسّنة من "دالة الضرب" كالتالي: interface MultiplyValues { value1: number; value2: number; } const parseArguments = (args: Array<string>): MultiplyValues => { if (args.length < 4) throw new Error('Not enough arguments'); if (args.length > 4) throw new Error('Too many arguments'); if (!isNaN(Number(args[2])) && !isNaN(Number(args[3]))) { return { value1: Number(args[2]), value2: Number(args[3]) } } else { throw new Error('Provided values were not numbers!'); } } const multiplicator = (a: number, b: number, printText: string) => { console.log(printText, a * b); } try { const { value1, value2 } = parseArguments(process.argv); multiplicator(value1, value2, `Multiplied ${value1} and ${value2}, the result is:`); } catch (e) { console.log('Error, something bad happened, message: ', e.message); } عندما ننفذ البرنامج الآن: npm run multiply 1 lol سنحصل على رسالة خطأ مناسبة للوضع: Error, something bad happened, message: Provided values were not numbers! يحتوي تعرف الدالة parseArguments بعض الأشياء الملفتة: const parseArguments = (args: Array<string>): MultiplyValues => { // ... } سنجد أولًا أنّ المعامل arg هو مصفوفة من النوع "string"، وأنّ القيم المعادة من النوع "MultiplayValues" الذي عُرِّف بالشكل التالي: interface MultiplyValues { value1: number; value2: number; } لقد استخدمنا في تعريف هذا النوع التعليمة Interface للغة TS، وهي إحدى الطرق المتبعة لتحديد شكل الكائن. إذ ينبغي كما هو واضح في حالتنا، أن تكون القيمة المعادة كائنًا له خاصيتين value1 وvalue2 من النوع "number". التمارين 9.1 - 9.3 التثبيت سننجز التمارين من 9.1 إلى 9.7 ضمن مشروع Node واحد. أنشئ المشروع في مجلد فارغ مستخدمًا الأمر npm init وثبِّت حزمتي ts-node وtypescript. أنشئ أيضًا ملفًا باسم tsconfig.json ضمن المجلد بحيث يحتوي الشيفرة التالية: { "compilerOptions": { "noImplicitAny": true, } } يستخدم هذا الملف لتحديد الطريقة التي سيفسر بها مصرِّف TS الشيفرة المكتوبة، وما هو مقدار التشدد الذي سيفرضه المصرِّف، وما هي الملفات التي ينبغي مراقبتها أو تجاهلها، والكثير الكثير من الأشياء. سنستخدم حاليًا خيار المصرِّف noImplicitAny الذي يشترط أن تحمل جميع المتغيرات أنواعًا محددة. 9.1 مؤشر كتلة الجسم ضع شيفرة هذا التمرين في الملف bmiCalculator.ts. اكتب دالة باسم calculateBmi تحسب مؤشر كتلة الجسم BMI بناء على طول محدد (سنتيمتر) ووزن محدد (كيلو غرام)، ثم أعد رسالة مناسبة تحمل النتيجة. استدعي الدالة من شيفرتك ومرر إليها معاملات جاهزة ثم اطبع النتيجة. ينبغي أن تطبع الشيفرة التالية: console.log(calculateBmi(180, 74)) النتيجة التالية: Normal (healthy weight) أنشئ سكربت npm لكي تنفذ البرنامج باستخدام الأمر npm run calculateBmi 9.2 آلة حاسبة لساعات التمرين ضع شيفرة هذا التمرين في الملف exerciseCalculatore.ts. اكتب دالة باسم calculateExercise تحسب متوسط ساعات التمرين اليومية وتقارنها بعدد الساعات التي ينبغي الوصول إليها يوميًا، ثم تعيد كائنًا يتضمن القيم التالية: عدد الأيام عدد أيام التمرين القيمة المستهدفة أساسًا متوسط الوقت المحسوب قيمة منطقية تحدد إن تم تحقيق الهدف أم لا. تقييمًا بين 1-3 يصف حسن التمرين خلال ساعات التمرين. قرر أسلوب التقييم كما تشاء. قيمة نصية تصف التقييم تمرر ساعات التمرين اليومية إلى الدالة مثلل مصفوفة تحتوي على عدد ساعات التمرين كل يوم خلال فترة التمرين. فلو فرضنا أن ساعات التمرين خلال أسبوع موزعة كالتالي: يوم الاثنين 3، يوم الثلاثاء 0، يوم الأربعاء 2، يوم الخميس 4.5 وهكذا، ستكون المصفوفة مشابهة للمصفوفة التالية: [3, 0, 2, 4.5, 0, 3, 1] عليك أن تنشئ واجهة interface من أجل توصيف الكائن الذي سيحمل النتيجة. لو استدعيت الدالة وقد مررت لها المصفوفة [3, 0, 2, 4.5, 0, 3, 1] والقيمة 2 للمعامل الآخر ستكون النتيجة على النحو: { periodLength: 7, trainingDays: 5, success: false, rating: 2, ratingDescription: 'not too bad but could be better', target: 2, average: 1.9285714285714286 } أنشئ سكربت npm لينفذ الأمر npm run calculateExercise الذي يستدعي الدالة بمعاملات قيمها موجودة مسبقًا في الشيفرة. 9.3 سطر الأوامر عدّل التمرينين السابقين بحيث يمكنك تمرير قيم معاملات الدالتين calculateBmi وcalculateExercise من خلال سطر الأوامر. يمكن أن تنفذ برنامجك على سبيل المثال على النحو التالي: $ npm run calculateBmi 180 91 Overweight أو على النحو: $ npm run calculateExercises 2 1 0 2 4.5 0 3 1 0 4 { periodLength: 9, trainingDays: 6, success: false, rating: 2, ratingDescription: 'not too bad but could be better', target: 2, average: 1.7222222222222223 } وانتبه إلى أنّ المعامل الأول للدالة السابقة هو القيمة المستهدفة. تعامل مع الاستثناءات والأخطاء بطريقة مناسبة. وانتبه إلى أنّ الدالة exerciseCalculator ستقبل قيمًا لمعاملاتها بأطوال مختلفة. قرر بنفسك كيف ستجمّع كل البيانات المطلوبة. المزيد حول قواعد تهيئة TS لقد استخدمنا في التمارين السابقة قاعدة تهيئة واحدة هي noImplicitAny. وهي بالفعل نقطة انطلاق جيدة، لكن يجب أن نطلع بشيء من التفصيل على الملف "config". يحتوي الملف tsconfig.json كل التفاصيل الجوهرية التي تحدد الطريقة التي ستنفذ بها TS مشروعك. فيمكنك أن تحدد مقدار التشدد في تفحص الشيفرة، وأن تحدد الملفات التي ستدرجها في المشروع والتي ستستثنيها (يستثنى الملف "node_modules" افتراضيًا)، وأين ستخزّن الملفات المصرِّفة (سنتحدث أكثر عن هذا الموضوع لاحقًا). لنضع القواعد التالية في الملف tsconfig.json: { "compilerOptions": { "target": "ES2020", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true } } لا تقلق بخصوص القواعد في الجزء compilerOptions من الملف، فسنمر عليها بشيئ من التفصيل في القسم 2. يمكنك أن تجد شروحات عن كل قاعدة تهيئة من خلال توثيق TS، أو من خلال صفحة الويب tsconfig page، أو من خلال تعريف تخطيط الملف tsconfig والذي صيغ لسوء الحظ بطريقة أقل وضوحًا من الخيارين السابقين. إضافة المكتبة express إلى الخلطة وصلنا إلى مرحلة لا بأس بها. فمشروعنا قد ثّبت، ويحتوي على نسخة قابلة للتنفيذ من الآلة الحاسبة. لكن طالما أنا نهتم بالتطوير الشامل لتطبيقات الويب، فقد حان الوقت لنعمل مع بعض طلبات HTTP. لنبدأ بتثبيت المكتبة express: npm install express ثم سنضيف سكربت start إلى الملف package.json { // .. "scripts": { "ts-node": "ts-node", "multiply": "ts-node multiplier.ts", "calculate": "ts-node calculator.ts", "start": "ts-node index.ts" }, // .. } سنتمكن الآن من إنشاء الملف index.ts، ثم سنكتب ضمنه طلب HTTP-GET للتحقق من الاتصال بالخادم: const express = require('express'); const app = express(); app.get('/ping', (req, res) => { res.send('pong'); }); const PORT = 3003; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); }); يبدو أن كل شيء يعمل على ما يرام، لكن كما هو متوقع، لا بدّ من تحديد نوع كل من المعاملين req وres للدالة app.get (لأننا وضعنا قاعدة تهيئة تفرض أن يكون لكل متغير نوع). لو نظرت جيدًا، ستجد أن VSCode سيعترض على إدراج express. ستجد خطًا منقطا أصفر اللون تحت التعليمة require. وعندما نمرر الفأرة فوق الخطأ ستظهر الرسالة التالية: إنّ سبب الاعتراض هو أن require يمكن أن تُحوَّل إلى import. لننفذ النصيحة ونستخدم import كالتالي: import express from 'express'; ملاحظة: تقدم لك VSCode ميزة إصلاح المشاكل تلقائيًا بالنقر على زر "…Quick fix". انتبه إلى تلك الاصلاحات التلقائية، وحاول أن تتابع النصائح التي يقدمها محرر الشيفرة، لأن ذلك سيحسّن من شيفرتك ويجعلها أسهل قراءة. كما يمكن أن يكون الإصلاح التلقائي للأخطاء عاملًا رئيسيًا في توفير الوقت. سنواجه الآن مشكلة جديدة. سيعترض المصرِّف على عبارة import. وكالعادة يمثل المحرر المكان الأفضل لإيجاد حلول للمشاكل: لم نثبّت أنواع express كما يشير المحرر، لنثبتها إذًا: npm install --save-dev @types/express وهكذا سيعمل البرنامج بلا أخطاء. فلو مررنا الفأرة على عبارة require سنرى أن المصرِّف قد فسّر كل ما يتعلق بالمكتبة express على أنه من النوع "any". لكن عند استخدام imports فسيعرف المصرِّف الأنواع الفعلية للمتغيرات: يعتمد استخدام عبارة الإدراج على أسلوب التصدير الذي تعتمده الحزمة التي ندرجها. وكقاعدة أساسية: حاول أن تدرج وحدات الشيفرة باستخدام التعليمة import أولًا. سنستخدم دائمًا هذا الأسلوب عند كتابة شيفرة الواجهة الأمامية. فإن لم تنجح التعليمة import، جرّب طريقة مختلطة بتنفيذ الأمر ('...')import...= require. كما نوصيك بشدة أن تطلع أكثر على وحدات TS. لا تزال هنالك مشكلة عالقة: يحدث هذا لأننا منعنا وجود معاملات غير مستخدمة عند كتابة قواعد التهيئة في الملف tsconfig.json. { "compilerOptions": { "target": "ES2020", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true } } ستسبب لك التهيئة مشاكلًا إن استخدمت دوال معرّفة مسبقُا لمكتبة وتتطلب تصريحًا عن متغير حتى لو لم يستخدم مطلقًا كالحالة التي تواجهنا. ولحسن الحظ فقد حُلًت هذه المشكلة على مستوى قواعد التهيئة. وبمجرد تمرير مؤشر الفأرة فوق الشيفرة التي سببت المشكلة سيعطينا فكرة الحل. وسننجز الحل هذه المرة بالنقر على زر الإصلاح السريع. إن كان من المستحيل التخلص من المتغيرات غير المستخدمة، يمكنك أن تضيف إليها البادئة (_) لإبلاغ المصرِّف بأنك قد فكرت بحل ولم تصل إلى نتيجة! لنعدّل اسم المتغير req ليصبح req_. وأخيرًا سنكون مستعدين لتشغيل البرنامج. ويبدو أن كل شيء على مايرام. ولتبسيط عملية التطوير، ينبغي علينا تمكين ميزة إعادة التحميل التلقائي، وذلك لتحسين انسيابية العمل. لقد استخدمنا سابقًا المكتبة nodemon التي توفر هذه الميزة، لكن البديل عنها في ts-node هي المكتبة ts-node-dev. وقد جرى تصميم هذه الأخيرة لتُستخدم فقط في بيئة التطوير، حيث تتولى أمور إعادة التصريف عند كل تغيير، وبالتالي لا حاجة لإعادة تشغيل التطبيق. لنثبّت المكتبة ts-node-dev كاعتمادية تطوير: npm install --save-dev ts-node-dev ثم علينا إضافة سكربت خاص بها ضمن الملف package.json. { // ... "scripts": { // ... "dev": "ts-node-dev index.ts", }, // ... } وهكذا ستحصل على بيئة تطوير تدعم إعادة التحميل التلقائي للمشروع بتنفيذ الأمر npm run dev. التمرينان 9.4 - 9.5 9.4 استخدام المكتبة Express أضف المكتبة express إلى اعتماديات التطبيق، ثم أنشئ وصلة تخديم endpoint لطلبات HTTP-GET تدعى hello تجيب على الطلب بالعبارة 'Hello FullStack'. ينبغي أن تُشغّل التطبيق باستخدام الأمر npm start وذلك في بيئة الإنتاج وبالأمر npm run dev في بيئة التطوير التي من المفترض أن تستخدم المكتبة ts-node-dev لتشغيل التطبيق. استبدل محتوى الملف tsconfig.json بالمحتوى التالي: { "compilerOptions": { "noImplicitAny": true, "noImplicitReturns": true, "strictNullChecks": true, "strictPropertyInitialization": true, "strictBindCallApply": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitThis": true, "alwaysStrict": true, "esModuleInterop": true, "declaration": true, } } تأكد من عدم وجود أية أخطاء! 9.5 تطبيق ويب لقياس مؤشر الكتلة BMI أضف وصلة تخديم لحاسبة مؤشر الكتلة يمكن استخدامها بإرسال طلبات HTTP-GET إلى وصلة التخديم bmi، بحيث يكون الدخل على شكل معاملات استعلام نصية (query string parameters). فلو أردت مثلًا الحصول على مؤشر كتلة شخص طوله 180 ووزنه 72، ستحصل عليه بطلب العنوان http://localhost:3002/bmi?height=180&weight=72. ستكون الاستجابة بيانات json لها الشكل التالي: { weight: 72, height: 180, bmi: "Normal (healthy weight)" } اطلع على توثيق express لتعرف آلية الوصول إلى بارامترات الاستعلام. إن كانت معاملات الطلب من نوع خاطئ أو مفقودة، أجب برمز حالة مناسب ورسالة الخطأ التالية: { error: "malformatted parameters" } لا تنسخ شيفرة الحاسبة إلى الملف index.ts، بل اجعلها وحدة TS مستقلة يمكن إدراجها ضمن هذا الملف. كوابيس استخدام النوع any قد نلاحظ بعد أن أكملنا تنفيذ أول وصلة تخديم أننا بالكاد استخدمنا TS في هذا الأمثلة الصغيرة. وقد نلاحظ أيضًا بعض النقاط الخطرة عند تفحص الشيفرة بعناية. لنضف وصلة تخديم طلبات HTTP-GET تدعى calculate إلى تطبيقنا: import { calculator } from './calculator' // ... app.get('/calculate', (req, res) => { const { value1, value2, op } = req.query const result = calculator(value1, value2, op) res.send(result); }); عندما نمرر مؤشر الفأرة فوق الدالة calculate، ستجد أنها تحمل نوعًا على الرغم من أن الشيفرة ذاتها لا تتضمن أية أنواع. وبتمرير مؤشر الفأرة فوق القيم المستخلصة من الطلب، ستظهر مشكلة جديدة: ستحمل جميع المتغيرات النوع any. لن نتفاجأ كثيرًا بما حدث، لأننا في الواقع لم نحدد نوعًا لأي قيمة منها. تُحل المشكلة بطرق عدة، لكن علينا أولًا أن نفهم سبب حدوث هذا السلوك وما الذي جعل القيم من النوع any. كل متغير في TS لا يملك نوعًا ولا يمكن الاستدلال على نوعه، سيعتبر من النوع any ضمنًا. وهذا النوع هو بمثابة نوع "بديل" ويعني حرفيًا "أيًا كان النوع". ويحدث هذا الأمر كثيرًا عندما ينسى المبرمج أن يحدد نوعًا للقيم التي تعيدها الدالة ولمعاملاتها. كما يمكن أن نصُرّح بأن نوع المتغيّر هو any. والفرق الوحيد بين التصريح عن هذا النوع أو تضمينه هو في مظهر الشيفرة المكتوبة، فلن يكترث المصرِّف لأي فرق. لكن سينظر المبرمجون إلى هذا الموضوع بشكل مختلف. فتضمين النوع any يوحي بالمشاكل، لأنه ينتج عادة عن نسيان تحديد نوع المتغير أو المعامل، كما يعني أنك لم تستخدم TS بالطريقة الملائمة. وهذا هو السبب في وجود قاعدة التهيئة noImplicitAny على مستوى المصرِّف. ومن الأفضل أن تبقيها دائمًا مفعّلة. في الحالات النادرة التي لا تعرف فيها فعلًا نوع المتغيًر، عليك التصريح على أنه من النوع any. const a : any = /* افعل هذا إن كنت لا تعلم حقًا نوع المتغير */ لقد فعلنا القاعدة في مثالنا، لماذا إذًا لم يعترض المصرِّف على القيم التي تحمل النوع any ضمنًا؟ يعود السبب إلى الحقل query من كائن الطلب العائد للمكتبة express، فهو يمتلك النوع any صراحة. وكذلك الأمر بالنسبة للحقل request.body الذي يُستخدم لإرسال المعلومات إلى التطبيق. هل يمكننا منع المطور من استخدام any نهائيًا؟ لدينا لحسن الحظ طرق أخرى غير استخدام tsconfig.ts لإجبار المطور على اتباع أسلوب محدد في كتابة الشيفرة. إذا يمكننا استخدام المدقق eslint لإدارة أسلوب كتابة الشيفرة. لنثبت eslint إذًا: npm install --save-dev eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser سنُهيئ المدقق بحيث يمنع التصريح بالنوع "any". اكتب القواعد التالية في الملف ذو اللاحقة "eslintrc.": { "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 11, "sourceType": "module" }, "plugins": ["@typescript-eslint"], "rules": { "@typescript-eslint/no-explicit-any": 2 } } لنكتب أيضًا السكربت lint داخل الملف package.json ليتحقق من وجود ملفات لاحقتها "ts.": { // ... "scripts": { "start": "ts-node index.ts", "dev": "ts-node-dev index.ts", "lint": "eslint --ext .ts ." // ... }, // ... } سيعترض المدقق لو حاولنا أن نصرّح عن متغير من النوع any. ستجد في ‎@typescript-eslint الكثير من قواعد المدقق eslint المتعلقة باللغة TS، كما يمكنك استخدام القواعد الأساسية للمدقق في مشاريع هذه اللغة. من الأفضل حاليًا استخدام الإعدادات الموصى بها، ثم يمكننا لاحقًا تعديل القواعد عندما نحتاج إلى ذلك. وعلينا الانتباه إلى التوصيات المتعلقة بأسلوب كتابة الشيفرة بما يتوافق مع الأسلوب الذي يتطلبه هذا القسم وكذلك وضع فاصلة منقوطة في نهاية كل سطر من أسطر الشيفرة. سنستخدم حاليًا ملف قواعد المدقق الذي يتضمن ما يلي: { "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking" ], "plugins": ["@typescript-eslint"], "env": { "node": true, "es6": true }, "rules": { "@typescript-eslint/semi": ["error"], "@typescript-eslint/no-explicit-any": 2, "@typescript-eslint/explicit-function-return-type": 0, "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "no-case-declarations": 0 }, "parser": "@typescript-eslint/parser", "parserOptions": { "project": "./tsconfig.json" } } هناك بعض الفواصل المنقوطة المفقودة، لكن إضافتها أمر سهل. والآن علينا إصلاح كل ما يحتاج إلى إصلاح. التمرينان 9.6 - 9.7 9.6 المدقق ESlint هيئ مشروعك ليستخدم الإعدادات السابقة وأصلح كل المشاكل. 9.7 تطبيق ويب لحساب ساعات التمرين أضف وصلة تخديم إلى تطبيقك الذي يحسب ساعات التمرين اليومية. ينبغي أن تستخدم الوصلة لإرسال طلب HTTP-POST إلى وصلة التخديم المقابلة exercises متضمنة معلومات الدخل في جسم الطلب. { "daily_exercises": [1, 0, 2, 0, 3, 0, 2.5], "target": 2.5 } سيكون الجواب بصيغة json على النحو التالي: { "periodLength": 7, "trainingDays": 4, "success": false, "rating": 1, "ratingDescription": "bad", "target": 2.5, "average": 1.2142857142857142 } إن لم يكن جسم الطلب بالتنسيق الصحيح، أعد رمز الحالة المناسب مع رسالة الخطأ هذه: { error: "parameters missing" } أو هذه: { error: "malformatted parameters" } بناء على الخطأ. ويحدث الخطأ الثاني إن لم تمتلك القيم المدخلة النوع المناسب، كأن لا تكون أرقامًا أو لا يمكن تحويلها إلى أرقام. يمكن أن تجد أن التصريح عن متغير بأنه من النوع any مفيدًا في هذا التمرين عندما تتعامل مع البيانات الموجودة ضمن جسم الطلب. ستمنعك طبعًا قواعد تهيئة المدقق، لكن يمكنك أن تلغي تفعيل هذه القاعدة من أجل سطر محدد من الشيفرة بوضع هذا التعليق قبل السطر المطلوب. // eslint-disable-next-line @typescript-eslint/no-explicit-any انتبه بأنك ستحتاج إلى تهيئة صحيحة لتتمكن من الاحتفاظ ببيانات جسم الطلب. راجع القسم 3. ترجمة -وبتصرف- للفصل First steps with TypeScript من سلسلة Deep Dive Into Modern Web Development
  25. TypeScript هي لغة برمجة مصممة لتنفيذ مشاريع JavaScript ضخمة، صممتها Microsoft. فقد بُنيت على سبيل المثال برنامج مثل Azure Management Portal الذي يبلغ عدد أسطر شيفرته 1.2 مليون، وبرنامج Visiual Studio Code الذي يبلغ عدد أسطر شيفرته 300 ألف باستخدام TypeScript. تقدم TypeScript ميزات عديدة لدعم مشاريع JavaScript الضخمة مثل أدوات تطوير أفضل وتحليل شيفرة ساكنة والتحقق من الأنواع عند الترجمة والتوثيق على مستوى الشيفرة. المبدأ الرئيسي تمثل اللغة TypeScript مجموعة مطوّرة من تعليمات JavaScript قادرة على تمييز الأنواع وتترجم عادة إلى شيفرة JavaScript صرفة. ويمكن للمطور أن يحدد إصدار الشيفرة الناتجة طالما أن إصدار ECMAScript هو الإصدار 3 أو أحدث. ويعني أنّ هذه اللغة مجموعة مطوّرة من تعليمات JavaScript أنها تتضمن كل ميزات JavaScript بالإضافة إلى ميزات خاصة بها. وتعتبر كل شيفرات JavaScript شيفرات TypeScript صحيحة. تتألف TypeScript من ثلاثة أقسام منفصلة لكنها متكاملة مع بعضها: اللغة المصرِّف خدمة اللغة تتألف اللغة من القواعد والكلمات المحجوزة (تعليمات) ومسجلات الأنواع Type annotations وتتشابه قواعدها مع مثيلاتها في JavaScript لكنها ليست متماثلة. وأكثر ما يتعامل معه المبرمجون من تلك الأقسام هي اللغة. تنحصر مهمة المصرِّف في محو المعلومات المتعلقة بالنوع بالإضافة إلى التحويلات على الشيفرة. إذ تمكن عملية تحويل الشيفرة من نقل شيفرة TypeScript إلى شيفرة JavaScript قابلة للتنفيذ. يزيل المصرِّف كل ما يتعلق بالأنواع أثناء الترجمة، وبالتالي لا تميز هذه اللغة الأنواع بشكل فعلي قبل الترجمة. تعني عملية التصريف بالمعنى المتعارف عليه، تحويل الشيفرة من الشكل الذي يمكن للإنسان قراءته وفهمه إلى الشكل الذي تفهمه الآلة. أما عملية الترجمة في TypeScript فهي عملية تحويل الشيفرة من شكل يفهمه الإنسان إلى شكل آخر يفهمه الإنسان أيضًا، لذا من الأنسب أن تسمى هذه العملية بالنقل Transpilling. لكن مصطلح الصريف هو الأشيع في هذا المضمار وسنستخدمه نحن بدورنا. ينفذ المصرِّف أيضًا عملية تحليل الشيفرة ما قبل التنفيذ. إذ يمكنه إظهار رسائل التحذير والخطأ إن كان هناك سبب لذلك، كما يمكن أن يُهيَّئ لتنفيذ مهام إضافية كضم الشيفرة الناتجة في ملف واحد. تجمع خدمة اللغة معلومات عن الأنواع الموجودة في الشيفرة المصدرية. وبالتالي ستتمكن أدوات التطوير من استخدامها في تقديم ميزة إكمال الشيفرة في بيئات التطوير وطباعة التلميحات وتقديم اقتراحات لإعادة كتابة أجزاء من الشيفرة. الميزات الرئيسية للغة TypeScript سنقدم تاليًا وصفًا لبعض ميزات اللغة TypeScript. والغاية من ذلك تزويدك بالأساسيات التي تقدمها اللغة وميزاتها، وذلك للتعامل مع الأفكار التي سنراها تباعًا في المنهاج. مسجلات الأنواع وهي طريقة خفيفة في TypeScript لتسجيل الأنواع التي نريد أن تمرر أو تعاد من الدوال أو أنواع المتغيرات. فلقد عرفنا في المثال التالي على سبيل المثال الدالة birthdayGreeter التي تقبل معاملين أحدهما من النوع string، والآخر من النوع number، وستعيد قيمة من النوع string. const birthdayGreeter = (name: string, age: number): string => { return `Happy birthday ${name}, you are now ${age} years old!`; }; const birthdayHero = "Jane User"; const age = 22; console.log(birthdayGreeter(birthdayHero, 22)); نظام الخصائص المعرفة للأنواع تستخدم اللغة TypeScript الخصائص المعرفة للأنواع structural typing. ويعتبر عنصران في هذا النظام متوافقان إن كان لكل ميزة في النوع الأول ميزة تماثلها تمامًا في النوع الثاني. ويعتبر النوعان متطابقان إذا كانا متوافقان مع بعضهما. الاستدلال على النوع يحاول المصرِّف أن يستدل على نوع المعلومة إن لم يحدد لها نوع. إذ يمكن الاستدلال على نوع المتغير بناء على القيمة التي أسندت له. تحدث هذه العملية عند تهيئة المتغيرات والأعضاء، وعند إسناد القيم الافتراضية للمعاملات، وعند تحديد القيمة التي تعيدها دالة. لنتأمل على سبيل المثال الدالة Add: const add = (a: number, b: number) => { /* تستخدم القيمة المعادة لتحديد نوع القيمة التي تعيدها الدالة*/ return a + b; } يستدل المصرِّف على نوع القيمة المعادة للدالة بتعقب الشيفرة حتى الوصول إلى عبارة return. تعيد هذه العبارة مجموع المعاملين a وb. وكما نرى فكلا المعاملين من النوع number وهكذا سيستدل المصرِّف على أن القيمة التي تعيدها الدالة من النوع number. وكمثال أكثر تعقيدًا، لنتأمل الشيفرة التالية (قد يكون المثال صعب الفهم قليلًا إن لم تكن قد استخدمت TypeScript مسبقًا. يمكنك تخطي هذا المثال حاليًا.) type CallsFunction = (callback: (result: string) => any) => void; const func: CallsFunction = (cb) => { cb('done'); cb(1); } func((result) => { return result; }); بداية نجد تصريحًا لاسم نوع مستعار type alias يدعى CallsFunction. يُمثل نوع دالة تقبل معاملًا واحدًا callback يمثِّل بدوره دالة تتلقى معاملًا من النوع "string" وتعيد قيمة من النوع any. وكما سنرى لاحقًا فالنوع any هو شكل من أشكال الحروف البديلة wildcards والتي يمكن أن تحل محل أي نوع. بعد ذلك نعرّف الدالة func من النوع CallsFunction. يمكننا أن نستدل من نوع الدالة بأن معاملها الدالة cb ستقبل فقط معاملًا من النوع string.ولتوضيح ذلك سنورد مثالًا آخر تُستدعى فيه دالة كمعامل لكن بقيمة من النوع number، وسيسبب هذا الاستدعاء خطأً في TypeScript. وأخيرًا نستدعي الدالة func بعد أن نمرر إليها الدالة التالية كمعامل: (result) => { return result; } وعلى الرغم من عدم تحديد أنواع معاملات الدالة، يمكننا الاستدلال من سياق الاستدعاء أن المعامل result من النوع String. إزالة الأنواع تزيل TypeScript جميع الأنواع التي يبنيها نظام تحديد الأنواع أثناء الترجمة: فلو كانت الشيفرة قبل الترجمة كالتالي: let x: SomeType; ستكون بعد الترجمة: let x; ويعني هذا عدم وجود أية معلومات عن الانواع أثناء التنفيذ، فلا يشير أي شيء على أن متغيرًا X على سبيل المثال قد عُرِّف أنه من نوع ما. سيكون النقص في المعلومات عن الأنواع في زمن التنفيذ مفاجئًا للمبرمجين الذين اعتادوا على استخدام تعليمات التحقق Reflection وغيرها من أنظمة إظهار البيانات الوصفية. هل علينا فعلا استعمال TypeScript؟ إن كنت من متابعي المنتديات البرمجية على الانترنت، ستجد تضاربًا في الآراء الموافقة والمعارضة لاستخدام هذه اللغة. والحقيقة أنّ مدى حاجتك لاستخدام الدوال التي تقدمها TypeScript هي من يحدد وجهة نظرك. سنعرض على أية حال بعض الأسباب التي قد تجعل استخدام هذه اللغة مفيدًا. أولًا: ستقدم ميزة التحقق من الأنواع وميزة التحليل الساكن للشيفرة (قبل الترجمة والتنفيذ). كما يمكننا أن نطلب قيمًا من نوع محدد وأن نتلقى تحذيرات من المصرِّف عندما نستخدم هذه القيم بشكل خاطئ. سيقلل هذا الأمر الأخطاء التي تحصل وقت التنفيذ، كما يمكن أن يقلل من عدد اختبارات الأجزاء unit tests التي يحتاجها التطبيق وخاصة ما يتعلق باختبارات الأنواع الصرفة pure types. ولا تحذرك عملية التحليل الساكن للشيفرة من الاستخدام الخاطئ للأنواع وحسب، بل تشير إلى الأخطاء الأخرى ككتابة اسم المتغير أو الدالة بشكل خاطئ أو استخدام المتغير خارج المجال المعرّف ضمنه. ثانيًا: يمكن لميزة تسجيل الأنواع أن تعمل كشكل من أشكال التوثيق على مستوى الشيفرة. إذ يمكنك أن تتحقق من خلال بصمة الدالة signature من الأنواع التي تتلقاها الدالة والأنواع التي ستعيدها. سيبقى هذا الشكل من التوثيق محدّثًا أثناء تقدم العمل، وسيسهّل ذلك التعامل مع مشاريع جاهزة وخاصة للمبرمجين الجدد. كما ستساعد كثيرًا عند العودة إلى مشاريع قديمة. يمكن إعادة استخدام الأنواع في أي مكان من الشيفرة، وستنتقل أية تغييرات على تعريف النوع إلى أي مكان استخدم فيه هذا النوع. وقد يجادل البعض بأن استخدام JSDoc سينجز التوثيق على مستوى الشيفرة بشكل مماثل، لكنه لا يرتبط بالشيفرة بشكل وثيق كما تفعل أنواع TypeScript، وقد يسبب هذا خللًا في تزامن عرض المعلومات، بالإضافة إلى أنها أطول. ثالثًا: ستقدم بيئة عمل اللغة ميزة إكمال الشيفرة بشكل أفضل لأنها تعلم تمامًا نوع البيانات التي تعالجها. وتعتبر الميزات الثلاث السابقة مفيدة للغاية عند إعادة كتابة الشيفرة. فسيحذرك التحليل الساكن للشيفرة من الأخطاء في شيفرتك، وسيرشدك معالج إكمال الشيفرة إلى الخصائص التي يقدمها المتغير أو الكائن أو حتى خيارات لإعادة كتابة الشيفرة بشكل أنسب، وسيساعدك التوثيق على مستوى الشيفرة في فهم الشيفرة المكتوبة. وبهذه المساعدة التي تقدمها لك بيئة عمل هذه اللغة، سيكون من السهل استخدام الميزات الأحدث من لغة JavaScript وفي أية مرحلة تقريبًا بمجرد تغيير إعدادات التهيئة. ما الأمور التي لا يمكن للغة TypeScript أن توفرها لك؟ لقد ذكرنا سابقًا أن تسجيل الأنواع والتحقق منها فقط في مراحل ما قبل التنفيذ. فحتى لو لم يشر المصرِّف إلى أخطاء فقط تحدث الأخطاء أثناء التنفيذ. تحدث أخطاء زمن التنفيذ بشكل متكرر عند التعامل مع مصدر دخل خارجي كالبيانات المستقبلة كطلب عبر شبكة. وضعنا أخيرًا قائمة بالمشاكل التي قد تواجهنا عند استخدام TypeScript، ويجب الانتباه إليها: الأنواع غير المكتملة أو الخاطئة أو المفقودة التي مصدرها مكتبات خارجية قد تجد في بعض المكتبات الخارجية أنواعًا غير معرّفة أو ذات تعريف خاطئ بشكل أو بآخر. والسبب المرجح أنّ المكتبة لم تُكتَب بلغة Typescript، وأنّ المبرمج الذي صرّح عن النوع يدويًا قد ارتكب خطأً. عليك في هذه الحال كتابة التصريح عن النوع يدويًا بنفسك. لكن هناك فرصة كبيرة أن يكون أحدهم قد فعل ذلك مسبقًا عوضًا عنك، تحقق من موقع DefinitelyTyped أو صفحة GitHub الخاصة بهذا الموقع أولًا. وهذان المصدران هما الأكثر شعبية لإيجاد ملفات تعريف الأنواع. في حال لم يحالفك الحظ، لابدّ أن تبدأ من الصفر بقراءة توثيق TypeScript بما يخص تعريف الأنواع. قد يحتاج الاستدلال على النوع إلى بعض المساعدة إن الاستدلال على النوع في هذه اللغة جيد جدًا لكنه ليس مثاليًا. قد تعتقد أحيانًا أنك صرحت عن نوع بشكل مثالي، لكن المصرِّف يحذرك بشكل مستمر من أن خاصية محددة غير موجودة أو أنّ التصريح بهذه الطريقة غير مسموح. عليك في حالات كهذه مساعدة المصرِّف بتنفيذ عملية تحقق إضافية من النوع الذي صرحت عنه، وخاصة فيما يتعلق بالتحويل بين الأنواع type casting أو حاميات الأنواع type gaurds. عند التحويل بين الأنواع أو عند استخدام الحاميات فأنت بذلك تضمن للمصرِّف أن القيمة التي وضعتها هي بالتأكيد من النوع المصرح عنه وهذا مصدر للأخطاء. ربما عليك الاطلاع على التوثيق فيما يتعلق بحاميات الأنواع أو مؤكدات الأنواع Type Assertions. أخطاء الأنواع الغامضة من الصعب أحيانًا فهم طبيعة الخطأ الذي يعطيه نظام تحديد الأنواع، وخاصة إن استخدمت أنواع معقدة. وكقاعدة أساسية، ستحمل لك رسائل خطأ TypeScript المعلومات الأكثر أهمية في نهاية الرسالة. فعندما تظهر لك رسالة طويلة مزعجة، ابدأ بالقراءة من آخر الرسالة. ترجمة -وبتصرف- للفصل Background and introduction من سلسلة Deep Dive Into Modern Web Development
×
×
  • أضف...