Simple, but not too simple: how using Zeit’s `micro` improves your Node applications
Leave the Express comfort zone to expand how you think about Node application architecture.
tl;dr
- using a function composition model for building HTTP servers is awesome
- Functions as a Service are great but have some drawbacks
micro
has a similar, simple API to FaaS but doesn’t have a compilation stepmicro
’s minimalism affects how you solve problems
An example comparing micro
and express
can be found at: github.com/HugoDF/micro-vs-express-example.
Table of contents:
Table of Contents
Functions as a composition model for JavaScript
Here are some of my application development beliefs that are relevant to this post:
- Functions are awesome
- An optimal solution is simple, but not too simple
- javascript’s most powerful feature is first-class functions
From the above follows that in JavaScript, composing functions tends to be the a good way to build a solution that’s simple to reason about but built of standard blocks.
That’s one of the reasons why “Functions as a Service” (FaaS), also called “serverless” platforms are attractive for Node developers. As part of building Post Frequenc, the initial approach was to write the backend on Netlify’s lambda platform. At the prototype stage, it worked, there were 2 transactional lambda functions:
- one to get a feed URL from a site URL
- one to parse an RSS/Atom feed from a URL.
Netlify Lambdas (which actually end up deployed as AWS lambdas) have a straightforward API, event in, data out (using a callback or an async function).
I realised that the best way to handle the input is a feed URL
and input is a site URL
dichotomy was to just try fetch as both and see what errors and what doesn’t. ie. I built something like this:
const coerceErrorToObjectProperty = promise =>
promise.catch(error => Promise.resolve({ error }));
function handler () {
// deal with input
const url = "some-url";
Promise.all([
coerceErrorToObjectProperty(parseFeed(url)),
coerceErrorToObjectProperty(getFeedUrl(url))
])
.then(([feedOutput, feedUrlOutput]) => {
if (!feedOutput.error) {
console.log('');
return feedOutput;
}
if (!feedUrlOutput.error) {
console.log('');
return feedUrlOutput;
}
throw new Error(feedOutput.error || feedUrlOutput.error);
})
.then(() => {
// deal with response
})
.catch(() => {
// deal with error
});
}
Functions as a Service pain points
At this point I hit one of my first developer experience issues running on FaaS:
- my backend now needs to be bundled
- debugging minified code is still not nice
This is beyond the cold-start and timeout issues that are usually used to argue against the FaaS route.
With widespread support for Node 8+, there’s less and less reason to transpile your backend code (even if you want to use ES Module syntax, see ES Modules, see Use ES Modules in Node without Babel/Webpack).
Beyond Express: micro
as a library to write simple Node applications
Hopefully this section doesn’t come off as too much of an “Express vs
micro
" debate.I love Express for its stability, maturity and flexibility, it’s has been and still is a true work horse for the Node.js server ecosystem.
micro
is more focused and built with fewer constraints (eg. it can leverage async/await support in Node)
After writing lambdas I was looking for a backend framework that had an API as self-contained as a lambda. That library is micro
. Which lead to the following reflections:
- I have experience with Express but I feel like it has a bit too much friction for tiny apps, which this would be. As most people who have used Express know, you have to install extra packages for middleware behaviour like body-parsing, this comes out of the box with
micro
. - To send a response in Express, you still use callback syntax:
res.json(data)
orres.send(data)
. - A middleware-based extension system is cool but it’s not always as explicit as you would want it to be. The request/response handler sometimes relies on a property being set by an arbitrary middleware up the middleware stack.
The above points are literally some of micro
’s selling points (see https://github.com/zeit/micro#features):
- Easy: Designed for usage with
async
andawait
(more) - Simple: Oriented for single purpose modules (function)
- Standard: Just HTTP!
- Explicit: No middleware - modules declare all dependencies
It also has the following extra’s that are marginal gains for me compared to Express (again from https://github.com/zeit/micro#features):
- Fast: Ultra-high performance (even JSON parsing is opt-in)
- Micro: The whole project is ~260 lines of code
- Agile: Super easy deployment and containerization
- Lightweight: With all dependencies, the package weighs less than a megabyte
With the following counterpoints in my opinion:
- Express is “fast enough”
- Express is “small enough” (even though sometimes running Express in Nodemon inside of Docker leads to 1s+ reload times)
- Express is “easy enough to containerise/deploy”
- Express is “lightweight enough” (an Express app + dependencies is seldom more than 10s of megabytes, compared to Rails or Django apps which easily hit 50-100+ megabytes)
It comes with body-parsing baked in but not much else. Which is a good thing, it keeps to its name.
Here are equivalent apps that respond to a POST that sends a number and increments it by 1 (simple and semi-useless but hey):
Express in an
express-app.js
file:const express = require('express'); const bodyParser = require('body-parser'); const app = express(); app.use(bodyParser.json()); app.post('/', (req, res) => { const { value = 0 } = req.body; return res.json({ value: Number(value) + 1 }); }); app.listen(process.env.PORT || 3000, () => { console.log('Server listening on PORT', process.env.PORT || 3000); });
micro
in amicro-app.js
file:const { json, createError } = require('micro'); module.exports = async (req) => { if (req.method !== 'POST') { throw createError(404, 'Not Found'); // to have the same behaviour as the Express app } const { value = 0 } = await json(req); return { value: Number(value) + 1 }; };
The package.json
looks like the following:
{
"main": "micro-app.js",
"scripts": {
"express": "node express-app.js",
"micro": "micro"
},
"dependencies": {
"body-parser": "^1.18.3",
"express": "^4.16.4",
"micro": "^9.3.3"
}
}
You can find this working example on GitHub: github.com/HugoDF/micro-vs-express-example.
How micro
helps your architecture
Mapping my lambdas into a micro app I used [fs-router](https://github.com/jesseditson/fs-router)
to have 2 routes, one for input is a site URL OR input is a feed URL
and one for input is a feed URL
.
What happened when adding fs-router
was interesting.
In micro, you have to put effort to have routes. This effort is similar to the effort required in Express to have body parsing. Which illustrates what each of these tools considers core to the problem they’re solving vs something that can be done but isn’t the core problem.
micro
’s problem is around building simple, explicit and a large number of (micro)services. This is why routing is not in the core: with microservices, with the right interfaces a chunk of services might not require routing.
express
takes the opposite view, it’s designed to be flexible for any server application on Node. From building simple one-route applications to REST APIs to building fully-featured, single-deployment web applications with server-rendered templates. With this in mind it goes about solving two core problems: routing and a standardised plugin system (based on middleware).
For this project, this friction made me realise that having two endpoints which have duplicated functionality is crazy: input is a feed URL
is already covered by input is a site URL OR input is a feed URL
. So I deleted the second route, removed fs-router
and deployed 👍.
Another feature of micro that I want to illustrate is the following:
const applyMiddlewares = handler =>
health(
log(
cors(
rateLimit(
handler
)
)
)
);
module.exports = applyMiddlewares((req, res) =>
'Service with /health, logging, CORS and rate-limiting'
);
“middleware” is just a set of functions, that are composed and applied to a handler (instead of the more opaque app.use(() => {})
or app.get(``'``/path``'``, middleware1, middleware2, handler)
). The handler defines what augmentations is wants/needs instead of expecting the top-level app to provide them.
The micro
docs, ecosystem and examples
Micro’s docs reflect the library’s philosophy: they’re clean and simple and showcase the few core use-cases and actions, the following is similar to what’s at https://github.com/zeit/micro#usage:
This is the hello world, app.js
module.exports = () => 'Hello world';
You can set the following package.json
:
{
"main": "app.js",
"scripts": {
"start": "micro"
},
"dependencies": {
"micro": "latest"
}
}
Which can then be run with npm start
.
Further information
I’ll be writing up some of the details of how I deploy my micro
service(s) and Express apps, there’s already a teaser on that topic: Deployment options: Netlify + Dokku on DigitalOcean vs now.sh, GitHub Pages, Heroku and AWS.
Feel free to tweet at me @hugo__df.
Get The Jest Handbook (100 pages)
Take your JavaScript testing to the next level by learning the ins and outs of Jest, the top JavaScript testing library.
orJoin 1000s of developers learning about Enterprise-grade Node.js & JavaScript