تطوير ونشر الواجهات الأمامية المصغرة (Micro-Frontends) باستخدام Single-SPA
تُمثل الواجهات الأمامية المصغرة (Micro-frontends) مستقبل تطوير الويب للواجهات الأمامية. استُلهم هذا المفهوم من الخدمات المصغرة (microservices) التي تتيح تقسيم الواجهة الخلفية (backend) إلى أجزاء أصغر، مما يسمح لك ببناء واختبار ونشر أجزاء من تطبيق الواجهة الأمامية بشكل مستقل عن بعضها البعض.
اعتمادًا على إطار عمل الواجهات الأمامية المصغرة الذي تختاره، يمكنك حتى دمج تطبيقات متعددة للواجهات الأمامية المصغرة – مكتوبة بلغات مثل React، Angular، Vue، أو أي تقنية أخرى – لتتعايش بسلام ضمن التطبيق الأكبر. في هذا المقال، سنقوم بتطوير تطبيق يتكون من واجهات أمامية مصغرة باستخدام single-spa ونشره على Heroku. سنقوم أيضًا بإعداد التكامل المستمر (continuous integration) باستخدام Travis CI. ستقوم كل دورة CI pipeline بتجميع شيفرة JavaScript لتطبيق الواجهة الأمامية المصغرة، ثم تحميل نواتج البناء (build artifacts) الناتجة إلى خدمة AWS S3. أخيرًا، سنجري تحديثًا على أحد تطبيقات الواجهة الأمامية المصغرة ونرى كيف يمكن نشره في بيئة الإنتاج بشكل مستقل عن التطبيقات الأخرى.
نظرة عامة على التطبيق التجريبي
التطبيق التجريبي – النتيجة النهائية
قبل أن نناقش التعليمات خطوة بخطوة، دعنا نلقي نظرة سريعة على ما يتكون منه التطبيق التجريبي. يتألف هذا التطبيق من أربعة تطبيقات فرعية:
- تطبيق حاوي (
container app) يعمل كصفحة رئيسية ويقوم بتنسيق تحميل وإلغاء تحميل تطبيقات الواجهة الأمامية المصغرة. - تطبيق واجهة أمامية مصغرة لشريط التنقل (
navbar app) يكون دائمًا موجودًا في الصفحة. - تطبيق واجهة أمامية مصغرة لـ "الصفحة 1" (
page 1 app) يظهر فقط عند تفعيله. - تطبيق واجهة أمامية مصغرة لـ "الصفحة 2" (
page 2 app) يظهر أيضًا فقط عند تفعيله.
تعيش هذه التطبيقات الأربعة جميعها في مستودعات (repos) منفصلة، ومتاحة على GitHub. النتيجة النهائية بسيطة إلى حد ما من حيث واجهة المستخدم، ولكن، للتوضيح، واجهة المستخدم ليست هي النقطة الأساسية هنا. إذا كنت تتبع الخطوات على جهازك الخاص، فبحلول نهاية هذا المقال، سيكون لديك كل البنية التحتية اللازمة للبدء بتطبيق الواجهة الأمامية المصغرة الخاص بك.
إنشاء التطبيق الحاوي (Container App)
لإنشاء التطبيقات لهذا العرض التوضيحي، سنستخدم أداة واجهة سطر الأوامر (CLI) تُدعى create-single-spa. إصدار create-single-spa وقت كتابة هذا المقال هو 1.10.0، وإصدار single-spa المثبت عبر CLI هو 4.4.2. سنتبع هذه الخطوات لإنشاء التطبيق الحاوي (الذي يُسمى أحيانًا root config):
mkdir single-spa-demo
cd single-spa-demo
mkdir single-spa-demo-root-config
cd single-spa-demo-root-config
npx create-single-spa
ثم سنتبع إرشادات CLI:
- اختر "
single spa root config" - اختر "
yarn" أو "npm" (اخترت "yarn") - أدخل اسم منظمة (استخدمت "
thawkin3"، ولكن يمكنك استخدام ما تريد)
رائع! الآن، إذا قمت بفحص مجلد single-spa-demo-root-config، يجب أن ترى تطبيق root config هيكليًا. سنقوم بتخصيص هذا لاحقًا، ولكن أولاً دعنا نستخدم أداة CLI أيضًا لإنشاء تطبيقات الواجهة الأمامية المصغرة الثلاثة الأخرى.
إنشاء تطبيقات الواجهة الأمامية المصغرة (Micro-Frontend Apps)
لإنشاء أول تطبيق واجهة أمامية مصغرة، وهو شريط التنقل (navbar)، سنتبع هذه الخطوات:
cd ..
mkdir single-spa-demo-nav
cd single-spa-demo-nav
npx create-single-spa
ثم سنتبع إرشادات CLI:
- اختر "
single-spa application / parcel" - اختر "
react" - اختر "
yarn" أو "npm" (اخترت "yarn") - أدخل اسم منظمة، وهو نفس الاسم الذي استخدمته عند إنشاء تطبيق
root config(في حالتي "thawkin3") - أدخل اسم مشروع (استخدمت "
single-spa-demo-nav")
الآن بعد أن أنشأنا تطبيق شريط التنقل، يمكننا اتباع نفس الخطوات لإنشاء تطبيقي الصفحات. ولكن، سنستبدل كل مكان نرى فيه "single-spa-demo-nav" بـ "single-spa-demo-page-1" في المرة الأولى، ثم بـ "single-spa-demo-page-2" في المرة الثانية. في هذه المرحلة، قمنا بإنشاء جميع التطبيقات الأربعة التي نحتاجها: تطبيق حاوي واحد وثلاثة تطبيقات واجهة أمامية مصغرة. حان الوقت الآن لربط تطبيقاتنا ببعضها البعض.
تسجيل تطبيقات الواجهة الأمامية المصغرة مع التطبيق الحاوي
كما ذكرنا سابقًا، إحدى المسؤوليات الأساسية للتطبيق الحاوي هي تنسيق متى يكون كل تطبيق "نشطًا" أو لا. بعبارة أخرى، هو يتعامل مع متى يجب عرض كل تطبيق أو إخفاؤه. لمساعدة التطبيق الحاوي على فهم متى يجب عرض كل تطبيق، نوفر له ما يُسمى "دوال النشاط" (activity functions). كل تطبيق لديه دالة نشاط تُرجع ببساطة قيمة منطقية (boolean)، إما true أو false، لتحديد ما إذا كان التطبيق نشطًا حاليًا أم لا.
داخل مجلد single-spa-demo-root-config، في ملف activity-functions.js، سنكتب دوال النشاط التالية لتطبيقات الواجهة الأمامية المصغرة الثلاثة:
export function prefix ( location, ...prefixes ) {
return prefixes.some( prefix => location.href.indexOf( ` ${location.origin} / ${prefix} ` ) !== -1 );
}
export function nav ( ) {
// The nav is always active
return true ;
}
export function page1 ( location ) {
return prefix(location, 'page1' );
}
export function page2 ( location ) {
return prefix(location, 'page2' );
}
بعد ذلك، نحتاج إلى تسجيل تطبيقات الواجهة الأمامية المصغرة الثلاثة لدينا مع single-spa. للقيام بذلك، نستخدم دالة registerApplication. تقبل هذه الدالة ثلاثة وسائط كحد أدنى: اسم التطبيق، وطريقة لتحميل التطبيق، ودالة نشاط لتحديد متى يكون التطبيق نشطًا.
داخل مجلد single-spa-demo-root-config، في ملف root-config.js، سنضيف الشيفرة التالية لتسجيل تطبيقاتنا:
import { registerApplication, start } from "single-spa" ;
import * as isActive from "./activity-functions" ;
registerApplication(
"@thawkin3/single-spa-demo-nav" ,
() => System.import( "@thawkin3/single-spa-demo-nav" ),
isActive.nav
);
registerApplication(
"@thawkin3/single-spa-demo-page-1" ,
() => System.import( "@thawkin3/single-spa-demo-page-1" ),
isActive.page1
);
registerApplication(
"@thawkin3/single-spa-demo-page-2" ,
() => System.import( "@thawkin3/single-spa-demo-page-2" ),
isActive.page2
);
start();
الآن بعد أن قمنا بإعداد دوال النشاط وتسجيل تطبيقاتنا، الخطوة الأخيرة قبل أن نتمكن من تشغيل هذا محليًا هي تحديث خريطة الاستيراد المحلية (local import map) داخل ملف index.ejs في نفس المجلد. سنضيف الشيفرة التالية داخل وسم <head> لتحديد مكان كل تطبيق عند التشغيل محليًا:
<% if (isLocal) { %>
< script type = "systemjs-importmap" >
{
"imports" : {
"@thawkin3/root-config" : "http://localhost:9000/root-config.js" ,
"@thawkin3/single-spa-demo-nav" : "http://localhost:9001/thawkin3-single-spa-demo-nav.js" ,
"@thawkin3/single-spa-demo-page-1" : "http://localhost:9002/thawkin3-single-spa-demo-page-1.js" ,
"@thawkin3/single-spa-demo-page-2" : "http://localhost:9003/thawkin3-single-spa-demo-page-2.js"
}
}
</ script >
<% } %>
يحتوي كل تطبيق على سكريبت التشغيل الخاص به، مما يعني أن كل تطبيق سيعمل محليًا على خادم التطوير الخاص به أثناء التطوير المحلي. كما ترون، يعمل تطبيق شريط التنقل الخاص بنا على المنفذ 9001، وتطبيق الصفحة 1 على المنفذ 9002، وتطبيق الصفحة 2 على المنفذ 9003. بعد الانتهاء من هذه الخطوات الثلاث، دعنا نجرب تطبيقنا.
اختبار التشغيل المحلي للتطبيق
لتشغيل تطبيقنا محليًا، يمكننا اتباع هذه الخطوات:
- افتح أربعة ألسنة طرفية (
terminal tabs)، واحد لكل تطبيق. - بالنسبة لتطبيق
root config، في مجلدsingle-spa-demo-root-config:yarn start(يعمل على المنفذ9000افتراضيًا). - بالنسبة لتطبيق شريط التنقل، في مجلد
single-spa-demo-nav:yarn start --port 9001. - بالنسبة لتطبيق الصفحة 1، في مجلد
single-spa-demo-page-1:yarn start --port 9002. - بالنسبة لتطبيق الصفحة 2، في مجلد
single-spa-demo-page-2:yarn start --port 9003.
الآن، سنتصفح في المتصفح إلى http://localhost:9000 لعرض تطبيقنا. يجب أن نرى… بعض النصوص! مثير للغاية.
التطبيق التجريبي – الصفحة الرئيسية
في صفحتنا الرئيسية، يظهر شريط التنقل لأن تطبيق شريط التنقل نشط دائمًا. الآن، دعنا ننتقل إلى http://localhost:9000/page1. كما هو موضح في دوال النشاط الخاصة بنا أعلاه، حددنا أن تطبيق الصفحة 1 يجب أن يكون نشطًا (معروضًا) عندما يبدأ مسار URL بـ "page1". لذا، هذا ينشط تطبيق الصفحة 1، ويجب أن نرى الآن نص كل من شريط التنقل وتطبيق الصفحة 1.
التطبيق التجريبي – مسار الصفحة 1
مرة أخرى، دعنا ننتقل الآن إلى http://localhost:9000/page2. كما هو متوقع، هذا ينشط تطبيق الصفحة 2، لذا يجب أن نرى الآن نص كل من شريط التنقل وتطبيق الصفحة 2.
التطبيق التجريبي – مسار الصفحة 2
إجراء تعديلات طفيفة على التطبيقات
حتى الآن، تطبيقنا ليس مثيرًا جدًا للنظر، لكن لدينا إعداد واجهة أمامية مصغرة يعمل محليًا. إذا لم تكن تهتف في مقعدك الآن، فيجب أن تكون! دعنا نجري بعض التحسينات الطفيفة على تطبيقاتنا لتظهر وتتصرف بشكل أفضل قليلاً.
تحديد حاويات التحميل (Mount Containers)
أولاً، إذا قمت بتحديث صفحتك مرارًا وتكرارًا عند عرض التطبيق، فقد تلاحظ أحيانًا أن التطبيقات تُحمّل بترتيب غير صحيح، حيث يظهر تطبيق الصفحة فوق تطبيق شريط التنقل. هذا لأننا لم نحدد فعليًا مكان تحميل كل تطبيق. يتم تحميل التطبيقات ببساطة بواسطة SystemJS، ثم يتم إلحاق التطبيق الذي ينتهي تحميله أسرع بالصفحة أولاً. يمكننا إصلاح ذلك عن طريق تحديد حاوية تحميل (mount container) لكل تطبيق عند تسجيلها.
في ملف index.ejs الذي عملنا فيه سابقًا، دعنا نضيف بعض شيفرة HTML لتعمل كحاويات محتوى رئيسية للصفحة:
< div id = "nav-container" > </ div >
< main >
< div id = "page-1-container" > </ div >
< div id = "page-2-container" > </ div >
</ main >
ثم، في ملف root-config.js حيث قمنا بتسجيل تطبيقاتنا، دعنا نوفر وسيطًا رابعًا لكل استدعاء دالة يتضمن عنصر DOM الذي نود تحميل كل تطبيق فيه:
import { registerApplication, start } from "single-spa" ;
import * as isActive from "./activity-functions" ;
registerApplication(
"@thawkin3/single-spa-demo-nav" ,
() => System.import( "@thawkin3/single-spa-demo-nav" ),
isActive.nav,
{ domElement : document .getElementById( 'nav-container' ) }
);
registerApplication(
"@thawkin3/single-spa-demo-page-1" ,
() => System.import( "@thawkin3/single-spa-demo-page-1" ),
isActive.page1,
{ domElement : document .getElementById( 'page-1-container' ) }
);
registerApplication(
"@thawkin3/single-spa-demo-page-2" ,
() => System.import( "@thawkin3/single-spa-demo-page-2" ),
isActive.page2,
{ domElement : document .getElementById( 'page-2-container' ) }
);
start();
الآن، سيتم تحميل التطبيقات دائمًا في موقع محدد ويمكن التنبؤ به. ممتاز!
تنسيق التطبيق (Styling the App)
بعد ذلك، دعنا نُنسق تطبيقنا قليلًا. النص الأسود العادي على خلفية بيضاء ليس مثيرًا للاهتمام. في مجلد single-spa-demo-root-config، في ملف index.ejs مرة أخرى، يمكننا إضافة بعض الأنماط الأساسية للتطبيق بأكمله عن طريق لصق CSS التالي في نهاية وسم <head>:
< style >
body , html {
margin : 0 ;
padding : 0 ;
font-size : 16px ;
font-family : Arial, Helvetica, sans-serif;
height : 100% ;
}
body {
display : flex;
flex-direction : column;
}
* {
box-sizing : border-box;
}
</ style >
بعد ذلك، يمكننا تنسيق تطبيق شريط التنقل الخاص بنا عن طريق العثور على مجلد single-spa-demo-nav، وإنشاء ملف root.component.css، وإضافة CSS التالي:
.nav {
display : flex;
flex-direction : row;
padding : 20px ;
background : #000 ;
color : #fff ;
}
.link {
margin-right : 20px ;
color : #fff ;
text-decoration : none;
}
.link :hover ,
.link :focus {
color : #1098f7 ;
}
يمكننا بعد ذلك تحديث ملف root.component.js في نفس المجلد لاستيراد ملف CSS وتطبيق تلك الفئات والأنماط على HTML الخاص بنا. سنقوم أيضًا بتغيير محتوى شريط التنقل ليحتوي فعليًا على رابطين حتى نتمكن من التنقل في التطبيق بالنقر على الروابط بدلاً من إدخال URL جديد في شريط عنوان المتصفح.
import React from "react" ;
import "./root.component.css" ;
export default function Root ( ) {
return (
< nav className = "nav" >
< a href = "/page1" className = "link" > Page 1 </ a >
< a href = "/page2" className = "link" > Page 2 </ a >
</ nav >
);
}
سنتبع عملية مماثلة لتطبيقي الصفحة 1 والصفحة 2 أيضًا. سنقوم بإنشاء ملف root.component.css لكل تطبيق في مجلدات مشاريعهما وتحديث ملفات root.component.js لكلا التطبيقين أيضًا. بالنسبة لتطبيق الصفحة 1، تبدو التغييرات كالتالي:
.container1 {
background : #1098f7 ;
color : white;
padding : 20px ;
display : flex;
align-items : center;
justify-content : center;
flex : 1 ;
font-size : 3rem ;
}
import React from "react" ;
import "./root.component.css" ;
export default function Root ( ) {
return (
< div className = "container1" >
< p > Page 1 App </ p >
</ div >
);
}
وبالنسبة لتطبيق الصفحة 2، تبدو التغييرات كالتالي:
.container2 {
background : #9e4770 ;
color : white;
padding : 20px ;
display : flex;
align-items : center;
justify-content : center;
flex : 1 ;
font-size : 3rem ;
}
import React from "react" ;
import "./root.component.css" ;
export default function Root ( ) {
return (
< div className = "container2" >
< p > Page 2 App </ p >
</ div >
);
}
إضافة React Router
التغيير الصغير الأخير الذي سنجريه هو إضافة React Router إلى تطبيقنا. في الوقت الحالي، الرابطان اللذان وضعناهما في شريط التنقل هما مجرد وسوم <a> عادية، لذا فإن التنقل من صفحة إلى أخرى يسبب تحديثًا للصفحة. سيشعر تطبيقنا بسلاسة أكبر إذا تم التعامل مع التنقل من جانب العميل (client-side) باستخدام React Router.
لاستخدام React Router، سنحتاج أولاً إلى تثبيته. من الطرفية، في مجلد single-spa-demo-nav، سنقوم بتثبيت React Router باستخدام yarn عن طريق إدخال yarn add react-router-dom. (أو إذا كنت تستخدم npm، يمكنك إدخال npm install react-router-dom).
ثم، في مجلد single-spa-demo-nav في ملف root.component.js، سنستبدل وسوم <a> الخاصة بنا بمكونات Link الخاصة بـ React Router كالتالي:
import React from "react" ;
import { BrowserRouter, Link } from "react-router-dom" ;
import "./root.component.css" ;
export default function Root ( ) {
return (
< BrowserRouter >
< nav className = "nav" >
< Link to = "/page1" className = "link" > Page 1 </ Link >
< Link to = "/page2" className = "link" > Page 2 </ Link >
</ nav >
</ BrowserRouter >
);
}
رائع. هذا يبدو ويعمل بشكل أفضل بكثير!
التطبيق التجريبي – مُنسق ويستخدم React Router
التحضير للإنتاج
في هذه المرحلة، لدينا كل ما نحتاجه لمواصلة العمل على التطبيق أثناء تشغيله محليًا. ولكن كيف نجعله مستضافًا في مكان متاح للجمهور؟ هناك العديد من الأساليب الممكنة التي يمكننا اتباعها باستخدام أدواتنا المفضلة، ولكن المهام الرئيسية هي: وجود مكان يمكننا تحميل نواتج البناء (build artifacts) الخاصة بنا إليه، مثل شبكة توصيل المحتوى (CDN)، وأتمتة عملية تحميل هذه النواتج في كل مرة ندمج فيها شيفرة جديدة في الفرع الرئيسي (master branch).
لهذا المقال، سنستخدم AWS S3 لتخزين أصولنا، وسنستخدم Travis CI لتشغيل مهمة بناء ومهمة تحميل كجزء من دورة التكامل المستمر. دعنا نُعد حاوية S3 bucket أولاً.
إعداد حاوية AWS S3
لا داعي للقول، ولكن ستحتاج إلى حساب AWS إذا كنت تتبع الخطوات هنا. إذا كنا المستخدم الجذر (root user) على حساب AWS الخاص بنا، يمكننا إنشاء مستخدم IAM جديد لديه وصول برمجي فقط. هذا يعني أن AWS ستمنحنا مفتاح وصول ID ومفتاح وصول سري (secret access key) عند إنشاء المستخدم الجديد. سنرغب في تخزين هذه المفاتيح في مكان آمن لأننا سنحتاجها لاحقًا. أخيرًا، يجب منح هذا المستخدم أذونات للعمل مع S3 فقط، بحيث يكون مستوى الوصول محدودًا إذا وقعت مفاتيحنا في الأيدي الخطأ. لدى AWS بعض الموارد الرائعة لأفضل الممارسات مع مفاتيح الوصول وإدارة مفاتيح الوصول لمستخدمي IAM والتي تستحق المراجعة إذا لم تكن على دراية بكيفية القيام بذلك.
بعد ذلك، نحتاج إلى إنشاء حاوية S3 bucket. يرمز S3 إلى Simple Storage Service وهو في الأساس مكان لتحميل وتخزين الملفات المستضافة على خوادم أمازون. الحاوية (bucket) هي ببساطة دليل (directory). لقد سميت حاويتي "single-spa-demo"، ولكن يمكنك تسمية حاويتك بما ترغب فيه. يمكنك اتباع أدلة AWS حول كيفية إنشاء حاوية جديدة لمزيد من المعلومات.
حاوية AWS S3
بمجرد إنشاء حاويتنا، من المهم أيضًا التأكد من أن الحاوية عامة وأن مشاركة الموارد عبر الأصول (CORS - cross-origin resource sharing) ممكّنة لحاويتنا حتى نتمكن من الوصول إلى أصولنا المحملة واستخدامها في تطبيقنا. في أذونات حاويتنا، يمكننا إضافة قواعد تكوين CORS التالية:
< CORSConfiguration >
< CORSRule >
< AllowedOrigin > * </ AllowedOrigin >
< AllowedMethod > GET </ AllowedMethod >
</ CORSRule >
</ CORSConfiguration >
في وحدة تحكم AWS، تبدو النتيجة كالتالي بعد النقر على "حفظ":
تكوين CORS
إنشاء مهمة Travis CI لتحميل النواتج إلى AWS S3
الآن بعد أن أصبح لدينا مكان لتحميل الملفات، دعنا نُعد عملية مؤتمتة تتولى تحميل حزم JavaScript الجديدة في كل مرة ندمج فيها شيفرة جديدة في الفرع الرئيسي (master branch) لأي من مستودعاتنا. للقيام بذلك، سنستخدم Travis CI. كما ذكرنا سابقًا، يعيش كل تطبيق في مستودعه الخاص على GitHub، لذا لدينا أربعة مستودعات GitHub للعمل معها. يمكننا دمج Travis CI مع كل من مستودعاتنا وإعداد دورات التكامل المستمر لكل منها.
لتكوين Travis CI لأي مشروع معين، نقوم بإنشاء ملف .travis.yml في الدليل الجذر للمشروع. دعنا ننشئ هذا الملف في مجلد single-spa-demo-root-config ونُدرج الشيفرة التالية:
language: node_js
node_js:
- node
script:
- yarn build
- echo "Commit sha - $TRAVIS_COMMIT"
- mkdir -p dist/@thawkin3/root-config/$TRAVIS_COMMIT
- mv dist/*.* dist/@thawkin3/root-config/$TRAVIS_COMMIT/
deploy:
provider: s3
access_key_id: "$AWS_ACCESS_KEY_ID"
secret_access_key: "$AWS_SECRET_ACCESS_KEY"
bucket: "single-spa-demo"
region: "us-west-2"
cache-control: "max-age=31536000"
acl: "public_read"
local_dir: dist
skip_cleanup: true
on:
branch: master
هذا التنفيذ هو ما توصلت إليه بعد مراجعة وثائق Travis CI لعمليات تحميل AWS S3 ومثال تكوين single-spa Travis CI. نظرًا لأننا لا نريد أن تكون أسرار AWS الخاصة بنا مكشوفة في مستودع GitHub الخاص بنا، يمكننا تخزينها كمتغيرات بيئة (environment variables). يمكنك وضع متغيرات البيئة وقيمها السرية داخل وحدة تحكم الويب الخاصة بـ Travis CI لأي شيء تريد إبقائه خاصًا، وهذا هو المكان الذي يحصل منه ملف .travis.yml على تلك القيم.
الآن، عندما نقوم بتثبيت ودفع شيفرة جديدة إلى الفرع الرئيسي (master branch)، ستعمل مهمة Travis CI، والتي ستبني حزمة JavaScript للتطبيق ثم تحمل تلك الأصول إلى S3. للتحقق، يمكننا فحص وحدة تحكم AWS لرؤية ملفاتنا المحملة حديثًا:
الملفات المحملة كنتيجة لمهمة Travis CI
رائع! حتى الآن الأمور تسير على ما يرام. الآن نحتاج إلى تنفيذ نفس تكوين Travis CI لتطبيقات الواجهة الأمامية المصغرة الثلاثة الأخرى، ولكن مع تبديل أسماء الأدلة في ملف .travis.yml حسب الحاجة. بعد اتباع نفس الخطوات ودمج شيفرتنا، لدينا الآن أربعة أدلة تم إنشاؤها في حاوية S3 bucket الخاصة بنا، واحد لكل مستودع.
أربعة أدلة داخل حاوية S3 الخاصة بنا
إنشاء خريطة استيراد للإنتاج (Import Map for Production)
دعنا نلخص ما فعلناه حتى الآن. لدينا أربعة تطبيقات، جميعها تعيش في مستودعات GitHub منفصلة. تم إعداد كل مستودع مع Travis CI لتشغيل مهمة عند دمج الشيفرة في الفرع الرئيسي (master branch)، وتتولى هذه المهمة تحميل نواتج البناء (build artifacts) إلى حاوية S3 bucket. مع كل هذا في مكان واحد، لا يزال هناك شيء واحد مفقود: كيف يتم الإشارة إلى نواتج البناء الجديدة هذه في تطبيقنا الحاوي؟ بعبارة أخرى، على الرغم من أننا ندفع حزم JavaScript جديدة لواجهاتنا الأمامية المصغرة مع كل تحديث جديد، إلا أن الشيفرة الجديدة لا تُستخدم فعليًا في تطبيقنا الحاوي بعد!
إذا عدنا بالذاكرة إلى كيفية تشغيل تطبيقنا محليًا، فقد استخدمنا خريطة استيراد (import map). هذه الخريطة هي ببساطة JSON تخبر التطبيق الحاوي أين يمكن العثور على كل حزمة JavaScript. ولكن، خريطة الاستيراد السابقة كانت تُستخدم خصيصًا لتشغيل التطبيق محليًا. الآن نحتاج إلى إنشاء خريطة استيراد ستُستخدم في بيئة الإنتاج.
إذا نظرنا في مجلد single-spa-demo-root-config، في ملف index.ejs، نرى هذا السطر:
< script type = "systemjs-importmap" src = "https://storage.googleapis.com/react.microfrontends.app/importmap.json" > </ script >
فتح هذا URL في المتصفح يكشف عن خريطة استيراد تبدو كالتالي:
{ "imports" : {
"react" : "https://cdn.jsdelivr.net/npm/react@16.13.1/umd/react.production.min.js" ,
"react-dom" : "https://cdn.jsdelivr.net/npm/react-dom@16.13.1/umd/react-dom.production.min.js" ,
"single-spa" : "https://cdn.jsdelivr.net/npm/single-spa@5.5.3/lib/system/single-spa.min.js" ,
"@react-mf/root-config" : "https://react.microfrontends.app/root-config/e129469347bb89b7ff74bcbebb53cc0bb4f5e27f/react-mf-root-config.js" ,
"@react-mf/navbar" : "https://react.microfrontends.app/navbar/631442f229de2401a1e7c7835dc7a56f7db606ea/react-mf-navbar.js" ,
"@react-mf/styleguide" : "https://react.microfrontends.app/styleguide/f965d7d74e99f032c27ba464e55051ae519b05dd/react-mf-styleguide.js" ,
"@react-mf/people" : "https://react.microfrontends.app/people/dd205282fbd60b09bb3a937180291f56e300d9db/react-mf-people.js" ,
"@react-mf/api" : "https://react.microfrontends.app/api/2966a1ca7799753466b7f4834ed6b4f2283123c5/react-mf-api.js" ,
"@react-mf/planets" : "https://react.microfrontends.app/planets/5f7fc62b71baeb7a0724d4d214565faedffd8f61/react-mf-planets.js" ,
"@react-mf/things" : "https://react.microfrontends.app/things/7f209a1ed9ac9690835c57a3a8eb59c17114bb1d/react-mf-things.js" ,
"rxjs" : "https://cdn.jsdelivr.net/npm/@esm-bundle/rxjs@6.5.5/system/rxjs.min.js" ,
"rxjs/operators" : "https://cdn.jsdelivr.net/npm/@esm-bundle/rxjs@6.5.5/system/rxjs-operators.min.js"
} }
كانت خريطة الاستيراد هذه هي الافتراضية المقدمة كمثال عندما استخدمنا CLI لإنشاء تطبيقنا الحاوي. ما نحتاج إلى فعله الآن هو استبدال خريطة الاستيراد المثال هذه بخريطة استيراد تشير فعليًا إلى الحزم التي نستخدمها. لذا، باستخدام خريطة الاستيراد الأصلية كقالب، يمكننا إنشاء ملف جديد يُسمى importmap.json، ووضعه خارج مستودعاتنا وإضافة JSON يبدو كالتالي:
{ "imports" : {
"react" : "https://cdn.jsdelivr.net/npm/react@16.13.0/umd/react.production.min.js" ,
"react-dom" : "https://cdn.jsdelivr.net/npm/react-dom@16.13.0/umd/react-dom.production.min.js" ,
"single-spa" : "https://cdn.jsdelivr.net/npm/single-spa@5.5.1/lib/system/single-spa.min.js" ,
"@thawkin3/root-config" : "https://single-spa-demo.s3-us-west-2.amazonaws.com/%40thawkin3/root-config/179ba4f2ce4d517bf461bee986d1026c34967141/root-config.js" ,
"@thawkin3/single-spa-demo-nav" : "https://single-spa-demo.s3-us-west-2.amazonaws.com/%40thawkin3/single-spa-demo-nav/f0e9d35392ea0da8385f6cd490d6c06577809f16/thawkin3-single-spa-demo-nav.js" ,
"@thawkin3/single-spa-demo-page-1" : "https://single-spa-demo.s3-us-west-2.amazonaws.com/%40thawkin3/single-spa-demo-page-1/4fd417ee3faf575fcc29d17d874e52c15e6f0780/thawkin3-single-spa-demo-page-1.js" ,
"@thawkin3/single-spa-demo-page-2" : "https://single-spa-demo.s3-us-west-2.amazonaws.com/%40thawkin3/single-spa-demo-page-2/8c58a825c1552aab823bcbd5bdd13faf2bd4f9dc/thawkin3-single-spa-demo-page-2.js"
} }
ستلاحظ أن الاستيرادات الثلاثة الأولى هي لتبعيات مشتركة (shared dependencies): react، react-dom، و single-spa. بهذه الطريقة لا يكون لدينا أربع نسخ من React في تطبيقنا مما يسبب تضخمًا وأوقات تحميل أطول. بعد ذلك، لدينا استيرادات لكل من تطبيقاتنا الأربعة. URL هو ببساطة URL لكل ملف مُحمّل في S3 (يُسمى "كائن" في مصطلحات AWS). الآن بعد أن أنشأنا هذا الملف، يمكننا تحميله يدويًا إلى حاويتنا في S3 عبر وحدة تحكم AWS.
ملاحظة هامة: هذه نقطة مهمة ومثيرة للاهتمام عند استخدام single-spa: ملف خريطة الاستيراد لا يعيش فعليًا في أي مكان في نظام التحكم بالمصادر (source control) أو في أي من مستودعات git. بهذه الطريقة، يمكن تحديث خريطة الاستيراد بسرعة دون الحاجة إلى تغييرات في المستودع. سنعود إلى هذا المفهوم بعد قليل.
خريطة استيراد مُحمّلة يدويًا إلى حاوية S3
أخيرًا، يمكننا الآن الإشارة إلى هذا الملف الجديد في ملف index.ejs بدلاً من الإشارة إلى خريطة الاستيراد الأصلية:
< script type = "systemjs-importmap" src = "//single-spa-demo.s3-us-west-2.amazonaws.com/%40thawkin3/importmap.json" > </ script >
إنشاء خادم إنتاج (Production Server)
نحن نقترب من الحصول على شيء يعمل في بيئة الإنتاج! سنستضيف هذا العرض التوضيحي على Heroku، وللقيام بذلك، سنحتاج إلى إنشاء خادم Node.js و Express بسيط لخدمة ملفنا. أولاً، في مجلد single-spa-demo-root-config، سنقوم بتثبيت express عن طريق تشغيل yarn add express (أو npm install express).
بعد ذلك، سنضيف ملفًا يُسمى server.js يحتوي على قدر قليل من الشيفرة لبدء تشغيل خادم express وخدمة ملف index.html الرئيسي الخاص بنا:
const express = require ( "express" );
const path = require ( "path" );
const PORT = process.env.PORT || 5000 ;
express()
.use(express.static(path.join(__dirname, "dist" )))
.get( "*" , ( req, res ) => {
res.sendFile( "index.html" , { root : "dist" });
})
.listen(PORT, () => console .log( `Listening on ${PORT} ` ));
أخيرًا، سنقوم بتحديث سكريبتات NPM في ملف package.json الخاص بنا للتمييز بين تشغيل الخادم في وضع التطوير (development mode) وتشغيل الخادم في وضع الإنتاج (production mode):
"scripts" : {
"build" : "webpack --mode=production" ,
"lint" : "eslint src" ,
"prettier" : "prettier --write './**'" ,
"start:dev" : "webpack-dev-server --mode=development --port 9000 --env.isLocal=true" ,
"start" : "node server.js" ,
"test" : "jest"
}
النشر على Heroku
الآن بعد أن أصبح لدينا خادم إنتاج جاهز، دعنا ننشر هذا التطبيق على Heroku! للقيام بذلك، ستحتاج إلى إنشاء حساب Heroku، وتثبيت Heroku CLI، وتسجيل الدخول. النشر على Heroku سهل للغاية:
- في مجلد
single-spa-demo-root-config:heroku create thawkin3-single-spa-demo(مع تغيير الوسيط الأخير إلى اسم فريد لاستخدامه لتطبيقHerokuالخاص بك). git push heroku masterheroku open
وبهذا، أصبح تطبيقنا يعمل في بيئة الإنتاج. عند تشغيل أمر heroku open، يجب أن ترى تطبيقك يفتح في متصفحك. حاول التنقل بين الصفحات باستخدام روابط التنقل لترى تطبيقات الواجهة الأمامية المصغرة المختلفة تُحمّل وتُفرّغ.
التطبيق التجريبي – يعمل في بيئة الإنتاج
إجراء التحديثات
في هذه المرحلة، قد تسأل نفسك، "كل هذا العمل من أجل ماذا؟ لماذا؟" وستكون محقًا. نوعًا ما. هذا كثير من العمل، وليس لدينا الكثير لنعرضه، على الأقل بصريًا. ولكن، لقد وضعنا الأساس لأي تحسينات نرغب فيها في التطبيق. تكلفة الإعداد لأي خدمة مصغرة (microservice) أو واجهة أمامية مصغرة (micro-frontend) غالبًا ما تكون أعلى بكثير من تكلفة إعداد التطبيق المتجانس (monolith)؛ لا تبدأ في جني الثمار إلا لاحقًا. لذا دعنا نبدأ في التفكير في التعديلات المستقبلية.
لنفترض أننا بعد خمس أو عشر سنوات، وقد نما تطبيقك كثيرًا. وفي ذلك الوقت، تم إصدار إطار عمل جديد ومثير، وأنت تتوق لإعادة كتابة تطبيقك بالكامل باستخدام هذا الإطار الجديد. عند العمل مع تطبيق متجانس، سيكون هذا على الأرجح جهدًا يستغرق سنوات وقد يكون شبه مستحيل الإنجاز. ولكن، مع الواجهات الأمامية المصغرة، يمكنك استبدال التقنيات جزءًا تلو الآخر من التطبيق، مما يسمح لك بالانتقال ببطء وسلاسة إلى حزمة تقنية جديدة. سحر!
أو، قد يكون لديك جزء واحد من تطبيقك يتغير بشكل متكرر وجزء آخر من تطبيقك نادرًا ما يتم لمسه. أثناء إجراء تحديثات على التطبيق المتقلب، ألن يكون من الرائع لو كان بإمكانك ترك الشيفرة القديمة وشأنها؟ مع التطبيق المتجانس، من المحتمل أن التغييرات التي تجريها في مكان واحد من تطبيقك قد تؤثر على أقسام أخرى من تطبيقك. ماذا لو قمت بتعديل بعض أوراق الأنماط (stylesheets) التي تستخدمها أقسام متعددة من التطبيق المتجانس؟ أو ماذا لو قمت بتحديث تبعية (dependency) كانت تُستخدم في العديد من الأماكن المختلفة؟ مع نهج الواجهة الأمامية المصغرة، يمكنك ترك هذه المخاوف وراءك، وإعادة هيكلة وتحديث تطبيق واحد عند الحاجة مع ترك التطبيقات القديمة وشأنها.
ولكن، كيف تجري هذه الأنواع من التحديثات؟ أو التحديثات من أي نوع، في الواقع؟ في الوقت الحالي، لدينا خريطة استيراد الإنتاج في ملف index.ejs الخاص بنا، ولكنها تشير فقط إلى الملف الذي حملناه يدويًا إلى حاوية S3 bucket الخاصة بنا. إذا أردنا إصدار بعض التغييرات الجديدة الآن، فسنحتاج إلى دفع شيفرة جديدة لأحد الواجهات الأمامية المصغرة، والحصول على ناتج بناء جديد، ثم تحديث خريطة الاستيراد يدويًا بإشارة إلى حزمة JavaScript الجديدة. هل هناك طريقة يمكننا أتمتة ذلك؟ نعم!
تحديث أحد التطبيقات
لنفترض أننا نريد تحديث تطبيق الصفحة 1 الخاص بنا ليعرض نصًا مختلفًا. من أجل أتمتة نشر هذا التغيير، يمكننا تحديث دورة CI pipeline الخاصة بنا ليس فقط لبناء ناتج (artifact) وتحميله إلى حاوية S3 bucket الخاصة بنا، ولكن أيضًا لتحديث خريطة الاستيراد للإشارة إلى URL الجديد لأحدث حزمة JavaScript.
دعنا نبدأ بتحديث ملف .travis.yml الخاص بنا كالتالي:
language: node_js
node_js:
- node
env:
global:
# include $HOME/.local/bin for `aws`
- PATH=$HOME/.local/bin:$PATH
before_install:
- pyenv global 3.7 .1
- pip install -U pip
- pip install awscli
script:
- yarn build
- echo "Commit sha - $TRAVIS_COMMIT"
- mkdir -p dist/@thawkin3/root-config/$TRAVIS_COMMIT
- mv dist/*.* dist/@thawkin3/root-config/$TRAVIS_COMMIT/
deploy:
provider: s3
access_key_id: "$AWS_ACCESS_KEY_ID"
secret_access_key: "$AWS_SECRET_ACCESS_KEY"
bucket: "single-spa-demo"
region: "us-west-2"
cache-control: "max-age=31536000"
acl: "public_read"
local_dir: dist
skip_cleanup: true
on:
branch: master
after_deploy:
- chmod +x after_deploy.sh
- "./after_deploy.sh"
التغييرات الرئيسية هنا هي إضافة متغير بيئة عام (global environment variable)، وتثبيت AWS CLI، وإضافة سكريبت after_deploy كجزء من الدورة. يشير هذا إلى ملف after_deploy.sh الذي نحتاج إلى إنشائه. سيكون المحتوى:
echo "Downloading import map from S3"
aws s3 cp s3://single-spa-demo/@thawkin3/importmap.json importmap.json
echo "Updating import map to point to new version of @thawkin3/root-config"
node update-importmap.mjs
echo "Uploading new import map to S3"
aws s3 cp importmap.json s3://single-spa-demo/@thawkin3/importmap.json --cache-control 'public, must-revalidate, max-age=0' --acl 'public-read'
echo "Deployment successful"
يقوم هذا الملف بتنزيل خريطة الاستيراد الموجودة من S3، وتعديلها للإشارة إلى ناتج البناء الجديد، ثم إعادة تحميل خريطة الاستيراد المحدثة إلى S3. للتعامل مع التحديث الفعلي لمحتويات ملف خريطة الاستيراد، نستخدم سكريبت مخصصًا سنضيفه في ملف يُسمى update-importmap.mjs:
// Note that this file requires node@13.2.0 or higher (or the --experimental-modules flag)
import fs from "fs" ;
import path from "path" ;
import https from "https" ;
const importMapFilePath = path.resolve(process.cwd(), "importmap.json" );
const importMap = JSON .parse(fs.readFileSync(importMapFilePath));
const url = `https://single-spa-demo.s3-us-west-2.amazonaws.com/%40thawkin3/root-config/ ${process.env.TRAVIS_COMMIT} /root-config.js` ;
https
.get(url, res => {
// HTTP redirects (301, 302, etc) not currently supported, but could be added
if (res.statusCode >= 200 && res.statusCode < 300 ) {
if (
res.headers[ "content-type" ] &&
res.headers[ "content-type" ].toLowerCase().trim() === "application/javascript"
) {
const moduleName = `@thawkin3/root-config` ;
importMap.imports[moduleName] = url;
fs.writeFileSync(importMapFilePath, JSON .stringify(importMap, null , 2 ));
console .log( `Updated import map for module ${moduleName} . New url is ${url} .` );
} else {
urlNotDownloadable(
url,
Error ( `Content-Type response header must be application/javascript` )
);
}
} else {
urlNotDownloadable(
url,
Error ( `HTTP response status was ${res.statusCode} ` )
);
}
})
.on( "error" , err => {
urlNotDownloadable(url, err);
});
function urlNotDownloadable ( url, err ) {
throw Error (
`Refusing to update import map - could not download javascript file at url ${url} . Error was ' ${err.message} '`
);
}
لاحظ أننا نحتاج إلى إجراء هذه التغييرات لهذه الملفات الثلاثة في جميع مستودعات GitHub الخاصة بنا حتى يتمكن كل منها من تحديث خريطة الاستيراد بعد إنشاء ناتج بناء جديد. ستكون محتويات الملفات متطابقة تقريبًا لكل مستودع، ولكننا سنحتاج إلى تغيير أسماء التطبيقات أو مسارات URL إلى القيم المناسبة لكل منها.
ملاحظة جانبية حول خريطة الاستيراد
ذكرت سابقًا أن ملف خريطة الاستيراد الذي حملناه يدويًا إلى S3 لا يعيش فعليًا في أي من مستودعات GitHub الخاصة بنا أو في أي من شيفرتنا التي تم فحصها. إذا كنت مثلي، فربما يبدو هذا غريبًا حقًا! ألا يجب أن يكون كل شيء في نظام التحكم بالمصادر (source control)؟
السبب في عدم وجوده في نظام التحكم بالمصادر هو أن دورة CI pipeline الخاصة بنا يمكنها التعامل مع تحديث خريطة الاستيراد مع كل إصدار جديد لتطبيق الواجهة الأمامية المصغرة. إذا كانت خريطة الاستيراد في نظام التحكم بالمصادر، فإن إجراء تحديث لتطبيق واجهة أمامية مصغرة واحد سيتطلب تغييرات في مستودعين: مستودع تطبيق الواجهة الأمامية المصغرة حيث يتم إجراء التغيير، ومستودع root config حيث سيتم فحص خريطة الاستيراد. هذا النوع من الإعداد سيبطل إحدى الفوائد الرئيسية لهندسة الواجهة الأمامية المصغرة، وهي أن كل تطبيق يمكن نشره بشكل مستقل تمامًا عن التطبيقات الأخرى. لتحقيق مستوى معين من التحكم بالمصادر على خريطة الاستيراد، يمكننا دائمًا استخدام ميزة تحديد الإصدارات (versioning feature) الخاصة بـ S3 لحاويتنا.
لحظة الحقيقة
مع هذه التعديلات على دورات CI pipeline الخاصة بنا، حان الوقت للحظة الحقيقة النهائية: هل يمكننا تحديث أحد تطبيقات الواجهة الأمامية المصغرة لدينا، ونشره بشكل مستقل، ثم رؤية هذه التغييرات تدخل حيز التنفيذ في بيئة الإنتاج دون الحاجة إلى لمس أي من تطبيقاتنا الأخرى؟
في مجلد single-spa-demo-page-1، في ملف root.component.js، دعنا نغير النص من "Page 1 App" إلى "Page 1 App - UPDATED!" بعد ذلك، دعنا نثبت هذا التغيير وندفعه وندمجه في الفرع الرئيسي (master). سيؤدي هذا إلى بدء دورة Travis CI لبناء ناتج تطبيق الصفحة 1 الجديد ثم تحديث خريطة الاستيراد للإشارة إلى URL الملف الجديد هذا.
إذا انتقلنا بعد ذلك في متصفحنا إلى https://thawkin3-single-spa-demo.herokuapp.com/page1، فسنرى الآن… طبلة الرجاء… تطبيقنا المحدث!
التطبيق التجريبي – تحديث أحد تطبيقات الواجهة الأمامية المصغرة بنجاح
الخلاصة التقنية
تُعد الواجهات الأمامية المصغرة (Micro-frontends) تطورًا حاسمًا في عالم تطوير الويب، حيث توفر مرونة وقابلية للتوسع لم تكن متاحة بسهولة في البنى المتجانسة. على الرغم من أن التكلفة الأولية للإعداد والتعقيد المتزايد لإدارة بنية موزعة قد تبدو عائقًا، إلا أن الفوائد طويلة الأجل تفوق بكثير هذه التحديات. إن القدرة على نشر الأجزاء بشكل مستقل، وتحديد مجالات الملكية، وتسريع أوقات البناء والاختبار، بالإضافة إلى حرية استخدام أطر عمل مختلفة، تجعل منها خيارًا استراتيجيًا للمشاريع الكبيرة والمتنامية. أداة Single-SPA تبسط بشكل كبير عملية تبني هذه المعمارية، مما يُمكّن المطورين من تفكيك التطبيقات المتجانسة والانتقال نحو مستقبل أكثر رشاقة وقابلية للتكيف في تطوير الواجهات الأمامية.