Introducing chairo, a hapi.js Microservices Plugin

Introduction

Over the past four years hapi grew to be the framework of choice for many projects, big or small. What makes hapi unique is its ability to scale to large deployments and large teams. As a project grows, so does its complexity – engineering complexity and process complexity. hapi’s architecture and philosophy handles the increased complexity without the need to constantly refactor the code or build meta-frameworks on top of it, while keeping the simple cases simple.

hapi being a web application framework is not concerned with how the actual business logic is implemented. It provides the developer with a few hooks (in the form of handlers and extensions) to implement its logic and largely stays out of what goes into these hooks. As the project complexity grows, so does the need to decouple functionality and distribute internal load. The hapi server becomes the outwards facing interface (either via an API or UI) while behind it an array of other technologies is used to break the monolithic business logic into smaller pieces (some of which can themselves use hapi).

As node extends deeper into the full system stack and is used to implement more and more core services all the way down to the database or file system, we need better tools to connect all these components together. While we can certainly use many standalone hapi servers for a distributed RESTful SOA, this might add complexity and overhead that is better addressed with other tools.

Microservices

The basic premise of microservices is to isolate business logic to its smallest components, each implemented separately and with a clear and simple interface. Complex solutions are then broken down into a set of small services which are composed together to provide the combined, orchestrated functionality.

The important part about microservices isn’t the deployment strategy which should be based on load and scale requirements (as well as policies and politics). The focus is on writing the code in a way to allow these services to be deployed as both a monolithic single executable and as many distributed processes based on the evolving needs of the environment in which they run. Such decisions represent a trade-off between software complexity and operational complexity.

A good microservices framework provides the tools to define these components and connect them together through a message bus which supports this range of deployment strategies. As the project grows, services can be moved, changed, or replaced with minimal impact of the rest of the architecture because they can live side-by-side with older versions.

Seneca

Seneca is a microservices framework from nearForm, a leading node consultancy based in Ireland. The nearForm team has been an early adopter for node and is an active member of the community (they organize the European NodeConf franchise among other activities).

The core feature of Seneca is the registration and invocation of actions through simple and powerful pattern matching. Each of these actions (which can be as simple as a single function) represents a microservice which in turn can invoke other actions. To reach another service, you just need to know it’s matching pattern regardless of where it is deployed.

var Seneca = require('seneca');

// Create instance
var seneca = Seneca();

// A microservice for loading a user record from a database
seneca.add({ record: 'user' }, function (message, callback) {
    db.load('user', message.id, callback);
});

// A microservice for information about today
seneca.add({ service: 'today' }, function (message, callback) {

    return callback(null, { date: (new Date()).toString(), weather: 'Sunny' });
});

// Invoking the two services
seneca.act({ record: 'user', id: '123' }, function (err, user) {
    seneca.act({ service: 'today' }, function (err, today) {
        console.log('Hi ' + user.name + '! It is a ' + today.weather + ' day today');
    });
});

And to make things easier, Seneca accepts string patterns as well using a loose JSON format:

seneca.act('record:user,id:123', function (err, user) {
    seneca.act('service:today', function (err, today) {
        console.log('Hi ' + user.name + '! It is a ' + today.weather + ' day today');
    });
});

The combination of the two services can be published as another service:

seneca.add('service:welcome', function (message, callback) {
    seneca.act({ record: 'user', id: message.id }, function (err, user) {
        seneca.act('service:today', function (err, today) {
            return callback(null, {
                message: 'Hi ' + user.name + '! It is a ' + today.weather + ' day today'
            });
        });
    });
});

seneca.act('service:welcome,id:123', function (err, result) {
    console.log(result.message);
})

The chairo plugin

Seneca is ideal for building microservices implementing the bits and pieces of the application business logic. However, its pattern matching routing interface is optimized for internal consumption and less for public exposure of these services. It would be unusual to expose Seneca actions direction as a public API. In addition, Seneca focuses on the backend architecture, not on interfacing with a front end experience (single page application or server-rendered views).

The new chairo (which means “happy” in ancient Greek) plugin brings the power of Seneca to hapi by bridging between these two frameworks and allowing developers to use the richness of serving web and API content via hapi while building their business logic using the Seneca microservices architecture.

chairo is registered with a hapi server like any other plugin using the hapi server.register() method. Once registered it decorates the server and request objects with a reference to the seneca instance initialized:

var Chairo = require('chairo');
var Hapi = require('hapi');

var server = new Hapi.Server();
server.connection();

// Pass options to the Seneca constructor
var senecaOptions = { log: 'silent' };

// Register plugin
server.register({ register: Chairo, options: senecaOptions }, function (err) {

    // Add a Seneca action
    var id = 0;
    server.seneca.add({ generate: 'id' }, function (message, next) {
        return next(null, { id: ++id });
    });

    // Invoke a Seneca action
    server.seneca.act({ generate: 'id' }, function (err, result) {
        // result: { id: 1 }
    });

    server.route({
	method: 'POST',
	path: '/id',
	handler: function (request, reply) {
            // Invoke a Seneca action using the request decoration
            request.seneca.act({ generate: 'id' }, function (err, result) {
            if (err) {
                return reply(err);
            }

            return reply(result);
        });
    }
});

hapi already provides its own version of actions using server methods. While server methods can be cached and used as handlers and prerequisites, they cannot be decoupled from the server implementation and must reside within the same process. The new server.action() method provided by chairo maps a Seneca action pattern to a hapi server method. This allows using Seneca actions anywhere server methods can be used with the Seneca flexibility of maintaining the actual business logic elsewhere.

var Chairo = require('chairo');
var Hapi = require('hapi');

var server = new Hapi.Server();
server.connection();
server.register(Chairo, function (err) {
    // Set up a Seneca action
    var id = 0;
    server.seneca.add({ generate: 'id' }, function (message, next) {
        return next(null, { id: ++id });
    });

    // Map action to a hapi server method
    server.action('generate', 'generate:id', { cache: { expiresIn: 1000 } });

    server.start(function () {
        // Invoke server method
        server.methods.generate(function (err, result1) {
            // Invoke the same server method
            server.methods.generate(function (err, result2) {
                // result1 === result2 (cached)
            });
        });
    });
});

In simple cases, all you want to do is map a Seneca action to a hapi endpoint and proxy the action result back. chairo adds a new reply() interface decorator reply.act() which sends back a handler response using the result of a Seneca action by specifying the action pattern.

server.route({
    method: 'POST',
    path: '/id',
    handler: function (request, reply) {
        // Reply using a Seneca action
        return reply.act({ generate: 'id' });
    }
});

In addition, the act handler shortcut is also provided:

server.route({
    method: 'POST',
    path: '/id',
    handler: { act: 'generate:id' }
});

For more complex cases where a hapi endpoint requires combining data from multiple source, some of which are based on Seneca actions, chairo provides the reply.compose() decorator which renders a template view using the provided template and context object. The context object combines regular object keys with top level keys with a $ suffix which are resolved into the corresponding Seneca actions matching the key’s value pattern.

// Set up a hapi view engine
server.views({
    engines: { html: require('handlebars') },
    path: '../templates'
});

// Add route
server.route({
    method: 'GET',
    path: '/welcome',
    handler: function (request, reply) {
        // Setup context with both Seneca actions and simple keys
        var context = {
            today$: 'service:today',
            user$: { record: 'user', id: 123 },
            general: {
                message: 'Welcome'
            }
        };

        // Reply with rendered view
        return reply.compose('example', context);
    }
});

Using the template ./templates/example.html:

<div>
    <h1>{{general.message}} {{user$.name}}!</h1>
    <h2>Today is {{today$.date}} and it's going to be a {{today$.weather}} day.</h2>
</div>

In addition, the compose handler shortcut is also provided:

server.route({
    method: 'POST',
    path: '/id',
    handler: {
        compose: {
            template: 'example',
            context: {
                today$: 'service:today',
                user$: { record: 'user', id: 123 },
                general: {
                    message: 'Welcome'
                }
            }
        }
    }
});

What’s Next?

The initial version of chairo is a very basic implementation of the Seneca features within the context of the hapi ecosystem. It maps the basic actions functionality and allows simple and elegant composition of API endpoints and web pages in hapi powered by existing or new Seneca deployments. When used with more advanced Seneca configuration, the actions can be moved to other processes, benefiting from the full power of a distributed microservices architecture.

Future versions of this plugin will look to incorporate more Seneca functionality such as data entities, make routing configuration simpler for a large distributed system, and combine the logging functionality of the two frameworks into a unified operations view.

Please give it a try and post questions, feedback, or issues.