الهندسة المعمارية الموحدة - طريقة أبسط لإنشاء تطبيقات كاملة المكدس

عادةً ما تحتوي التطبيقات الكاملة المكدسة الحديثة - مثل تطبيقات الصفحة الواحدة أو تطبيقات الأجهزة المحمولة - على ست طبقات

  • الدخول الى البيانات
  • نموذج الخلفية
  • خادم API
  • عميل API
  • نموذج الواجهة الأمامية
  • وواجهة المستخدم.

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

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

يبدو أننا لا نستطيع الحصول على كل شيء. علينا أن نتنازل.

تكمن المشكلة في أن المطورين عادةً ما يبنون كل طبقة على أنها عالم مختلف تمامًا بمفردها.

حتى إذا قمت بتطبيق الطبقات بنفس اللغة ، فلن يتمكنوا من التواصل مع بعضهم البعض بسهولة شديدة.

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

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

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

إليك الحيلة: بالتأكيد ، يجب فصل طبقات التطبيق "فعليًا". لكنهم لا يحتاجون إلى الانفصال "المنطقي".

العمارة الموحدة

العمارة التقليدية مقابل العمارة الموحدة

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

لنتخيل أن لدينا فصلًا Bيرث من الفصل A. ثم، Aو Bيمكن أن ينظر إليه على فئتين المادية. لكن من الناحية المنطقية ، لا يتم فصلهما ، Bويمكن اعتبارهما فئة منطقية تؤلف خصائصها بخصائصها Aالخاصة.

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

ماذا عن تطبيق نفس النهج على طبقات التطبيق؟ ألن يكون رائعًا ، على سبيل المثال ، أن ترث الواجهة الأمامية بطريقة ما من الواجهة الخلفية؟

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

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

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

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

لكن لدينا مشكلة. على حد علمي ، لا توجد لغة تسمح لنا بوراثة الفصول عبر بيئات تنفيذ متعددة. لكننا مبرمجون ، أليس كذلك؟ يمكننا بناء كل ما نريد ، ويمكننا توسيع اللغة لتوفير إمكانات جديدة.

ولكن قبل أن نصل إلى ذلك ، دعنا نقسم المكدس لنرى كيف يمكن لكل طبقة أن تتلاءم مع بنية موحدة.

الدخول الى البيانات

بالنسبة لغالبية التطبيقات ، يمكن تلخيص قاعدة البيانات باستخدام نوع من ORM. لذلك ، من منظور المطور ، لا توجد طبقة وصول إلى البيانات تقلق بشأنها.

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

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

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

نموذج الواجهة الخلفية

عادةً ما تتولى طبقة نموذج الواجهة الخلفية المسؤوليات التالية:

  • تشكيل نموذج المجال.
  • تنفيذ منطق الأعمال.
  • التعامل مع آليات الترخيص.

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

طبقات API

لتوصيل الواجهة الأمامية والخلفية ، نقوم عادةً بإنشاء واجهة برمجة تطبيقات الويب (REST و GraphQL وما إلى ذلك) ، وهذا يعقد كل شيء.

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

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

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

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

لكن لنكن صادقين ، فمعظم التطبيقات ليس لها مثل هذا المطلب. وعندما يفعلون ذلك ، يكون من السهل التعامل معه بعد ذلك. يمكننا ببساطة تنفيذ واجهة برمجة تطبيقات الويب في طبقة جديدة ترث من طبقة نموذج الواجهة الخلفية.

يمكن العثور على مزيد من المعلومات حول هذا الموضوع في هذه المقالة.

نموذج الواجهة الأمامية

Since the backend is the source of truth, it should implement all the business logic, and the frontend should not implement any. So, the frontend model is simply inherited from the backend model, with almost no additions.

User Interface

We usually implement the frontend model and the UI in two separate layers. But as I showed in this article, it is not mandatory.

When the frontend model is made of classes, it is possible to encapsulate the views as simple methods. Don't worry if you don't see what I mean right now, it will become clearer in the example later on.

Since the frontend model is basically empty (see above), it is fine to implement the UI directly into it, so there is no user interface layer per se.

Implementing the UI in a separate layer is still needed when we want to support multiple platforms (e.g., a web app and a mobile app). But, since it is just a matter of inheriting a layer, that can come later in the development roadmap.

Putting Everything Together

The unified architecture allowed us to unify six physical layers into one single logical layer:

  • In a minimal implementation, data access is encapsulated into the backend model, and the same goes for UI that is encapsulated into the frontend model.
  • The frontend model inherits from the backend model.
  • The API layers are not required anymore.

Again, here's what the resulting implementation looks like:

العمارة التقليدية مقابل العمارة الموحدة

That's pretty spectacular, don't you think?

Liaison

To implement a unified architecture, all we need is cross-layer inheritance, and I started building Liaison to achieve exactly that.

You can see Liaison as a framework if you wish, but I prefer to describe it as a language extension because all its features lie at the lowest possible level — the programming language level.

So, Liaison does not lock you into a predefined framework, and a whole universe can be created on top of it. You can read more on this topic in this article.

Behind the scene, Liaison relies on an RPC mechanism. So, superficially, it can be seen as something like CORBA, Java RMI, or .NET CWF.

But Liaison is radically different:

  • It is not a distributed object system. Indeed, a Liaison backend is stateless, so there are no shared objects across layers.
  • It is implemented at the language-level (see above).
  • Its design is straightforward and it exposes a minimal API.
  • It doesn't involve any boilerplate code, generated code, configuration files, or artifacts.
  • It uses a simple but powerful serialization protocol (Deepr) that enables unique features, such as chained invocation, automatic batching, or partial execution.

Liaison starts its journey in JavaScript, but the problem it tackles is universal, and it could be ported to any object-oriented language without too much trouble.

Hello Counter

Let's illustrate how Liaison works by implementing the classic "Counter" example as a single-page application.

First, we need some shared code between the frontend and the backend:

// shared.js import {Model, field} from '@liaison/liaison'; export class Counter extends Model { // The shared class defines a field to keep track of the counter's value @field('number') value = 0; } 

Then, let's build the backend to implement the business logic:

// backend.js import {Layer, expose} from '@liaison/liaison'; import {Counter as BaseCounter} from './shared'; class Counter extends BaseCounter { // We expose the `value` field to the frontend @expose({get: true, set: true}) value; // And we expose the `increment()` method as well @expose({call: true}) increment() { this.value++; } } // We register the backend class into an exported layer export const backendLayer = new Layer({Counter}); 

Finally, let's build the frontend:

// frontend.js import {Layer} from '@liaison/liaison'; import {Counter as BaseCounter} from './shared'; import {backendLayer} from './backend'; class Counter extends BaseCounter { // For now, the frontend class is just inheriting the shared class } // We register the frontend class into a layer that inherits from the backend layer const frontendLayer = new Layer({Counter}, {parent: backendLayer}); // Lastly, we can instantiate a counter const counter = new frontendLayer.Counter(); // And play with it await counter.increment(); console.log(counter.value); // => 1 

What's going on? By invoking counter.increment(), we got the counter's value incremented. Notice that the increment() method is neither implemented in the frontend class nor in the shared class. It only exists in the backend.

So, how is it possible that we could call it from the frontend? This is because the frontend class is registered in a layer that inherits from the backend layer. So, when a method is missing in the frontend class, and a method with the same name is exposed in the backend class, it is automatically invoked.

From the frontend point of view, the operation is transparent. It doesn't need to know that a method is invoked remotely. It just works.

The current state of an instance (i.e., counter's attributes) is automatically transported back and forth. When a method is executed in the backend, the attributes that have been modified in the frontend are sent. And inversely, when some attributes change in the backend, they are reflected in the frontend.

Note that in this simple example, the backend is not exactly remote. Both the frontend and the backend run in the same JavaScript runtime. To make the backend truly remote, we can easily expose it through HTTP. See an example here.

How about passing/returning values to/from a remotely invoked method? It is possible to pass/return anything that is serializable, including class instances. As long as a class is registered with the same name in both the frontend and the backend, its instances can be automatically transported.

How about overriding a method across the frontend and the backend? It is no different than with regular JavaScript – we can use super. For example, we can override the increment() method to run additional code in the context of the frontend:

// frontend.js class Counter extends BaseCounter { async increment() { await super.increment(); // Backend's `increment()` method is invoked console.log(this.value); // Additional code is executed in the frontend } } 

Now, let's build a user interface with React and the encapsulated approach shown earlier:

// frontend.js import React from 'react'; import {view} from '@liaison/react-integration'; class Counter extends BaseCounter { // We use the `@view()` decorator to observe the model and re-render the view when needed @view() View() { return ( {this.value}  this.increment()}>+ ); } } 

Finally, to display the counter, all we need is:

Voilà! We built a single-page application with two unified layers and an encapsulated UI.

Proof of Concept

To experiment with the unified architecture, I built a RealWorld example app with Liaison.

I might be biased, but the outcome looks pretty amazing to me: simple implementation, high code cohesion, 100% DRY, and no glue code.

In terms of the amount of code, my implementation is significantly lighter than any other one I have examined. Check out the results here.

Certainly, the RealWorld example is a small application, but since it covers the most important concepts that are common to all applications, I'm confident that a unified architecture can scale up to more ambitious applications.

Conclusion

Separation of concerns, loose coupling, simplicity, cohesion, and agility.

It seems we get it all, finally.

If you are an experienced developer, I guess you feel a bit skeptical at this point, and this is totally fine. It is hard to leave behind years of established practices.

If object-oriented programming is not your cup of tea, you will not want to use Liaison, and this is totally fine as well.

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

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

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

ناقش هذه المقالة في Changelog News .