حقن التبعية الحقيقية مع مكونات Symfony
نشرت: 2022-03-11Symfony2 ، إطار عمل PHP عالي الأداء ، يستخدم نمط Dependency Injection Container حيث توفر المكونات واجهة حقن تبعية لحاوية DI. هذا يسمح لكل مكون بعدم الاهتمام بالتبعية الأخرى. تقوم فئة "Kernel" بتهيئة حاوية DI وتحقنها في مكونات مختلفة. ولكن هذا يعني أنه يمكن استخدام حاوية DI كمحدد موقع الخدمة.
يحتوي Symfony2 أيضًا على فئة "ContainerAware" لذلك. يعتقد الكثيرون أن محدد مواقع الخدمة هو مضاد للنمط في Symfony2. أنا شخصياً لا أوافق. إنه نمط أبسط مقارنةً بـ DI وهو جيد للمشاريع البسيطة. لكن نمط محدد موقع الخدمة ونمط حاوية DI مجتمعة في مشروع واحد هما بالتأكيد نمط مضاد.
سنحاول في هذه المقالة إنشاء تطبيق Symfony2 بدون تنفيذ نمط محدد موقع الخدمة. سوف نتبع قاعدة واحدة بسيطة: يمكن لمنشئ حاوية DI فقط أن يعرف عن حاوية DI.
حاوية DI
في نمط حقن التبعية ، يمكن أن تعطي حاوية DI تبعيات الخدمة والخدمات فقط واجهة للحقن. هناك العديد من المقالات حول حقن التبعية ، وربما تكون قد قرأت كل منهم. لذلك دعونا لا نركز على النظرية ونلقي نظرة فقط على الفكرة الأساسية. يمكن أن يكون DI من 3 أنواع:
في Symfony ، يمكن تعريف بنية الحقن باستخدام ملفات التكوين البسيطة. إليك كيفية تكوين أنواع الحقن الثلاثة هذه:
services: my_service: class: MyClass constructor_injection_service: class: SomeClass1 arguments: ["@my_service"] method_injection_service: class: SomeClass2 calls: - [ setProperty, "@my_service" ] property_injection_service: class: SomeClass3 properties: property: "@my_service"
مشروع التمهيد
لنقم بإنشاء هيكل التطبيق الأساسي الخاص بنا. أثناء وجودنا فيه ، سنقوم بتثبيت مكون Symfony DI-container.
$ mkdir trueDI $ cd trueDI $ composer init $ composer require symfony/dependency-injection $ composer require symfony/config $ composer require symfony/yaml $ mkdir config $ mkdir www $ mkdir src
لجعل أداة التحميل التلقائي للملحن تعثر على الفئات الخاصة بنا في مجلد src ، يمكننا إضافة خاصية "autoloader" في ملف composer.json:
{ // ... "autoload": { "psr-4": { "": "src/" } } }
ودعنا ننشئ أداة إنشاء الحاويات الخاصة بنا ونمنع حقن الحاويات.
// in src/TrueContainer.php use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Config\FileLocator; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\DependencyInjection\ContainerInterface; class TrueContainer extends ContainerBuilder { public static function buildContainer($rootPath) { $container = new self(); $container->setParameter('app_root', $rootPath); $loader = new YamlFileLoader( $container, new FileLocator($rootPath . '/config') ); $loader->load('services.yml'); $container->compile(); return $container; } public function get( $id, $invalidBehavior = ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE ) { if (strtolower($id) == 'service_container') { if (ContainerInterface::EXCEPTION_ON_INVALID_REFERENCE !== $invalidBehavior ) { return; } throw new InvalidArgumentException( 'The service definition "service_container" does not exist.' ); } return parent::get($id, $invalidBehavior); } }
هنا نستخدم مكونات Config و Yaml symfony. يمكنك العثور على التفاصيل في الوثائق الرسمية هنا. قمنا أيضًا بتعريف معلمة مسار الجذر "app_root" في حالة حدوث ذلك. يؤدي أسلوب get إلى زيادة تحميل سلوك get الافتراضي للفئة الأصلية ويمنع الحاوية من إرجاع "service_container".
بعد ذلك ، نحتاج إلى نقطة دخول للتطبيق.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__));
هذا واحد مخصص للتعامل مع طلبات http. يمكننا الحصول على المزيد من نقاط الدخول لأوامر وحدة التحكم ومهام cron والمزيد. من المفترض أن تحصل كل نقطة دخول على خدمات معينة ويجب أن تعرف هيكل حاوية DI. هذا هو المكان الوحيد الذي يمكننا فيه طلب الخدمات من الحاوية. من هذه اللحظة سنحاول إنشاء هذا التطبيق فقط باستخدام ملفات تكوين حاوية DI.
HttpKernel
سيكون HttpKernel (وليس نواة إطار العمل مع مشكلة محدد موقع الخدمة) هو المكون الأساسي لجزء الويب الخاص بالتطبيق. فيما يلي سير عمل HttpKernel نموذجي:
المربعات الخضراء أحداث.
يستخدم HttpKernel مكون HttpFoundation لكائنات الطلب والاستجابة ومكون EventDispatcher لنظام الحدث. لا توجد مشاكل في تهيئتها مع ملفات تكوين حاوية DI. يجب تهيئة HttpKernel باستخدام EventDispatcher و ControllerResolver واختيارياً مع خدمات RequestStack (للطلبات الفرعية).
هنا هو تكوين الحاوية لها:
# in config/events.yml services: dispatcher: class: Symfony\Component\EventDispatcher\EventDispatcher
# in config/kernel.yml services: request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] request_stack: class: Symfony\Component\HttpFoundation\RequestStack resolver: class: Symfony\Component\HttpKernel\Controller\ControllerResolver http_kernel: class: Symfony\Component\HttpKernel\HttpKernel arguments: ["@dispatcher", "@resolver", "@request_stack"]
#in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' }
كما ترى ، نستخدم خاصية "المصنع" لإنشاء خدمة الطلب. تحصل خدمة HttpKernel على كائن الطلب فقط وتقوم بإرجاع كائن الاستجابة. يمكن القيام بذلك في وحدة التحكم الأمامية.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $HTTPKernel = $container->get('http_kernel'); $request = $container->get('request'); $response = $HTTPKernel->handle($request); $response->send();
أو يمكن تعريف الاستجابة على أنها خدمة في التكوين باستخدام خاصية "المصنع".
# in config/kernel.yml # ... response: class: Symfony\Component\HttpFoundation\Response factory: [ "@http_kernel", handle] arguments: ["@request"]
وبعد ذلك نحصل عليه في وحدة التحكم الأمامية.
// in www/index.php require_once('../vendor/autoload.php'); $container = TrueContainer::buildContainer(dirname(__DIR__)); $response = $container->get('response'); $response->send();
تحصل خدمة محلل وحدة التحكم على خاصية '_controller' من سمات خدمة Request لحل وحدة التحكم. يمكن تعريف هذه السمات في تكوين الحاوية ، لكنها تبدو أكثر صعوبة بعض الشيء لأنه يتعين علينا استخدام كائن ParameterBag بدلاً من مصفوفة بسيطة.
# in config/kernel.yml # ... request_attributes: class: \Symfony\Component\HttpFoundation\ParameterBag calls: - [ set, [ _controller, \App\Controller\DefaultController::defaultAction ]] request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] properties: attributes: "@request_attributes" # ...
وهنا فئة DefaultController مع طريقة defaultAction.
// in src/App/Controller/DefaultController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Response; class DefaultController { function defaultAction() { return new Response("Hello cruel world"); } }
مع وجود كل هذه الأشياء في مكانها الصحيح ، يجب أن يكون لدينا تطبيق عملي.
وحدة التحكم هذه عديمة الفائدة إلى حد كبير لأنه لا يمكنها الوصول إلى أي خدمة. في إطار عمل Symfony ، يتم حل هذه المشكلة عن طريق حقن حاوية DI في وحدة تحكم واستخدامها كمحدد موقع خدمة. لن نفعل ذلك. لذلك دعونا نعرّف وحدة التحكم على أنها خدمة ونضخ خدمة الطلب فيها. هنا التكوين:
# in config/controllers.yml services: controller.default: class: App\Controller\DefaultController arguments: [ "@request"]
# in config/kernel.yml # ... request_attributes: class: \Symfony\Component\HttpFoundation\ParameterBag calls: - [ set, [ _controller, ["@controller.default", defaultAction ]]] request: class: Symfony\Component\HttpFoundation\Request factory: [ Symfony\Component\HttpFoundation\Request, createFromGlobals ] properties: attributes: "@request_attributes" # ...
#in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' } - { resource: 'controllers.yml' }
ورمز التحكم:
// in src/App/Controller/DefaultController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class DefaultController { /** @var Request */ protected $request; function __construct(Request $request) { $this->request = $request; } function defaultAction() { $name = $this->request->get('name'); return new Response("Hello $name"); } }
الآن يمكن لوحدة التحكم الوصول إلى خدمة الطلب. كما ترى ، يحتوي هذا المخطط على تبعيات دائرية. إنه يعمل لأن خدمة DI-container تشارك الخدمة بعد الإنشاء وقبل حقن الأسلوب والممتلكات. لذلك عند إنشاء خدمة وحدة التحكم ، تكون خدمة الطلب موجودة بالفعل.
وإليك كيف يعمل:
لكن هذا لا يعمل إلا لأن خدمة الطلب تم إنشاؤها أولاً. عندما نحصل على خدمة استجابة في وحدة التحكم الأمامية ، فإن خدمة الطلب هي أول تبعية تمت تهيئتها. إذا حاولنا الحصول على خدمة وحدة التحكم أولاً ، فسيؤدي ذلك إلى حدوث خطأ دائري في التبعية. يمكن إصلاحه باستخدام الطريقة أو حقن الممتلكات.
لكن هناك مشكلة أخرى. سوف تقوم حاوية DI بتهيئة كل وحدة تحكم بالتبعيات. لذلك سيتم تهيئة جميع الخدمات الموجودة حتى لو لم تكن هناك حاجة إليها. لحسن الحظ ، تتميز الحاوية بوظيفة التحميل البطيء. يستخدم مكون Symfony DI "ocramius / proxy-manager" لفئات الوكيل. علينا نصب جسر بينهما.
$ composer require symfony/proxy-manager-bridge
وتعريفها في مرحلة بناء الحاوية:
// in src/TrueContainer.php //... use Symfony\Bridge\ProxyManager\LazyProxy\Instantiator\RuntimeInstantiator; // ... $container = new self(); $container->setProxyInstantiator(new RuntimeInstantiator()); // ...
الآن يمكننا تحديد الخدمات البطيئة.
# in config/controllers.yml services: controller.default: lazy: true class: App\Controller\DefaultController arguments: [ "@request" ]
لذلك لن تتسبب وحدات التحكم في تهيئة الخدمات المعتمدة إلا عند استدعاء طريقة فعلية. أيضًا ، يتجنب خطأ التبعية الدائري لأنه سيتم مشاركة خدمة وحدة التحكم قبل التهيئة الفعلية ؛ على الرغم من أنه لا يزال يتعين علينا تجنب المراجع المعممة. في هذه الحالة ، لا يجب حقن خدمة وحدة التحكم في خدمة الطلب أو خدمة الطلب في خدمة وحدة التحكم. من الواضح أننا بحاجة إلى خدمة طلب في وحدات التحكم ، لذلك دعونا نتجنب الحقن في خدمة الطلب في مرحلة بدء الحاوية. يحتوي HttpKernel على نظام أحداث لهذا الغرض.

التوجيه
يبدو أننا نريد أن يكون لدينا وحدات تحكم مختلفة لطلبات مختلفة. لذلك نحن بحاجة إلى نظام توجيه. لنقم بتثبيت مكون التوجيه symfony.
$ composer require symfony/routing
يحتوي مكون التوجيه على فئة Router والتي يمكنها استخدام ملفات تكوين التوجيه. لكن هذه التكوينات هي مجرد معلمات ذات قيمة أساسية لفئة المسار. يستخدم إطار عمل Symfony محلل وحدة التحكم الخاص به من FrameworkBundle والذي يقوم بحقن الحاوية في وحدات التحكم باستخدام واجهة "ContainerAware". هذا بالضبط ما نحاول تجنبه. يقوم محلل وحدة التحكم HttpKernel بإرجاع كائن الفئة كما هو إذا كان موجودًا بالفعل في السمة '_controller' كمصفوفة مع كائن وحدة التحكم وسلسلة أسلوب الإجراء (في الواقع ، سيعيده محلل وحدة التحكم كما هو إذا كان مجرد مصفوفة). لذلك علينا تحديد كل مسار كخدمة وإدخال وحدة تحكم فيه. دعنا نضيف بعض خدمات التحكم الأخرى لنرى كيف تعمل.
# in config/controllers.yml # ... controller.page: lazy: true class: App\Controller\PageController arguments: [ "@request"]
// in src/App/Controller/PageController.php namespace App\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class PageController { /** @var Request */ protected $request; function __construct(Request $request) { $this->request = $request; } function defaultAction($id) { return new Response("Page $id doesn't exist"); } }
يحتوي مكون HttpKernel على فئة RouteListener التي تستخدم حدث "kernel.request". إليك أحد التكوينات الممكنة باستخدام وحدات التحكم البطيئة:
# in config/routes/default.yml services: route.home: class: Symfony\Component\Routing\Route arguments: path: / defaults: _controller: ["@controller.default", 'defaultAction'] route.page: class: Symfony\Component\Routing\Route arguments: path: /page/{id} defaults: _controller: ["@controller.page", 'defaultAction']
# in config/routing.yml imports: - { resource: 'routes/default.yml' } services: route.collection: class: Symfony\Component\Routing\RouteCollection calls: - [ add, ["route_home", "@route.home"] ] - [ add, ["route_page", "@route.page"] ] router.request_context: class: Symfony\Component\Routing\RequestContext calls: - [ fromRequest, ["@request"] ] router.matcher: class: Symfony\Component\Routing\Matcher\UrlMatcher arguments: [ "@route.collection", "@router.request_context" ] router.listener: class: Symfony\Component\HttpKernel\EventListener\RouterListener arguments: matcher: "@router.matcher" request_stack: "@request_stack" context: "@router.request_context"
# in config/events.yml service: dispatcher: class: Symfony\Component\EventDispatcher\EventDispatcher calls: - [ addSubscriber, ["@router.listener"]]
#in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' } - { resource: 'controllers.yml' } - { resource: 'routing.yml' }
نحتاج أيضًا إلى مولد URL في تطبيقنا. ها هو:
# in config/routing.yml # ... router.generator: class: Symfony\Component\Routing\Generator\UrlGenerator arguments: routes: "@route.collection" context: "@router.request_context"
يمكن حقن مولد URL في خدمات التحكم والعرض. الآن لدينا تطبيق أساسي. يمكن تعريف أي خدمة أخرى بنفس الطريقة التي يتم بها حقن ملف التكوين في بعض وحدات التحكم أو مرسل الأحداث. على سبيل المثال ، فيما يلي بعض التكوينات لـ Twig و Doctrine.
غصين
Twig هو محرك القالب الافتراضي في إطار عمل Symfony2. يمكن للعديد من مكونات Symfony2 استخدامه بدون أي محولات. لذلك فهو اختيار واضح لتطبيقنا.
$ composer require twig/twig $ mkdir src/App/View
# in config/twig.yml services: templating.twig_loader: class: Twig_Loader_Filesystem arguments: [ "%app_root%/src/App/View" ] templating.twig: class: Twig_Environment arguments: [ "@templating.twig_loader" ]
عقيدة
العقيدة هي ORM مستخدمة في إطار عمل Symfony2. يمكننا استخدام أي ORM أخرى ، لكن مكونات Symfony2 يمكنها بالفعل استخدام العديد من ميزات Docrine.
$ composer require doctrine/orm $ mkdir src/App/Entity
# in config/doctrine.yml parameters: doctrine.driver: "pdo_pgsql" doctrine.user: "postgres" doctrine.password: "postgres" doctrine.dbname: "true_di" doctrine.paths: ["%app_root%/src/App/Entity"] doctrine.is_dev: true services: doctrine.config: class: Doctrine\ORM\Configuration factory: [ Doctrine\ORM\Tools\Setup, createAnnotationMetadataConfiguration ] arguments: paths: "%doctrine.paths%" isDevMode: "%doctrine.is_dev%" doctrine.entity_manager: class: Doctrine\ORM\EntityManager factory: [ Doctrine\ORM\EntityManager, create ] arguments: conn: driver: "%doctrine.driver%" user: "%doctrine.user%" password: "%doctrine.password%" dbname: "%doctrine.dbname%" config: "@doctrine.config"
#in config/services.yml imports: - { resource: 'events.yml' } - { resource: 'kernel.yml' } - { resource: 'controllers.yml' } - { resource: 'routing.yml' } - { resource: 'twig.yml' } - { resource: 'doctrine.yml' }
يمكننا أيضًا استخدام ملفات تكوين تعيين YML و XML بدلاً من التعليقات التوضيحية. نحتاج فقط إلى استخدام طريقتين "createYAMLMetadataConfiguration" و "createXMLMetadataConfiguration" وتعيين المسار لمجلد به ملفات التكوين هذه.
يمكن أن يصبح إدخال كل خدمة مطلوبة في كل وحدة تحكم على حدة أمرًا مزعجًا للغاية. لجعله أفضل قليلاً ، يحتوي مكون DI-container على خدمات مجردة ووراثة خدمة. حتى نتمكن من تحديد بعض أدوات التحكم المجردة:
# in config/controllers.yml services: controller.base_web: lazy: true abstract: true class: App\Controller\Base\WebController arguments: request: "@request" templating: "@templating.twig" entityManager: "@doctrine.entity_manager" urlGenerator: "@router.generator" controller.default: class: App\Controller\DefaultController parent: controller.base_web controller.page: class: App\Controller\PageController parent: controller.base_web
// in src/App/Controller/Base/WebController.php namespace App\Controller\Base; use Symfony\Component\HttpFoundation\Request; use Twig_Environment; use Doctrine\ORM\EntityManager; use Symfony\Component\Routing\Generator\UrlGenerator; abstract class WebController { /** @var Request */ protected $request; /** @var Twig_Environment */ protected $templating; /** @var EntityManager */ protected $entityManager; /** @var UrlGenerator */ protected $urlGenerator; function __construct( Request $request, Twig_Environment $templating, EntityManager $entityManager, UrlGenerator $urlGenerator ) { $this->request = $request; $this->templating = $templating; $this->entityManager = $entityManager; $this->urlGenerator = $urlGenerator; } } // in src/App/Controller/DefaultController // … class DefaultController extend WebController { // ... } // in src/App/Controller/PageController // … class PageController extend WebController { // ... }
هناك العديد من مكونات Symfony المفيدة الأخرى مثل Form و Command و Assets. تم تطويرها كمكونات مستقلة لذا لا ينبغي أن يكون تكاملها باستخدام حاوية DI مشكلة.
العلامات
تحتوي حاوية DI أيضًا على نظام علامات. يمكن معالجة العلامات بواسطة فئات Compiler Pass. يحتوي مكون Event Dispatcher على Compiler Pass الخاص به لتبسيط اشتراك مستمع الأحداث ، ولكنه يستخدم فئة ContainerAwareEventDispatcher بدلاً من فئة EventDispatcher. لذلك لا يمكننا استخدامه. ولكن يمكننا تنفيذ تصاريح التحويل الخاصة بنا للأحداث والمسارات والأمان وأي غرض آخر.
على سبيل المثال ، دعنا ننفذ العلامات لنظام التوجيه. الآن لتحديد المسار ، يتعين علينا تحديد خدمة المسار في ملف تكوين المسار في مجلد config /ways ثم إضافته إلى خدمة تجميع المسار في ملف config / routing.yml. يبدو غير متسق لأننا نحدد معلمات جهاز التوجيه في مكان واسم جهاز التوجيه في مكان آخر.
باستخدام نظام العلامات ، يمكننا فقط تحديد اسم مسار في علامة وإضافة خدمة المسار هذه إلى مجموعة المسار باستخدام اسم علامة.
يستخدم مكون حاوية DI فئات تمرير المحول البرمجي لإجراء أي تعديل على تكوين الحاوية قبل التهيئة الفعلية. لذلك دعونا نطبق فئة تمرير المترجم لنظام علامة جهاز التوجيه.
// in src/CompilerPass/RouterTagCompilerPass.php namespace CompilerPass; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; class RouterTagCompilerPass implements CompilerPassInterface { /** * You can modify the container here before it is dumped to PHP code. * * @param ContainerBuilder $container */ public function process(ContainerBuilder $container) { $routeTags = $container->findTaggedServiceIds('route'); $collectionTags = $container->findTaggedServiceIds('route_collection'); /** @var Definition[] $routeCollections */ $routeCollections = array(); foreach ($collectionTags as $serviceName => $tagData) $routeCollections[] = $container->getDefinition($serviceName); foreach ($routeTags as $routeServiceName => $tagData) { $routeNames = array(); foreach ($tagData as $tag) if (isset($tag['route_name'])) $routeNames[] = $tag['route_name']; if (!$routeNames) continue; $routeReference = new Reference($routeServiceName); foreach ($routeCollections as $collection) foreach ($routeNames as $name) $collection->addMethodCall('add', array($name, $routeReference)); } } }
// in src/TrueContainer.php //... use CompilerPass\RouterTagCompilerPass; // ... $container = new self(); $container->addCompilerPass(new RouterTagCompilerPass()); // ...
الآن يمكننا تعديل التكوين الخاص بنا:
# in config/routing.yml # … route.collection: class: Symfony\Component\Routing\RouteCollection tags: - { name: route_collection } # ...
# in config/routes/default.yml services: route.home: class: Symfony\Component\Routing\Route arguments: path: / defaults: _controller: ["@controller.default", 'defaultAction'] tags: - { name: route, route_name: 'route_home' } route.page: class: Symfony\Component\Routing\Route arguments: path: /page/{id} defaults: _controller: ["@controller.page", 'defaultAction'] tags: - { name: route, route_name: 'route_page' }
كما ترى ، نحصل على مجموعات المسار حسب اسم العلامة بدلاً من اسم الخدمة ، لذلك لا يعتمد نظام علامات المسار لدينا على التكوين الفعلي. أيضًا ، يمكن إضافة المسارات إلى أي خدمة تجميع باستخدام طريقة "الإضافة". يمكن لمارة المترجمين تبسيط تكوينات التبعيات بشكل ملحوظ. لكن يمكنهم إضافة سلوك غير متوقع إلى حاوية DI ، لذلك من الأفضل عدم تعديل المنطق الموجود مثل تغيير الوسائط أو استدعاءات الطريقة أو أسماء الفئات. ما عليك سوى إضافة واحدة جديدة موجودة كما فعلنا باستخدام العلامات.
يتم إحتوائه
لدينا الآن تطبيق يستخدم نمط حاوية DI فقط ، وقد تم إنشاؤه باستخدام ملفات تكوين حاوية DI فقط. كما ترى ، لا توجد تحديات جدية في بناء تطبيق Symfony بهذه الطريقة. ويمكنك ببساطة تصور كل تبعيات تطبيقك. السبب الوحيد الذي يجعل الناس يستخدمون DI-container كمحدد موقع خدمة هو أن مفهوم محدد موقع الخدمة أسهل في الفهم. ومن المحتمل أن تكون قاعدة الشفرة الضخمة مع حاوية DI المستخدمة كمحدد موقع خدمة نتيجة لهذا السبب.
يمكنك العثور على الكود المصدري لهذا التطبيق على GitHub.