Ενοποιημένη αρχιτεκτονική - ένας απλούστερος τρόπος δημιουργίας εφαρμογών πλήρους στοίβας

Οι σύγχρονες εφαρμογές πλήρους στοίβας - όπως εφαρμογές μιας σελίδας ή εφαρμογές για κινητά - έχουν συνήθως έξι επίπεδα

  • πρόσβαση δεδομένων
  • μοντέλο backend
  • Διακομιστής API
  • Πελάτης API
  • μοντέλο frontend
  • και διεπαφή χρήστη.

Με την αρχιτεκτονική με αυτόν τον τρόπο, μπορείτε να επιτύχετε ορισμένα χαρακτηριστικά μιας καλά σχεδιασμένης εφαρμογής, όπως ο διαχωρισμός των ανησυχιών ή η χαλαρή σύνδεση.

Αυτό όμως δεν έρχεται χωρίς μειονεκτήματα. Συνήθως έρχεται στο κόστος άλλων σημαντικών χαρακτηριστικών, όπως η απλότητα, η συνοχή και η ευελιξία.

Φαίνεται ότι δεν μπορούμε να τα έχουμε όλα. Πρέπει να συμβιβαστούμε.

Το πρόβλημα είναι ότι οι προγραμματιστές συνήθως χτίζουν κάθε επίπεδο ως έναν εντελώς διαφορετικό κόσμο από μόνο του.

Ακόμα κι αν εφαρμόσετε τα επίπεδα με την ίδια γλώσσα, δεν μπορούν να επικοινωνούν μεταξύ τους πολύ εύκολα.

Θα χρειαστείτε πολλή κόλλα κώδικα για να τα συνδέσετε όλα και το μοντέλο τομέα αντιγράφεται σε όλη τη στοίβα. Ως αποτέλεσμα, η ευελιξία ανάπτυξης σας υποφέρει δραματικά.

Για παράδειγμα, η προσθήκη ενός απλού πεδίου σε ένα μοντέλο απαιτεί συχνά τροποποίηση όλων των επιπέδων της στοίβας. Αυτό μπορεί να είναι λίγο γελοίο.

Λοιπόν, σκεφτόμουν πολλά για αυτό το πρόβλημα πρόσφατα. Και πιστεύω ότι βρήκα μια διέξοδο.

Εδώ είναι το κόλπο: σίγουρα, τα επίπεδα μιας εφαρμογής πρέπει να διαχωριστούν «φυσικά». Αλλά δεν χρειάζεται να διαχωριστούν «λογικά».

Η ενοποιημένη αρχιτεκτονική

Παραδοσιακή έναντι ενοποιημένης αρχιτεκτονικής

Στον αντικειμενοστρεφή προγραμματισμό, όταν χρησιμοποιούμε κληρονομιά, έχουμε κάποιες τάξεις που μπορούν να φανούν με δύο τρόπους: φυσική και λογική. Τι εννοώ με αυτό;

Ας φανταστούμε ότι έχουμε μια τάξη Bπου κληρονομεί από μια τάξη A. Στη συνέχεια, Aκαι Bμπορεί να θεωρηθεί ως δύο φυσικές τάξεις. Αλλά λογικά, δεν διαχωρίζονται και Bμπορεί να θεωρηθεί ως μια λογική τάξη που συνθέτει τις ιδιότητες Aμε τις δικές της ιδιότητες.

Για παράδειγμα, όταν καλούμε μια μέθοδο σε μια τάξη, δεν χρειάζεται να ανησυχούμε αν η μέθοδος εφαρμόζεται σε αυτήν την τάξη ή σε μια γονική τάξη. Από την άποψη του καλούντος, υπάρχει μόνο μία τάξη που πρέπει να ανησυχεί. Ο γονέας και το παιδί ενοποιούνται σε μία λογική τάξη.

Τι γίνεται με την εφαρμογή της ίδιας προσέγγισης στα επίπεδα μιας εφαρμογής; Δεν θα ήταν υπέροχο αν, για παράδειγμα, το frontend θα μπορούσε με κάποιο τρόπο να κληρονομήσει από το backend;

Με αυτόν τον τρόπο, το frontend και το backend θα ενοποιηθούν σε ένα μόνο λογικό επίπεδο. Και αυτό θα αφαιρούσε όλα τα προβλήματα επικοινωνίας και κοινής χρήσης. Πράγματι, οι τάξεις, τα χαρακτηριστικά και οι μέθοδοι backend θα είναι άμεσα προσβάσιμες από το frontend.

Φυσικά, συνήθως δεν θέλουμε να εκθέσουμε ολόκληρο το backend στο frontend. Το ίδιο ισχύει και για την κληρονομιά της τάξης, και υπάρχει μια κομψή λύση που ονομάζεται "ιδιωτικές ιδιότητες". Ομοίως, το backend θα μπορούσε επιλεκτικά να εκθέσει ορισμένα χαρακτηριστικά και μεθόδους.

Το να είσαι σε θέση να κατανοήσεις όλα τα επίπεδα μιας εφαρμογής από έναν ενιαίο κόσμο δεν είναι μικρή υπόθεση. Αλλάζει εντελώς το παιχνίδι. Είναι σαν να πηγαίνεις από έναν τρισδιάστατο κόσμο σε έναν 2D κόσμο. Όλα γίνονται πολύ πιο εύκολα.

Η κληρονομικότητα δεν είναι κακό. Ναι, μπορεί να γίνει κακή χρήση και σε ορισμένες γλώσσες μπορεί να είναι αρκετά άκαμπτο. Αλλά όταν χρησιμοποιείται σωστά, είναι ένας πολύτιμος μηχανισμός στην εργαλειοθήκη μας.

Έχουμε όμως πρόβλημα. Από όσο γνωρίζω, δεν υπάρχει καμία γλώσσα που να μας επιτρέπει να κληρονομούμε τάξεις σε πολλά περιβάλλοντα εκτέλεσης. Αλλά είμαστε προγραμματιστές, έτσι δεν είναι; Μπορούμε να χτίσουμε ό, τι θέλουμε και μπορούμε να επεκτείνουμε τη γλώσσα για να παρέχουμε νέες δυνατότητες.

Αλλά πριν φτάσουμε σε αυτό, ας σπάσουμε τη στοίβα για να δούμε πώς κάθε στρώμα μπορεί να χωρέσει σε μια ενοποιημένη αρχιτεκτονική.

Πρόσβαση δεδομένων

Για την πλειονότητα των εφαρμογών, η βάση δεδομένων μπορεί να αφαιρεθεί χρησιμοποιώντας κάποιο είδος ORM. Έτσι, από την πλευρά του προγραμματιστή, δεν υπάρχει επίπεδο πρόσβασης δεδομένων που να ανησυχείτε.

Για πιο φιλόδοξες εφαρμογές, ίσως χρειαστεί να βελτιστοποιήσουμε σχήματα και αιτήματα βάσεων δεδομένων. Αλλά δεν θέλουμε να γεμίσουμε το μοντέλο backend με αυτές τις ανησυχίες, και αυτό είναι όπου ένα επιπλέον επίπεδο μπορεί να είναι κατάλληλο.

Χτίζουμε ένα επίπεδο πρόσβασης δεδομένων για να εφαρμόσουμε τις ανησυχίες βελτιστοποίησης και αυτό συμβαίνει συνήθως αργά στον κύκλο ανάπτυξης, αν συμβεί ποτέ.

Τέλος πάντων, εάν χρειαζόμαστε ένα τέτοιο επίπεδο, μπορούμε να το φτιάξουμε αργότερα. Με κληρονομικότητα πολλαπλών επιπέδων, μπορούμε να προσθέσουμε ένα επίπεδο πρόσβασης δεδομένων πάνω από το επίπεδο μοντέλου backend χωρίς σχεδόν καμία αλλαγή στον υπάρχοντα κώδικα.

Μοντέλο Backend

Συνήθως, ένα επίπεδο μοντέλου backend χειρίζεται τις ακόλουθες ευθύνες:

  • Διαμόρφωση του μοντέλου τομέα.
  • Εφαρμογή επιχειρηματικής λογικής.
  • Χειρισμός των μηχανισμών έγκρισης.

Για τα περισσότερα backend, είναι καλό να τα εφαρμόζουμε όλα σε ένα μόνο επίπεδο. Ωστόσο, εάν θέλουμε να αντιμετωπίσουμε ορισμένες ανησυχίες ξεχωριστά, για παράδειγμα, θέλουμε να διαχωρίσουμε την εξουσιοδότηση από τη λογική της επιχείρησης, μπορούμε να τις εφαρμόσουμε σε δύο επίπεδα που κληρονομούνται μεταξύ τους.

Επίπεδα API

Για να συνδέσουμε το frontend και το backend, συνήθως δημιουργούμε ένα API Ιστού (REST, GraphQL κ.λπ.) και αυτό περιπλέκει τα πάντα.

Το API Ιστού πρέπει να εφαρμοστεί και στις δύο πλευρές: ένας πελάτης API στο frontend και ένας διακομιστής API στο backend. Αυτό πρέπει να ανησυχείτε για δύο επιπλέον επίπεδα και συνήθως οδηγεί σε αναπαραγωγή ολόκληρου του μοντέλου τομέα.

Ένα API Ιστού δεν είναι τίποτα περισσότερο από τον κώδικα κόλλας, και είναι πόνος στον κώλο να χτίσεις Έτσι, αν μπορούμε να το αποφύγουμε, αυτή είναι μια τεράστια βελτίωση.

Fortunately, we can take advantage of cross-layer inheritance again. In a unified architecture, there is no web API to build. All we have to do is to inherit the frontend model from the backend model, and we are done.

However, there are still some good use cases for building a web API. That's when we need to expose a backend to some third-party developers, or when we need to integrate with some legacy systems.

But let's be honest, most applications don't have such a requirement. And when they do, it is easy to handle it afterward. We can simply implement the web API into a new layer that inherits from the backend model layer.

Further information on this topic can be found in this article.

Frontend Model

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, παρακαλώ κρατήστε ένα μικρό παράθυρο ανοιχτό στο μυαλό σας και την επόμενη φορά που θα πρέπει να δημιουργήσετε μια εφαρμογή πλήρους στοίβας, προσπαθήστε να δείτε πώς θα ταιριάζει σε μια ενοποιημένη αρχιτεκτονική.

Το Liaison βρίσκεται ακόμη σε πρώιμο στάδιο, αλλά εργάζομαι ενεργά σε αυτό, και αναμένω να κυκλοφορήσω την πρώτη έκδοση beta στις αρχές του 2020.

Εάν σας ενδιαφέρει, προσθέστε αστέρι στο αποθετήριο και μείνετε ενημερωμένοι ακολουθώντας το ιστολόγιο ή εγγραφείτε στο ενημερωτικό δελτίο.

Συζητήστε αυτό το άρθρο στις ειδήσεις Changelog .