capsule.adrianhesketh.com
Migrating to async/await (Node.js / AWS Lambda / Serverless Framework)
As I mentioned (//2017/07/27/serverless-web-apps-without-client-side-javascript) I'm using the Serverless Framework (serverless.com) to build a new product using Node.js.
I'm fairly new to node, but there was a feature I was really missing, and that's async/await which I think makes Node.js much less cluttered with boilerplate syntax than using callbacks or promises.
When I first tried to use it I got a syntax error. _Of course!_ I should have noticed that the version of Node.js that AWS Lambda currently supports (6.10.3) doesn't include async/await.
Surely there's an easy way to get async/await to work with Serverless?
What are callbacks, promises and async/await anyway?
Callbacks came first, then Promises (in their various guises) then finally async/await. It looks like quite a bit of a mess to anyone who's coming fresh to Node.js, but on the other hand, I've been using a similar feature in C# since 2012, and it's not really any different to the situation there - delegates / event handlers for handling events in the early days (callbacks), then later lambda expressions and `Task` were added (promises), then the language got async / await on top to make the code clearer.
Since there's 3 ways of achieving the same objective, Node.js libraries use all of the different mechanisms depending on how old they are.
I must have missed the official promises developments entirely, since there were a number of various ways of doing it floating about. Fortunately there's a really good interactive guide at [0] which explains how the different systems work and why they're important. This was really helpful for me.
This blog was also handy to guide me in how I could convert code I'd written which used callbacks to using promises. [1]
Once I had my code using Promises, I wanted to step up to async/await to simplify more. Since I can't update Node.js on AWS, I'd need to update my code somehow.
Babel steps up
From doing a bit of React.js, I knew that modern javascript code needs to be "transpiled" (i.e. modified / converted) to run on anything but the very latest Web browsers and that Babel [2] is often used to do this.
So, I knew I needed to get Babel to convert my modern javascript to javascript suitable to run on an older Node.js runtime.
I didn't find Babel's documentation particularly helpful, and a lot of the suggestions on Stackoverflow etc. are out-of-date with current best practice, but thanks to a few issues and questions on Stackoverflow, I worked out what I needed to do:
- Install Babel into my project (`npm install --save-dev babel-cli` and `npm install --save-dev babel-preset-env`)
- Add a `.babelrc` file to tell Babel what rules to apply (in my case, compile to the version of node that the current environment is using - which I've also set to be the same version that AWS Lambda uses)
- You've got to be careful here. If the Node.js version on the machine that's transpiling the code is newer than the target version, then Babel won't do the correct operations.
- Update the `packages.json` to add scripts in to:
- Run the Babel executable against my Node.js code
- Ignore the node_modules folder so that it doesn't attempt to transpile everything.
- Ignore the output folder.
- Run tests against the newly transpiled output.
- Update the Serverless.yml to use the transpiled output (under the /lib directory) which is compatible with the AWS Node.js version.
Migrating the code to use async/await
Once I had support for it, I needed to refactor my code. Here's an example of the process I used to migrate some code I wrote to read from Google's Geolocation API and parse the output into a Location class.
# Callbacks
module.exports.postcodes = (apiKey, postCode, callback) => {
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${escape(stripWhitespace(postCode))}&key=${escape(apiKey)}`;
console.log(`postcode: ${url}`);
fetch(url)
.then(response => response.json())
.then((s) => {
console.log(`postcode: retrieved data: ${JSON.stringify(s)}`);
const rv = new Location(s.results[0].geometry.location.lat,
s.results[0].geometry.location.lng);
callback(null, rv);
})
.catch((error) => {
console.log(`postcode: error: ${error}`);
callback(error, null);
});
};
# Promises
module.exports.postcodes = (apiKey, postCode) => new Promise((resolve, reject) => {
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${escape(stripWhitespace(postCode))}&key=${escape(apiKey)}`;
console.log(`postcode: ${url}`);
fetch(url)
.then(response => response.json())
.then((s) => {
console.log(`postcode: retrieved data: ${JSON.stringify(s)}`);
resolve(createLocationFromGoogleResponse(s));
})
.catch((error) => {
console.log(`postcode: error: ${error}`);
reject(error);
});
});
# async/await
module.exports.postcodes = async (apiKey, postCode) => {
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${escape(stripWhitespace(postCode))}&key=${escape(apiKey)}`;
console.log(`postcode: ${url}`);
const response = await fetch(url);
const locationData = await response.json();
console.log(`postcode: retrieved data: ${JSON.stringify(locationData)}`);
return createLocationFromGoogleResponse(locationData);
};
Usage
function exampleCallback(a, b, callback) {
callback(null, a+b);
}
function examplePromise(a, b) {
return new Promise((resolve, reject) => resolve(a + b));
}
exampleCallback(1, 2, function(err, result) {
console.log(result);
});
// Doesn't work at all. Callbacks and promises are not backwards compatible.
examplePromise(3, 4, function(err, result) {
console.log(result);
});
// Promises are handled with then and catch.
examplePromise(5, 6)
.then(results => console.log(results))
.catch(err => console.log(err));
// But it's possible to await the promise instead inside an async function.
// console.log(await examplePrommise(7, 8));
The usage example shows that it's important to note that the API surface changes when migrating from callbacks to Promises, so calling code _will_ be needed for that, just not from when migrating to async / await.
Conclusion
I think it's worth the effort of back-porting async/await to AWS Lambda to make code much easier to read, despite the added complication of a build step.
# Sources
- https://javascript.info/callbacks
- https://benmccormick.org/2015/12/30/es6-patterns-converting-callbacks-to-promises/
- https://github.com/babel/babel/issues/5532
- https://stackoverflow.com/questions/35748116/babel-ignore-several-directories?noredirect=1&lq=1
- https://stackoverflow.com/questions/10753288/how-to-specify-test-directory-for-mocha
- https://stackoverflow.com/questions/33527653/babel-6-regeneratorruntime-is-not-defined
- https://github.com/babel/babel-preset-env