دليل SaaS الشامل: بناء أول منتج برمجي كخدمة (Software-as-a-Service) خطوة بخطوة

دقائق القراءة: 59

مقدمة

في هذا الدليل الشامل، سأستعرض كيف تجمعت كل الأجزاء الأساسية معًا لبناء أول منتج برمجي كخدمة (SaaS) أطلقته على الإطلاق. بدءًا من تطبيق أيقونة الموقع (favicon) وصولًا إلى النشر على منصة سحابية (cloud platform)، سأشارك كل ما تعلمته. كما سأقدم مقتطفات برمجية (code snippets) مفصلة، وأفضل الممارسات (best practices)، والدروس المستفادة، والأدلة، والموارد الأساسية. آمل أن تجد هنا ما يفيدك. شكرًا لقراءتك. ❤️

في عام 2013، غيرت مساري المهني إلى تطوير الويب لسببين رئيسيين. أولًا، وجدت نفسي أستغرق في بناء منتجات موجهة للمستخدمين، مستمتعًا بالتنوع اللوني والإمكانيات اللانهائية للتفاعل. تذكرت المقولة الشائعة: "ابحث عن وظيفة تستمتع بها ولن تضطر للعمل يومًا واحدًا في حياتك"، فكرت: "لماذا لا أجعل هذا وظيفتي؟" ثانيًا، أردت أن أصنع شيئًا ذا قيمة، بعد أن أمضيت سنوات مراهقتي مستلهمًا من عصر الويب 2.0 (خاصة موقع Digg.com حوالي عام 2005 الذي فتح لي آفاقًا جديدة!). كانت خطتي هي العمل على تحقيق طموحاتي بالتوازي مع وظيفتي.

لكن سرعان ما استهلكتني الوظيفة وما يسمى بـ "إرهاق جافاسكريبت" (JavaScript fatigue) بالكامل. ولم يساعدني أيضًا تهوري في السعي وراء طموحي، متأثرًا بالخطاب السائد في "وادي السيليكون" (Silicon Valley). قرأت كتابي Hackers & Painters لبول جراهام (Paul Graham) و Zero to One لبيتر ثيل (Peter Thiel). اعتقدت أنني متحمس تمامًا ومستعد للعمل الجاد، وأنني أستطيع فعل ذلك أيضًا! لكن للأسف، لم أستطع، على الأقل ليس بمفردي. كنت دائمًا منهكًا بعد العمل، ولم أتمكن من العثور على فريق يشاركني أحلامي وقيمي. لذلك، كنت أكرر مشاريع غير مكتملة في وقت فراغي، مما أصابني بالقلق والاكتئاب المزمن.

مع مرور السنوات، هدأت وتطورت لدي فلسفة شخصية حول ريادة الأعمال والتكنولوجيا تتوافق بشكل أفضل مع شخصيتي وظروف حياتي – حتى سبتمبر 2019. عندها، تبدد الضباب من أمامي. أصبحت جيدًا جدًا في عملي، وأصبحت الوظيفة أقل إرهاقًا، وتمكنت من السيطرة على "إرهاق جافاسكريبت". لأول مرة منذ فترة طويلة، توفرت لدي الطاقة الذهنية والوقت والعقلية التي سمحت لي بإنجاز مشروع جانبي. وفي تلك المرة، بدأت صغيرًا. اعتقدت أنني أمتلك القدرة على ذلك! لكنني كنت مخطئًا.

بصفتي مطور واجهات أمامية (front-end developer) طوال مسيرتي المهنية، لم أستطع سوى تسمية الأشياء التي تخيلت أنني سأحتاجها: "خادم" (server)، "قاعدة بيانات" (database)، "نظام مصادقة" (authentication system)، "استضافة" (host)، "اسم نطاق" (domain name)، لكن كيف… أين… وماذا… لم أكن أعرف حتى!

كنت أعلم أن حياتي ستكون أسهل لو قررت استخدام إحدى تلك الأدوات المجردة مثل create-react-app، أو حزم تطوير البرامج (Firebase SDK)، أو أدوات رسم الخرائط العلائقية (ORM)، وخدمات "النشر بنقرة واحدة" (one-click-deployment). مقولة "لا تعيد اختراع العجلة. كرر بسرعة" كانت تتردد في ذهني. لكن كانت لدي بعض المتطلبات التي أردت أن تلبيها قراراتي:

  • عدم الارتباط بمورد معين (No vendor lock-in): استبعد هذا استخدام Firebase SDK في جميع أنحاء قاعدة التعليمات البرمجية الخاصة بي. وشمل ذلك create-react-app، لأن إخراجه (ejecting) أجبرني على وراثة وصيانة بنيته التحتية الضخمة من الأدوات.
  • بسيط ومبسط (Simple & Minimalistic): تجنب الحاجة إلى تعلم صيغ وأنماط جديدة محددة. استبعد هذا: 1) مولدات المشاريع التي تنتج بنية معمارية معقدة وطبقات من التعليمات البرمجية الجاهزة (boilerplate codes)، 2) استخدام مكتبات الطرف الثالث مثل knex.js أو sequelize ORM.
  • الدفع حسب الحاجة (Pay-as-you-need): أردت أن تظل تكلفة التشغيل متناسبة مع مستوى الاستخدام. استبعد هذا خدمات مثل "النشر بنقرة واحدة" (one-click-deployment).

لأكون منصفًا، كانت لدي بعض المزايا: كنت أبني منتج SaaS بسيطًا. لم أكن قلقًا بشأن التوسع أو الهيمنة أو إحداث ثورة وما إلى ذلك. كنت لا أزال أحتفظ بوظيفتي اليومية. وقد تقبلت احتمالات فشلي. 🤷‍♂️

أيضًا، يجب أن تضع في اعتبارك أن: هذا كان مشروعًا فرديًا – تصميم، تطوير، صيانة، تسويق، إلخ. لست مبرمجًا شاملًا (full-stack programmer) خارقًا (10x rockstar).

الأهم من ذلك، أردت الالتزام بمبدأ توجيهي: بناء الأشياء بمسؤولية. على الرغم من أن هذا، وبشكل غير مفاجئ، كان له تأثير كبير على سرعة تطويري، إلا أنه أجبرني على توضيح دوافعي: إذا كان علي إطلاق شيء ما في أقرب وقت ممكن، ما لم يكن الأمر مسألة حياة أو موت، فربما لم أكن أحل مشكلة فريدة وصعبة. في هذه الحالة – بافتراض أنني كنت لا أزال في وظيفتي اليومية وليس لدي ديون – ما هو العجلة؟ ومن منظور أخلاقي: هل كانت هذه مشكلة تستدعي الحل في الأساس؟ وما هي العواقب الثانوية إذا قمت بحلها؟ هل يمكن توجيه نواياي الحسنة بشكل أفضل في مكان آخر؟

لذا، ما يلي في هذا المقال هو كل ما تعلمته أثناء تطوير المشروع الأول الذي أطلقته على الإطلاق، والذي يسمى Sametable، ويساعد في إدارة عملك في جداول البيانات (spreadsheets). لنبدأ.

إيجاد الأفكار

حسنًا، أولًا وقبل كل شيء، تحتاج إلى معرفة ما تريد بناءه. اعتدت أن أفقد النوم بسبب هذا، أفكر وأعيد صياغة الأفكار، آملًا في لحظات إلهام مفاجئة (eureka moments)، حتى بدأت أنظر إلى الداخل:

  • ابنِ أشياء تحل المشكلات التي تواجهك وتزعجك بشكل متكرر.
  • حل ما يسمى بـ "نقاط الألم" (pain points) أو "الاحتكاكات" (frictions).
  • اخرج، لا تتوقف عن الاستماع إلى الناس وتعلم منهم.
  • كن خبيرًا في مجالك. اشعر بآلامه. ربما حل إحداها.

يبدو لي أن العديد من المؤسسين أسسوا شركات تتعلق بمجالهم الذي بنوا فيه مسيرتهم المهنية وشبكتهم الاجتماعية.

البنية التقنية (The Stack)

كيف تبدو بنيتك التقنية (stack) سيعتمد على كيفية رغبتك في عرض تطبيقك. إليك مناقشة شاملة حول ذلك، ولكن باختصار:

  • العرض من جانب العميل (Client-side rendering - CSR)؛ تطبيقات الصفحة الواحدة (SPA)؛ واجهات برمجة تطبيقات JSON (JSON APIs): ربما يكون هذا هو النهج الأكثر شيوعًا. إنه رائع لبناء تطبيقات ويب تفاعلية. لكن كن على دراية بسلبياته وخطوات تخفيفها. هذا هو النهج الذي اتبعته، لذلك سنتحدث عنه بالتفصيل.

  • النموذج الهجين (Hybrid)؛ العرض من جانب العميل والخادم (CSR & SSR): باستخدام هذا النهج، لا يزال بإمكانك بناء تطبيقك كـ SPA. ولكن عندما يطلب المستخدم تطبيقك، على سبيل المثال، الصفحة الرئيسية، فإنك تعرض مكون الصفحة الرئيسية إلى HTML ثابت في خادمك وتخدمه للمستخدم. ثم في متصفح المستخدم، ستحدث عملية "الترطيب" (hydration) ليصبح كل شيء تطبيق SPA المقصود. الفوائد الرئيسية لهذا النهج هي أنك تحصل على تحسين جيد لمحركات البحث (SEO) ويمكن للمستخدمين رؤية المحتوى الخاص بك بشكل أسرع (faster 'First Meaningful Paint'). لكن هناك سلبيات أيضًا. بصرف النظر عن تكاليف الصيانة الإضافية، سيتعين علينا تنزيل نفس الحمولة مرتين – أولًا، HTML، وثانيًا، نظيره من Javascript لعملية "الترطيب"، مما سيبذل جهدًا كبيرًا على الخيط الرئيسي للمتصفح (browser's main thread). هذا يطيل "الوقت الأول للتفاعل" (First time to interactive)، وبالتالي يقلل من الفوائد المكتسبة من "الرسم الأول ذي المعنى" الأسرع. التقنيات المعتمدة لهذا النهج هي NextJs، NuxtJs، و GatsbyJs.

  • العرض من جانب الخادم (Server-side rendering) و "رشها بـ Javascript": كانت هذه هي الطريقة التقليدية للبناء على الويب! – استخدم PHP لبناء قوالبك بالبيانات في خادمك، ثم اربط معالجات الأحداث (event handlers) بـ DOM باستخدام jQuery في المتصفح. قد يكون هذا النهج غير مناسب لبناء التطبيقات المعقدة بشكل متزايد التي طلبتها الشركات على الويب، ولكن ظهرت بعض التقنيات التي تستدعي إعادة النظر:

    لمزيد من المعلومات، تحقق من موضوع تويتر هذا. لأكون صريحًا، لو كنت أكثر صبرًا مع نفسي، لكنت اتبعت هذا المسار. هذا النهج يعود بقوة في ضوء الكم الهائل من Javascript على الويب الحديث.

الخلاصة هي: اختر أي نهج تتقنه بالفعل. لكن كن واعيًا للسلبيات المرتبطة به، وحاول تخفيفها قبل النشر للمستخدمين. مع ذلك، إليك البنية التقنية (stack) لـ Sametable:

الواجهة الأمامية (Front-end)

  • Webpack
  • Babel
  • Preact

الواجهة الخلفية (Back-end)

  • Node – خادم API مع ExpressJS
  • Postgresql – قاعدة البيانات
  • Redis – لتخزين بيانات جلسات المستخدمين ونتائج الاستعلامات المخزنة مؤقتًا (cache queries' results).

الاستضافة (Hosting)

  • Google Cloud PlatformGAE لاستضافة Nodejs، و GCE لاستضافة Redis.
  • Firebase – لاستضافة تطبيق SPA الخاص بي.

رسم بياني يوضح البنية التقنية لتطبيق Sametable مع الواجهة الأمامية والخلفية وخدمات الاستضافة.

المستودع (Repo)

https://github.com/kilgarenone/boileroom

يحتوي هذا المستودع على الهيكل الذي أستخدمه لتطوير منتج SaaS الخاص بي. لدي مجلد واحد لأشياء العميل (client stuff)، وآخر لأشياء الخادم (server stuff):

- client
  - src
    - components
    - index.html
    - index.js
  - package.json
  - webpack.config.js
  -.env
  -.env.development
- server
  - server.js
  - package.json
  - .env
- package.json
- .gitignore
- .eslintrc.js
- .prettierrc.js
- .stylelintrc.js

يهدف هيكل الملفات دائمًا إلى أن يكون مسطحًا، متماسكًا، وسهل التنقل قدر الإمكان. كل "مكون" (component) محتوى ذاتيًا داخل مجلد مع جميع ملفاته المكونة (html|css|js). على سبيل المثال، في مجلد مسار "تسجيل الدخول" (Login):

- client
  - src
    - routes
      - Login
        - Login.js
        - Login.scss
        - Login.redux.js

تعلمت هذا من دليل نمط Angular2 الذي يحتوي على الكثير من الأشياء الجيدة الأخرى التي يمكنك الاستفادة منها. أوصي به بشدة.

بدء تطوير الواجهة الأمامية والخلفية محليًا (Full-stack Development Locally)

يحتوي ملف package.json في الجذر على سكربت npm سأقوم بتشغيله لتشغيل كل من العميل والخادم لبدء تطويري المحلي:

{
  "scripts": {
    "client": "cd client && npm run dev",
    "server": "cd server && npm run dev",
    "dev": "npm-run-all --parallel server client"
  }
}

قم بتشغيل ما يلي في الطرفية في جذر مشروعك:

npm run dev

العميل (Client)

- client
  - src
    - components
    - index.html
    - index.js
  - package.json
  - webpack.config.js
  -.env
  -.env.development

يشبه هيكل ملفات "العميل" (client) إلى حد كبير هيكل create-react-app. يقع جوهر كود تطبيقك داخل مجلد src الذي يحتوي على مجلد components لمكونات React الوظيفية؛ index.html هو قالبك المخصص المقدم إلى html-webpack-plugin؛ index.js هو ملف كنقطة دخول لـ Webpack.

ملاحظة: لقد قمت منذ ذلك الحين بإعادة هيكلة بيئة البناء الخاصة بي لتحقيق الخدمة التفاضلية (differential serving). تم تنظيم Webpack و Babel بشكل مختلف، وتغيرت سكربتات npm قليلًا. كل شيء آخر لا يزال كما هو.

سكربتات Npm (العميل)

يحتوي ملف package.json الخاص بالعميل على أهم سكربتات npm: 1) dev لبدء التطوير، 2) build لتجميع الإنتاج.

{
  "scripts": {
    "dev": "cross-env NODE_ENV=development webpack-dev-server",
    "build": "cross-env NODE_ENV=production node_modules/.bin/webpack"
  }
}

متغيرات البيئة (Environment Variables)

من الممارسات الجيدة أن يكون لديك ملف .env حيث تحدد قيمك الحساسة مثل مفاتيح API وبيانات اعتماد قاعدة البيانات:

SQL_PASSWORD=admin
STRIPE_API_KEY=1234567890

عادةً ما تُستخدم مكتبة تسمى dotenv لتحميل هذه المتغيرات في كود تطبيقنا للاستهلاك. ومع ذلك، في سياق Webpack، سنستخدم dotenv_webpack للقيام بذلك أثناء وقت التجميع والبناء كما هو موضح هنا. ستكون المتغيرات بعد ذلك قابلة للوصول في كائن process.env في قاعدة التعليمات البرمجية الخاصة بك:

// payment.jsx
if (process.env.STRIPE_API_KEY) {
  // do stuff
}

Webpack و Babel

يستخدم Webpack لتجميع جميع مكونات واجهة المستخدم الخاصة بي واعتمادياتها (مكتبات npm، ملفات مثل الصور، الخطوط، SVG) في ملفات مناسبة مثل ملفات .js و .css و .png. أثناء التجميع، سيقوم Webpack بتشغيل تكوين babel الخاص بي، وإذا لزم الأمر، يقوم بتحويل Javascript الذي كتبته إلى إصدار أقدم (مثل es5) لدعم المتصفحات المستهدفة. عندما ينتهي Webpack من عمله، سيكون قد أنشأ واحدًا (أو عدة) ملفات .js و .css. ثم باستخدام إضافة webpack تسمى 'html-webpack-plugin'، يتم حقن المراجع إلى ملفات JS و CSS هذه تلقائيًا (السلوك الافتراضي) على التوالي كـ <script> و <link> في ملف index.html الخاص بك. ثم عندما يطلب المستخدم تطبيقك في المتصفح، يتم جلب وتحليل 'index.html'. عندما يرى <script> و <link>، سيقوم بجلب وتنفيذ الأصول المشار إليها، وأخيرًا يتم عرض تطبيقك (أي العرض من جانب العميل) بكل مجده للمستخدم.

إذا كنت جديدًا على Webpack/Babel، أقترح تعلمهما من مبادئهما الأولى لبناء تكوينك ببطء بدلاً من نسخ ولصق الأجزاء من الويب. لا يوجد خطأ في ذلك، لكنني أجد أنه يصبح أكثر منطقية بمجرد أن يكون لدي النماذج الذهنية لكيفية عمل الأشياء. لقد كتبت عن الأساسيات هنا:

بمجرد أن فهمت الأساسيات، بدأت بالرجوع إلى هذا المصدر للحصول على تكوين أكثر تقدمًا.

أداء الويب (Web Performance)

ببساطة، تطبيق الويب الذي يعمل بشكل جيد هو أمر جيد لمستخدميك وعملك. على الرغم من أن أداء الويب موضوع ضخم وموثق جيدًا، إلا أنني أود التحدث عن عدد قليل من الأشياء الأكثر تأثيرًا التي أقوم بها لتحسين أداء الويب (بصرف النظر عن تحسين الصور التي يمكن أن تشكل أكثر من 50% من وزن الصفحة).

مسار العرض الحرج (Critical rendering path)

الهدف من التحسين لـ "مسار العرض الحرج" (critical rendering path) في صفحتك هو جعلها تُعرض وتكون تفاعلية في أقرب وقت ممكن لمستخدميك. دعنا نفعل ذلك.

ذكرنا سابقًا أن 'html-webpack-plugin' يحقن تلقائيًا مراجع جميع ملفات .js و .css التي تم إنشاؤها بواسطة Webpack لنا في ملف index.html الخاص بنا. لكننا لا نريد القيام بذلك الآن للحصول على تحكم كامل في مواضعها وتطبيق تلميحات الموارد (resource hints)، وكلاهما عامل في مدى كفاءة المتصفح في اكتشافها وتنزيلها كما هو موضح في هذا المقال.

الآن، هناك إضافات Webpack يبدو أنها تساعدنا في هذا الصدد، ولكن:

  • لم تكن هناك طريقة بديهية للتحكم في ترتيب <script>. حسنًا، هناك هذه الطريقة، ولكن ماذا عن الترتيب بين <link> أيضًا؟
  • لم تكن هناك إضافة تقوم بـ preload لـ CSS بالطريقة التي أردتها كما سنرى لاحقًا. حسنًا، هناك هذا (لا يوجد تحكم في السمات)، و هذا (نفس الشيء)، و هذا (لا يوجد دعم واضح لـ MiniCssExtractPlugin). حتى لو كان بإمكاني تجميعها كلها بطريقة ما، لكنت قررت عدم القيام بذلك في لحظة لو كنت أعلم أنني أستطيع فعل ذلك بطريقة بديهية ومتحكم بها. وقد فعلت.

لذا، امض قدمًا وقم بتعطيل الحقن التلقائي:

// webpack.production.js
plugins: [
  new HtmlWebpackPlugin({
    template: settings.templatePath,
    filename: "index.html",
    inject: false, // we will inject ourselves
    mode: process.env.NODE_ENV,
  }),
];

وبمعرفة أنه يمكننا الحصول على الأصول التي تم إنشاؤها بواسطة Webpack من كائن htmlWebpackPlugin.files داخل index.html الخاص بنا:

// example of what you would see if you
// console.log(htmlWebpackPlugin.files)
{
  "publicPath": "/",
  "js": [
    "/js/runtime.a201e1a.js",
    "/vendors~app.d8e8c.js",
    "/app.f8fb511.js",
    "/components.3811eb.js"
  ],
  "css": [
    "/app.5597.css",
    "/components.b49d382.css"
  ]
}

نحن نحقن أصولنا في index.html بأنفسنا:

<% if ( htmlWebpackPlugin.options.mode === 'production' ) { %>
  <script defer src="<%= htmlWebpackPlugin.files.js.filter(e => /^\/vendors/.test(e))[0] %>"></script>
  <script defer src="<%= htmlWebpackPlugin.files.js.filter(e => /^\/app/.test(e))[0] %>"></script>
  <link rel="stylesheet" href="<%= htmlWebpackPlugin.files.css.filter(e => /app/.test(e))[0] %>" />
<% } %>

ملاحظة: نقوم بذلك فقط عند البناء للإنتاج؛ نترك webpack-dev-server يحقن لنا أثناء التطوير المحلي. نطبق السمة defer على <script> بحيث يقوم المتصفح بجلبها أثناء تحليل HTML الخاص بنا، ولا ينفذ JS إلا بعد تحليل HTML.

رسم بياني يوضح كيفية عمل سمة defer في تحميل السكربتات.

المصدر: HTML5 Rocks

تضمين CSS و JS (Inlining CSS and JS)

إذا تمكنت من فصل CSS الحرج الخاص بك أو كان لديك سكربت JS صغير، فقد ترغب في التفكير في تضمينهما في <style> و <script>. "التضمين" (Inlining) يعني وضع المحتوى الخام المقابل في HTML. هذا يوفر رحلات الشبكة، على الرغم من أن عدم القدرة على تخزينها مؤقتًا هو مصدر قلق يستحق النظر فيه. دعنا نضمن ملف runtime.js الذي تم إنشاؤه بواسطة Webpack كما هو مقترح هنا. بالعودة إلى index.html أعلاه، أضف هذا المقتطف:

<!-- more <link> and <script> -->
<script>
  <%= compilation.assets[htmlWebpackPlugin.files.js.filter(
    e => /runtime/.test(e))[0].substr(htmlWebpackPlugin.files.publicPath.length)].source() %>
</script>

المفتاح كان compilation.assets[<ASSET_FILE_NAME>].source():

compilation: كائن تجميع webpack. يمكن استخدامه، على سبيل المثال، للحصول على محتويات الأصول المعالجة وتضمينها مباشرة في الصفحة، من خلال compilation.assets[...].source() (انظر مثال القالب المضمن). (المصدر: GitHub)

يمكنك استخدام هذه الطريقة لتضمين CSS الحرج الخاص بك أيضًا:

<style>
  <%= compilation.assets[
    htmlWebpackPlugin.files.css.filter(
      e => /app/.test(e))[0].substr(htmlWebpackPlugin.files.publicPath.length)
  ].source() %>
</style>

بالنسبة لـ CSS غير الحرج، يمكنك التفكير في تحميله مسبقًا (preload).

تحميل CSS غير الحرج مسبقًا (Preload non-critical CSS)

باختصار:

<link rel="stylesheet" href="/path/to/my.css" media="print" onload="this.media='all'" />

المصدر: Smashing Magazine

لكن دعنا نرى كيفية القيام بذلك باستخدام Webpack. لدي CSS غير الحرج الخاص بي موجودًا في ملف CSS، والذي أحدده كنقطة دخول خاصة به في Webpack:

// webpack.config.js
module.exports = {
  entry: {
    app: "index.js",
    components: path.resolve(__dirname, "../src/css/components.scss"),
  },
};

أخيرًا، أقوم بحقنه فوق CSS الحرج الخاص بي:

<!-- Preloading non-critical CSS -->
<link rel="stylesheet" href="<%= htmlWebpackPlugin.files.css.filter(e => /components/.test(e))[0] %>" media="print" onload="this.media='all'" />

<!-- Inlined critical CSS -->
<style>
  <%= compilation.assets[
    htmlWebpackPlugin.files.css.filter(
      e => /app/.test(e))[0].substr(htmlWebpackPlugin.files.publicPath.length)
  ].source() %>
</style>

دعنا نقيس ما إذا كنا قد حققنا أي شيء جيد بعد كل هذا. قياس صفحة التسجيل في Sametable:

قبل (BEFORE)

لقطة شاشة لنتائج أداء صفحة تسجيل الدخول في Sametable قبل التحسينات.

بعد (AFTER)

لقطة شاشة لنتائج أداء صفحة تسجيل الدخول في Sametable بعد التحسينات، تظهر تحسنًا ملحوظًا.

يبدو أننا قد حسّنا تقريبًا جميع المقاييس الهامة التي تركز على المستخدم (لست متأكدًا من تأخير الإدخال الأول (First Input Delay)!) 🚀 إليك فيديو تعليمي جيد حول قياس أداء الويب في أدوات مطوري Chrome.

تقسيم الكود (Code splitting)

بدلاً من تجميع جميع مكونات تطبيقك ومساراته ومكتبات الطرف الثالث في ملف .js واحد، يجب عليك تقسيمها وتحميلها عند الطلب بناءً على إجراء المستخدم في وقت التشغيل. سيؤدي ذلك إلى تقليل حجم حزمة SPA بشكل كبير ويقلل من تكاليف معالجة Javascript الأولية. هذا يحسن مقاييس مثل "وقت التفاعل الأول" (First interactive time) و "الرسم الأول ذي المعنى" (First meaningful paint). يتم تقسيم الكود باستخدام "الاستيراد الديناميكي" (dynamic imports):

// Editor.jsx
// LAZY-LOAD A GIGANTIC THIRD-PARTY LIBRARY
componentDidMount() {
  const { default: MarkdownIt } = await import(/* webpackChunkName: "markdown-it" */ "markdown-it");
  new MarkdownIt({ html: true }).render(/* stuff */);
}

// OR LAZY-LOAD A COMPONENT BASED ON USER ACTION
checkout = () => {
  const { default: CheckoutModal } = await import(/* webpackChunkName: "checkoutModal" */ "../routes/CheckoutModal");
}

حالة استخدام أخرى لتقسيم الكود هي التحميل الشرطي لـ polyfill لواجهة برمجة تطبيقات ويب (Web API) في متصفح لا يدعمها. هذا يوفر على الآخرين الذين يدعمونها دفع تكلفة polyfill. على سبيل المثال، إذا لم يكن IntersectionObserver مدعومًا، فسنقوم بتوفير polyfill له باستخدام مكتبة 'intersection-observer':

// InfiniteScroll.jsx
componentDidMount() {
  (window.IntersectionObserver
    ? Promise.resolve()
    : import("intersection-observer")
  ).then(() => {
    this.io = new window.IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          // do stuff
        });
      },
      {
        threshold: 0.5
      }
    );
    this.io.observe(/* DOM element */);
  });
}

دليل: https://medium.com/@kilgarenone/pragmatic-code-splitting-with-preact-and-webpack-a3d3b19f86a3

الخدمة التفاضلية (Differential Serving)

ربما تكون قد قمت بتكوين Webpack الخاص بك لبناء تطبيقك مستهدفًا كلاً من المتصفحات الحديثة والقديمة مثل IE11، بينما تقدم نفس الحمولة لكل مستخدم. هذا يجبر المستخدمين الذين يستخدمون المتصفحات الحديثة على دفع تكلفة (تحليل/تجميع/تنفيذ) polyfills غير الضرورية والتعليمات البرمجية المحولة الزائدة التي تهدف إلى دعم المستخدمين على المتصفحات القديمة.

ستقدم "الخدمة التفاضلية" (Differential serving)، من ناحية، كودًا أكثر رشاقة للمستخدمين على المتصفحات الحديثة. ومن ناحية أخرى، ستقدم كودًا محولًا ومزودًا بـ polyfills بشكل صحيح لدعم المستخدمين على المتصفحات القديمة مثل IE11.

على الرغم من أن هذا النهج يجعل إعداد البناء أكثر تعقيدًا ولا يخلو من بعض المحاذير، إلا أن الفوائد المكتسبة (يمكنك العثور عليها في الموارد أدناه) تفوق التكاليف بالتأكيد. هذا ما لم يكن غالبية قاعدة المستخدمين لديك على IE11. في هذه الحالة، يمكنك على الأرجح تخطي هذا. ولكن حتى لو كان الأمر كذلك، فإن هذا النهج مقاوم للمستقبل حيث يتم التخلص التدريجي من المتصفحات القديمة.

المستودع (Repo): https://github.com/kilgarenone/differential-serving

الموارد:

الخطوط (Fonts)

يمكن أن تكون ملفات الخطوط مكلفة. خذ خطي المفضل Inter على سبيل المثال: إذا استخدمت 3 من أنماط الخطوط الخاصة به، يمكن أن يصل الحجم الإجمالي إلى 300 كيلوبايت، مما يؤدي إلى تفاقم حالات FOUT و FOIT، خاصة في الأجهزة منخفضة المواصفات. لتلبية احتياجات الخطوط في مشاريعي، عادةً ما أستخدم "خطوط النظام" (system fonts) التي تأتي مع الأجهزة:

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}

code {
  font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New";
}

ولكن إذا كان يجب عليك استخدام خطوط ويب مخصصة، ففكر في القيام بذلك بشكل صحيح:

  • يجب عليك استضافتها بنفسك.
  • "تقسيم الخطوط الفرعية" (Font-subsetting) لتقليل حجم ملف الخط بشكل كبير.
  • راجع قائمة التحقق هذه.

الأيقونات (Icons)

الأيقونات في Sametable هي من نوع SVG. هناك طرق مختلفة يمكنك القيام بها:

  • نسخ ولصق ترميز أيقونة SVG أينما احتجت إليها. الجانب السلبي هو أنها ستضخم HTML وتتحمل تكاليف التحليل خاصة على الهاتف المحمول.
  • طلب أيقونات SVG الخاصة بك عبر الشبكة: <img src="./tick.svg" />. ما لم يكن SVG ضخمًا (أكبر من 5 كيلوبايت)، فإن إجراء طلب لكل منها يبدو مبالغًا فيه بعض الشيء.
  • جعل الأيقونة قابلة لإعادة الاستخدام في شكل مكون React. الجانب السلبي هو أنها تقدم Javascript وتكاليفه المرتبطة به بشكل غير ضروري.

بدلاً من ذلك، كان الحل الذي اخترته لأيقوناتي هو "SVG sprites" وهو أقرب إلى طبيعة SVG نفسها (<use> و <symbol>). دعنا نرى كيف. لنفترض أن هناك العديد من الأماكن التي ستستخدم اثنتين من أيقونات SVG الخاصة بنا. في ملف index.html الخاص بك:

<body>
  <svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
    <symbol id="pin-it" viewBox="0 0 96 96">
      <title>Give it a title</title>
      <desc>Give it a description for accessibility</desc>
      <path d="M67.7 40.3c-.3 2.7-2" />
    </symbol>
    <symbol id="unpin-it" viewBox="0 0 96 96">
      <title>Un-pin this entity</title>
      <desc>Click to un-pin this entity</desc>
      <path d="M67.7 40.3c-.3 2.7-2" />
    </symbol>
  </svg>
</body>

أخفِ العنصر الأب SVG باستخدام style="display: none". أعطِ كل رمز SVG معرفًا فريدًا <symbol id="unique-id">. تأكد من تحديد viewBox (عادة ما يتم توفيره بالفعل)، ولكن تخطى width و height. أعطه title و desc لإمكانية الوصول. وبالطبع، بيانات المسار (path data) للأيقونة.

وأخيرًا، إليك كيفية استخدامها في مكوناتك:

// example.jsx
render() {
  <svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" width="24" height="24">
    <use xlinkHref="#pin-it" />
  </svg>
}

حدد width و height حسب الرغبة. حدد id الخاص بـ <symbol>: <use xlinkHref="#pin-it" />.

تحميل SVG sprites بشكل كسول (Lazy load SVG sprites)

بدلاً من وضع رموز SVG الخاصة بك في index.html، يمكنك وضعها في ملف .svg يتم تحميله فقط عند الحاجة:

<svg xmlns="http://www.w3.org/2000/svg">
  <symbol id="header-1" viewBox="0 0 26 24">
    <title>Header 1</title>
    <desc>Toggle a h1 header</desc>
    <text x="0" y="20" font-weight="600">H1</text>
  </symbol>
  <symbol id="header-2" viewBox="0 0 26 24">
    <title>Header 2</title>
    <desc>Toggle a h2 header</desc>
    <text x="0" y="20" font-weight="600">H2</text>
  </symbol>
</svg>

ضع هذا الملف في client/src/assets:

- client
  - src
    - assets
      - svg-sprites.svg

أخيرًا، لاستخدام أحد الرموز في الملف:

// Editor.js
import svgSprites from "../../assets/svg-sprites.svg";

/* component stuff */
render() {
  return (
    <button type="button">
      <svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" width="24" height="24">
        <use xlinkHref={`${svgSprites}#header-1`} />
      </svg>
    </button>
  )
}

وسيقوم المتصفح، أثناء وقت التشغيل، بجلب ملف .svg إذا لم يكن قد فعل ذلك بالفعل. وهكذا! لا مزيد من لصق بيانات المسار (path data) الطويلة في كل مكان.

مصادر الأيقونات:

المراجع:

أيقونة الموقع (Favicon)

لو لم أكن قد قمت بتعطيل خيار inject في 'html-webpack-plugin'، لكنت استخدمت إضافة تسمى 'favicons-webpack-plugin' التي تولد تلقائيًا جميع أنواع أيقونات الموقع (احذر – إنها كثيرة!)، وتحقنها في index.html الخاص بي:

// webpack.config.js
plugins: [
  new HtmlWebpackPlugin(), // 'inject' is true by default
  // must come after html-webpack-plugin
  new FaviconsWebpackPlugin({
    logo: path.resolve(__dirname, "../src/assets/logo.svg"),
    prefix: "icons-[hash]/",
    persistentCache: true,
    inject: true,
    favicons: {
      appName: "Sametable",
      appDescription: "Manage your tasks in spreadsheets",
      developerName: "Kheoh Yee Wei",
      developerURL: "https://kheohyeewei.com",
      // prevent retrieving from the nearest package.json
      theme_color: "#fcbdaa",
      // specify the vendors that you want favicon for
      icons: {
        coast: false,
        yandex: false,
      },
    },
  }),
];

ولكن نظرًا لأنني قمت بتعطيل الحقن التلقائي، إليك كيفية تعاملي مع أيقونة الموقع (favicon) الخاصة بي:

  1. انتقل إلى https://realfavicongenerator.net/
  2. قدم شعارك بتنسيق SVG.
  3. حدد خيار "الإصدار/التحديث" (Version/Refresh) لتمكين إبطال التخزين المؤقت لأصل أيقونة الموقع الخاص بك في متصفح المستخدمين.
  4. أكمل التعليمات في النهاية.

يمكنك تخزين أيقونات الموقع الخاصة بك في أي مجلد في مشروعك. استخدم 'copy-webpack-plugin' لنسخ جميع أصول أيقونات الموقع التي تم إنشاؤها من الخطوة 1، من المجلد الذي تخزنها فيه (في حالتي، src/assets/favicon) إلى مسار إخراج Webpack (السلوك الافتراضي)، بحيث تكون قابلة للوصول من الجذر (أي https://example.com/favicon.ico).

// webpack.config.js
const CopyWebpackPlugin = require("copy-webpack-plugin");

plugins: [
  new CopyWebpackPlugin([{ from: "src/assets/favicon" }])
];

وهذا كل شيء!

استدعاءات API (API Calls)

يحتاج العميل إلى التواصل مع الخادم لإجراء عمليات "إنشاء، قراءة، تحديث، حذف" (CRUD):

رسم بياني يوضح تدفق استدعاءات API بين الواجهة الأمامية والخلفية لعمليات CRUD.

إليك ملف api.js الخاص بي، والذي آمل أن يكون سهل الفهم:

غلاف API (API WRAPPER)

import { route } from "preact-router";

function checkStatus(response) {
  const responseCode = response.status;
  if (responseCode >= 200 && responseCode < 300) {
    return response;
  }
  // handle user not authorized scenario
  if (responseCode === 401) {
    response
      .json()
      .then((json) => route(`/signin${json.refererUri ? `?dest=${json.refererUri}` : ""}`)
      );
    return;
  }
  // pass along error response to the 'catch' block of your await/async try & catch block
  return response.json().then((json) => {
    return Promise.reject({
      status: responseCode,
      ok: false,
      statusText: response.statusText,
      body: json,
    });
  });
}

function handleError(error) {
  error.response = {
    status: 0,
    statusText: "Cannot connect. Please make sure you are connected to internet.",
  };
  throw error;
}

function parseJSON(response) {
  if (response.status === 204 || response.status === 205) {
    return null;
  }
  return response.json();
}

function request(url, options) {
  return fetch(url, options)
    .catch(handleError) // handle network issues
    .then(checkStatus)
    .then(parseJSON)
    .catch((e) => {
      throw e;
    });
}

export function api(endPoint, userOptions = {}) {
  const url = process.env.API_BASE_URL + endPoint;
  // to pass along our auth cookie to server
  userOptions.credentials = "include";

  const defaultHeaders = {
    "Content-Type": "application/json",
    Accept: "application/json",
  };

  if (userOptions.body instanceof File) {
    const formData = new FormData();
    formData.append("file", userOptions.body);
    userOptions.body = formData;
    // let browser set content-type to multipart/etc.
    delete defaultHeaders["Content-Type"];
  }

  if (userOptions.body instanceof FormData) {
    // let browser set content-type to multipart
    delete defaultHeaders["Content-Type"];
  }

  const options = {
    ...userOptions,
    headers: {
      ...defaultHeaders,
      ...userOptions.headers,
    },
  };

  return request(url, options);
}

لا يوجد شيء جديد تقريبًا لتعلمه لبدء استخدام وحدة API هذه إذا كنت قد استخدمت fetch الأصلي من قبل.

الاستخدام (Usage)

// Home.jsx
import { api } from "../lib/api";

async componentDidMount() {
  try {
    // POST-ing data
    const response = await api(
      '/projects/save/121212121',
      {
        method: 'PUT',
        body: JSON.stringify(dataObject)
      }
    )
    // or GET-ting data
    const { myWorkspaces } = await api('/users/home');

  } catch (err) {
    // handle Promise.reject passed from api.js
  }
}

ولكن إذا كنت تفضل استخدام مكتبة للتعامل مع استدعاءات HTTP الخاصة بك، فإنني أوصي بـ 'redaxios'. فهي لا تشارك واجهة برمجة تطبيقات (API) مع axios الشهير فحسب، بل إنها أخف بكثير.

اختبار بناء الإنتاج محليًا (Test Production Build Locally)

أقوم دائمًا ببناء تطبيق العميل الخاص بي محليًا للاختبار والقياس في متصفحي قبل النشر إلى السحابة. لدي سكربت npm (npm run test-build) في ملف package.json الخاص بمجلد "العميل" (client) الذي سيقوم بالبناء والخدمة على خادم ويب محلي. بهذه الطريقة يمكنني اللعب به في متصفحي على http://localhost:5000:

{
  "scripts": {
    "test-build": "cross-env NODE_ENV=production TEST_RUN=true node_modules/.bin/webpack && npm run serve",
    "serve": "ws --spa index.html --directory dist --port 5000 --hostname localhost"
  }
}

يتم تقديم التطبيق باستخدام أداة تسمى 'local-web-server'. إنها حتى الآن الوحيدة التي أجدها تعمل بشكل مثالي لتطبيق SPA.

الأمان (Security)

فكر في إضافة رؤوس أمان سياسة أمان المحتوى (CSP). لإضافة رؤوس في Firebase: https://firebase.google.com/docs/hosting/full-config#headers

مثال على رؤوس CSP في ملف firebase.json الخاص بك:

{
  "source": "**",
  "headers": [
    {
      "key": "Strict-Transport-Security",
      "value": "max-age=63072000; includeSubdomains; preload"
    },
    {
      "key": "Content-Security-Policy",
      "value": "default-src 'none'; img-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'"
    },
    {
      "key": "X-Content-Type-Options",
      "value": "nosniff"
    },
    {
      "key": "X-Frame-Options",
      "value": "DENY"
    },
    {
      "key": "X-XSS-Protection",
      "value": "1; mode=block"
    },
    {
      "key": "Referrer-Policy",
      "value": "same-origin"
    }
  ]
}

إذا كنت تستخدم Stripe، فتأكد من إضافة توجيهات CSP الخاصة بهم أيضًا: https://stripe.com/docs/security/guide#content-security-policy

أخيرًا، تأكد من حصولك على درجة A هنا واهنئ نفسك!

التصميم (Design)

قبل أن أبدأ في كتابة أي كود، أردت أن يكون لدي تصور ذهني لكيفية إعداد مستخدم جديد لتطبيقي. ثم كنت أرسم في دفتر ملاحظات كيف قد يبدو ذلك، وأعيد تكرار الرسومات بينما أستعرض وأعيد صياغة التصور في ذهني. بالنسبة لـ "السباق" (sprint) الأول لي، كنت سأبني بشكل أساسي "إطار عمل لواجهة المستخدم/تجربة المستخدم" (UI/UX framework) سأضيف إليه قطعًا بمرور الوقت. ومع ذلك، من المهم أن تتذكر أن كل قرار تتخذه خلال هذه العملية يجب أن يكون قرارًا مفتوحًا وسهل التراجع عنه. بهذه الطريقة، لن يؤدي قرار "صغير" – ولكنه حذر – إلى الهلاك عندما تنجرف وراء أي قناعات مفرطة الثقة والرومانسية. لست متأكدًا مما إذا كان ذلك منطقيًا، ولكن دعنا نستكشف بعض المفاهيم التي ساعدت في هيكلة تصميمي ليكون متماسكًا عمليًا.

المقياس المعياري (Modular Scale)

سيكون تصميمك أكثر منطقية لمستخدميك عندما يتدفق وفقًا لـ "مقياس معياري" (modular scale). يجب أن يحدد هذا المقياس نطاقًا من المسافات أو الأحجام التي تزداد كل منها بنسبة معينة.

رسم بياني يوضح مفهوم المقياس المعياري في التصميم، مع تزايد الأحجام بنسبة ثابتة.

الشكل: المقياس المعياري

إحدى طرق إنشاء مقياس هي باستخدام "خصائص CSS المخصصة" (CSS 'Custom Properties') (شكرًا لـ view-source every-layout.dev):

:root {
  --ratio: 1.414;
  --s-3: calc(var(--s0) / var(--ratio) / var(--ratio) / var(--ratio));
  --s-2: calc(var(--s0) / var(--ratio) / var(--ratio));
  --s-1: calc(var(--s0) / var(--ratio));
  --s0: 1rem;
  --s1: calc(var(--s0) * var(--ratio));
  --s2: calc(var(--s0) * var(--ratio) * var(--ratio));
  --s3: calc(var(--s0) * var(--ratio) * var(--ratio) * var(--ratio));
}

إذا كنت لا تعرف أي مقياس تستخدمه، فما عليك سوى اختيار مقياس يتناسب بشكل أقرب مع تصميمك والالتزام به. ثم قم بإنشاء مجموعة من فئات الأدوات المساعدة (utility classes)، كل منها مرتبط بمقياس، في ملف يسمى spacing.scss. سأستخدمها لتباعد عناصر واجهة المستخدم الخاصة بي عبر المشروع:

.mb-1 {
  margin-bottom: var(--s1);
}
.mb-2 {
  margin-bottom: var(--s2);
}
.mr-1 {
  margin-right: var(--s1);
}
.mr--1 {
  margin-right: var(--s-1);
}

لاحظ أنني أحاول تحديد التباعد فقط في الاتجاهين right و bottom كما هو مقترح هنا. في تجربتي، من الأفضل عدم تضمين أي تعريفات تباعد في مكونات واجهة المستخدم الخاصة بك:

لا تفعل (DON’T)

// Button.scss
.btn {
  margin: 10px; // a default spacing; annoying to have in most cases
  font-style: normal;
  border: 0;
  background-color: transparent;
}

// Button.jsx
import s from './Button.scss';

export function Button({children, ...props}) {
  return (
    <button class={s.btn} {...props}>
      {children}
    </button>
  )
}

// Usage
<Button />

افعل (DO)

// Button.scss
.btn {
  font-style: normal;
  border: 0;
  background-color: transparent;
}

// Button.jsx
import s from './Button.scss';

export function Button({children, className, ...props}) {
  return (
    <button class={`${s.btn} ${className}`} {...props}>
      {children}
    </button>
  )
}

// Usage
// Pass your spacing utility classes when building your pages
<Button className="mr-1 pb-1">Sign Up</Button>

الألوان (Colors)

هناك العديد من أدوات لوحة الألوان المتاحة. ولكن الأداة من Material هي التي أذهب إليها دائمًا لألواني ببساطة لأنها معروضة بكل مجدها! 🎨 ثم سأقوم بتعريفها كخصائص CSS مخصصة مرة أخرى:

:root {
  --black-100: #0b0c0c;
  --black-80: #424242;
  --black-60: #555759;
  --black-50: #626a6e;

  font-size: 105%;
  color: var(--black-100);
}

إعادة تعيين CSS (CSS Reset)

الغرض من "إعادة تعيين CSS" (CSS reset) هو إزالة التنسيق الافتراضي للمتصفحات الشائعة. هناك عدد لا بأس به منها. احذر من أن بعضها يمكن أن يكون محددًا جدًا وقد يسبب لك المزيد من المشاكل أكثر مما يستحق. إليك أحدها الشائع:

وإليك ما استخدمته:

*, *::before, *::after {
  box-sizing: border-box;
  overflow-wrap: break-word;
  margin: 0;
  padding: 0;
  border: 0 solid;
  font-family: inherit;
  color: inherit;
}

/* Set core body defaults */
body {
  scroll-behavior: smooth;
  text-rendering: optimizeLegibility;
}

/* Make images easier to work with */
img {
  max-width: 100%;
}

/* Inherit fonts for inputs and buttons */
button, input, textarea, select {
  color: inherit;
  font: inherit;
}

يمكنك أيضًا التفكير في استخدام postcss-normalize الذي يولد واحدة وفقًا للمتصفحات المستهدفة.

ممارسة التنسيق (A Styling Practice)

أحاول دائمًا التنسيق على مستوى العلامة (tag-level) أولاً قبل استخدام الأداة الكبيرة إذا لزم الأمر، في حالتي، "وحدات CSS" (CSS Modules)، لتغليف الأنماط لكل مكون:

- src
  - routes
    - SignIn
      - SignIn.js
      - SignIn.scss

يحتوي ملف SignIn.scss على CSS الذي يخص فقط مكون <SignIn />. علاوة على ذلك، لا أستخدم مكتبات CSS الشائعة في نظام React البيئي مثل 'styled-components' و 'emotion'. أحاول استخدام HTML و CSS النقيين كلما أمكنني ذلك، وأترك Preact فقط يتعامل مع تحديثات DOM والحالة (state updates) نيابة عني. على سبيل المثال، لعنصر <input/>:

// index.scss
label {
  display: block;
  color: var(--black-100);
  font-weight: 600;
}

input {
  width: 100%;
  font-weight: 400;
  font-style: normal;
  border: 2px solid var(--black-100);
  box-shadow: none;
  outline: none;
  appearance: none;
}

input:focus {
  box-shadow: inset 0 0 0 2px;
  outline: 3px solid #fd0;
  outline-offset: 0;
}

ثم استخدامه في ملف JSX مع علامته الأصلية:

// SignIn.js
render() {
  return (
    <div class="form-control">
      <label htmlFor="email">Email &nbsp;<strong><abbr title="This field is required">*</abbr></strong></label>
      <input required value={this.email} type="email" id="email" name="email" placeholder="e.g. sara@widgetco.com" />
    </div>
  )
}

التخطيط (Layout)

أستخدم CSS Flexbox لأعمال التخطيط في Sametable. لم أكن بحاجة إلى أي أطر عمل CSS. تعلم CSS Flexbox من مبادئه الأولى للقيام بالمزيد بكود أقل. بالإضافة إلى ذلك، في كثير من الحالات، ستكون النتيجة متجاوبة (responsive) بالفعل بفضل خوارزميات التخطيط، مما يوفر استعلامات @media تلك. دعنا نرى كيفية بناء تخطيط شائع في Flexbox بأقل قدر من CSS:

مثال على تخطيط Flexbox بسيط لصفحة ويب تحتوي على شريط جانبي ومحتوى رئيسي.

انظر تخطيط الشريط الجانبي/المحتوى على CodePen.

الموارد:

الخادم (Server)

- server
  - server.js
  - package.json
  - .env

يعمل الخادم على NodeJS (إطار عمل ExpressJS) لتقديم جميع نقاط نهاية API الخاصة بي.

// Example endpoint: https://example.com/api/tasks/save/12345
router.put("/save/:taskId", (req, res, next) => {});

يحتوي ملف server.js على الأكواد المألوفة لبدء خادم Nodejs.

هيكل الملفات (File Structure)

أنا ممتن لهذا الدليل الهضمي حول هيكل المشروع، والذي سمح لي بالتركيز وبناء API الخاص بي بسرعة.

سكربتات Npm (الخادم)

في ملف package.json داخل مجلد "الخادم" (server)، يوجد سكربت npm سيقوم بتشغيل خادمك نيابة عنك:

{
  "scripts": {
    "dev": "nodemon -r dotenv/config server.js",
    "start": "node server.js"
  }
}

يقوم سكربت dev بـ "تحميل مسبق" (preload) لـ dotenv كما هو مقترح هنا. وهذا كل شيء – سيكون لديك وصول إلى متغيرات البيئة المعرفة في ملف .env من كائن process.env. يستخدم سكربت start لبدء خادم Nodejs الخاص بنا في الإنتاج. في حالتي، سيقوم GCP بتشغيل هذا السكربت لتشغيل Nodejs الخاص بي.

قاعدة البيانات (Database)

أستخدم Postgresql كقاعدة بيانات لي. ثم أستخدم مكتبة 'node-postgres' (المعروفة أيضًا باسم pg) لربط Nodejs الخاص بي بقاعدة البيانات. بمجرد الانتهاء من ذلك، يمكنني إجراء عمليات CRUD بين نقاط نهاية API وقاعدة البيانات.

الإعداد (Setup)

للتطوير المحلي:

  1. قم بتنزيل Postgresql هنا. احصل على أحدث إصدار.
  2. اترك كل شيء كما هو. تذكر كلمة المرور التي قمت بتعيينها.
  3. ثم، افتح 'pgAdmin'. إنه تطبيق متصفح.
  4. أنشئ قاعدة بيانات لتطبيقك:

لقطة شاشة لـ pgAdmin تعرض واجهة إنشاء قاعدة بيانات جديدة.

حدد مجموعة من متغيرات البيئة في ملف .env:

DB_HOST='localhost'
DB_USER=postgres
DB_NAME=<YOUR_CUSTOM_DATABASE_NAME_HERE>
DB_PASSWORD=<YOUR_MASTER_PASSWORD>
DB_PORT=5432

ثم سنقوم بربط عميل جديد عبر مجمع اتصال (connection pool) بقاعدة بيانات Postgresql الخاصة بنا من Nodejs. أقوم بذلك في ملف server/db/index.js:

const { Pool } = require("pg");

const pool = new Pool({
  user: process.env.DB_USER,
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  password: process.env.DB_PASSWORD,
});

// TRANSACTION
// https://github.com/brianc/node-postgres/issues/1252#issuecomment-293899088
const tx = async (callback, errCallback) => {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    await callback(client);
    await client.query("COMMIT");
  } catch (err) {
    console.log(("DB ERROR:", err));
    await client.query("ROLLBACK");
    errCallback && errCallback(err);
  } finally {
    client.release();
  }
};

// the pool will emit an error on behalf of any idle clients
// it contains if a backend error or network partition happens
pool.on("error", (err) => {
  process.exit(-1);
});

pool.on("connect", () => {
  console.log("❤️ Connected to the Database ❤️");
});

module.exports = {
  query: (text, params, callback) => pool.query(text, params, callback),
  tx,
  pool,
};

سأستخدم دالة tx في API إذا كان علي استدعاء العديد من الاستعلامات التي تعتمد على بعضها البعض. إذا كنت أقوم بإجراء استعلام واحد، فسأستخدم دالة query. وهذا كل شيء! الآن لديك قاعدة بيانات للعمل معها لتطويرك المحلي 🥳

الاستخدام (Usage)

سأعترف: لقد قمت بصياغة جميع الاستعلامات لـ Sametable يدويًا. في رأيي، لغة SQL نفسها هي بالفعل لغة تعريفية لا تحتاج إلى مزيد من التجريد – إنها سهلة القراءة والفهم والكتابة. يمكن صيانتها إذا فصلت نقاط نهاية API الخاصة بك جيدًا. إذا كنت تعلم أنك تبني تطبيقًا بحجم facebook، فربما يكون من الحكمة استخدام ORM. لكنني مجرد شخص عادي يبني منتج SaaS ذو نطاق ضيق جدًا بمفردي. لذلك كنت بحاجة إلى تجنب النفقات العامة والتعقيد مع مراعاة عوامل مثل سهولة الإعداد، والأداء، وسهولة التكرار، والعمر الافتراضي المحتمل للمعرفة. هذا يذكرني بالحاجة إلى تعلم Vanilla JavaScript قبل الانضمام إلى موجة إطار عمل واجهة أمامية شائع. لأنك قد تدرك:

هذا كل ما تحتاجه لتحقيق ما شرعت في تحقيقه للوصول إلى عميلك الألف.

لكي أكون منصفًا، عندما قررت اتباع هذا المسار، كانت لدي تجارب متواضعة في كتابة MySQL. لذا إذا كنت لا تعرف شيئًا عن SQL وكنت حريصًا على إطلاق المنتج، فقد ترغب في التفكير في مكتبة مثل knex.js.

مثال (Example)

// server/routes/projects.js
const express = require("express");
const asyncHandler = require("express-async-handler");
const db = require("../db");
const router = express.Router();

module.exports = router;

// [POST] api/projects/create
router.post("/create", express.json(), asyncHandler(async (req, res, next) => {
  const { title, project_id } = req.body;
  db.tx(async (client) => {
    const { rows, } = await client.query(
      `INSERT INTO tasks (title) VALUES ($1) RETURNING mask_id(task_id) as masked_task_id, task_id`,
      [title]
    );
    res.json({ id: rows[0].masked_task_id });
  }, next);
}));

يستخدم express-async-handler بشكل أساسي للتعامل مع الأخطاء غير المتزامنة في معالجات المسار الخاصة بي. لن تكون هناك حاجة إليه بعد الآن عندما يصدر Express 5. قم باستيراد وحدة db لاستخدام طريقة tx. مرر استعلامات SQL والمعلمات التي صاغتها يدويًا. هذا كل شيء!

إنشاء مخططات الجداول (Creating table schemas)

قبل أن تتمكن من البدء في استعلام قاعدة بيانات، تحتاج إلى إنشاء جداول. يحتوي كل جدول على معلومات حول كيان. لكننا لا نجمع جميع المعلومات حول كيان في نفس الجدول. نحتاج إلى تنظيم المعلومات بطريقة تعزز أداء الاستعلام وصيانة البيانات. وما ساعدني في هذا التمرين هو مفهوم يسمى عدم التطبيع (denormalization).

كما ذكرنا، لا نريد تخزين كل شيء عن كيان في نفس الجدول. على سبيل المثال، لنفترض أن لدينا جدول users يخزن fullname و password و email. هذا جيد حتى الآن. لكن المشكلة تنشأ عندما نقوم أيضًا بتخزين معرفات جميع المشاريع المخصصة لمستخدم معين في عمود منفصل في نفس الجدول. بدلاً من ذلك، سأقوم بتقسيمها إلى جداول منفصلة:

أنشئ جدول users. لاحظ أنه لا يخزن أي بيانات متعلقة بـ "المشاريع":

CREATE TABLE users (
  user_id BIGSERIAL PRIMARY KEY,
  fullname TEXT NOT NULL,
  pwd TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL,
);

أنشئ جدول projects لتخزين البيانات فقط حول تفاصيل المشروع:

CREATE TABLE projects(
  project_id BIGSERIAL PRIMARY KEY,
  title TEXT,
  content TEXT,
  due_date TIMESTAMPTZ,
  status SMALLINT,
  created_on TIMESTAMPTZ NOT NULL DEFAULT now()
);

أنشئ جدول "جسر" (bridge) حول ملكية المشاريع عن طريق ربط معرف المستخدم بمعرف المشروع الذي يملكه:

CREATE TABLE project_ownerships(
  project_id BIGINT REFERENCES projects ON DELETE CASCADE,
  user_id BIGINT REFERENCES users ON DELETE CASCADE,
  PRIMARY KEY (project_id, user_id),
  CONSTRAINT project_user_unique UNIQUE (user_id, project_id)
);

أخيرًا، للحصول على جميع المشاريع المخصصة لمستخدم معين، سنفعل ما تفعله قواعد البيانات العلائقية بشكل أفضل: join. سأضع جميع مخططاتي في ملف .sql في جذر مشروعي:

CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE users (
  user_id BIGSERIAL PRIMARY KEY,
  fullname TEXT NOT NULL,
  pwd TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL,
  created_on TIMESTAMPTZ NOT NULL DEFAULT now()
);

ثم، سأقوم بنسخها ولصقها وتشغيلها في pgAdmin:

لقطة شاشة لـ pgAdmin تعرض محرر الاستعلام حيث يمكن تشغيل أوامر SQL لإنشاء الجداول.

لا شك أن هناك طرقًا أكثر تقدمًا للقيام بذلك، لذا الأمر متروك لك إذا كنت ترغب في استكشاف ما يعجبك.

حذف قاعدة بيانات (Dropping a database)

كان حذف قاعدة بيانات بأكملها للبدء بمجموعة جديدة من المخططات شيئًا كان علي القيام به كثيرًا في البداية. الحيلة هي: حسنًا، تقوم بنسخ ولصق وتشغيل الأمر أدناه في محرر استعلام قاعدة البيانات في pgAdmin:

DROP SCHEMA public CASCADE;
CREATE SCHEMA public;

GRANT ALL ON SCHEMA public TO postgres;
GRANT ALL ON SCHEMA public TO public;
COMMENT ON SCHEMA public IS 'standard public schema';

صياغة استعلامات SQL (Crafting SQL queries)

أكتب استعلامات SQL الخاصة بي في pgAdmin للحصول على البيانات التي أريدها من نقطة نهاية API. لإعطاء شعور بالاتجاه للقيام بذلك في pgAdmin:

لقطة شاشة لـ pgAdmin توضح مكان كتابة وتشغيل استعلامات SQL.

تعبيرات الجدول المشتركة (Common Table Expressions – CTEs)

لقد عثرت على نمط يسمى CTEs عندما كنت أستكشف كيفية الحصول على البيانات التي أردتها من جداول متباينة وهيكلتها كما أردت، دون إجراء الكثير من استعلامات قاعدة البيانات المنفصلة وحلقات التكرار (for-loops). طريقة عمل CTE بسيطة بما فيه الكفاية، على الرغم من أنها تبدو شاقة: تكتب استعلاماتك. يُعطى كل استعلام اسمًا مستعارًا (q، q1، q3). ويمكن للاستعلام التالي الوصول إلى نتائج أي استعلام سابق باسمه المستعار (q1.workspace_id):

WITH q AS (
  SELECT * FROM projects_tasks WHERE task_id=$1
),
q1 AS (
  SELECT wp.workspace_id, wp.project_id, q.task_id FROM workspaces_projects wp, q WHERE wp.project_id = q.project_id
),
q3 AS (
  SELECT
    q1.workspace_id AS workspace_id,
    wp.name AS workspace_title,
    mask_id(q1.project_id) AS project_id,
    p.title AS project_title,
    mask_id(t.task_id) AS task_id,
    t.title,
    t.content,
    t.due_date,
    t.priority,
    t.status
)
SELECT * FROM q3;

تقريبًا جميع الاستعلامات في Sametable مكتوبة بهذه الطريقة.

Redis

Redis هي قاعدة بيانات NoSQL تخزن البيانات في الذاكرة. في Sametable، استخدمت Redis لغرضين:

  • تخزين بيانات جلسة المستخدم ومعلومات أساسية من جدول users – الاسم، البريد الإلكتروني، وعلامة تشير إلى ما إذا كان المستخدم مشتركًا أم لا – بمجرد تسجيل الدخول.
  • تخزين نتائج بعض استعلامات Postgresql الخاصة بي مؤقتًا لتجنب الاضطرار إلى استعلام قاعدة البيانات إذا كان التخزين المؤقت لا يزال حديثًا.

التثبيت (Installation)

أنا أستخدم جهاز Windows 10 مع تثبيت نظام Windows Subsystem Linux (WSL). كان هذا هو الدليل الوحيد الذي اتبعته لتثبيت Redis على جهازي: https://redislabs.com/blog/redis-on-windows-10/

اتبع الدليل لتثبيت WSL إذا لم يكن لديك بالفعل. ثم سأقوم بتشغيل خادم Redis المحلي الخاص بي في WSL bash:

  1. اضغط على Win + R.
  2. اكتب bash واضغط Enter.
  3. في الطرفية، قم بتشغيل sudo service redis-server start.

الآن قم بتثبيت حزمة redis npm:

cd server
npm i redis

تأكد من تثبيتها في ملف package.json الخاص بـ server، ومن هنا جاء الأمر cd server. ثم أقوم بإنشاء ملف باسم redis.js تحت server/db:

// server/db/redis.js
const redis = require("redis");
const { promisify } = require("util");

const redisClient = redis.createClient(
  NODE_ENV === "production"
    ? {
        host: process.env.REDISHOST,
        no_ready_check: true,
        auth_pass: process.env.REDIS_PASSWORD,
      }
    : {}
);

redisClient.on("error", (err) => console.error("ERR:REDIS:", err));

const redisGetAsync = promisify(redisClient.get).bind(redisClient);
const redisSetExAsync = promisify(redisClient.setex).bind(redisClient);
const redisDelAsync = promisify(redisClient.del).bind(redisClient);

// 1 day expiry
const REDIS_EXPIRATION = 7 * 86400; // seconds

module.exports = {
  redisGetAsync,
  redisSetExAsync,
  redisDelAsync,
  REDIS_EXPIRATION,
  redisClient,
};

بشكل افتراضي، سيتصل node-redis بـ localhost على المنفذ 6379. لكن هذا قد لا يكون هو الحال في الإنتاج إذا استضفت Redis الخاص بك في جهاز افتراضي (VM). لذلك أقدم هذا الكائن إذا كان في وضع الإنتاج:

{
  host: process.env.REDISHOST,
  no_ready_check: true,
  auth_pass: process.env.REDIS_PASSWORD,
}

لأكون صريحًا، لست متأكدًا تمامًا من no_ready_check. حصلت عليها من هذا البرنامج التعليمي الرسمي. يتم توفير auth_pass و host كقيم مخصصة لأنني أستضيف Redis الخاص بي في جهاز افتراضي (GCE VM) حيث قمت بتعيين كلمة مرور على Redis الخاص بي. أقوم بتحويل طرق Redis التي سأستخدمها إلى وعود (promisfy) لجعلها غير متزامنة (async) لتجنب حظر الخيط الفردي لـ NodeJS.

والآن لديك Redis لتطويرك المحلي!

التعامل مع الأخطاء والتسجيل (Error Handling & Logging)

التعامل مع الأخطاء (Error handling)

يحتوي التعامل مع الأخطاء في Nodejs على نموذج سنستكشفه في 3 سياقات مختلفة. لتهيئة المشهد، نحتاج إلى شيئين في مكانهما أولاً:

  1. حزمة npm تسمى http-errors ستمنحنا بنية بيانات خطأ قياسية للعمل معها خاصة في جانب العميل.
npm install http-errors

ننشئ معالج أخطاء مخصصًا على المستوى العام لالتقاط جميع الأخطاء المنتشرة من المسارات أو كتل catch عبر next(err):

// app.js
const express = require("express");
const app = express();
const createError = require("http-errors");

// our central custom error handler
// NOTE: DON"T REMOVE THE 'next' even though eslint complains it's not being used!!!
app.use(function (err, req, res, next) {
  // errors wrapped by http-errors will have 'status' property defined. Otherwise, it's a generic unexpected error
  const error = err.status ? err : createError(500, "Something went wrong. Notified dev.");
  res.status(error.status).json(error);
});

كما سترى، يدور النمط العام للتعامل مع الأخطاء في Nodejs حول سلسلة "البرمجيات الوسيطة" (middleware) ومعامل next:

تشير استدعاءات next() و next(err) إلى أن المعالج الحالي قد اكتمل وفي أي حالة. سيقوم next(err) بتخطي جميع المعالجات المتبقية في السلسلة باستثناء تلك التي تم إعدادها للتعامل مع الأخطاء… المصدر

لاحظ أنه على الرغم من أن هذا نمط شائع للتعامل مع الأخطاء في Express، فقد ترغب في التفكير في طريقة بديلة تكون، مع ذلك، أكثر تعقيدًا.

التعامل مع أخطاء التحقق من الإدخال (Handle input validation errors)

من الممارسات الجيدة التحقق من مدخلات المستخدم في كل من جانب العميل والخادم. في جانب الخادم، أستخدم مكتبة تسمى 'express-validator' للقيام بالمهمة. إذا كان أي إدخال غير صالح، فسأتعامل معه عن طريق الاستجابة برمز HTTP ورسالة خطأ لإبلاغ المستخدم بذلك. على سبيل المثال، عندما يكون البريد الإلكتروني المقدم من المستخدم غير صالح، سنخرج مبكرًا عن طريق إنشاء كائن خطأ باستخدام مكتبة 'http-errors'، ثم تمريره إلى دالة next:

const { body, validationResult } = require("express-validator");

router.post("/login", upload.none(), [
  body("email", "Invalid email format").isEmail()
],
  asyncHandler(async (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return next(createError(422, errors.mapped()));
    }
    res.json({});
  })
);

سيتم إرسال الاستجابة التالية إلى العميل:

{
  "message": "Unprocessable Entity",
  "email": {
    "value": "hello@mail.com232",
    "msg": "Invalid email format",
    "param": "email",
    "location": "body"
  }
}

ثم الأمر متروك لك فيما تريد فعله به. على سبيل المثال، يمكنك الوصول إلى خاصية email.msg لعرض رسالة الخطأ أسفل حقل إدخال البريد الإلكتروني.

التعامل مع الأخطاء من منطق العمل (Handle errors from business logic)

لنقل أن لدينا موقفًا حيث أدخل المستخدم بريدًا إلكترونيًا غير موجود في قاعدة البيانات. في هذه الحالة، نحتاج إلى إخبار المستخدم بالمحاولة مرة أخرى:

router.post("/login", upload.none(), asyncHandler(async (req, res, next) => {
  const { email, password } = req.body;
  const { rowCount } = await db.query(
    `SELECT * FROM users WHERE email=($1)`,
    [email]
  );

  if (rowCount === 0) {
    // issue an error with generic message
    return next(
      createError(422, "Please enter a correct email and password")
    );
  }
  res.json({});
}));

تذكر، أي كائن خطأ يتم تمريره إلى 'next' (next(err)) سيتم التقاطه بواسطة معالج الأخطاء المخصص الذي قمنا بتعيينه أعلاه.

التعامل مع الأخطاء غير المتوقعة من قاعدة البيانات (Handle unexpected errors from database)

أمرر next الخاص بمعالج المسار إلى دالة غلاف المعاملات في قاعدة البيانات الخاصة بي للتعامل مع أي أخطاء غير متوقعة.

router.post("/invite", async (req, res, next) => {
  db.tx(async (client) => {
    const { rows, rowCount, } = await client.query(
      `SELECT mask_id(user_id) AS user_id, status FROM users WHERE users.email=$1`,
      [email]
    );
  }, next)
));

التسجيل (Logging)

عند حدوث خطأ، من الممارسات الشائعة: 1) تسجيله في نظام للسجلات، و 2) إعلامك به تلقائيًا. هناك العديد من الأدوات في هذا المجال. لكنني انتهيت باثنتين منها:

  • Sentry لتخزين تفاصيل (مثل تتبعات المكدس (stack traces)) أخطائي، وعرضها على لوحة التحكم المستندة إلى الويب.
  • pino لتمكين التسجيل في Nodejs الخاص بي.

لماذا Sentry؟ حسنًا، أوصى به العديد من المطورين والشركات الناشئة الصغيرة. يقدم 5000 خطأ يمكنك إرسالها شهريًا مجانًا. من منظور، إذا كنت تدير مشروعًا جانبيًا صغيرًا وكنت حذرًا بشأنه، أود أن أقول إن ذلك سيستمر معك حتى تتمكن من تحمل تكاليف بائع أو خطة أكثر فخامة. خيار آخر يستحق الاستكشاف هو honeybadger.io مع طبقة مجانية أكثر سخاء ولكن بدون نقل pino.

لماذا Pino – لماذا ليس SDK الرسمي الذي توفره Sentry؟ لأن Pino "نفقات عامة منخفضة" (low overhead)، بينما Sentry SDK، على الرغم من أنه يمنحك صورة أكثر اكتمالًا للخطأ، بدا أنه يحتوي على مشكلة ذاكرة معقدة لم أستطع تجاوزها بنفسي. مع ذلك، إليك كيفية ربط نظام التسجيل في Sametable:

// server/lib/logger.js
// install missing packages
const pino = require("pino");
const { createWriteStream } = require("pino-sentry");
const expressPino = require("express-pino-logger");

const options = {
  name: "sametable",
  level: "error"
};

// SENTRY_DSN is provided by Sentry. Store it as env var in the .env file.
const stream = createWriteStream({ dsn: process.env.SENTRY_DSN });
const logger = pino(options, stream);
const expressLogger = expressPino({ logger });

module.exports = {
  expressLogger, // use it like app.use(expressLogger) -> req.log.info('haha)
  logger,
};

بدلاً من إرفاق المسجل (expressLogger) كبرمجية وسيطة في أعلى السلسلة (app.use(expressLogger))، أستخدم كائن logger فقط حيث أريد تسجيل خطأ. على سبيل المثال، يستخدم معالج الأخطاء العام المخصص كائن logger:

app.use(function (err, req, res, next) {
  const error = err.status ? err : createError(500, "Something went wrong. Notified dev.");

  if (isProduction) {
    // LOG THIS ERROR IN MY SENTRY DASHBOARD
    logger.error(error);
  } else {
    console.log("Custom error handler:", error);
  }
  res.status(error.status).json(error);
});

هذا كل شيء! ولا تنس تمكين إشعارات البريد الإلكتروني في لوحة تحكم Sentry لتلقي تنبيه عندما يتلقى Sentry الخاص بك خطأ! ❤️

رابط دائم لمشاركة الروابط (Permalink for URL Sharing)

لقد رأينا عناوين URL تتكون من سلسلة أبجدية رقمية غامضة مثل تلك الموجودة على Youtube: https://youtube.com/watch?v=upyjlOLBv5o. يشير عنوان URL هذا إلى فيديو معين، والذي يمكن مشاركته مع شخص ما عن طريق مشاركة عنوان URL. المكون الرئيسي في عنوان URL الذي يمثل الفيديو هو المعرف الفريد في النهاية: upyjlOLBv5o. نرى هذا النوع من المعرفات في مواقع أخرى أيضًا: vimeo.com/259411563 ومعرف الاشتراك في Stripe sub_aH2s332nm04.

على حد علمي، هناك ثلاث طرق لتحقيق هذه النتيجة:

  1. إنشاء المعرف عند إدخال البيانات في قاعدة البيانات الخاصة بك. سيكون المعرف الذي تم إنشاؤه هو المعرف في عمود id الخاص بك بدلاً من المعرفات ذات الزيادة التلقائية:

    | id         | title      |
    | ---------- | ------------ |
    | owmCAx552Q | How to cry |
    | ZIofD6l3X9 | How to smile |
    

    ثم ستقوم بكشف هذه المعرفات في عناوين URL العامة: https://example.com/task/owmCAx552Q. بالنظر إلى عنوان URL هذا إلى الواجهة الخلفية الخاصة بك، يمكنك استرداد المورد المعني من قاعدة البيانات:

    router.get("/task/:taskId", (req, res, next) => {
      const { taskId } = req.params;
      // SELECT * FROM tasks WHERE id=<taskId>
    });
    

    السلبيات لهذه الطريقة التي أعرفها:

    • قد تكون المعرفات معلومات حساسة يتم كشفها علنًا بهذه الطريقة.
    • هذه المعرفات ضارة بأداء الفهرسة و "الربط" (joining) على جداولك.
  2. تحتفظ بمعرفاتك ذات الزيادة التلقائية في جداولك، ولكنك ستمثلها عن طريق إنشاء نظيرها الأبجدي الرقمي أثناء عمليات قاعدة البيانات:

    SELECT hash_encode(123, 'this is my salt', 10); -- Result: 4xpAYDx0mQ
    SELECT hash_decode('4xpAYDx0mQ', 'this is my salt', 10); -- Result: 123
    

    واجهت صعوبة في دمج هذه المكتبة على جهاز Windows الخاص بي. لذلك اخترت الخيار التالي.

  3. مشابه للخيار الثاني أعلاه ولكن بنهج مختلف. سيولد هذا معرفًا رقميًا: https://example.com/task/2013732563294762

نظام مصادقة المستخدم (User Authentication System)

يمكن أن يصبح نظام مصادقة المستخدم معقدًا للغاية إذا كنت بحاجة إلى دعم أشياء مثل تسجيل الدخول الموحد (SSO) وموفري OAuth من جهات خارجية. لهذا السبب لدينا أدوات من جهات خارجية مثل Auth0 و Okta و PassportJS لتجريد ذلك عنا. لكن هذه الأدوات لها تكلفة: الارتباط بمورد معين (vendor lock-in)، وحمولة Javascript أكبر، ونفقات معرفية. أود أن أقول إنه إذا كنت في البداية وتحتاج فقط إلى نوع من نظام المصادقة حتى تتمكن من الانتقال إلى أجزاء أخرى من تطبيقك، وفي نفس الوقت، غارق في جميع البرامج التعليمية القديمة التي تتعامل مع أشياء لا تستخدمها، حسنًا، فمن المحتمل أن كل ما تحتاجه هو الطريقة القديمة الجيدة للمصادقة:

ملف تعريف ارتباط الجلسة (Session cookie) مع البريد الإلكتروني وكلمة المرور!

ونحن لا نتحدث عن 'JWT' أيضًا! لا شيء من ذلك.

دليل (Guide)

إليك دليل كتبته. اتبعه وسيكون لديك نظام مصادقة للمستخدمين!

البريد الإلكتروني (Email)

حاليًا، في Sametable، رسائل البريد الإلكتروني الوحيدة التي يرسلها هي من النوع "المعاملاتي" (transactional) مثل إرسال بريد إلكتروني لإعادة تعيين كلمة المرور عندما يقوم المستخدمون بإعادة تعيين كلمة المرور الخاصة بهم. هناك طريقتان لإرسال رسائل البريد الإلكتروني في Nodejs:

  • بناء نظامك الخاص باستخدام Nodemailer. لن أسلك هذا المسار لأنه على الرغم من أن إرسال بريد إلكتروني واحد قد يبدو مهمة تافهة، إلا أن القيام بذلك "على نطاق واسع" صعب؛ يجب إرسال كل بريد إلكتروني بنجاح؛ ويجب ألا ينتهي بهم المطاف في مجلد البريد العشوائي للمستخدم؛ وأشياء أخرى لا أعرفها.

  • اختيار أحد مزودي خدمة البريد الإلكتروني. تقدم العديد من خدمات البريد الإلكتروني خطة مجانية تقدم عددًا محدودًا من رسائل البريد الإلكتروني التي يمكنك إرسالها شهريًا/يوميًا مجانًا. عندما بدأت في استكشاف هذا المجال لـ Sametable في أكتوبر 2019، برز Mailgun كخيار لا يحتاج لتفكير – فقد كان يقدم 10,000 رسالة بريد إلكتروني مجانية شهريًا! ولكن، للأسف، بينما كنت أبحث عن هذا القسم، علمت أنه لم يعد يقدم ذلك. على الرغم من ذلك، سأظل ألتزم بـ Mailgun، على خطتهم "الدفع حسب الاستخدام" (pay-as-you-go): 1000 رسالة بريد إلكتروني مرسلة ستكلفك 80 سنتًا. إذا كنت تفضل عدم الدفع مطلقًا لأي سبب من الأسباب، فإليك خياران وجدتهما:

    لكن امضِ في هذا المسار مع العلم أنه لا يوجد ضمان بأن هذه الخطط المجانية ستبقى على حالها إلى الأبد كما كان الحال مع Mailgun.

التطبيق (Implementation)

ملف الغلاف (Wrapper file)
// server/lib/email.js
// Run 'npm install mailgun-js' in your 'server' folder
const mailgun = require("mailgun-js");

const DOMAIN = "mail.sametable.app";
const mg = mailgun({
  apiKey: process.env.MAILGUN_API_KEY,
  domain: DOMAIN,
});

function send(data) {
  mg.messages().send(data, function (error) {
    if (!error) return;
    console.log("Email send error:", error);
  });
}

module.exports = {
  send,
};
الاستخدام (Usage)
const mailer = require("../lib/email");

// Simplified for only email-related stuff
router.post("/resetPassword", upload.none(), (req, res, next) => {
  const { email } = req.body;
  const data = {
    from: "Sametable <feedback@sametable.app>",
    to: email,
    subject: "Reset your password",
    text: `Click this link to reset your password: https://example.com?token=1234`,
  };
  mailer.send(data);
  res.json({});
});

قوالب البريد الإلكتروني (Email templates)

يمكن أن يكون لكل نوع من رسائل البريد الإلكتروني التي ترسلها قالب بريد إلكتروني خاص به يمكن أن يختلف محتواه بقيم ديناميكية يمكنك توفيرها.

الأداة (Tool)

mjml هي الأداة التي أستخدمها لبناء قوالب البريد الإلكتروني الخاصة بي. بالتأكيد، هناك العديد من أدوات بناء البريد الإلكتروني بالسحب والإفلات التي لا تخيف بمشهد "الأكواد". ولكن إذا كنت تعرف فقط أساسيات React/HTML/CSS، فإن mjml ستمنحك قابلية استخدام رائعة وأقصى قدر من المرونة. من السهل البدء. مثل أدوات بناء البريد الإلكتروني، تقوم بإنشاء قالب بمجموعة من المكونات القابلة لإعادة الاستخدام، وتقوم بتخصيصها عن طريق توفير قيم لخصائصها (props).

إليك الأماكن التي أكتب فيها قوالبي:

مثال على قالب بريد إلكتروني (Example template)
<mjml>
  <mj-head>
    <mj-attributes>
      <mj-class name="font-family" font-family="-apple-system,system-ui,BlinkMacSystemFont,'Segoe UI',sans-serif" />
      <mj-class name="fw-600" font-weight="600" />
    </mj-attributes>
  </mj-head>
  <mj-body>
    <mj-section>
      <mj-column>
        <mj-image width="150px" src="https://www.dl.dropboxusercontent.com/s/pgtwrnfa3lqkf5r/sametable_logo_with_text.png" />
      </mj-column>
    </mj-section>
    <mj-section>
      <mj-column>
        <mj-text align="center" font-size="20px" mj-class="font-family" >{{assigner_name}} assigned a project to you</mj-text >
        <mj-spacer height="10px" />
        <mj-text align="center" font-size="25px" mj-class="font-family fw-600" >{{project_title}}</mj-text >
        <mj-spacer height="25px" />
        <mj-button font-size="16px" mj-class="font-family fw-600" background-color="#000" color="white" href="{{invite_link}}" >View the project</mj-button >
      </mj-column>
    </mj-section>
    <mj-spacer height="55px" />
    <mj-section background-color="#EEEBE7" padding="25px 40px">
      <mj-column>
        <mj-text align="center" color="#45495d" font-size="15px" line-height="14px" > Problems or questions? Feel free to reply to this email. </mj-text>
        <mj-text padding="30px 0 0 0" align="center" font-size="16px"> Made with ❤️ by <a href="https://twitter.com/kheohyeewei">@kheohyeewei</a> </mj-text>
      </mj-column>
    </mj-section>
  </mj-body>
</mjml>
النتيجة (Result)

لقطة شاشة لقالب بريد إلكتروني مصمم باستخدام MJML، يعرض دعوة لمشروع.

لاحظ أسماء العناصر النائبة (placeholder names) المحاطة بأقواس متعرجة مزدوجة مثل {{project_title}}. سيتم استبدالها بقيمتها المقابلة بواسطة Mailgun، في حالتي، قبل إرسالها.

الاندماج مع Mailgun (Integration with Mailgun)

أولاً، قم بإنشاء HTML من قوالب mjml الخاصة بك. يمكنك القيام بذلك باستخدام امتداد VSCode أو المحرر المستند إلى الويب:

لقطة شاشة لمحرر MJML على الويب، يوضح خيار توليد HTML من قالب MJML.

ثم أنشئ قالبًا جديدًا في لوحة تحكم Mailgun:

لقطة شاشة للوحة تحكم Mailgun، تعرض واجهة إنشاء قالب بريد إلكتروني جديد.

أرسل بريدًا إلكترونيًا باستخدام Mailgun في Nodejs داخل مسار:

const data = {
  from: "Sametable <feedback@sametable.app>",
  to: email,
  subject: `Hello`,
  template: "invite_project", // the template's name you gave when you created it in mailgun
  "v:invite_link": inviteLink,
  "v:assigner_name": fullname,
  "v:project_title": title,
};
mailer.send(data);

لاحظ أنه لربط قيمة باسم عنصر نائب في قالب: "v:project_title":'Project Mario'.

كيف تحصل على عنوان بريد إلكتروني مثل hi@example.com؟

إنه عنوان بريد إلكتروني يستخدمه الأشخاص للتواصل معك بشأن منتج SaaS الخاص بك، بدلاً من استخدام عنوان مثل lola887@hotmail.com. هناك ثلاثة خيارات في ذهني:

  • إذا كنت تستخدم Mailgun، فاتبع هذا الدليل. ومع ذلك، فإن الطبقة الجديدة "الدفع حسب الاستخدام" (pay-as-you-go) قد استبعدت الميزة (Inbound Email Routing) التي تجعل هذا ممكنًا. لذا ربما الخيار التالي؛
  • إذا تم إخراجي من خطتي المجانية "10,000" في Mailgun، فسأجرب هذا: https://forwardemail.net/en
  • إذا فشل كل شيء آخر، فادفع مقابل "Gmail on G Suite".

التعددية الإيجارية (Tenancy)

عندما تسجل مؤسسة، على سبيل المثال، Acme Inc.، في منتج SaaS الخاص بك، فإنها تعتبر "مستأجرًا" (tenant) – فهي "تشغل" مكانًا في خدمتك. بينما سمعت مصطلح "التعددية الإيجارية" (multi-tenancy) مرتبطًا بـ SaaS من قبل، لم يكن لدي أدنى فكرة عن كيفية تنفيذه. كنت أعتقد دائمًا أنه سيتضمن بعض المناورات الغامضة في علوم الكمبيوتر التي لا يمكنني اكتشافها بنفسي. لحسن الحظ، هناك طريقة سهلة للقيام بـ "التعددية الإيجارية":

قاعدة بيانات واحدة؛ جميع العملاء يشاركون نفس الجداول؛ كل عميل لديه tenant_id؛ يتم استعلام قاعدة البيانات لكل طلب API بواسطة WHERE tenant_id = $ID. لذا لا تقلق – إذا كنت تعرف أساسيات SQL (مما يشير مرة أخرى إلى أهمية إتقان الأساسيات في أي شيء تفعله!)، يجب أن يكون لديك صورة واضحة عن الخطوات المطلوبة لتنفيذ ذلك.

إليك ثلاثة موارد أساسية حول "التعددية الإيجارية" قمت بحفظها مسبقًا:

اسم النطاق (Domain Name)

نطاق Sametable.app وجميع سجلات DNS الخاصة به مستضافة في NameCheap. كنت أستخدم Hover من قبل (لا يزال يستضيف نطاق موقعي الشخصي). لكنني واجهت قيودًا هناك عندما حاولت إدخال قيمة DKIM الخاصة بـ Mailgun. يتمتع Namecheap أيضًا بأسعار أكثر تنافسية في تجربتي.

في أي مرحلة من تطوير SaaS يجب أن تحصل على اسم نطاق؟ حسنًا، أود أن أقول ليس حتى يمنعك نقص مسجل DNS من تطويرك. في حالتي، أجلت ذلك حتى اضطررت إلى دمج Mailgun الذي يتطلب إنشاء مجموعة من سجلات DNS في نطاق.

كيف تحصل على عنوان مثل app.example.com؟

هل تعرف عناوين URL التي تحتوي على app أمامها مثل app.example.io؟ نعم، هذا "نطاق مخصص" (custom domain) مع "app" كنطاق فرعي (subdomain). وقد بدأ كل ذلك بالحصول على اسم نطاق. لذا امضِ قدمًا واحصل على واحد في Namecheap أو أي مكان آخر. ثم، في حالتي مع Firebase، ما عليك سوى اتباع هذا البرنامج التعليمي وستكون بخير.

النشر (Deployment)

آه. كانت هذه مرحلة عانيت فيها لفترة طويلة 😩. كانت رحلة شاقة وجدت فيها نفسي أضاعف جهودي على منصة سحابية ثم أتراجع عنها عندما اكتشفت سلبياتها في تحسين تجربة المطور، التكاليف، الحصص، والأداء (الكمون). بدأت الرحلة بالقفز مباشرة (فكرة سيئة) إلى Digital Ocean لأنني رأيت توصيات كثيرة له في منتدى IndieHackers. وبالتأكيد، تمكنت من تشغيل Nodejs الخاص بي في جهاز افتراضي (VM) باتباع البرامج التعليمية عن كثب. ثم اكتشفت أن DO Space لم يكن بالضبط مثل AWS S3 – لا يمكنه استضافة تطبيق SPA الخاص بي. على الرغم من أنني كان بإمكاني استضافته في جهاز droplet الخاص بي وربط شبكة توصيل محتوى (CDN) تابعة لجهة خارجية مثل CloudFlare بـ droplet، إلا أن ذلك بدا لي معقدًا بشكل غير ضروري مقارنة بإعداد S3+Cloudfront. كنت أستخدم أيضًا قاعدة بيانات مدارة (Postgresql) من DO لأنني لم أرغب في إدارة قاعدة البيانات الخاصة بي وتعديل ملفات *.config بنفسي. كلف ذلك 15 دولارًا شهريًا ثابتًا.

ثم علمت بـ AWS Lightsail وهو صورة طبق الأصل من DO، ولكن لدهشتي، مع حصة أكثر تنافسية عند نقطة سعر معينة:

  • AWS Lightsail (5 دولارات شهريًا)
    • ذاكرة 1 جيجابايت
    • معالج أحادي النواة
    • قرص SSD بسعة 40 جيجابايت
    • نقل بيانات 2 تيرابايت
  • Digital Ocean (5 دولارات شهريًا)
    • ذاكرة 1 جيجابايت
    • معالج أحادي النواة
    • قرص SSD بسعة 25 جيجابايت
    • نقل بيانات 1 تيرابايت

وقاعدة بيانات مدارة بسعر 15 دولارًا شهريًا:

  • AWS Lightsail (15 دولارًا شهريًا)
    • ذاكرة 1 جيجابايت
    • معالج أحادي النواة
    • قرص SSD بسعة 40 جيجابايت
  • Digital Ocean (15 دولارًا شهريًا)
    • ذاكرة 1 جيجابايت
    • معالج أحادي النواة
    • قرص SSD بسعة 10 جيجابايت

لذا بدأت الرهان على Lightsail بدلاً من ذلك. ولكن، مبلغ 15 دولارًا شهريًا لقاعدة بيانات مدارة في Lightsail أزعجني في مرحلة ما. لم أرغب في دفع هذا المبلغ عندما لم أكن متأكدًا حتى من أنني سأحصل على أي عملاء يدفعون. في هذه المرحلة، افترضت أنه كان علي أن أتعمق في التفاصيل لتحسين عامل التكلفة. لذلك بدأت في البحث عن ربط AWS EC2 و RDS، وما إلى ذلك. ولكن كان هناك الكثير من الأشياء الخاصة بـ AWS التي كان علي تعلمها، ولم تساعد وثائق AWS أيضًا – كانت متاهة تلو الأخرى للقيام بشيء واحد فقط لأنني كنت بحاجة فقط إلى شيء لاستضافة تطبيق SPA و Nodejs الخاص بي من أجل الله!

ثم راجعت IndieHacker للتحقق من سلامة عقلي، وعثرت على render.com. بدا مثاليًا! إنها إحدى تلك الأدوات التي تهدف إلى "تمكينك من التركيز على بناء تطبيقك". كانت البرامج التعليمية قصيرة وساعدتني على البدء في وقت قصير. وإليك "لكن" – كانت مكلفة:

مقارنة بين Lightsail و Render بأقل نقطة سعر:

  • AWS Lightsail (3.50 دولارات شهريًا)
    • ذاكرة 512 ميجابايت
    • معالج أحادي النواة
    • قرص SSD بسعة 20 جيجابايت
    • نقل بيانات 1 تيرابايت
  • Render (7 دولارات شهريًا)
    • ذاكرة 512 ميجابايت
    • معالج مشترك
    • قرص SSD بسعة 0.25 دولار/جيجابايت/شهريًا (20 جيجابايت = 5 دولارات شهريًا)
    • نقل بيانات 100 جيجابايت/شهريًا. 0.10 دولار/جيجابايت فوق ذلك (1 تيرابايت = 90 دولارًا شهريًا)

وهذا فقط لاستضافة Nodejs الخاص بي! فماذا الآن؟! هل أقول فقط "فليذهب للجحيم" وأفعل كل ما يلزم "لإطلاق المنتج"؟ لكنني تمسكت بموقفي. أعدت النظر في AWS مرة أخرى. ما زلت أعتقد أن AWS هو الحل لأن الجميع يغني أغنيته. لا بد أنني أفتقد شيئًا ما! هذه المرة نظرت في أدواتهم عالية المستوى مثل AWS AppSync و Amplify. لكنني لم أستطع التغاضي عن حقيقة أن كلاهما يجبرني على العمل بالكامل وفقًا لمعاييرهما ومكتباتهما. لذا في هذه المرحلة، اكتفيت من AWS، واتجهت إلى منصة أخرى: Google Cloud Platform (GCP).

تستضيف GCP خادم Nodejs و Redis و Postgresql الخاص بـ Sametable. الشيء الذي جذبني إلى GCP هو وثائقه – إنها أكثر خطية؛ مقتطفات برمجية في كل مكان للغتك المحددة؛ أدلة خطوة بخطوة حول الأشياء الشائعة التي ستقوم بها لتطبيق ويب. بالإضافة إلى ذلك، إنها بلا خادم (serverless)! مما يعني أن تكلفتك تتناسب مع استخدامك.

نشر Nodejs (Deploy Nodejs)

تستضيف بيئة GAE 'standard environment' خادم Nodejs الخاص بي.

التكلفة (Cost)

تتمتع بيئة GAE القياسية بحصة مجانية على عكس "البيئة المرنة" (flexible environment). بعد ذلك، ستدفع فقط إذا كان شخص ما يستخدم منتج SaaS الخاص بك 🤑.

الدليل (Guide)

كان هذا هو الدليل الوحيد الذي اعتمدت عليه. كان نجمي الشمالي. يغطي Nodejs و Postgresql و Redis وتخزين الملفات والمزيد: https://cloud.google.com/appengine/docs/standard/nodejs

ابدأ ببرنامج "البدء السريع" (Quick Start) لأنه سيعدك بـ gcloud cli الذي ستحتاجه عند اتباع بقية الأدلة، حيث ستجد أوامر يمكنك تشغيلها للمتابعة. إذا لم تكن مرتاحًا لبيئة سطر الأوامر (CLI)، فستوفر الأدلة خطوات بديلة لتحقيق نفس الشيء على لوحة تحكم GCP. أحب ذلك. لاحظت أنه أثناء تصفح وثائق GCP، لم أضطر أبدًا إلى فتح أكثر من 4 علامات تبويب في متصفحي. كان العكس تمامًا مع وثائق AWS – كان متصفحي مليئًا بها.

نشر Postgresql (Deploy Postgresql)

الدليل (Guide)

https://cloud.google.com/sql/docs/postgres/connect-app-engine-standard

ما عليك سوى اتباعه وستكون بخير.

التكلفة (Cost)

يعمل مثيل Cloud SQL على جهاز افتراضي كامل. وبمجرد توفير جهاز افتراضي، فإنه لن يتوقف تلقائيًا عن التشغيل عندما، على سبيل المثال، لم يشهد أي استخدام لمدة 15 دقيقة. لذلك ستتم محاسبتك على كل ساعة يعمل فيها المثيل لمدة شهر كامل ما لم يتم إيقافه يدويًا. العامل الأساسي الذي سيؤثر على تكلفتك هنا، خاصة في الأيام الأولى، هو درجة نوع الجهاز. نوع الجهاز الافتراضي لـ Cloud SQL هو db-n1-standard-1، وأرخص واحد يمكنك الحصول عليه هو db-f1-micro:

  • db-n1-standard-1 (~51.01 دولار أمريكي)
    • 1 وحدة معالجة مركزية افتراضية (vCPU)
    • ذاكرة 3.75 جيجابايت
    • تخزين SSD بسعة 10 جيجابايت
  • db-f1-micro (~9.37 دولار أمريكي)
    • 1 وحدة معالجة مركزية افتراضية مشتركة
    • ذاكرة 0.6 جيجابايت
    • تخزين SSD بسعة 10 جيجابايت
  • Digital Ocean Managed DB (15.00 دولار أمريكي)
    • 1 وحدة معالجة مركزية افتراضية
    • ذاكرة 1 جيجابايت
    • تخزين SSD بسعة 10 جيجابايت

عاملان آخران للتكلفة هما التخزين وخروج الشبكة (network egress). لكن يتم فرض رسوم عليهما شهريًا، لذلك من المحتمل ألا يكون لهما تأثير كبير على فاتورة منتج SaaS الناشئ الخاص بك. إذا وجدت أن الأسعار باهظة جدًا لذوقك، فضع في اعتبارك أنها قاعدة بيانات مدارة. أنت تدفع مقابل كل الأوقات والقلق الذي تم توفيره من القيام بعمليات devops على قاعدة بياناتك. بالنسبة لي، الأمر يستحق ذلك.

إعداد المخططات في قاعدة بيانات الإنتاج (Setup schemas in production database)

الآن بعد أن قمت بنشر قاعدة بيانات للإنتاج، حان الوقت لتجهيزها بجميع مخططاتي من ملف .sql. للقيام بذلك، أحتاج إلى الاتصال بقاعدة البيانات من pgAdmin: https://cloud.google.com/sql/docs/postgres/external-connection-methods

ستجد هناك جدولًا يحتوي على قائمة بالخيارات للاتصال من تطبيق خارجي. اخترت الخيار الأول: عنوان IP عام مع SSL. اتبع جميع الأدلة في عمود "مزيد من المعلومات" وستحصل على جميع المعلومات اللازمة لإنشاء خادم في pgAdmin الخاص بك. ستكون بخير. إن لم يكن كذلك، راسلني وسأقدم المساعدة.

نشر Redis (Deploy Redis)

إذا كنت تتبع الدليل الرئيسي حول Nodejs، فلا يمكنك أن تفوت هذا الدليل حول إعداد Redis الخاص بك في MemoryStore. لكنني اعتقدت أنه سيكون أكثر فعالية من حيث التكلفة استضافة Redis الخاص بي في Google Compute Engine (GCE) الذي يتمتع، على عكس MemoryStore، بحصة مجانية في جوانب معينة. (انظر هذا للمقارنة بين الحصص المجانية عبر منصات السحابة المختلفة)

الدليل (Guide)

  1. إعداد Redis في جهاز افتراضي (VM).
  2. إعداد VPC: يتيح لك الوصول إلى VPC بدون خادم الاتصال من تطبيق App Engine الخاص بك مباشرة إلى شبكة VPC الخاصة بك، مما يسمح بالوصول إلى مثيلات أجهزة Compute Engine VM، ومثيلات Memorystore، وأي موارد أخرى ذات عنوان IP داخلي.

في ملف app.yaml الخاص بك:

vpc_access_connector:
  name: "<YOURS_HERE>"
env_variables:
  REDIS_PASSWORD: "<PASSWORD_YOU_SET_IN_A_GUIDE_ABOVE>"
  REDISHOST: "<INTERNAL_IP_OF_YOUR_VM>"
  REDISPORT: "6379" # default port when install redis

لقطة شاشة للوحة تحكم Google Cloud Platform تعرض عنوان IP داخلي لجهاز افتراضي في GCE.

عنوان IP داخلي لـ GCE

أخيرًا، في ملف lib/redis.js:

const redis = require("redis");

const redisClient = redis.createClient(
  NODE_ENV === "production"
    ? {
        host: process.env.REDISHOST,
        port: process.env.REDISPORT, // default to 6379 if wasn't set
        no_ready_check: true,
        auth_pass: process.env.REDIS_PASSWORD,
      }
    : {} // just use the default: localhost and ports
);

module.exports = {
  redisClient,
};

تخزين الملفات (File Storage)

التخزين السحابي (Cloud Storage) هو ما تحتاجه لمستخدميك لتحميل ملفاتهم مثل الصور التي سيحتاجون إلى استردادها وربما عرضها لاحقًا.

التكلفة (Cost)

هناك طبقة مجانية للتخزين السحابي أيضًا.

الدليل (Guide)

ستكون بخير: https://cloud.google.com/appengine/docs/standard/nodejs/using-cloud-storage

نشر التغييرات الجديدة في الواجهة الخلفية (Deploy New Changes in Back-end)

لدي سكربت npm في ملف package.json الجذري لنشر التغييرات الجديدة في الواجهة الخلفية الخاصة بي إلى GCP:

{
  "scripts": {
    "deploy-server": "gcloud app deploy ./server/app.yaml"
  }
}

ثم قم بتشغيله في الطرفية في جذر مشروعك:

npm run deploy-server

استضافة تطبيق الصفحة الواحدة (Hosting Your SPA)

عندما كنت لا أزال على Lightsail، كان تطبيق SPA الخاص بي مستضافًا على S3+Cloudfront لأنني افترضت أنه من الأفضل الاحتفاظ بهما تحت نفس المنصة لتحسين الكمون. ثم وجدت GCP. كلاجئ منهك من AWS يهبط في GCP، استكشفت أولاً "التخزين السحابي" (Cloud Storage) لاستضافة تطبيق SPA الخاص بي، واتضح أنه لم يكن مثاليًا لتطبيقات SPA. إنه معقد إلى حد ما. لذا يمكنك تخطي ذلك.

ثم حاولت استضافة تطبيق SPA الخاص بي في Firebase. تم ذلك بسهولة في دقائق حتى عندما كانت المرة الأولى لي هناك. أحب ذلك. خيار آخر يمكنك التفكير فيه هو Netlify وهو سهل للغاية للبدء أيضًا.

نشر التغييرات الجديدة في الواجهة الأمامية (Deploy New Changes in Front-end)

بالمثل لنشر تغييرات الواجهة الخلفية، لدي سكربت npm آخر في ملف package.json الجذري لنشر التغييرات الجديدة في الواجهة الأمامية الخاصة بي إلى Firebase:

{
  "scripts": {
    "deploy-client": "npm run build-client && firebase deploy",
    "build-client": "npm run test && cd client && npm i && npm run build",
    "test": "npm run lint",
    "lint": "npm run lint:js && npm run lint:css",
    "lint:js": "eslint 'client/src/**/*.js' --fix",
    "lint:css": "stylelint '**/*.{scss,css}' '!client/dist/**/*'"
  }
}

"مهلا، من أين جاء كل هذا؟" إنها سلسلة من السكربتات التي يتم تشغيل كل منها بالتتابع عند تشغيلها بواسطة سكربت deploy-client. الحرف && هو ما يربطها ببعضها البعض. دعنا نمسك أيدي بعضنا البعض ونسير خلالها من البداية إلى النهاية:

أولاً، نقوم بتشغيل npm run deploy-client، والذي يقوم بتشغيل build-client أولاً، والذي يقوم بتشغيل test أولاً، (انظر، نحن نتبع فقط حيث يقودنا سكربت و && الخاص به، ولهذا السبب لن يتم تشغيل firebase deploy بعد) والذي يقوم بتشغيل lint، والذي يقودنا إلى lint:js أولاً، ثم lint:css، ثم نعود إلى cd client، يليه npm i و npm run build، وأخيرًا، حان دور firebase deploy للتشغيل.

نصيحة: إذا كانت التغييرات التي أجريتها شاملة للواجهة الأمامية والخلفية (full-stack)، فيمكنك الحصول على سكربت ينشر "العميل" و "الخادم" معًا:

{
  "scripts": {
    "deploy-all": "npm run deploy-server && npm run deploy-client",
  }
}

محرر النصوص الغني (Rich-text Editor)

كان بناء محرر النصوص الغني في Sametable هو ثاني أكثر الأشياء تحديًا بالنسبة لي. أدركت أنه كان بإمكاني الحصول على حل سهل باستخدام المحررات الجاهزة مثل CKEditor و TinyMCE، لكنني أردت أن أتمكن من صياغة تجربة الكتابة في المحرر، ولا شيء يمكن أن يفعل ذلك أفضل من ProseMirror. بالتأكيد، كان لدي خيارات أخرى أيضًا، والتي قررت عدم استخدامها لعدة أسباب:

  • Quilljs: بدا أن لديه العديد من المشكلات غير المعالجة. تم تشكيله خصيصًا من قبل مجموعة ذات اهتمامات خاصة. يتضمن حلولًا اختراقية (hacky workaround) بمجرد خروجك عن مجموعة حالات الاستخدام القياسية.

  • Draftjs: مرتبطة بإحكام بـ React. مع النفقات العامة لـ virtual DOM، لن تؤدي بنفس جودة Prosemirror.

  • trix: يعتمد على مكون الويب (Web Component). واجهت مشكلات في دمجه في Preact. لم يكن مرنًا لبناء تجربة تحرير مخصصة.

Prosemirror هي مكتبة لا تشوبها شائبة بلا شك. لكن تعلمها لم يكن لضعاف القلوب بقدر ما يتعلق الأمر بي. فشلت في بناء أي نماذج ذهنية شاملة لها حتى بعد أن قرأت الدليل عدة مرات. الطريقة الوحيدة التي تمكنت بها من إحراز تقدم من هناك كانت عن طريق مقارنة أمثلة الأكواد الموجودة والدليل، والتجربة والخطأ من هناك. وإذا استنفدت ذلك أيضًا، كنت أسأل في المنتدى وكان يتم الرد دائمًا. لم أكن لأزعج نفسي بـ StackOverflow إلا ربما لـ Quilljs الشهير.

كانت هذه هي الأماكن التي ذهبت إليها للبحث عن عينات التعليمات البرمجية:

تماشيًا مع روح رحلة التعلم هذه، قمت باستخراج محرر النصوص الغني الخاص بـ Sametable في CodeSandBox: https://codesandbox.io/s/compassionate-montalcini-gcgwc 🚀 (ملاحظة: Prosemirror مستقل عن الإطار؛ يستخدم عرض CodeSandBox فقط 'create-react-app' لتجميع وحدات ES6.)

CORS

لإيقاف متصفحك عن الشكوى بشأن مشكلات CORS، الأمر كله يتعلق بجعل الواجهة الخلفية الخاصة بك ترسل رؤوس Access-Control-Allow-* هذه مرة أخرى لكل طلب. (اعتذاري عن التبسيط المفرط ضروري)

ولكن، صححني إذا كنت مخطئًا، لا توجد طريقة لتكوين CORS في GAE نفسها. لذلك كان علي القيام بذلك باستخدام حزمة cors npm:

const express = require("express");
const app = express();
const cors = require("cors");

const ALLOWED_ORIGINS = [
  "http://localhost:8008",
  "https://web.sametable.app", // your SPA's domain
];

app.use(
  cors({
    credentials: true, // include Access-Control-Allow-Credentials: true. remember set xhr.withCredentials = true;
    origin(origin, callback) {
      // allow requests with no origin
      // (like mobile apps or curl requests)
      if (!origin) return callback(null, true);
      if (ALLOWED_ORIGINS.indexOf(origin) === -1) {
        const msg =
          "The CORS policy for this site does not " +
          "allow access from the specified Origin.";
        return callback(new Error(msg), false);
      }
      return callback(null, true);
    },
  })
);

الدفع والاشتراك (Payment & Subscription)

عادةً ما يسمح منتج SaaS للمستخدمين بالدفع والاشتراك للوصول إلى الميزات المدفوعة التي حددتها. لتمكين هذه الإمكانية في Sametable، أستخدم Stripe للتعامل مع تدفقات الدفع والاشتراك.

الدليل (Guide)

هناك طريقتان لتنفيذها:

الخطافات الويب (Webhook)

المكون الرئيسي الأخير الذي احتجته لهذه القطعة كان "خطاف ويب" (webhook) وهو في الأساس نقطة نهاية نموذجية في Nodejs الخاص بك يمكن استدعاؤها بواسطة طرف ثالث مثل Stripe. لقد أنشأت خطاف ويب سيتم استدعاؤه عند شحن دفعة بنجاح للإشارة في سجل المستخدم الذي يتوافق مع الدافع كمستخدم PRO في Sametable من هناك فصاعدًا:

router.post("/webhook/payment_success", bodyParser.raw({ type: "application/json" }), asyncHandler(async (req, res, next) => {
  const sig = req.headers["stripe-signature"];
  let event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle the checkout.session.completed event
  if (event.type === "checkout.session.completed") {
    // 'session' doc: https://stripe.com/docs/api/checkout/sessions/object
    const session = event.data.object;

    // here you can query your database to, for example,
    // update a particular user/tenant's record

    // Return a res to acknowledge receipt of the event
    res.json({ received: true });
  } else {
    res.status(400);
  }
}));

المرجع (Reference)

إليك مقتطف كود لخطاف ويب: https://stripe.com/docs/webhooks/signatures#verify-official-libraries

الدليل (Guide)

https://stripe.com/docs/webhooks

الصفحة المقصودة (Landing Page)

البناء (Building)

أستخدم Eleventy لبناء الصفحة المقصودة لـ Sametable. لا أوصي بـ Gatsby أو Nextjs. إنهما مبالغة في هذه المهمة. بدأت بأحد المشاريع الأولية لأنني كنت غير صبور لإطلاق صفحتي. لكنني كافحت في العمل بها. على الرغم من أن Eleventy يدعي أنه مولد مواقع ثابتة (SSG) بسيط، إلا أن هناك في الواقع عددًا لا بأس به من المفاهيم التي يجب فهمها إذا كنت جديدًا على مولدات المواقع الثابتة. بالإضافة إلى الأدوات التي قدمتها المجموعات الأولية، يمكن أن تصبح الأمور معقدة. لذلك قررت البدء من الصفر وأخذ وقتي في قراءة الوثائق من البداية إلى النهاية، والبناء ببطء. بهدوء وسهولة.

الأدلة (Guides)

النسخة الطويلة
النسخة القصيرة
  • https://github.com/kilgarenone/personal-website (أول موقع بنيته كموقعي الشخصي أثناء تعلم 11ty. يحتوي على صفحة رئيسية ومنشورات مدونة. عدد قليل جدًا من المفاهيم المقدمة هنا. يمكنك البدء بهذا "المشروع الأولي")

الاستضافة (Hosting)

أستخدم Netlify لاستضافة الصفحة المقصودة. هناك أيضًا surge.sh و Vercel. ستكون بخير هنا.

الشروط والأحكام (Terms and Conditions)

الشروط والأحكام تجعل منتج SaaS الخاص بك شرعيًا. على حد علمي، إليك خياراتك للتوصل إليها:

  • اكتبها بنفسك: https://pinboard.in/tos/.
  • انسخ والصق من الآخرين. قم بالتغيير وفقًا لذلك. لم يكن سهلاً أبدًا في تجربتي.
  • استشر محاميًا.
  • قم بإنشائها في getterms.io.

التسويق (Marketing)

لا يوجد نقص في منشورات التسويق التي تقول إنها فكرة سيئة أن "تترك المنتج يتحدث عن نفسه". حسنًا، ليس إلا إذا كنت تحاول "اختراق النمو" للفوز باللعبة. فيما يلي مسار الوجود الذي أضعه في الاعتبار لـ Sametable:

  • ابنِ شيئًا يزعم أنه يحل مشكلة.
  • قم بتحسين محركات البحث (SEO) الخاصة بك.
  • اكتب منشورات المدونة.

أي شخص يتأثر بالمشكلة التي حللتها سيبحث عنها، أو سيعرف عنها عن طريق الكلام الشفهي. إذا لم تنجح بعد، حسنًا، فمن المحتمل أنك لم تكن تحل مشكلة حقيقية ضخمة، أو أن عددًا كافيًا من الأشخاص قد حلها بالفعل. في هذه الحالة، كن ممتنًا لأي نجاح يأتي في طريقك على المدى الطويل.

الموارد (Resources)

الرفاهية (Well-being)

من السهل الجلوس والضياع في عملنا المعاصر. ونفعل ذلك عن طريق تراكم الديون من المستقبل. أحد هذه الديون هو صحتنا الشخصية. إليك كيف أحاول البقاء على اطلاع على ديوني الصحية:

  • قم بتثبيت Workrave. يمكنك ضبطه لقفل شاشتك بعد مرور فترة زمنية. الأهم من ذلك، يمكنه إظهار بعض التمارين التي يمكنك القيام بها خلف جهاز الكمبيوتر الخاص بك!
  • احصل على مكتب وقوف قابل للتعديل إذا كنت تستطيع تحمل تكلفته. لقد حصلت على مكتبي من IKEA.
  • قم بتمارين burpees.
  • قم بتمديد مفاصلك.
  • حافظ على وضعية جيدة. تمرين البلانك (Planking) يساعد.
  • تأمل للحفاظ على صحتك العقلية. أنا أستخدم Medito. 🧘‍♂️

شكرًا لك على القراءة. تأكد من مراجعة أداة SaaS الخاصة بي Sametable لإدارة عملك في جداول البيانات.

اقرأ المزيد من المنشورات. إذا كان هذا المقال مفيدًا، شاركه. تعلم البرمجة مجانًا. ساعد منهج freeCodeCamp مفتوح المصدر أكثر من 40,000 شخص في الحصول على وظائف كمطورين. ابدأ.

إعلان

الخلاصة التقنية

يمثل بناء منتج برمجي كخدمة (SaaS) رحلة معقدة تتطلب مزيجًا من المهارات التقنية، الفهم العميق لاحتياجات المستخدم، والالتزام بممارسات التطوير المسؤولة. كما يوضح هذا الدليل، فإن القرارات المتعلقة بالبنية التقنية (stack)، وأداء الويب، والأمان، ونظام المصادقة، وحتى الجوانب غير التقنية مثل التسويق والرفاهية الشخصية، تلعب دورًا حاسمًا في نجاح المنتج. من الضروري البدء صغيرًا، التركيز على حل مشكلات حقيقية، وتجنب التعقيد غير الضروري، مع الحفاظ على المرونة للتكيف. إن إتقان الأساسيات، سواء في SQL أو JavaScript، يظل حجر الزاوية الذي يمكن للمطورين البناء عليه، مما يقلل الاعتماد على الأدوات المجردة التي قد تفرض قيودًا أو تكاليفًا غير متوقعة على المدى الطويل. في نهاية المطاف، النجاح في عالم SaaS لا يقتصر فقط على الكود، بل على القدرة على الابتكار بمسؤولية وتقديم قيمة مستدامة للمستخدمين.

اترك تعليقاً

لن يتم نشر عنوان بريدك الإلكتروني. الحقول الإلزامية مشار إليها بـ *