Unified Architecture - En enklere måde at opbygge full-stack apps på

Moderne full-stack apps - som apps på en side eller mobilapps - har normalt seks lag

  • adgang til data
  • backend-model
  • API-server
  • API-klient
  • frontend model
  • og brugergrænseflade.

Ved at arkitektere på denne måde kan du opnå nogle karakteristika ved et veldesignet program, såsom adskillelse af bekymringer eller løs kobling.

Men dette kommer ikke uden ulemper. Det kommer normalt på bekostning af andre vigtige egenskaber, som enkelhed, samhørighed og smidighed.

Det ser ud til, at vi ikke kan få det hele. Vi er nødt til at gå på kompromis.

Problemet er, at udviklere normalt bygger hvert lag som en helt anden verden alene.

Selv hvis du implementerer lagene med det samme sprog, kan de ikke kommunikere med hinanden meget let.

Du har brug for en masse limkode for at forbinde dem alle, og domænemodellen bliver duplikeret over stakken. Som et resultat lider din udviklingsfleksibilitet dramatisk.

For eksempel kræver tilføjelse af et simpelt felt til en model ofte at ændre alle lagene i stakken. Dette kan føles lidt latterligt.

Nå, jeg har tænkt meget på dette problem for nylig. Og jeg tror, ​​jeg har fundet en vej ud.

Her er tricket: Sikkert skal lagene i en applikation være "fysisk" adskilt. Men de behøver ikke at være "logisk" adskilt.

Den samlede arkitektur

Traditionel vs samlet arkitektur

I objektorienteret programmering, når vi bruger arv, får vi nogle klasser, der kan ses på to måder: fysisk og logisk. Hvad mener jeg med det?

Lad os forestille os, at vi har en klasse, Bder arver fra en klasse A. Derefter Aog Bkan ses som to fysiske klasser. Men logisk er de ikke adskilt og Bkan ses som en logisk klasse, der sammensætter egenskaberne Amed sine egne egenskaber.

For eksempel, når vi kalder en metode i en klasse, behøver vi ikke bekymre os, om metoden er implementeret i denne klasse eller en overordnet klasse. Fra opkaldsperspektivet er der kun en klasse at bekymre sig om. Forældre og barn forenes i en enkelt logisk klasse.

Hvad med at anvende den samme tilgang til lagene i en applikation? Ville det ikke være godt, hvis f.eks. Frontend på en eller anden måde kunne arve fra backend?

Hvis du gør det, vil frontend og backend blive samlet i et enkelt logisk lag. Og det ville fjerne alle kommunikations- og delingsproblemer. Faktisk ville backend-klasser, attributter og metoder være direkte tilgængelige fra frontend.

Selvfølgelig vil vi normalt ikke udsætte hele backend til frontend. Men det samme gælder klassearv, og der er en elegant løsning, der kaldes "private ejendomme". Tilsvarende kunne backend selektivt udsætte nogle attributter og metoder.

At være i stand til at forstå alle lagene i en applikation fra en enkelt samlet verden er ikke en lille ting. Det ændrer spillet fuldstændigt. Det er som at gå fra en 3D-verden til en 2D-verden. Alt bliver meget lettere.

Arv er ikke ondt. Ja, det kan misbruges, og på nogle sprog kan det være ret stift. Men når det bruges korrekt, er det en uvurderlig mekanisme i vores værktøjskasse.

Vi har dog et problem. Så vidt jeg ved, er der intet sprog, der tillader os at arve klasser på tværs af flere eksekveringsmiljøer. Men vi er programmører, ikke? Vi kan bygge alt, hvad vi vil, og vi kan udvide sproget til at give nye muligheder.

Men inden vi kommer til det, lad os nedbryde stakken for at se, hvordan hvert lag kan passe i en samlet arkitektur.

Dataadgang

For et flertal af applikationer kan databasen abstraheres ved hjælp af en slags ORM. Så fra udviklerperspektivet er der intet lag om dataadgang at bekymre sig om.

For mere ambitiøse applikationer er vi muligvis nødt til at optimere databaseskemaer og anmodninger. Men vi ønsker ikke at rod i backendmodellen med disse bekymringer, og det er her, et ekstra lag kan være passende.

Vi bygger et dataadgangslag til implementering af optimeringsproblemerne, og dette sker normalt sent i udviklingscyklussen, hvis det nogensinde sker.

Under alle omstændigheder, hvis vi har brug for et sådant lag, kan vi bygge det senere. Med arv på tværs af lag kan vi tilføje et dataadgangslag oven på backendmodellaget næsten uden ændringer til den eksisterende kode.

Backend Model

Typisk håndterer et backend-modelag følgende ansvarsområder:

  • Formgivning af domænemodellen.
  • Implementering af forretningslogik.
  • Håndtering af autorisationsmekanismer.

For de fleste backends er det fint at implementere dem alle i et enkelt lag. Men hvis vi f.eks. Vil håndtere nogle bekymringer separat, for eksempel vil vi adskille godkendelsen fra forretningslogikken, vi kan implementere dem i to lag, der arver fra hinanden.

API-lag

For at forbinde frontend og backend bygger vi normalt en web-API (REST, GraphQL osv.), Og det komplicerer alt.

Web-API'en skal implementeres på begge sider: en API-klient i frontend og en API-server i backend. Det er to ekstra lag at bekymre sig om, og det fører normalt til at duplikere hele domænemodellen.

En web-API er intet andet end limkode, og det er en smerte i røvet at bygge. Så hvis vi kan undgå det, er det en massiv forbedring.

Heldigvis kan vi udnytte arven på tværs af lag igen. I en samlet arkitektur er der ingen web-API at bygge. Alt hvad vi skal gøre er at arve frontendmodellen fra backendmodellen, og vi er færdige.

Der er dog stadig nogle gode brugssager til opbygning af en web-API. Det er når vi har brug for at udsætte en backend for nogle tredjepartsudviklere, eller når vi har brug for at integrere med nogle ældre systemer.

Men lad os være ærlige, de fleste applikationer har ikke et sådant krav. Og når de gør det, er det let at håndtere det bagefter. Vi kan simpelthen implementere web-API'en i et nyt lag, der arver fra backend-modelaget.

Yderligere information om dette emne kan findes i denne artikel.

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:

Traditionel vs samlet arkitektur

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.

Men hvis du er interesseret i OOP, skal du holde et lille vindue åbent i dit sind, og næste gang du skal opbygge en full-stack-applikation, skal du prøve at se, hvordan den passer ind i en samlet arkitektur.

Forbindelsen er stadig på et tidligt tidspunkt, men jeg arbejder aktivt på det, og jeg forventer at frigive den første betaversion i begyndelsen af ​​2020.

Hvis du er interesseret, bedes du stjerne arkivet og holde dig opdateret ved at følge bloggen eller abonnere på nyhedsbrevet.

Diskuter denne artikel på Changelog News .