Updating Etherpad for Modern JavaScript


This is a guest post from our contributor Ray Bellis, from Internet Systems Consortium, Inc.
Ray took care of migrating Etherpad code to async/await. His work landed on #3540, and will be part of next Etherpad release (1.8).

 

 


Etherpad is a popular collaborative real-time editing application. It is written to run within the NodeJS server-side JavaScript engine.

A lot of its code is quite old and written using coding paradigms that have since been replaced with much better alternatives. This resulted in a significant “technical debt” and a code base that is difficult to maintain and enhance.

At ISC we use Etherpad extensively. We wanted to add some functionality but found the code base very difficult to work with, and in particular the program’s flow of execution was difficult to analyse. I determined that the code could probably benefit from rewriting parts of it to take advantage of new language features.

With ISC’s support, I therefore recently spent a few weeks working on a significant refactoring of the Etherpad code, with that work described here.

A Brief Intro to Event-Driven Programming

As a language that originally only ran inside the user interface of a web browser, JavaScript is primarily event-driven. Almost every action taken by the code in a <script> tag happens because some event triggered it, whether that be a mouse click, a key press, the expiration of a timer, or the completion of an AJAX request, etc.

Whenever an event happens it gets added to a list of pending events waiting to be processed. The interpreter’s “event loop” takes the first entry from the list, and then passes information about that event in turn to each of the functions that have registered their interest in that event. Each function runs to completion, and then the whole event loop starts over. This is asynchronous programming – the execution of a task is decoupled from the event that triggered it. The flow of code execution is highly non-linear.

In NodeJS it’s just the same. If, for example, you want to retrieve a value from a database, your code doesn’t wait around for that value to become available. You ask the database to start looking for the value, and some time later when it’s ready an event is triggered and the value is returned, asynchronously.

The examples below show calls to the euberDB2 database layer used by Etherpad where each call to the database is asynchronous, i.e. an operation to the database is initiated, and an event is triggered when that operation has completed.

The Old Way – aka “Callback Hell”

Most asynchronous APIs in NodeJS originally used the following model, where the function to be called on completion of the function’s operation is directly passed to that function as a parameter. By convention that callback is (eventually) invoked with two parameters – the first contains any error generated by the function, and the second contains any result. The error parameter is null if there’s no error. This particular pattern of callback function signature is sometimes referred to as a “nodeback”.

In this (slightly contrived) example we are retrieving the values of key1 and key2 from the database and then joining their values and storing that result in key3, and then finally when all that is completed we’re passing control to allDone():

This is only a simple example with three operations taking place, but taken to extreme you can end up with multiple levels of nested functions, which harms readability and maintainability.

Note also that last comment – any code that follows this block will run straight away, before any of the database operations have completed.

The Middle Way – “Promises”

The ES2015 version of JavaScript introduced “Promises” as a standard language feature. In Promise-based APIs, instead of passing a callback to a function, the function returns a special object (a “Promise”) that encapsulates the concept that at some future time it will either resolve with a success value, or reject with an error.

The callbacks are then registered on that Promise object – promise_obj.then(successFn) for success values and promise_obj.catch(errorFn) for errors. The nice thing about Promise API calls is that they can be “chained” and their return values (and errors) can cascade through that chain.

Here’s the Promise version of the above database example (NB: as of now, euberdb2 doesn’t actually support this API, but you can fake it – see below):

However even with Promises you can end up with long blocks of heavily nested code. It’s also very important to ensure that each function does return its Promise, otherwise the chain of calls breaks.

As before, execution of any following continues immediately without waiting for the database operations to complete.

The New Way – await/async

ES2016 took Promises a step further – instead of using .then to get the result of an asynchronous from a returned Promise, you can now await it instead. When the interpreter sees an await expression it actually suspends execution of the calling code at that point (ceding control back to the event loop) and then when the Promise gets resolved it continues right where it left off! If the Promise was rejected instead, then the await call automatically converts that rejected Promise into a thrown exception.

This allows for a much more linear style of programming, making asynchronous programming much easier to comprehend, and allowing synchronous and asynchronous operations to be interspersed at will.

The caveat is that you can only use await within a function that has itself been tagged as an async function:

Since it’s not possible to use await outside of an async function (e.g. at the top level of your script) it’s common for the very outermost function of the application to be wrapped like this:

A function that has been declared as an async function will always return a Promise. The job of await is to wait for that Promise to be resolved or rejected. If you call an async function without using await then you get access to the Promise object itself.

Migration Strategy

To allow for a phased migration from callbacks and async.* functions to await / async and Promises I took the approach of converting each function that was passed a callback into one that instead returns a Promise if no callback function was passed.

This was done by using the thenify module for NodeJS, such that given a function:

I would replace this with:

When all of the lowest level “leaf” functions were converted, I then started working on the places were those functions were called, using await or Promise syntax as appropriate to convert these mid-level functions into ones that would work with either a callback or a Promise.

At each significant change I would re-run the backend and frontend test suites to try to ensure that no functionality had changed or been broken by the updates.

Eventually, large pieces of the code no longer used callbacks at all, at which point I was able to remove all of the thenify wrapping leaving those parts of the code entirely async based.

There are a few remaining places where some functions still need to be passed a callback because they call third party libraries that themselves still only support callbacks. In those cases the lowest level Etherpad code is still written using the async model but the function is wrapped via a module called nodeify that does the opposite to thenify – it adds “nodeback” support to functions that only support Promises.

Reducing use of the async library

The final complication was that the code made extensive use of a library called async that does make some asynchronous operations easier, but is still nowhere near as simple as the later-introduced async keyword.

Example 1 – async.series

A typical usage of that library is this call to async.series, which calls each of the functions in the passed array sequentially.

Should you forget to arrange for the passed callback to get called the chain gets broken.

The await equivalent is just this:

Example 2 – async.forEach

Another commonly-used function was async.forEach which calls an asynchronous function for each member of an array. In this example, the array result gets filled with the return value of calling db.get for each key in the array. Although the operations are all asynchronous, they operate one at a time, with each one started as the previous one completes:

With the await equivalent being:

There’s no explicit error checking required at this level of the code because in the event of an error the await call will throw an exception and terminate the loop and the exception gets passed up to the calling code.

Example 3 – async.parallel

One of the main advantages of asynchronous code is that multiple tasks can actually be running in parallel, for example multiple database updates could be running all at the same time, which would often be more efficient than having them each run one after the other:

This is a case where the underlying Promise API is still of use – the Promise.all function takes an array of Promises, and returns a new Promise that is itself only “resolved” when all of the original Promises have also resolved:

Conclusion

With some fantastic assistance from the Etherpad project’s lead my work was recently merged into the development branch of the code and will form the basis of the next major release.

The resulting code is 15% smaller than before, and is also much easier to comprehend.

Leave a Reply

Your email address will not be published. Required fields are marked *