Clustering in Node.js (Part 3) – Basic flag parsing/loading for node.js processes

Using PM2’s API to start/stop/restart processes within our collector framework means we need way of passing in configuration data into each process so that each collector knows what to run and with what parameters. There are a few ways of doing this, but in my mind, the clearest and easiest way to do this was to just use command line parameters. It’s familiar to users, allows us to modify parameters on the fly when developing locally (i.e. without looking up and editing config files), and also gives us insight into the process when using os-level command line binaries like ps. Additionally, I knew going forward that our configs would change, though many of our collectors would reuse the same base configuration parameters, so I didn’t want to write a big switch statement with different logic for each inherited collector class. I also had no interest in going back into each class and modifying the same source code multiple times whenever a change was made. Thus, it made sense to me to create a separate config file for each type of collector we had which would extend the base configuration and would be able to launch and error check based on its own specific configuration.

In a nutshell, here’s the behavior I wanted:

  1. Ability to specify a default base configuration for launching a process
  2. Ability to modify the default base configuration easily (e.g. adding or removing config flags)
  3. Abstract out parsing details and logic so a process would always have everything it needs to run, even if a user failed to pass in all necessary config info
  4. Ability to extend the base configuration with different configs based on the type of collector process

To do this, I created a default configuration object with a map of keys corresponding to the flags, where the map contained the name of the key, the default value, and whether the flag was required or not. Using this object, I could iterate through the process.argv values passed into the node.js process to determine which parameters were passed in, if they matched the corresponding configuration file, and throw an error and not launch if there was some error in the config. I only added enough code to meet our needs, so there are some limitations to the configuration, such as reading multiple parameters passed in after a flag (currently, only allows 0 or 1 parameters afterwards).

Take a look:

'use strict'

var InvalidConfigError = require('../../exceptions/InvalidConfigError.js');

/**
* Load Balancer Config is a class that abstracts configuration loading for load balancers
*
* @class LoadBalancerConfig
* @constructor
* @param {Object} configs An array passed in through process.argv
*/
var LoadBalancerConfig = function(configs) {

	/** 
	 * Loads configuration based on the process.argv and fills in defaults where necessary
	 * Note, this will load the config set on the prototype scope
	 */
	function loadConfig(configs) {
		var self=this;

                /* 
                 * Just iterates over the process.argv configs and peeks at the next value if there is one
                 */
		configs.forEach(function(c, index) {
			if (self.config.hasOwnProperty(c)) {
				var peek = self.config[c].peekat;
				if (configs.length > index + peek) {
					self.config[c].value = peek > 0 ? configs[index+peek] : true;
				} else {
					throw new InvalidConfigError('Invalid configuration: ' + c + ' should have a parameter trailing it.')
				}

				if (Object.keys(self.config).indexOf(self.config[c].value) > -1) {
					throw new InvalidConfigError('Invalid configuration: ' + c + ' should have a parameter trailing it.')
				}
			}
		});

                /*
                 * If certain flags are left out, fill them with the default
                 */
		for (var key in self.config) {
			if (self.config[key].value == undefined && self.config[key].default !== undefined) {
				console.log('%s is undefined, so setting default to %s', key, self.config[key].default);
				self.config[key].value = self.config[key].default;
			}
		}
	}

	if (configs) {
		loadConfig.call(this, configs);
	}

	this.getConfig = function() {
		return this.config;
	}
};

//Default configuration
//keys are the argument flags
//peekat is the index to peek at for the value; if 0, then default should be false, so if flag is present, will defualt to true
LoadBalancerConfig.prototype.config = {
	'-p': {
		name: 'port',
		peekat: 1,
		default: 5005,
		required: true
	},
	'-i': {
		name: 'instances',
		peekat: 1,
		default: 1,
		required: true
	},
	'-retry': {
		name: 'retry',
		peekat: 0,
		default: false,
		required: false
	},
	'-name': {
		name: 'name',
		peekat: 1,
		default: 'Load_Balancer',
		required: false
	},
	'-protocol': {
		name: 'protocol',
		peekat: 1,
		required: true
	},
	'-workername': {
		name: 'workername',
		peekat: 1,
		required: false,
		default: 'Worker'
	}
};

/* 
 * @return {Object} config Returns a config object with all the defaults selected
 */ 
LoadBalancerConfig.prototype.getDefault = function() {
	for (var key in this.config) {
		this.config[key].value = this.config[key].default;
	}
	return this.config;
}

module.exports = LoadBalancerConfig;

It turns out that using this pattern allows for us to extend configs very easily. See here:


'use strict';

var LoadBalancerConfig = require('./LoadBalancerConfig.js');
/**
*
* @class NewLoadBalancerConfig
* @constructor
* @param {Object} configs An array passed in through process.argv
*/
var NewLoadBalancerConfig = function(config) {
	LoadBalancerConfig.call(this, config);
}

NewLoadBalancerConfig.prototype = Object.create(LoadBalancerConfig.prototype);
NewLoadBalancerConfig.prototype.config['-p'].default = 5005;
NewLoadBalancerConfig.prototype.config['-protocol'].default = 'udp';
NewLoadBalancerConfig.prototype.config['-name'].default = 'New_Load_Balancer';
NewLoadBalancerConfig.prototype.config['-workername'].default = 'Some Worker';

NewLoadBalancerConfig.prototype.config['-logdir'] = {
	name: 'log directory',
	peekat: 1,
	default: 'logs/',
	required: true
};

module.exports = NewLoadBalancerConfig;



///// Then in the load balancer class, do this:

	var config;
	try {
		config = (new NewLoadBalancerConfig(process.argv)).getConfig();
	} catch (err) {
		if (err instanceof InvalidConfigError) {
			console.log('There was a configuration error in starting up!');
			config = (new NewLoadBalancerConfig()).getDefault();
		} else {
			throw err;
		}
	}

Here you can see how easy it is to extend the base config to do things like replace the defaults or to add new flags that are required by the configuration type. All the options, error handling and loading of configs are kept track by config corresponding to the load balancer. I’ve already had to use this several times to add flags on the fly and it has saved tons of time by letting us not have to go back to each process and modify the logic to handle individual loading of flags. I love simple and easy wins like this.

Here’s an example log output:

-p is undefined, so setting default to 5005
-i is undefined, so setting default to 1
-retry is undefined, so setting default to false
-name is undefined, so setting default to New_Load_Balancer
-protocol is undefined, so setting default to udp
-workername is undefined, so setting default to Some Worker
-logdir is undefined, so setting default to logs/
{ '-p':
   { name: 'port',
     peekat: 1,
     default: 5005,
     required: true,
     value: 5005 },
  '-i':
   { name: 'instances',
     peekat: 1,
     default: 1,
     required: true,
     value: 1 },
  '-name':
   { name: 'name',
     peekat: 1,
     default: 'New_Load_Balancer',
     required: false,
     value: 'New_Load_Balancer' },
  '-protocol':
   { name: 'protocol',
     peekat: 1,
     required: true,
     default: 'udp',
     value: 'udp' },
  '-workername':
   { name: 'workername',
     peekat: 1,
     required: false,
     default: 'Some Worker',
     value: 'Some Worker' },
  '-logdir':
   { name: 'log directory',
     peekat: 1,
     required: true,
     default: 'logs/',
     value: 'logs/' } }
Advertisement