تکوین (Node.js / TypeScript) REST API : Express.js | الجزء 1

REST API : Express.js

كيف أكتب REST API في Node.js؟

عند إنشاء واجهة خلفية لواجهة برمجة تطبيقات REST غالبًا ما يكون Express.js هو الخيار الأول بين أطر عمل Node.js. بينما يدعم أيضًا إنشاء HTML ثابت ونماذج. في هذه السلسلة سنركز على تطوير الواجهة الخلفية باستخدام TypeScript. ستكون واجهة برمجة تطبيقات REST الناتجة هي تلك التي يمكن لأي إطار عمل أمامي أو خدمة خلفية خارجية الاستعلام عنها.

سوف تحتاج إلى:

  • معرفة أساسية بلغة JavaScript و TypeScript
  • معرفة أساسية بـ Node.js
  • معرفة أساسية بهندسة REST
  • تثبيت جاهز لـ Node.js (يفضل الإصدار 14+)

في terminal (أو موجه الأوامر) سننشئ مجلدًا للمشروع. من هذا المجلد قم بتشغيل npm init. سيؤدي ذلك إلى إنشاء بعض ملفات مشروع Node.js الأساسية التي نحتاجها.

بعد ذلك سنضيف إطار عمل Express.js وبعض المكتبات المفيدة:

npm i express debug winston express-winston cors

هناك أسباب وجيهة لأن هذه المكتبات مفضلة لدى مطوري Node.js:

  • debug هو وحدة نمطية سنستخدمها لتجنب استدعاء  console.log ()  أثناء تطوير تطبيقنا. بهذه الطريقة يمكننا بسهولة تصفية بيانات تصحيح الأخطاء أثناء استكشاف الأخطاء وإصلاحها. يمكن أيضًا إيقاف تشغيلها بالكامل أثناء الإنتاج بدلاً من الاضطرار إلى إزالتها يدويًا.
  • winston هو المسؤول عن طلبات التسجيل إلى واجهة برمجة التطبيقات الخاصة بنا وإرجاع الردود (والأخطاء). يتكامل Express-winston مباشرةً مع Express.js بحيث يتم بالفعل تنفيذ جميع كودات تسجيل Winston القياسية المتعلقة بواجهة برمجة التطبيقات.
  • cors هو جزء من برمجية Express.js الوسيطة التي تسمح لنا بتمكين مشاركة الموارد عبر الأصل. دون ذلك ستكون واجهة برمجة التطبيقات الخاصة بنا قابلة للاستخدام فقط من الواجهات الأمامية التي يتم تقديمها من نفس النطاق الفرعي تمامًا مثل نهايتنا الخلفية.

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

npm i --save-dev @types/cors @types/express @types/debug source-map-support tslint typescript

هذه التبعيات مطلوبة لتمكين TypeScript لكود التطبيق الخاص بنا جنبًا إلى جنب مع الأنواع المستخدمة بواسطة Express.js والتبعيات الأخرى. يمكن أن يوفر هذا الكثير من الوقت عندما نستخدم IDE مثل WebStorm أو VSCode من خلال السماح لنا بإكمال بعض طرق الوظائف تلقائيًا أثناء الترميز.

يجب أن تكون التبعيات النهائية في package.json على النحو التالي:

"dependencies": {
    "debug": "^4.2.0",
    "express": "^4.17.1",
    "express-winston": "^4.0.5",
    "winston": "^3.3.3",
    "cors": "^2.8.5"
},
"devDependencies": {
    "@types/cors": "^2.8.7",
    "@types/debug": "^4.1.5",
    "@types/express": "^4.17.2",
    "source-map-support": "^0.5.16",
    "tslint": "^6.0.0",
    "typescript": "^3.7.5"
}

الآن وبعد أن تم تثبيت جميع التبعيات المطلوبة، فلنبدأ في بناء الكود الخاص بنا!

هيكل مشروع TypeScript REST API

سننشئ في هذا البرنامج التعليمي ثلاثة ملفات فقط:

1.	./app.ts
2.	./common/common.routes.config.ts
3.	./users/users.routes.config.ts

تكمن الفكرة وراء مجلدي بنية المشروع (common و users) في الحصول على وحدات فردية لها مسؤوليات خاصة بها. بهذا المعنى سنحصل في النهاية على بعض أو كل ما يلي لكل وحدة:

  • تكوين المسار (route configuration) لتحديد الطلبات التي يمكن لواجهة برمجة التطبيقات لدينا التعامل معها
  • خدمات (services) لمهام مثل الاتصال بنماذج قاعدة البيانات الخاصة بنا أو إجراء الاستعلامات أو الاتصال بالخدمات الخارجية التي يتطلبها الطلب المحدد
  • البرمجيات الوسيطة (middleware) لتشغيل عمليات التحقق من صحة الطلبات المحددة قبل أن يعالج المتحكم النهائي للمسار تفاصيله
  • نماذج (models) لتحديد نماذج البيانات المطابقة لمخطط قاعدة بيانات معين لتسهيل تخزين البيانات واسترجاعها
  • وحدات تحكم (controllers) لفصل تكوين المسار عن الكود الذي يقوم أخيرًا (بعد أي برنامج وسيط) بمعالجة طلب المسار ويستدعي وظائف الخدمة المذكورة أعلاه إذا لزم الأمر ويعطي استجابة للعميل

توفر بنية المجلد هذه تصميمًا أساسيًا لـ REST API ونقطة بداية مبكرة لبقية سلسلة البرامج التعليمية هذه والكافية لبدء التدريب.

ملف المسارات المشتركة في TypeScript

في المجلد العام دعنا ننشئ ملف common.routes.config.ts ليبدو كما يلي:

import express from 'express';
export class CommonRoutesConfig {
    app: express.Application;
    name: string;

    constructor(app: express.Application, name: string) {
        this.app = app;
        this.name = name;
    }
    getName() {
        return this.name;
    }
}

الطريقة التي ننشئ بها المسارات هنا اختيارية. ولكن نظرًا إلى أننا نعمل مع TypeScript فإن سيناريو المسارات لدينا هو فرصة لممارسة استخدام الوراثة مع الكلمة الرئيسية الممتدة كما سنرى قريبًا. في هذا المشروع جميع ملفات المسار لها نفس السلوك: لها اسم (سنستخدمه لأغراض التصحيح) والوصول إلى كائن تطبيق Express.js الرئيسي.

الآن يمكننا البدء بإنشاء ملف توجيه المستخدمين. في مجلد المستخدمين دعنا ننشئ users.routes.config.ts وابدأ في ترميزه كما يلي:

import {CommonRoutesConfig} from '../common/common.routes.config';
import express from 'express';

export class UsersRoutes extends CommonRoutesConfig {
    constructor(app: express.Application) {
        super(app, 'UsersRoutes');
    }
}

هنا نقوم باستيراد فئة CommonRoutesConfig وتوسيعها لتشمل فئة جديدة تسمى UsersRoutes. باستخدام المُنشئ نرسل التطبيق (الكائن الرئيسي express.Application) والاسم UsersRoutes إلى مُنشئ CommonRoutesConfig.

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

لنفترض أننا نريد إضافة ميزات جديدة في هذا الملف مثل التسجيل. يمكننا إضافة الحقل الضروري إلى فئة CommonRoutesConfig ومن ثم ستتمكن جميع المسارات التي تمتد لـ CommonRoutesConfig من الوصول إليه.

استخدام وظائف TypeScript Abstract للدوال المماثلة عبر الفئات

ماذا لو أردنا الحصول على بعض الدوال المتشابهة بين هذه الفئات (مثل تكوين نقاط نهاية API) لكن هذا يحتاج إلى تطبيق مختلف لكل فئة؟ أحد الخيارات هو استخدام ميزة TypeScript تسمى التجريد (abstraction).

لنقم بإنشاء دالة مجردة بسيطة جدًا ترثها فئة UsersRoutes (وفئات التوجيه المستقبلية) من CommonRoutesConfig. لنفترض أننا نريد إجبار جميع المسارات على الحصول على وظيفة (حتى نتمكن من تسميتها من المُنشئ المشترك) المسمى configRoutes (). هذا هو المكان الذي سنعلن فيه عن نقاط النهاية لكل مورد لفئة التوجيه.

للقيام بذلك سنضيف ثلاثة أشياء سريعة إلى common.routes.config.ts:

  • الکلمه الرئیسیة abstract لخط الفصل الخاص بنا لتمكين التجريد لهذه الفئة.
  • إعلان دالة جديدة في نهاية فصلنا abstract configureRoutes(): express.Application;. هذا يفرض على أي فئة تمتد CommonRoutesConfig لتوفير تطبيق يطابق ذلك التوقيع – إذا لم يكن كذلك، فإن برنامج التحويل البرمجي TypeScript سيتسبب في حدوث خطأ.
  • استدعاء إلى this.configureRoutes () ؛ في نهاية المُنشئ حيث يمكننا الآن التأكد من وجود هذه الوظيفة.

النتيجة:

import express from 'express';
export abstract class CommonRoutesConfig {
    app: express.Application;
    name: string;

    constructor(app: express.Application, name: string) {
        this.app = app;
        this.name = name;
        this.configureRoutes();
    }
    getName() {
        return this.name;
    }
    abstract configureRoutes(): express.Application;
}

مع ذلك يجب أن تحتوي أي فئة ممتدة CommonRoutesConfig على دالة تسمى configRoutes () تقوم بإرجاع كائن express.Application. هذا يعني أن users.routes.config.ts بحاجة إلى التحديث:

import {CommonRoutesConfig} from '../common/common.routes.config';
import express from 'express';

export class UsersRoutes extends CommonRoutesConfig {
    constructor(app: express.Application) {
        super(app, 'UsersRoutes');
    }

    configureRoutes() {
        // (we'll add the actual route configuration here next)
        return this.app;
    }

}

تلخيصًا لما قمنا به:

نقوم أولاً باستيراد ملف common.routes.config ثم الوحدة النمطية السريعة. نحدد بعد ذلك فئة UserRoutes ونقول إننا نريدها أن توسع الفئة الأساسية CommonRoutesConfig مما يعني أننا نعد بتنفيذ configRoutes ().

لإرسال معلومات إلى فئة CommonRoutesConfig نستخدم مُنشئ الفصل. يتوقع أن يتلقى الكائن express.Application والذي سنصفه بمزيد من العمق في الخطوة التالية. باستخدام super ()  نمرر إلى مُنشئ CommonRoutesConfig للتطبيق واسم مساراتنا والتي في هذا السيناريو هي UsersRoutes.

 super ()  بدوره سوف يستدعي تطبيق configRoutes ().

تكوين مسارات Express.js لنقاط نهاية المستخدمين

إن وظيفة configRoutes () هي المكان الذي سننشئ فيه نقاط النهاية لمستخدمي REST API. هناك سنستخدم التطبيق ووظائف المسار الخاصة به من Express.js.

الفكرة في استخدام دالة app.route ()  هي تجنب تكرار الكود وهو أمر سهل لأننا ننشئ واجهة برمجة تطبيقات REST بموارد محددة جيدًا. المصدر الرئيسي لهذا البرنامج التعليمي هو المستخدمون. لدينا حالتان في هذا السيناريو:

  • عندما يريد طالب واجهة برمجة التطبيقات إنشاء مستخدم جديد أو سرد جميع المستخدمين الحاليين يجب أن يكون لنقطة النهاية في البداية مستخدمون في نهاية المسار المطلوب. (لن ندخل في تصفية طلبات البحث أو ترقيم الصفحات أو غيرها من طلبات البحث المماثلة في هذه المقالة.)
  • عندما يريد المتصل القيام بشيء محدد لسجل مستخدم معين فإن مسار مورد الطلب سيتبع النمط users /: userId.

تتيح لنا طريقة عمل   .route () في Express.js التعامل مع أفعال HTTP ببعض التسلسل الأنيق. هذا لأن .get() و .post ()  وما إلى ذلك تُرجع جميعها نفس مثيل IRoute الذي يقوم به أول استدعاء .route()  سيكون التكوين النهائي على النحو التالي:

configureRoutes() {

    this.app.route(`/users`)
        .get((req: express.Request, res: express.Response) => {
            res.status(200).send(`List of users`);
        })
        .post((req: express.Request, res: express.Response) => {
            res.status(200).send(`Post to users`);
        });

    this.app.route(`/users/:userId`)
        .all((req: express.Request, res: express.Response, next: express.NextFunction) => {
            // this middleware function runs before any request to /users/:userId
            // but it doesn't accomplish anything just yet---
            // it simply passes control to the next applicable function below using next()
            next();
        })
        .get((req: express.Request, res: express.Response) => {
            res.status(200).send(`GET requested for id ${req.params.userId}`);
        })
        .put((req: express.Request, res: express.Response) => {
            res.status(200).send(`PUT requested for id ${req.params.userId}`);
        })
        .patch((req: express.Request, res: express.Response) => {
            res.status(200).send(`PATCH requested for id ${req.params.userId}`);
        })
        .delete((req: express.Request, res: express.Response) => {
            res.status(200).send(`DELETE requested for id ${req.params.userId}`);
        });

    return this.app;
}

يسمح الكود أعلاه لأي عميل REST API بالاتصال بنقطة نهاية مستخدمينا من خلال طلب POST أو GET. وبالمثل فإنه يتيح للعميل الاتصال بنقطة النهاية / users /: userId مع طلب GET أو PUT أو PATCH أو DELETE.

ولكن بالنسبة إلى / users /: userId أضفنا أيضًا برمجيات وسيطة عامة باستخدام دالة all ()  والتي سيتم تشغيلها قبل أي من دوال get () أو put () أو patch () أو delete (). ستكون هذه الوظيفة مفيدة عندما (لاحقًا في السلسلة) نقوم بإنشاء مسارات من المفترض أن يتم الوصول إليها فقط من قبل المستخدمين المصادق عليهم.

ربما لاحظت أنه في دالة  .all () الخاصة بنا – كما هو الحال مع أي جزء من البرامج الوسيطة – لدينا ثلاثة أنواع من الحقول: Request و Response و NextFunction.

  • الطلب (the request) هو الطريقة التي يمثل بها Express.js طلب HTTP ليتم التعامل معه. يقوم هذا النوع بترقية وتوسيع نوع طلب Node.js الأصلي.
  • الاستجابة (the response) هي بالمثل الطريقة التي يمثل بها Express.js استجابة HTTP، مما يوسع مرة أخرى نوع استجابة Node.js الأصلي.
  • دالة NextFunction لا تقل أهمية، فهي بمثابة دالة رد اتصال مما يسمح بالتحكم في المرور عبر أي وظائف وسيطة أخرى. على طول الطريق ستشارك جميع البرامج الوسيطة نفس كائنات الطلب والاستجابة قبل أن ترسل وحدة التحكم في النهاية ردًا إلى الطالب.

ملف نقطة الدخول Node.js الخاص بنا، app.ts

الآن بعد أن قمنا بتكوين بعض الهياكل الأساسية للمسار، سنبدأ في تكوين نقطة دخول التطبيق. لننشئ ملف app.ts في جذر مجلد مشروعنا ونبدأ بهذا الرمز:

import express from 'express';
import * as http from 'http';

import * as winston from 'winston';
import * as expressWinston from 'express-winston';
import cors from 'cors';
import {CommonRoutesConfig} from './common/common.routes.config';
import {UsersRoutes} from './users/users.routes.config';
import debug from 'debug';

اثنتان فقط من هذه الواردات جديدة في هذه المرحلة من المقالة:

  • http هو وحدة Node.js الأساسية. من الضروري بدء تشغيل تطبيق Express.js الخاص بنا.
  • body-parser هو برنامج وسيط يأتي مع Express.js. يقوم بتحليل الطلب (في حالتنا مثل JSON) قبل انتقال التحكم إلى معالجات الطلبات الخاصة بنا.

الآن بعد أن استوردنا الملفات سنبدأ في التصريح عن المتغيرات التي نريد استخدامها:

const app: express.Application = express();
const server: http.Server = http.createServer(app);
const port = 3000;
const routes: Array<CommonRoutesConfig> = [];
const debugLog: debug.IDebugger = debug('app');

تُرجع الدالة express ()  كائن تطبيق Express.js الرئيسي الذي سنمرره في جميع أنحاء الكود الخاص بنا بدءًا من إضافته إلى كائن http.Server . (سنحتاج إلى بدء تشغيل http.Server بعد تكوين تطبيق Express.Application الخاص بنا).

سنستمع إلى المنفذ 3000 – والذي سيستنتج TypeScript تلقائيًا أنه رقم – بدلاً من المنافذ القياسية 80  (HTTP) أو 443  (HTTPS) لأن هذه المنافذ تُستخدم عادةً للواجهة الأمامية للتطبيق.

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

أخيرًا سينتهي الأمر debugLog کدالة مشابهة لـ console.log ولكن الأفضل: من الأسهل ضبطه لأنه يتم تحديد نطاقه تلقائيًا لأي شيء نريد أن نطلق عليه سياق الملف/ الوحدة. (في هذه الحالة أطلقنا عليه اسم “app” عندما قمنا بتمريره في سلسلة إلى مُنشئ debug ().)

الآن نحن جاهزون لتهيئة جميع وحدات البرامج الوسيطة Express.js ومسارات واجهة برمجة التطبيقات الخاصة بنا:

// here we are adding middleware to parse all incoming requests as JSON 
app.use(express.json());

// here we are adding middleware to allow cross-origin requests
app.use(cors());

// here we are preparing the expressWinston logging middleware configuration,
// which will automatically log all HTTP requests handled by Express.js
const loggerOptions: expressWinston.LoggerOptions = {
    transports: [new winston.transports.Console()],
    format: winston.format.combine(
        winston.format.json(),
        winston.format.prettyPrint(),
        winston.format.colorize({ all: true })
    ),
};

if (!process.env.DEBUG) {
    loggerOptions.meta = false; // when not debugging, log requests as one-liners
}

// initialize the logger with the above configuration
app.use(expressWinston.logger(loggerOptions));

// here we are adding the UserRoutes to our array,
// after sending the Express.js application object to have the routes added to our app!
routes.push(new UsersRoutes(app));

// this is a simple route to make sure everything is working properly
const runningMessage = `Server running at http://localhost:${port}`;
app.get('/', (req: express.Request, res: express.Response) => {
    res.status(200).send(runningMessage)
});

يتم ربط ExpressWinston.logger ببرنامج Express.js ويقوم بتسجيل التفاصيل تلقائيًا – عبر نفس البنية الأساسية مثل debug – لكل طلب مكتمل. الخيارات التي مررناها إليها ستعمل على تنسيق وتلوين الإخراج الطرفي المقابل بدقة مع المزيد من التسجيل المطول (الافتراضي) عندما نكون في وضع التصحيح.

لاحظ أنه يتعين علينا تحديد مساراتنا بعد إعداد expressWinston.logger.

أخيرًا والأهم:

server.listen(port, () => {
    routes.forEach((route: CommonRoutesConfig) => {
        debugLog(`Routes configured for ${route.getName()}`);
    });
    // our only exception to avoiding console.log(), because we
    // always want to know when the server is done starting up
    console.log(runningMessage);
});

هذا في الواقع يبدئ خادمنا. بمجرد أن تبدأ ستقوم Node.js بتشغيل وظيفة رد الاتصال الخاصة بنا والتي في وضع التصحيح تُبلّغ عن أسماء جميع المسارات التي قمنا بتكوينها — حتى الآن UsersRoutes فقط. بعد ذلك يُعلمنا رد الاتصال بأن نهايتنا الخلفية جاهزة لتلقي الطلبات حتى عند التشغيل في وضع الإنتاج.

تحديث package.json إلى Transpile TypeScript إلى JavaScript وتشغيل التطبيق

الآن وبعد أن أصبح الهيكل العظمي جاهزًا للتشغيل، نحتاج أولاً إلى بعض التكوين المعياري لتمكين الترجمة من TypeScript> دعونا نضيف الملف tsconfig.json في جذر المشروع:

{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "outDir": "./dist",
    "strict": true,
    "esModuleInterop": true,
    "inlineSourceMap": true
  }
}

ثم نحتاج فقط إلى إضفاء اللمسات الأخيرة إلى package.json في شكل البرامج النصية التالية:

"scripts": {
    "start": "tsc && node --unhandled-rejections=strict ./dist/app.js",
    "debug": "export DEBUG=* && npm run start",
    "test": "echo \"Error: no test specified\" && exit 1"
},

نص الاختبار هو عنصر نائب، سنقوم باستبداله لاحقًا في هذه السلسلة.

ينتمي tsc في البرنامج النصي start إلى TypeScript. إنها مسؤولة عن تحويل شفرة TypeScript الخاصة بنا إلى JavaScript والتي سيتم إخراجها في مجلد dist. بعد ذلك نقوم فقط بتشغيل الإصدار المبني مع العقدة /dist/app.js.

مررنا --unhandled-rejections=strict لـ Node.js  حتى مع Node.js v16 +  لأنه من الناحية العملية، فإن التصحيح باستخدام نهج “التعطل وإظهار المكدس” المباشر هو أكثر مباشرة من التسجيل المربي باستخدام كائن expressWinston.errorLogger. غالبًا ما يكون هذا صحيحًا حتى في الإنتاج حيث من المرجح أن يؤدي السماح لـ Node.js بالاستمرار في العمل بالرغم من الرفض غير المعالج إلى ترك الخادم في حالة غير متوقعة مما يسمح بحدوث المزيد من الأخطاء (وأكثر تعقيدًا).

يستدعي البرنامج النصي لتصحيح الأخطاء برنامج start النصي ولكنه يعرّف أولاً متغير بيئة DEBUG. يؤدي هذا إلى تمكين جميع عبارات debugLog ()  الخاصة بنا (بالإضافة إلى العبارات المماثلة من Express.js نفسها والتي تستخدم نفس وحدة تصحيح الأخطاء التي نستخدمها) لإخراج تفاصيل مفيدة إلى المحطة الطرفية – التفاصيل التي تكون (بشكل ملائم) مخفية عند التشغيل الخادم في وضع الإنتاج ببدء تشغيل npm start.

حاول تشغيل npm run debug بنفسك وبعد ذلك قارن ذلك بـ npm start لترى كيف يتغير إخراج وحدة التحكم.

ربما يحتاج مستخدمو Windows إلى تغيير التصدير إلى SET لأن التصدير هو كيف يعمل على نظامي التشغيل Mac و Linux. إذا كان مشروعك يحتاج إلى دعم بيئات تطوير متعددة فإن الحزمة الشاملة توفر حلاً مباشرًا هنا.

اختبار  Express.js Back End

مع استمرار تشغيل npm run debug أو npm start ستكون REST API جاهزة لخدمة الطلبات على المنفذ 3000. في هذه المرحلة يمكننا استخدام cURL و Postman و Insomnia وما إلى ذلك لاختبار النهاية الخلفية.

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

curl --request GET 'localhost:3000/users/12345'

يجب أن ترسل نهايتنا الخلفية الإجابة GET requested for id 12345

أما بالنسبة لـ POST:

curl --request POST 'localhost:3000/users' \
--data-raw ''

هذا وكل أنواع الطلبات الأخرى التي بنينا الهياكل العظمية من أجلها ستبدو متشابهة تمامًا.

تستعد لتطوير Rapid Node.js REST API مع TypeScript

في هذه المقالة بدأنا في إنشاء واجهة برمجة تطبيقات REST من خلال تكوين المشروع من البداية والغوص في أساسيات إطار عمل Express.js. بعد ذلك اتخذنا خطوتنا الأولى نحو إتقان TypeScript من خلال إنشاء نمط باستخدام UsersRoutesConfig بتوسيع CommonRoutesConfig وهو نمط سنعيد استخدامه للمقالة التالية في هذه السلسلة. انتهينا من تكوين نقطة دخول app.ts لدينا لاستخدام مساراتنا الجديدة و package.json مع البرامج النصية لإنشاء تطبيقنا وتشغيله.

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

المصدر

منشور ذات صلة

اترك تعليقاً

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

السلة