9

I'm trying to learn a little about Node and asynchronous programming. I read about Promises and have made an attempt at using them in a small project that copies posts for a user from Service A to Service B. I am having some trouble understanding how best to pass state between Promises

The project is written for NodeJS using the Promise library

A simple definition of my current problem is:

  • Copy posts for a user from Service A to Service B if the posts don't already exist in Service B.
  • Both services offer http APIs that requires a non-memorable user id to look up posts for that user so user id must be looked up from the user name.
  • All of the http calls are asynchronous.

This is some pseudo code that illustrates how I have chained the Promises together.

Promise.from('service_A_username')
  .then(getServiceAUserIdForUsername)
  .then(getServiceAPostsForUserId)
  .then(function(serviceAPosts) {
    // but what? store globally for access later?
    doSomethingWith(serviceAPosts);
    return Promise.from('service_B_username');
  })
  .then(getServiceBUserIdForUsername)
  .then(getServiceBPostsForUserId)
  .done(function(serviceBPosts) {
    // how do we interact with Service A posts?
    doSomethingThatInvolvesServiceAPostsWith(serviceBPosts); 
  });

There are a couple of things that I have thought about doing:

  1. Bring the getIdForUsername call inside the getPostsForUserId function. However, I wanted to keep each unit of functionality as simple as possible along the principle of 'do one thing and do it well'.
  2. Create a 'context' object and pass it through the whole chain, reading and storing state in this object. However this approach makes each function very bespoke to a chain and therefore difficult to use in isolation.

Are there any other options, and what approach is recommended?

Andy Stanton
  • 195
  • 1
  • 9
  • 1
    FWIW, the context object option isn't all *that* bad. If you think of the `getServiceAUserIdForUsername` function as "accepts a `userName` argument and returns a `userId`", then "accepts an object with a `userName` property and fills in a `userId` property" isn't *that* much more specific to a use case. There's definitely more coupling there (calling code and function need to agree on those names, which normally they wouldn't), but the fact is that if you want more than just "one arg results in one return value", you'll need to have some way of identifying the different bits. – T.J. Crowder Apr 06 '14 at 10:28
  • But if you wanted, I could see having a generic aggregator infrastructure so only the calling code knows those property names. The aggregator infrastructure would accept a list of your "one arg yields one result" Promise-enabled functions (so no names known by those functions) and the names to use, and wrap the calls to them to fill in the object. Hmmm. I wonder what that would look like. – T.J. Crowder Apr 06 '14 at 10:31
  • Thanks @t-j-crowder With regard to a context object being passed through a pipeline, are you aware of any JS libraries that make use of this pattern specifically? Java has [Apache Camel](http://camel.apache.org/) which is specifically geared to that sort of routing and I wonder if there is anything similar for js. – Andy Stanton Apr 06 '14 at 11:08
  • 1
    @Andy may I propose a Bluebird solution? It cuts the cruft a lot here... – Benjamin Gruenbaum Apr 06 '14 at 11:34
  • I will take a look @benjamin-gruenbaum, thank you. Are there any other promise/A+ implementations that you consider worth evaluating? – Andy Stanton Apr 06 '14 at 13:13
  • @AndyStanton I've decided to add an answer that doesn't depend on any library but recommends one. I think showing how simple the implementation of these library features can be can dispel some magic :) – Benjamin Gruenbaum Apr 06 '14 at 13:36
  • Bluebird also supports context objects out of the box by the way. Not that it's required in this case :) – Benjamin Gruenbaum Apr 06 '14 at 19:24

2 Answers2

6

First of all good question. This is something we (at least I) deal with with promises often. It's also a place where promises really shine over callbacks in my opinion.

What's going on here basically is that you really want two things that your library doesn't have:

  1. .spread that takes a promise that returns an array and changes it from an array parameter to parameters. This allows cutting things like .then(result) { var postsA = result[0], postsB = result[1]; into .spread(postsA,postsB.

  2. .map that takes an array of promises and maps each promise in an array to another promise - it's like .then but for each value of an array.

There are two options, either use an implementation that already uses them like Bluebird which I recommend since it is vastly superior to the alternatives right now (faster, better stack traces, better support, stronger feature set) OR you can implement them.

Since this is an answer and not a library recommendation, let's do that:

Let's start with spreading, this is relatively easy - all it means is calling Function#apply which spreads an array into varargs. Here is a sample implementation I stole from myself:

if (!Promise.prototype.spread) {
    Promise.prototype.spread = function (fn) {
        return this.then(function (args) {
         //this is always undefined in A+ complaint, but just in case
            return fn.apply(this, args); 
        });

    };
}

Next, let's do mapping. .map on promises is basically just Array mapping with a then:

if(!Promise.prototype.map){
    Promise.prototype.map = function (mapper) {
        return this.then(function(arr){
             mapping = arr.map(mapper); // map each value
             return Promise.all(mapping); // wait for all mappings to complete
        });
    }
}

For convenience, we can introduce a static counterpart of .map to start chains:

Promise.map = function(arr,mapping){
     return Promise.resolve(arr).map(mapping);
};

Now, we can write your code like we actually want to:

var names = ["usernameA","usernameB"]; // can scale to arbitrarily long.
Promise.map(names, getUsername).map(getPosts).spread(function(postsA,postsB){
     // work with postsA,postsB and whatever
});

Which is the syntax we really wanted all along. No code repetition, it's DRY, concise and clear, the beauty of promises.

Note that this doesn't scratch the surface of what Bluebird does - for example, Bluebird will detect it's a map chain and will 'push' functions on to the second request without the first one even finishing, so the getUsername for the first user won't wait to the second user but will actually call getPosts if that's quicker, so in this case it's as fast as your own gist version while clearer imo.

However, it is working, and is nice.

Barebones A+ implementations are more for interoperability between promise libraries and are supposed to be a 'base line'. They're useful when designing specific platform small APIs - IMO almost never. A solid library like Bluebird could significantly reduce your code. The Promise library you're using, even says in their documentation:

It is designed to get the basics spot on correct, so that you can build extended promise implementations on top of it.

Community
  • 1
  • 1
Benjamin Gruenbaum
  • 270,886
  • 87
  • 504
  • 504
3

I would use Promise.all, like this

Promise.all([Promise.from('usernameA'), Promise.from('usernameB')])
    .then(function(result) {
        return Promise.all([getUsername(result[0]),getUsername(result[1])])
    })
    .then(function(result) {
        return Promise.all([getPosts(result[0]),getPosts(result[1])]);
    })
    .then(function(result) {
        var postsA = result[0], postsB = result[1];
        // Work with both the posts here
    });
Esailija
  • 138,174
  • 23
  • 272
  • 326
thefourtheye
  • 233,700
  • 52
  • 457
  • 497
  • I like this answer @thefourtheye. Is it reasonable to keep the promise chains in the array passed to Promise.all as follows: https://gist.github.com/andystanton/f9ba6135a523971b6775 Sorry for the 50 reposts - I didn't realise you can't put code in replies to answers! – Andy Stanton Apr 06 '14 at 11:00
  • 1
    @Andy Well, I gotta say, it looks better than the solution I proposed :) – thefourtheye Apr 06 '14 at 11:01