Showing with 261 additions and 32 deletions.
  1. +45 −10 README.md
  2. +14 −2 application.js
  3. +18 −0 lib/jobs/index.js
  4. +16 −0 lib/router-cache.js
  5. +20 −1 lib/routes/jobs.js
  6. +2 −5 lib/routes/text.js
  7. +71 −10 lib/text/index.js
  8. +64 −1 lib/weather/index.js
  9. +11 −3 package.json
55 changes: 45 additions & 10 deletions README.md
Expand Up @@ -14,7 +14,22 @@ with getting weather information it can lead to response times that are higher
than we or our end users are happy with. This application also facilitates
sending text messages via it's RESTful API.

## Part 1
## Prerequisites
You should install the following on your machine and use the default ports and
settings.

* node.js >= v4.4.3
* npm (will be installed with node.js)
* git
* MongoDB >= v2.4.6
* Redis >= v3.0

*NOTE: Installing Git, node.js (v4 +), npm, and mongodb is outside the scope of
this article, but it's very easy on all major platforms. Getting a Dark Sky API
key is also simple, just go to [darksky.net](https://darksky.net/dev/) and
signup for one.*

## Part 1 ([Blog Link](http://red.ht/2jyKGMB))
In the first part of these blog posts we discuss how building performant mobile
APIs is extremely important if you hope to create a mobile strategy that is
well received by your users. Should the user experience suffer due to poor API
Expand All @@ -25,14 +40,9 @@ For an example of what slow response times look like take a look at the tag
on this repo named _part-one_. If you're not sure how to do that then try the
following from a terminal.

*NOTE: Installing Git, node.js (v4 +), and npm is outside the scope of this
article, but it's very easy on all major platforms. Getting a Dark Sky API key
is also simple, just go to [darksky.net](https://darksky.net/dev/) and signup
for one.*

```
# Clone the repo locally and cd into it's directory
git clone git@github.com:evanshortiss/performant-mobile-apis.git
git clone git@github.com:evanshortiss/performant-mobile-apis.git performant-mobile-apis
cd performant-mobile-apis
# access the tag
Expand Down Expand Up @@ -107,6 +117,31 @@ Percentage of the requests served within a certain time (ms)
```

In each part of this series we will be updating this codebase with optimizations
to improve the performance of this API and get those numbers down lower.

## Part 2
to improve the performance of this API and get those numbers much lower.

## Part 2 ([Blog Link](http://red.ht/2k1G8Lo))
In part two of the blog series we aim to reduce the volume of data transmitted
between the client and server since this will shorten request times on slow
connections, reduce the mobile data usage, and drain less battery.

To view the code for part two simply perform a `git fetch origin` and follow
the steps outlined in part one, but instead change the `git checkout` to use
`part-two`.

If you'd like to see the small changes we made between the original codebase and
part two just run `git diff part-one..part-two`. These changes can reduce
bandwidth usage of this app by up to 95% depending on the structure of the data!

## Part 3
In part 3 we aimed to reduce request processing time. Reducing processing time
improves user experience since your application will feel faster and it can also
reduce battery usage since a connection does not need to be kept open for as
long.

We also utilised the cache-control header to ensure clients that needed the same
resources frequently would use a locally cached copy for a time period defined
by the express application.

To view the code for part three simply perform a `git fetch origin` and follow
the steps outlined in part one, but instead change the `git checkout` to use
`part-three`.
16 changes: 14 additions & 2 deletions application.js
Expand Up @@ -5,6 +5,7 @@ const mbaasApi = require('fh-mbaas-api');
const mbaasExpress = mbaasApi.mbaasExpress();
const cors = require('cors');
const log = require('fh-bunyan').getLogger('application');
const cache = require('lib/router-cache');

const app = express();

Expand All @@ -15,6 +16,8 @@ const app = express();
*/
app.use(cors());

app.use(require('compression')());

// Returns a response time header to easily determine request processing time
app.use(require('response-time')());

Expand All @@ -25,7 +28,9 @@ app.use('/mbaas', mbaasExpress.mbaas);
app.use(mbaasExpress.fhmiddleware());

// Our jobs API for our mobile application, allows us to GET all, or by ID
app.use('/jobs', require('lib/routes/jobs'));
// Returns cached responses for GET requests since it uses our cache middleware
app.use('/jobs', cache, require('lib/routes/jobs'));

// Texting API, allows devices to send a text to a number
app.use('/text', require('lib/routes/text'));

Expand All @@ -34,6 +39,13 @@ app.use(mbaasExpress.errorHandler());

var port = process.env.FH_PORT || process.env.OPENSHIFT_NODEJS_PORT || 8009;
var host = process.env.OPENSHIFT_NODEJS_IP || '0.0.0.0';
app.listen(port, host, function () {
app.listen(port, host, function (err) {
if (err) {
throw err;
}

// Start cron jobs. They will run at the specified cron tab
require('lib/jobs').startJobs();

log.info('App started at: %s on port: %s', new Date(), port);
});
18 changes: 18 additions & 0 deletions lib/jobs/index.js
@@ -0,0 +1,18 @@
'use strict';

const CronJob = require('cron').CronJob;

const messageJob = new CronJob({
onTick: require('lib/text').sendQueuedMessages,
start: false,
cronTime: '* * * * *'
});


/**
* Start all jobs ticking so that they continuously run at the desired time
* @return {void}
*/
exports.startJobs = function () {
messageJob.start();
};
16 changes: 16 additions & 0 deletions lib/router-cache.js
@@ -0,0 +1,16 @@
'use strict';

// cache instance that our middleware will use
const expeditiousInstance = require('expeditious')({
namespace: 'httpcache',
// needs to be set to true if we plan to pass it to express-expeditious
objectMode: true,
// Store cache entries for 1 minute
defaultTtl: 60 * 1000,
// Store cache entries in node.js memory
engine: require('expeditious-engine-memory')()
});

module.exports = require('express-expeditious')({
expeditious: expeditiousInstance
});
21 changes: 20 additions & 1 deletion lib/routes/jobs.js
@@ -1,12 +1,21 @@
'use strict';

const R = require('ramda');
const log = require('fh-bunyan').getLogger(__filename);
const esb = require('lib/legacy-esb');
const Promise = require('bluebird');
const weather = require('lib/weather');

const route = module.exports = require('express').Router();

// Creates a function that will strip listed keys from an object
const omitJobFields = R.omit([
'latitude', 'longitude', 'index', 'about', 'tags'
]);

// Create a function that will loop over jobs and strip fields
const stripUnusedFieldsFromJobs = R.map(omitJobFields);

// Allows us to get a specific job based on id
route.get('/:jobId', function (req, res, next) {
esb.getJobWithId(req.params.jobId)
Expand All @@ -19,6 +28,11 @@ route.get('/:jobId', function (req, res, next) {
return attachWeatherToJob(job);
}
})
.then((job) => omitJobFields(job))
.tap(() => {
// This response can be considered stale after 5 minutes
res.set('cache-control', `max-age=${(5 * 60)}`);
})
.then((job) => res.json(job))
.catch(next);
});
Expand All @@ -33,7 +47,12 @@ route.get('/', function (req, res, next) {
.tap(function () {
log.info('got jobs and weather, now responding');
})
.then((jobsWithWeather) => res.json(jobsWithWeather))
.then((jobsWithWeather) => stripUnusedFieldsFromJobs(jobsWithWeather))
.tap(() => {
// This response can be considered stale after 5 minutes
res.set('cache-control', `max-age=${(5 * 60)}`);
})
.then((formattedJobs) => res.json(formattedJobs))
.catch(next);
});

Expand Down
7 changes: 2 additions & 5 deletions lib/routes/text.js
Expand Up @@ -17,12 +17,9 @@ route.post('/:number', function (req, res, next) {
});
}

log.info('sending text to %s', number);

// Fetch jobs and send to the client
text.sendText(number, req.body)
text.queueMessage(number, req.body)
.tap(function () {
log.info('sucessfully sent text to %s', number);
log.info('sucessfully queued text to be sent to %s', number);
})
.then((status) => res.json(status))
.catch(next);
Expand Down
81 changes: 71 additions & 10 deletions lib/text/index.js
Expand Up @@ -3,29 +3,90 @@
const log = require('fh-bunyan').getLogger('text');
const utils = require('lib/utils');
const Promise = require('bluebird');
const db = Promise.promisify(require('fh-mbaas-api').db);

const MESSAGE_STATES = {
UNSENT: 'unsent',
SENT: 'sent'
};

const MESSAGE_COLLECTION = 'text-messages';

/**
* This function pretends it sent a text message and responds with a "success"
* style JSON response
* @return {Array}
* @return {void}
*/
function sendMessage (number, params) {
return {
status: 'message "' + params.message + '" sent to ' + number
};
log.debug(`attempt to send message "${params.message}" to ${number}`);
return Promise.delay(utils.getRandomDelay())
.then(() => {
log.debug(`message "${params.message}" sent to ${number} successfully`);
});
}


/**
* Send a queued message and update its state if done successfully
* @param {Object} msg
* @return {Promise}
*/
function sendQueuedMessage (msg) {
return sendMessage(msg.fields.number, msg.fields.params)
.then(() => {
// Set state to sent
msg.fields.state = MESSAGE_STATES.SENT;

// Mark as sent in mongodb
return db({
act: 'update',
type: MESSAGE_COLLECTION,
guid: msg.guid,
fields: msg.fields
});
});
}


/**
* Returns a promise that will resolve with the status of the text operation
* @return {Promise}
*/
exports.sendText = function (number, params) {
log.info('sending text to %s', number);
exports.queueMessage = function (number, params) {
log.info('queuing text to be sent to %s', number);

// Simulate the delay of an API call to a text messaging service
return Promise.delay(utils.getRandomDelay())
.then(function () {
return sendMessage(number, params);
// Store the message
return db({
act: 'create',
type: MESSAGE_COLLECTION,
fields: {
number: number,
params: params,
state: MESSAGE_STATES.UNSENT
}
})
.thenReturn({
status: `message "${params.message}" will be sent to ${number}`
});
};


/**
* Loops over queued messages and sends each.
* @return {Promise}
*/
exports.sendQueuedMessages = function () {
log.info('start sending any messages that are queued');

return db({
act: 'list',
type: MESSAGE_COLLECTION,
eq: {
state: MESSAGE_STATES.UNSENT
}
})
.then((ret) => Promise.map(ret.list, sendQueuedMessage))
.then(() => {
log.info('finished sending all queued messages');
});
};