Recently I had the experience of reviewing a project and accessing its scalability and maintainability. There were a few bad practices here and there, a few strange pieces of code with lack of meaningful comments. Nothing uncommon for a relatively big (legacy) codebase, right?
However was something that I keep finding. A pattern that repeated itself throughout this codebase and a number of other projects I've looked through.They could be all by lack of abstraction. Ultimately, this was the cause for maintenance difficulty.
- In object-oriented programming, abstraction is one of the three central principles (along with encapsulation and inheritance). Abstraction is valuable for two key reasons:
- Abstraction hides certain details and only show the essential features of the object. It tries to reduce and factor out details so that the developer can focus on a few concepts at a time. This approach improves understandability as well as maintainability of the code.
- Abstraction helps us to reduce code duplication. Abstraction provides ways of dealing with crosscutting concerns and enables us to avoid tightly coupled code.
The lack of abstraction inevitably leads to problems with maintainability.
Often I've seen colleagues that want to take a step further towards more maintainable code, but they struggle to figure out and implement fundamental abstractions. Therefore, in this article, I'll share a few useful abstractions I use for the most common thing in the web world: working with remote data.
It's important to mention that, just like everything in the JavaScript world, there are tons of ways and different approaches how to implement a similar concept. I'll share my approach, but feel free to upgrade it or to tweak it based on your own needs. Or even better - improve it and share it in the comments below! ❤️
API Abstraction
I haven't had a project which doesn't use an external API to receive and send data in a while. That's usually one of the first and fundamental abstractions I define. I try to store as much API related configuration and settings there like:
- the API base url
- the request headers:
- the global error handling logic
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 this module, we have 2 public methods, get()
and post()
which both return a Promise. On all places where we need to work with remote data, instead of directly calling the Fetch API via window.fetch()
, we use our API module abstraction - API.get()
or API.post()
.
Therefore, the Fetch API is not tightly coupled with our code.
Let's say down the road we read Zell Liew's comprehensive summary of using Fetch and we realize that our error handling is not really advanced, like it could be. We want to check the content type before we process with our logic any further. No problem. We modify only our APP
module, the public methods API.get()
and API.post()
we use everywhere else works just fine.
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) })
}
};
Let's say we decide to switch to zlFetch, the library which Zell introduces that abstracts away the handling of the response (so you can skip ahead to and handle both your data and errors without worrying about the response). As long as our public methods return a Promise, no 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) });
}
};
Let's say down the road due to whatever reason we decide to switch to jQuery Ajax for working with remote data. Not a huge deal once again, as long as our public methods return a Promise. The jqXHR objects returned by $.ajax()
as of jQuery 1.5 implement the Promise interface, giving them all the properties, methods, and behavior of a Promise.
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
});
}
};
But even if jQuery's $.ajax()
didn't return a Promise, you can always wrap anything in a new Promise(). All good. Maintainability++!
Now let's abstract away the receiving and storing of the data locally.
Data Repository
Let's assume we need to take the current weather. API returns us the temperature, feels-like, wind speed (m/s), pressure (hPa) and humidity (%). A common pattern, in order for the JSON response to be as slim as possible, attributes are compressed up to the first letter. So here's what we receive from the server:
{
"t": 30,
"f": 32,
"w": 6.7,
"p": 1012,
"h": 38
}
We could go ahead and use API.get('weather').t
and API.get('weather').w
wherever we need it, but that doesn't look semantically awesome. I'm not a fan of the one-letter-not-much-context naming.
Additionally, let's say we don't use the humidity (h
) and the feels like temperature (f
) anywhere. We don't need them. Actually, the server might return us a lot of other information, but we might want to use only a couple of parameters only. Not restricting what our weather module actually needs (stores) could grow to a big overhead.
Enter repository-ish pattern abstraction!
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);
}
}
Now throughout our codebase use WeatherRepository.get()
and access meaningful attributes like .temperature
and .windspeed
. Better!
Additionally, via the _normalizeData()
we expose only parameters we need.
There is one more big benefit. Imagine we need to wire-up our app with another weather API. Surprise, surprise, this one's response attributes names are different:
{
"temp": 30,
"feels": 32,
"wind": 6.7,
"press": 1012,
"hum": 38
}
No worries! Having our WeatherRepository
abstraction all we need to tweak is the _normalizeData()
method! Not a single other module (or file).
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
};
},
/* ... */
};
The attribute names of the API response object are not tightly coupled with our codebase. Maintainability++!
Down the road, say we want to display the cached weather info if the currently fetched data is not older than 15 minutes. So, we choose to use localStorage
to store the weather info, instead of doing an actual network request and calling the API each time WeatherRepository.get()
is referenced.
As long as WeatherRepository.get()
returns a Promise, we don't need to change the implementation in any other module. All other modules which want to access the current weather don't (and shouldn't) care how the data is retrieved - if it comes from the local storage, from an API request, via Fetch API or via jQuery's $.ajax()
. That's irrelevant. They only care to receive it in the "agreed" format they implemented - a Promise which wraps the actual weather data.
So, we introduce two "private" methods _isDataUpToDate()
- to check if our data is older than 15 minutes or not and _storeData()
to simply store out data in the browser storage.
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);
}
};
Finally, we tweak the get()
method: in case the weather data is up to date, we wrap it in a Promise and we return it. Otherwise - we issue an API call. Awesome!
There could be other use-cases, but I hope you got the idea. If a change requires you to tweak only one module - that's excellent! You designed the implementation in a maintainable way!
If you decide to use this repository-ish pattern, you might notice that it leads to some code and logic duplication, because all data repositories (entities) you define in your project will probably have methods like _isDataUpToDate()
, _normalizeData()
, _storeData()
and so on...
Since I use it heavily in my projects, I decided to create a library around this pattern that does exactly what I described in this article, and more!
Introducing SuperRepo
SuperRepo is a library that helps you implement best practices for working with and storing data on the client-side.
/**
* 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`);
});
The library does the same things we implemented before:
- Gets data from the server (if it's missing or out of date on our side) or otherwise - gets it from the cache.
- Just like we did with
_normalizeData()
, thedataModel
option applies a mapping to our rough data. This means:- Throughout our codebase, we will access meaningful and semantic attributes like
.temperature
and.windspeed
instead of.t
and.s
.- Expose only parameters you need and simply don't include any others.
- If the response attributes names change (or you need to wire-up another API with different response structure), you only need to tweak here - in only 1 place of your codebase.
Plus, a few additional improvements:
- Performance: if
WeatherRepository.getData()
is called multiple times from different parts of our app, only 1 server request is triggered. - Scalability:
- You can store the data in the
localStorage
, in the browser storage (if you're building a browser extension), or in a local variable (if you don't want to store data across browser sessions). See the options for thestorage
setting. - You can initiate an automatic data sync with
WeatherRepository.initSyncer()
. This will initiate a setInterval, which will countdown to the point when the data is out of date (based on theoutOfDateAfter
value) and will trigger a server request to get fresh data. Sweet.
- You can store the data in the
To use SuperRepo, install (or simply download) it with NPM or Bower:
npm install --save super-repo
Then, import it into your code via one of the 3 methods available:
- Static HTML:
<script src="/node_modules/super-repo/src/index.js"></script>
- Using ES6 Imports:
// If transpiler is configured (Traceur Compiler, Babel, Rollup, Webpack) import SuperRepo from 'super-repo';
- … or using CommonJS Imports
// If module loader is configured (RequireJS, Browserify, Neuter) const SuperRepo = require('super-repo');
And finally, define your SuperRepositories :)
For advanced usage, read the documentation I wrote. Examples included!
Summary
The abstractions I described above could be one fundamental part of the architecture and software design of your app. As your experience grows, try to think about and apply similar concepts not only when working with remote data, but in other cases where they make sense, too.
When implementing a feature, always try to discuss change resilience, maintainability, and scalability with your team. Future you will thank you for that!
The Importance Of JavaScript Abstractions When Working With Remote Data is a post from CSS-Tricks
from CSS-Tricks http://ift.tt/2xtMCML
via IFTTT
No comments:
Post a Comment