Services

Sean Kennedy edited this page Jan 23, 2018 · 18 revisions

A service is a module that can be made available to handlers and other services throughout an app. A service might seem like an ordinary JavaScript module, but it's different in that it contains lifecycle methods for things like initialization.

Additionally, services make use of dependency injection, making code easier to test.

Creating Services

Services are placed in the services directory of your application. The name of the JS file containing the service determines the service's name.

At a minimum, a service must export an init method. Any other functions exported by a service are available to be invoked after startup.

exports.init = function() {

}

exports.doSomething = function() {

}

The init method is called when the server starts up.

To access other services, rather than having to require them, they're injected as parameters to the init function. Dependency injection relies on the variable name to load the correct service. For example, if you want to use the logger service to log that the service is starting up, add a logger parameter to the init function.

exports.init = function(logger) {
  logger.info('Starting my service');
}

Naming Conventions

JavaScript modules often contain dashes, which cannot be used as a variable name. To get around that, service names are converted to camelCase. For example, a service defined in my-server.js can be loaded through a myService parameter.

For convenience, parameter names can be surrounded by underscores. This is helpful in the case that a parameter dependency-injected during initialization needs to be made available to other functions within a service.

var logger; //will be set after initialization

exports.init = function(_logger_) {
  logger = _logger_;
}

exports.doSomething = function() {
  logger.info('doSomething was called');
}

Asynchronous Startup

Suppose your service needs to perform an asynchronous task during startup. If the last parameter of the init function is named callback, startup will be asynchronous. Any error that occurs during initialization can be passed to the callback to be logged appropriately.

Keep in mind that if the callback is never called, the server won't finish starting.

Example of an asynchronous service.

var fs = require('fs');

var _content
exports.init = function(logger, callback) {
  fs.readFile('/some/file', function(err, data) {
    if (err) {
      return callback(err);
    }
    _content = data.toString();
    callback();
  });
}

Dependencies

Server startup uses dependencies to determine the order to initialize services. As long as no cycles exist within the dependencies, the server will only initialize a service only after all its dependencies have also been initialized.

Accessing services inside of modules

Suppose you have an ordinary module that's loaded via require, and you need to be able to access a service like the logger. You won't be able to use the dependency-injection mechanism because there's no init method.

Instead you can use the global services object to get the logger.

    var logger = services.get('logger');

However, this can be tricky because a required module will likely be loaded before all the services have initialized. To better handle that case, a callback can be specified to make the call asynchronous. The callback won't be called until after the specified module has been initialized.

    services.get('logger', function(logger) {
        logger.info('It worked!');
    });

Built-in Services

Config

Configuration is stored in JSON files within the config directory of the application. The config service is based off of the node-config project. Comments are allowed in the JSON files.

Projects should typically include a defaults.json config file, but additional environment-specific files can be added and merged in automatically. For example, a config file named production.json will be merged in as long as the $NODE_ENV is set to production. See the node-config docs for more detailed information.

Service Config

Most services have a top-level field in the config, e.g. logger for the logger service and express for the express service.

Each field can contain either an array or object.

{
  "service1": {
    "test": true
  },

  "service2": ["foo", "bar"]
}

API

To access the config data, place the config service as an argument in the init method method.

{
  "myConfig": {
    "foo": "bar"
  }
}
exports.init = function(config) {
  var fooValue = config.get('myConfig').foo; //fooValue === "bar"
}

Securing Config Files

The config can contain encrypted strings. If an encrypted string is found in the value of an array or object, the server will prompt for a passphrase during startup.

The format for encrypted values is {cipher}value=, e.g. {aes-256-cbc}cf3d490f602b718d5694e2ca1a231d08=

{
  "data": {
    "field": "{aes-256-cbc}cf3d490f602b718d5694e2ca1a231d08="
  }
}

Generating Encrypted Config

A tool is provided, blueoak-server/bin/encrypt.js, for encrypting values.

Usage is encrypt.js -c <cipher> <key> <data>. The default cipher is aes-256-cbc.

IMPORTANT: To protect against brute force attacks, the key needs to be reasonably long. Even so, we don't recommend checking encrypted values into a public repo.

Bypassing the password prompt

The key/passphrase can either be specified as an environment variable, decryptionKey, or included in the security section of the config file. Storing the key in the config isn't secure and is only suggested for avoiding sensitive data in plain text.

{
  "security": {
    "key": "myKey"
  }
}

Separating Config Into Individual Files

Blocks of config can be placed into separate files.

For example, suppose there's a block of config called service1.

 "service1": {
    "foo": "bar"
  }

Additional config data can instead be placed into a file named service1.json.

{
  "key": "value"
}

Any config from the other config files will be merged with the content from service1.json. In the above case, the final config for service1 will be

{
  "foo": "bar",
  "key": "value"
}

Logger

The logging service uses winston. Out of the box log levels debug, verbose, info, warn, and error are enabled.

  var logger = server.get('logger');
  logger.debug('debug message');
  logger.info('info message');
  logger.warn('warn message');
  logger.error('error message');

Configuration

Configuring transports

Additional transports can be used in place of the default console transport. Here you can see a mongodb transport being used.

 "logger": {
    "transports": [
      {
        "package": "winston-mongodb",
        "field": "MongoDB",
        "options": {
          "db": "test"
        }
      }
    ]
  }

A transport requires a package, field, and options. The package will be loaded through require, the specified field will than be added to the logger with the given options.

For example, to use the file transport, the config might look like

 "logger": {
    "transports": [
      {
        "package": "winston",
        "field": "transports.File",
        "options": {
          "filename": "foo.log"
        }
      }
    ]
  }

Or to use the console transport with a different log level

    "transports": [
      {
        "package": "winston",
        "field": "transports.Console",
        "options": {
          "level": "info",
          "colorize": true
        }
      }
    ]

Programmatically adding transports

If transport settings can't be adequately represented in config, e.g. if you need to add a custom function to a transport, there's an alternative way to add a transport.

Create a logger.js in your application directory with an init method. The winston logger will be passed to the init method during server startup and let you add transports. Keep in mind though that any logger configuration in the config files will be ignored.

The example below creates a Console transport with a custom timestamp function.

var winston = require('winston');

module.exports.init = function(logger) {

    logger.add(winston.transports.Console, {
        timestamp: function() {
            return Date.now();
        }
    });
}

Monitor

The monitor service is a client for communicating with the StatsD daemon. StatsD can then integrate with many back-end monitoring services, such as Librato, Datadog, or Graphite.

Configuration

StatsD communicates via UDP and at a minimum needs a host and a port. The port defaults to 8125. The debug parameter can be enabled to log all monitoring calls to stdout.

"monitor": {
  "host": "localhost"
}

API

The monitoring service uses the Node StatsD client. Check out their documentation for more details.

To use the monitoring service, include the monitor parameter in your init method.

exports.init = function(monitor) {
    monitor.increment('some.stat.value');
}

The service provides several ways to record stats.

increment

Increment a counter by 1, or by the optional value.

monitor.increment('some.counter'); //increment by 1
monitor.increment('some.counter', 10); //increment by 10

decrement

Decrement a counter by 1, or by the optional value.

monitor.decrement('some.counter'); //decrement by 1
monitor.decrement('some.counter', -10); //decrement by 10

gauge

Set a counter to a specific value.

monitor.gauge('some.value', 99); //set counter to 99

unique

Counts unique occurrences of a stat

monitor.unique('some.value', 'foobar');

timing

Record the duration of an event.

monitor.timing('some.time', 55); //record 55 ms

express

Use to get an express function that can be added to a route.

app.get('/hello', monitor.express('myPrefix'), function(req, res) {
  ...
}

Express JS

In addition to the express function, there's also a middleware service for enabling monitoring on all routes.

Cache

The cache service provides a key-value store backed by either node-cache or Redis.

The interface is the same regardless of which backing store is used. It's therefore simple to switch between them simply by modifying config.

Configuration

By default, the node-cache is used. To change it to redis, first set the type to redis in the cache config.

    "cache": {
        "type": "redis"
    }

Then create a redis config block containing the host and port.

    "redis": {
        "host": "localhost",
        "port": "6379"
    }

If using node-cache, additional options can be specified in a node-cache config block. See the node-cache documentation for available options.

    "node-cache": {
        "stdTTL": 10
    }

API

cache.set(key, val, [ttl], [callback)

Put a value in the cache.

cache.set('my.key', {foo: 'bar'});

The value can be any JS object, even null or JSON. The optional ttl (time to live) is a value in seconds.

cache.get(key, callback)

Retrieve a value from the cache. If the key does not exist in the cache, undefined is returned.

cache.get('my.key', function(err, value) {
  ...
});

Swagger

The swagger service exposes info related to the Swagger (OpenAPI) that may be useful to application developers.

A really useful function for exposing the loaded specifications for other uses, say for serving documentation, is the getPrettySpecs(). A good example of this is the bos-openapi-doc-server. If you want to use the dereferenced version of the specs, those that have all $refs inlined, use getSimpleSpecs(). You can get the names of the processed specs using getSpecNames().

Other informational functions include, those that tell you about the config:

  • getResponseModelValidationLevel()
  • isPolymorphicValidationEnabled()

You can also add a validation format, for strings or numbers, using addFormat(format, validationFunction). See tv4.addFormat for more details.

The most powerful function in the swagger service is validateObject(config, model, object). This gives application developers access to the same validation algorithm that runs for request and response bodies.

validateObject

/**
 * Validate an arbitrary object against a model definition in a given specification
 * 
 * @param {Object|String} config - configuration for this validation,
 *          or, simply, the name of the spec to use with the default config
 * @param {String|Object} config.spec - the name of the specification in which the model is defined
 *          or the specification model definition object to use for validation
 * @param {Boolean} [config.banUnknownProperties=false] - whether to fail validation when there are undefined properties
 * @param {Boolean} [config.failFast=false] - whether to stop validation when the first error is found
 * @param {Boolean} [config.skipPolymorphicChecks=false] - whether to disable polymorphic checks
 * @param {String|Object} model - the name of the model, in the given spec, to validate against
 *          or the actual model to use
 * @param {Object} object - the object to be validated
 * 
 * @returns {Object} an object containing the validation result:
 *          .valid is a boolean indicated whether the object validated against the model;
 *          .errors is an array of tv4 validation errors for particular fields;
 *          .polymorphicValidationErrors is an array of tv4 validation errors that only
 *              show with polymorphic validation;
 * 
 * @throws {Error} when the spec or model cannot be found
 */
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.