Kürzlich hatte ich die Erfahrung, ein Projekt zu überprüfen und seine Skalierbarkeit und Wartbarkeit zu beurteilen. Es gab ein paar schlechte Praktiken hier und da, ein paar seltsame Code-Schnipsel mit mangelnden aussagekräftigen Kommentaren. Nichts Ungewöhnliches für eine relativ große (Legacy-)Codebasis, oder?
Es gibt jedoch etwas, das ich immer wieder finde. Ein Muster, das sich in dieser Codebasis und einer Reihe anderer Projekte, die ich mir angesehen habe, wiederholte. Sie könnten alle durch mangelnde Abstraktion zusammengefasst werden. Letztendlich war dies die Ursache für Wartungsschwierigkeiten.
In der objektorientierten Programmierung ist Abstraktion einer der vier zentralen Prinzipien (neben Kapselung, Vererbung und Polymorphie). Abstraktion ist aus zwei Hauptgründen wertvoll
- Abstraktion verbirgt bestimmte Details und zeigt nur die wesentlichen Merkmale des Objekts. Sie versucht, Details zu reduzieren und herauszufiltern, damit sich der Entwickler auf wenige Konzepte gleichzeitig konzentrieren kann. Dieser Ansatz verbessert die Verständlichkeit und Wartbarkeit des Codes.
- Abstraktion hilft uns, Code-Duplizierung zu reduzieren. Abstraktion bietet Wege, um mit übergreifenden Anliegen umzugehen, und ermöglicht es uns, eng gekoppelten Code zu vermeiden.
Der Mangel an Abstraktion führt unweigerlich zu Problemen bei der Wartbarkeit.
Oft habe ich Kollegen gesehen, die einen Schritt weiter in Richtung wartbarerer Code gehen möchten, aber Schwierigkeiten haben, grundlegende Abstraktionen zu erkennen und zu implementieren. Daher werde ich in diesem Artikel einige nützliche Abstraktionen teilen, die ich für die häufigste Sache in der Web-Welt verwende: die Arbeit mit Remote-Daten.
Es ist wichtig zu erwähnen, dass es, wie bei allem in der JavaScript-Welt, unzählige Wege und verschiedene Ansätze gibt, um ein ähnliches Konzept zu implementieren. Ich werde meinen Ansatz teilen, aber Sie können ihn gerne aufrüsten oder an Ihre eigenen Bedürfnisse anpassen. Oder noch besser – verbessern Sie ihn und teilen Sie ihn unten in den Kommentaren! ❤️
API-Abstraktion
Ich hatte seit einiger Zeit kein Projekt mehr, das keine externe API zur Empfangs- und Sendung von Daten nutzte. Das ist normalerweise eine der ersten und grundlegendsten Abstraktionen, die ich definiere. Ich versuche, dort so viele API-bezogene Konfigurationen und Einstellungen zu speichern, wie
- die API-Basis-URL
- die Anforderungs-Header
- die globale Fehlerbehandlungslogik
const API = { /** * Simple service for generating different HTTP codes. Useful for * testing how your own scripts deal with varying responses. */ url: 'http://httpstat.us/', /** * fetch() will only reject a promise if the user is offline, * or some unlikely networking error occurs, such a DNS lookup failure. * However, there is a simple `ok` flag that indicates * whether an HTTP response's status code is in the successful range. */ _handleError(_res) { return _res.ok ? _res : Promise.reject(_res.statusText); }, /** * Get abstraction. * @return {Promise} */ get(_endpoint) { return window.fetch(this.url + _endpoint, { method: 'GET', headers: new Headers({ 'Accept': 'application/json' }) }) .then(this._handleError) .catch( error => { throw new Error(error) }); }, /** * Post abstraction. * @return {Promise} */ post(_endpoint, _body) { return window.fetch(this.url + _endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: _body, }) .then(this._handleError) .catch( error => { throw new Error(error) }); } };
In diesem Modul haben wir 2 öffentliche Methoden, get() und post(), die beide ein Promise zurückgeben. An allen Stellen, an denen wir mit Remote-Daten arbeiten müssen, rufen wir anstelle des direkten Aufrufs der Fetch-API über window.fetch() unsere API-Modul-Abstraktion – API.get() oder API.post() – auf.
Daher ist die Fetch-API nicht eng gekoppelt mit unserem Code.
Nehmen wir an, wir lesen Zell Liews umfassende Zusammenfassung der Verwendung von Fetch und stellen fest, dass unsere Fehlerbehandlung nicht wirklich fortgeschritten ist, wie sie sein könnte. Wir möchten den Inhaltstyp überprüfen, bevor wir mit unserer Logik weiter fortfahren. Kein Problem. Wir ändern nur unser API-Modul, die öffentlichen Methoden API.get() und API.post(), die wir überall sonst verwenden, funktionieren weiterhin einwandfrei.
const API = {
/* ... */
/**
* Check whether the content type is correct before you process it further.
*/
_handleContentType(_response) {
const contentType = _response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return _response.json();
}
return Promise.reject('Oops, we haven\'t got JSON!');
},
get(_endpoint) {
return window.fetch(this.url + _endpoint, {
method: 'GET',
headers: new Headers({
'Accept': 'application/json'
})
})
.then(this._handleError)
.then(this._handleContentType)
.catch( error => { throw new Error(error) })
},
post(_endpoint, _body) {
return window.fetch(this.url + _endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: _body
})
.then(this._handleError)
.then(this._handleContentType)
.catch( error => { throw new Error(error) })
}
};
Nehmen wir an, wir entscheiden uns für zlFetch, die Bibliothek, die Zell einführt und die die Verarbeitung der Antwort abstrahiert (sodass Sie überspringen und sowohl Ihre Daten als auch Fehler behandeln können, ohne sich um die Antwort kümmern zu müssen). Solange unsere öffentlichen Methoden ein Promise zurückgeben, ist das kein Problem:
import zlFetch from 'zl-fetch';
const API = {
/* ... */
/**
* Get abstraction.
* @return {Promise}
*/
get(_endpoint) {
return zlFetch(this.url + _endpoint, {
method: 'GET'
})
.catch( error => { throw new Error(error) })
},
/**
* Post abstraction.
* @return {Promise}
*/
post(_endpoint, _body) {
return zlFetch(this.url + _endpoint, {
method: 'post',
body: _body
})
.catch( error => { throw new Error(error) });
}
};
Nehmen wir an, aus irgendeinem Grund entscheiden wir uns später dafür, für die Arbeit mit Remote-Daten zu jQuery Ajax zu wechseln. Wieder kein großes Problem, solange unsere öffentlichen Methoden ein Promise zurückgeben. Die von $.ajax() zurückgegebenen jqXHR-Objekte implementieren seit jQuery 1.5 die Promise-Schnittstelle und geben ihnen alle Eigenschaften, Methoden und Verhaltensweisen eines Promises.
const API = {
/* ... */
/**
* Get abstraction.
* @return {Promise}
*/
get(_endpoint) {
return $.ajax({
method: 'GET',
url: this.url + _endpoint
});
},
/**
* Post abstraction.
* @return {Promise}
*/
post(_endpoint, _body) {
return $.ajax({
method: 'POST',
url: this.url + _endpoint,
data: _body
});
}
};
Aber selbst wenn jQuerys $.ajax() kein Promise zurückgeben würde, können Sie immer alles in ein neues Promise() wrappen. Alles gut. Wartbarkeit++!
Nun abstrahieren wir das Empfangen und Speichern der Daten lokal.
Daten-Repository
Nehmen wir an, wir müssen das aktuelle Wetter abrufen. Die API gibt uns die Temperatur, gefühlte Temperatur, Windgeschwindigkeit (m/s), Druck (hPa) und Luftfeuchtigkeit (%). Ein übliches Muster, um die JSON-Antwort so schlank wie möglich zu halten, ist, Attribute auf den ersten Buchstaben zu komprimieren. Hier ist also, was wir vom Server erhalten
{
"t": 30,
"f": 32,
"w": 6.7,
"p": 1012,
"h": 38
}
Wir könnten API.get('weather').t und API.get('weather').w überall dort verwenden, wo wir sie brauchen, aber das sieht nicht semantisch beeindruckend aus. Ich bin kein Fan von Ein-Buchstaben-Namen, die wenig Kontext bieten.
Zusätzlich sagen wir, wir verwenden die Luftfeuchtigkeit (h) und die gefühlte Temperatur (f) nirgends. Wir brauchen sie nicht. Tatsächlich könnte der Server uns viele andere Informationen zurückgeben, aber wir möchten vielleicht nur ein paar Parameter verwenden. Die Einschränkung dessen, was unser Wettermodul tatsächlich benötigt (speichert), könnte zu einem großen Aufwand führen.
Hier kommt die Repository-ähnliche Muster-Abstraktion ins Spiel!
import API from './api.js'; // Import it into your code however you like
const WeatherRepository = {
_normalizeData(currentWeather) {
// Take only what our app needs and nothing more.
const { t, w, p } = currentWeather;
return {
temperature: t,
windspeed: w,
pressure: p
};
},
/**
* Get current weather.
* @return {Promise}
*/
get(){
return API.get('/weather')
.then(this._normalizeData);
}
}
Nun verwenden wir in unserer gesamten Codebasis WeatherRepository.get() und greifen auf aussagekräftige Attribute wie .temperature und .windspeed zu. Besser!
Zusätzlich geben wir über die _normalizeData() nur die Parameter preis, die wir benötigen.
Es gibt noch einen weiteren großen Vorteil. Stellen Sie sich vor, wir müssen unsere App mit einer anderen Wetter-API verbinden. Überraschung, Überraschung, die Attributnamen dieser Antwort sind anders
{
"temp": 30,
"feels": 32,
"wind": 6.7,
"press": 1012,
"hum": 38
}
Keine Sorge! Mit unserer WeatherRepository-Abstraktion müssen wir nur die Methode _normalizeData() anpassen! Kein einziges anderes Modul (oder Datei).
const WeatherRepository = {
_normalizeData(currentWeather) {
// Take only what our app needs and nothing more.
const { temp, wind, press } = currentWeather;
return {
temperature: temp,
windspeed: wind,
pressure: press
};
},
/* ... */
};
Die Attributnamen des API-Antwortobjekts sind nicht eng mit unserer Codebasis gekoppelt. Wartbarkeit++!
Später, sagen wir, wir möchten die zwischengespeicherten Wetterinformationen anzeigen, wenn die aktuell abgerufenen Daten nicht älter als 15 Minuten sind. Also entscheiden wir uns, localStorage zum Speichern der Wetterinformationen zu verwenden, anstatt eine tatsächliche Netzwerkanfrage zu stellen und die API jedes Mal aufzurufen, wenn WeatherRepository.get() referenziert wird.
Solange WeatherRepository.get() ein Promise zurückgibt, müssen wir die Implementierung in keinem anderen Modul ändern. Alle anderen Module, die auf das aktuelle Wetter zugreifen möchten, kümmern sich nicht (und sollten sich nicht kümmern), wie die Daten abgerufen werden – ob sie aus dem lokalen Speicher, von einer API-Anfrage, über Fetch API oder über jQuerys $.ajax() stammen. Das ist irrelevant. Sie kümmern sich nur darum, sie im "vereinbarten" Format zu erhalten, das sie implementiert haben – ein Promise, das die eigentlichen Wetterdaten umschließt.
Wir führen also zwei "private" Methoden ein: _isDataUpToDate() – um zu prüfen, ob unsere Daten älter als 15 Minuten sind oder nicht, und _storeData(), um unsere Daten einfach im Browser-Speicher zu speichern.
const WeatherRepository = {
/* ... */
/**
* Checks weather the data is up to date or not.
* @return {Boolean}
*/
_isDataUpToDate(_localStore) {
const isDataMissing =
_localStore === null || Object.keys(_localStore.data).length === 0;
if (isDataMissing) {
return false;
}
const { lastFetched } = _localStore;
const outOfDateAfter = 15 * 1000; // 15 minutes
const isDataUpToDate =
(new Date().valueOf() - lastFetched) < outOfDateAfter;
return isDataUpToDate;
},
_storeData(_weather) {
window.localStorage.setItem('weather', JSON.stringify({
lastFetched: new Date().valueOf(),
data: _weather
}));
},
/**
* Get current weather.
* @return {Promise}
*/
get(){
const localData = JSON.parse( window.localStorage.getItem('weather') );
if (this._isDataUpToDate(localData)) {
return new Promise(_resolve => _resolve(localData));
}
return API.get('/weather')
.then(this._normalizeData)
.then(this._storeData);
}
};
Schließlich passen wir die Methode get() an: Wenn die Wetterdaten aktuell sind, umschließen wir sie in einem Promise und geben sie zurück. Andernfalls – wir lösen eine API-Anfrage aus. Großartig!
Es könnte andere Anwendungsfälle geben, aber ich hoffe, Sie haben die Idee verstanden. Wenn eine Änderung erfordert, dass Sie nur ein Modul anpassen – das ist ausgezeichnet! Sie haben die Implementierung wartbar gestaltet!
Wenn Sie sich für dieses Repository-ähnliche Muster entscheiden, werden Sie vielleicht feststellen, dass es zu einigen Code- und Logik-Duplikationen führt, da alle Daten-Repositories (Entitäten), die Sie in Ihrem Projekt definieren, wahrscheinlich Methoden wie _isDataUpToDate(), _normalizeData(), _storeData() usw. haben werden...
Da ich es in meinen Projekten intensiv nutze, habe ich beschlossen, eine Bibliothek um dieses Muster herum zu erstellen, die genau das tut, was ich in diesem Artikel beschrieben habe, und mehr!
Einführung von SuperRepo
SuperRepo ist eine Bibliothek, die Ihnen hilft, Best Practices für die Arbeit mit und die Speicherung von Daten auf der Client-Seite zu implementieren.
/**
* 1. Define where you want to store the data,
* in this example, in the LocalStorage.
*
* 2. Then - define a name of your data repository,
* it's used for the LocalStorage key.
*
* 3. Define when the data will get out of date.
*
* 4. Finally, define your data model, set custom attribute name
* for each response item, like we did above with `_normalizeData()`.
* In the example, server returns the params 't', 'w', 'p',
* we map them to 'temperature', 'windspeed', and 'pressure' instead.
*/
const WeatherRepository = new SuperRepo({
storage: 'LOCAL_STORAGE', // [1]
name: 'weather', // [2]
outOfDateAfter: 5 * 60 * 1000, // 5 min // [3]
request: () => API.get('weather'), // Function that returns a Promise
dataModel: { // [4]
temperature: 't',
windspeed: 'w',
pressure: 'p'
}
});
/**
* From here on, you can use the `.getData()` method to access your data.
* It will first check if out data outdated (based on the `outOfDateAfter`).
* If so - it will do a server request to get fresh data,
* otherwise - it will get it from the cache (Local Storage).
*/
WeatherRepository.getData().then( data => {
// Do something awesome.
console.log(`It is ${data.temperature} degrees`);
});
Die Bibliothek macht die gleichen Dinge, die wir zuvor implementiert haben
- Ruft Daten vom Server ab (wenn sie fehlen oder veraltet sind) oder andernfalls – ruft sie aus dem Cache ab.
- Ähnlich wie bei
_normalizeData()wendet die OptiondataModeleine Zuordnung auf unsere Rohdaten an. Das bedeutet- In unserer gesamten Codebasis greifen wir auf aussagekräftige und semantische Attribute zu, wie z. B.
.temperatureund.windspeedanstelle von.tund.s.- Geben Sie nur die Parameter frei, die Sie benötigen, und schließen Sie einfach keine anderen ein.
- Wenn sich die Antwortattributnamen ändern (oder Sie eine andere API mit einer anderen Antwortstruktur verbinden müssen), müssen Sie nur hier anpassen – an nur einer Stelle Ihres Codes.
Außerdem ein paar zusätzliche Verbesserungen
- Performance: Wenn
WeatherRepository.getData()mehrmals aus verschiedenen Teilen unserer App aufgerufen wird, wird nur eine Serveranfrage ausgelöst. - Skalierbarkeit
- Sie können die Daten im
localStorage, im Browser-Speicher (wenn Sie eine Browser-Erweiterung erstellen) oder in einer lokalen Variable (wenn Sie Daten nicht sitzungsübergreifend speichern möchten) speichern. Sehen Sie sich die Optionen für diestorage-Einstellung an. - Sie können eine automatische Datensynchronisierung mit
WeatherRepository.initSyncer()initiieren. Dies startet einsetInterval, das herunterzählt, bis die Daten veraltet sind (basierend auf dem Wert vonoutOfDateAfter), und eine Serveranfrage auslöst, um neue Daten abzurufen. Super.
- Sie können die Daten im
Um SuperRepo zu verwenden, installieren (oder laden Sie es einfach herunter) mit NPM oder Bower
npm install --save super-repo
Importieren Sie es dann in Ihren Code über eine der 3 verfügbaren Methoden
- Statisches HTML
<script src="/node_modules/super-repo/src/index.js"></script> - Verwendung von ES6 Imports
// If transpiler is configured (Traceur Compiler, Babel, Rollup, Webpack) import SuperRepo from 'super-repo'; - … oder Verwendung von CommonJS Imports
// If module loader is configured (RequireJS, Browserify, Neuter) const SuperRepo = require('super-repo');
Und definieren Sie schließlich Ihre SuperRepositories :)
Für fortgeschrittene Nutzung lesen Sie die Dokumentation, die ich geschrieben habe. Beispiele inklusive!
Zusammenfassung
Die oben beschriebenen Abstraktionen können ein grundlegender Teil der Architektur und des Designs Ihrer App sein. Denken Sie mit wachsender Erfahrung darüber nach und wenden Sie ähnliche Konzepte nicht nur bei der Arbeit mit Remote-Daten an, sondern auch in anderen Fällen, in denen sie sinnvoll sind.
Versuchen Sie bei der Implementierung eines Features immer, Änderungsresistenz, Wartbarkeit und Skalierbarkeit mit Ihrem Team zu besprechen. Ihr zukünftiges Ich wird Ihnen dafür danken!
Haben Sie dafür nicht Angular?
Nein. Zumindest nicht eingebaut. Natürlich können Sie, unabhängig vom verwendeten Framework, etwas Ähnliches erreichen, wenn Sie Ihren Code auf diese Weise organisieren.
Ich poste normalerweise nicht auf diese Weise, aber es ist ein Ärgernis...
Es gibt 4 Grundlagen der OOP: Kapselung, Abstraktion, Vererbung und Polymorphie. Polymorphie ist die Fähigkeit, ein Objekt als etwas anderes zu deklarieren und es als dieses Ding zu verwenden. Viele Leute verwenden dafür Vererbung, obwohl es ein anderes Konzept ist – nicht einmal ein sehr gutes, da es Polymorphie in seine Definition einbezieht.
Ja, das stimmt! Ich habe eine Bearbeitung vorgenommen und Polymorphie hinzugefügt. Danke!
Ausgezeichneter Artikel. Ich habe einige Bedenken bezüglich der Browserunterstützung – z. B. Fetch für IE?
Danke, Pedro!
Der Zweck der Verwendung von Fetch in meinen Codebeispielen war lediglich, einen Anwendungsfall zu illustrieren, wie die API-Abstraktion einen Wechsel zu einem anderen Mechanismus für die Arbeit mit Remote-Daten handhabt.
Die Wahl des zugrunde liegenden Mechanismus ist ein anderes Thema. Wenn Sie Fetch verwenden möchten, aber IE unterstützen müssen, schauen Sie sich das Fetch Polyfill an, ein Projekt, das eine Teilmenge der standardmäßigen Fetch-Spezifikation implementiert, genug, um Fetch zu einem brauchbaren Ersatz für die meisten Verwendungen von XMLHttpRequest in traditionellen Webanwendungen zu machen. Sie bieten Unterstützung bis zurück zu IE 10+, sodass Sie sicher sein sollten.