Promises

A Promise is the eventual result of an asynchronous operation, just like giving someone a promise. Promises help handle errors, which results in writing cleaner code without callbacks. Instead of passing in an additional function that takes an error and result as parameters to every function, you can simply call your function with its parameter and get a Promise:

 getUserinfo('leo', function(err, user){
   if (err) {
    // handle error
     onFail(err);
     return;
   }
  
  onSuccess(user);
 });

versus

var promiseUserInfo = getUserinfo('leo'),

promiseUserInfo.then(function(user) {
  onSuccess(user);
});

promiseUserInfo.catch(function(error) {
  // code to handle error
  onFail(user);
});

The benefit of using Promises isn't obvious if there is only one async operation. If there are many async operations with one depending on another, the callback pattern will quickly turn into a deeply nested structure, while Promises can keep your code shallow and easier to read.

Promises can centralize your error handling and when an exception happens, you will get stack traces that reference actual function names instead of anonymous ones.

In our word game, you could use Promises to turn this:

var onJoinSuccess = function(user) {console.log('user', user.name, 'joined game!'),
  return user;
};

var onJoinFail = function(err) {console.error('user fails to join, err', err);
};

User.join('leo', function(err, user) {if (err) {return onJoinFail(err);
  }

  onJoinSuccess(user);
});

into this:

User.join('leo')
.then(function(user) {onJoinSuccess(user);})
.catch(function(err) {onJoinFail(err);
});

or even simplier:

User.join('leo')
.then(onJoinSuccess)
.catch(onJoinFail);

To understand the execution flow of the preceding, let's create a complete example that calls the user model's join() method, and then add some log statements to see the output:

var User = require('../app/models/user.js'),
var db = require('../db'),

var onJoinSuccess = function(user) {
  console.log('user', user.name, 'joined game!'),
  return user;
};

var onJoinFail = function(err) {
  console.error('user fails to join, err', err);
};

console.log('Before leo send req to join game'),

User.join('leo')
.then(onJoinSuccess)
.catch(onJoinFail);

console.log('After leo send req to join game'),

If a user joins the game successfully, the Promise returned by the User.join() method will be resolved. A newly created user document object will be passed to the onJoinSuccess callback and the output result will be printed as follows:

Promises

If we run this script again, we will see that the user fails to join the game and the error is printed. It fails because the user model already has an index on name property because a user with the name leo was created when we ran the script the first time. When we run it again, we can't create another user with the same name leo, so the Promise fails and the error is passed into onJoinFail.

Promises

A Promise has three states: pending, fulfilled, or rejected; a Promise's initial state is pending, then it Promises that it will either succeed (fulfilled) or fail (rejected). Once it is fulfilled or rejected, it cannot change again. A major benefit of this is that you can chain multiple Promises together and define one error handler to handle all the errors.

As the join() method returns a Promise, we can define the success and fail callbacks as follows.

The then and catch method

The then and catch methods are used to define success and fail callbacks; you might wonder when they are actually being called. When the User.create() method is called, it will return a Promise object and at the same time send an async query to MongoDB. The success callback, onJoinSuccess, is then passed into the then method and will be called when the async query is successfully completed, resolving the Promise.

Once the Promise is resolved, it can't be resolved again, so onJoinSuccess won't be called again, it will only be called once at the most.

Chain multiple Promises

You can chain Promise operations by calling them on the Promise that is returned by the previous then() function. We use the .then() method when we want to do something with the result from the Promise (once x resolves, then do y) as follows:

var User = require('../app/models/user.js'),
var db = require('../db'),

var onJoinSuccess = function(user) {
  console.log('user', user.name, 'joined game!'),
  return user;
};

var onJoinFail = function(err) {
  console.error('user fails to join, err', err);
};

console.log('Before leo send req to join game'),
User.join('leo')
.then(onJoinSuccess)
.then(function(user) {
  return User.findAllUsers();
})
.then(function(allUsers) {
  return JSON.stringify(allUsers);
})
.then(function(val) {
  console.log('all users json string:', val);
})
.catch(onJoinFail);

console.log('After leo send req to join game'),

We can centralize the error handling at the end. It's much easier to deal with errors with Promise chains. If we run the code, we will get the following result:

Chain multiple Promises

Now that we've gone through all the logic and error handling of creating a new user, let's look into how we will ensure that multiple users with the same name can't join.

Prevent duplicates

Earlier, when we defined our user schema, we added index with a unique set to true on the name field:

var schema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    index: {
      unique: true
    }
  }
});

MongoDB will issue a query to see whether there is another record with the same value for the unique property and, if that query comes back empty, it allows the save or update to proceed. If another user joins with the same name, Mongo throws the error: Duplicate Key Error. This prevents the user from being saved and the player must choose another name to join with.

To make sure our code works as we want it to, we need to create tests; we will create a test case with Mocha. The test case will pass a username to the User.join method and expect that the username of the newly created user is valid. The User.join method returns a Promise. If it succeeds, the object returned from the Promise will be sent to the then method; otherwise it fails and the Promise will .reject with an error that will be caught by the catch method.

In the case of the success callback, we have the newly created user, and we can check whether it's correct by expecting user.name to return leo, since leo was entered as the username (illustrated in the following code).

In the case of fail callback, we can pass the error object to Mocha, done(error), to fail the test case. Since we created a user named leo for the first time, we expect this test to pass. Since Mocha tests are synchronous and Promises are async, we need to wait for the function to be done. When the code is successful, it will call the done() function and report success to Mocha; if it fails, the catch method will catch the error and return the error to the done method, which will tell Mocha to fail the test case.

var User = require('../../app/models/user'),

  describe('when leo joins', function() {
    it('should return leo', function(done) {
      User.join('leo')

        .then(function(user) {
          expect(user.name).to.equal('leo'),
          done();
        })
        .catch(function(error) {
          done(error);
        });
    });
  });

Version 1.18.0 or above of Mocha allows you to return a Promise in a test case. Mocha will fail the test case if the Promise fails without needing to explicitly catch the error as given in the following:

  describe('when leo joins', function() {
    it('should return leo', function() {
      return User.join('leo')
        .then(function(user) {
          expect(user.name).to.equal('leo'),
        });
    });
  });

Now that we tested that submitting the first user with a unique name works, we want to test what happens when another user with the same name joins:

 describe('when another leo joins', function() {
    it('should be rejected', function() {
      return User.join('leo')
        .then(function() {
          throw new Error('should return Error'),
        })
        .catch(function(err) {
          expect(err.code).to.equal(11000);
          return true;
        });
    });
  });

When we submit leo again as a username, the Promise of Game.join comes back rejected and goes to the .catch method. The return true turns a failed Promise into a success, which tells us that it succeeded in rejecting the second leo and that we successfully caught the error; we basically swallow the error to tell Mocha that this is the correct behavior we expect.

User leaves the game

When a user leaves the game, we need to remove their entry in the database; this would also free up their user name so that a new user can take it. Mongoose has a delete method called findOneAndRemove, which can find that player by name, and then remove it as shown in the following code.

For our Promises, we use Bluebird (https://github.com/petkaantonov/bluebird) (spec: PromiseA) because of its better performance, utility, and popularity (support).

We call the Promise.resolve method, which creates a Promise that is resolved with the value inside: Promise.resolve(value). Therefore, we can take a method that does not normally return a Promise and wrap it with the Bluebird Promise.resolve method to get a Promise back, which we can then chain with then if it succeeds or catch if it fails. Receiving Promises from our methods will ensure that we deal with successes and errors efficiently and also lets the callee deal with the error when it runs (.exec()).

schema.statics.leave = function(name) {
  return Promise.resolve(this.findOneAndRemove({name: name
  })
  .exec());
};

Show all active users

So far we demonstrated how to add and remove users, we will now dive into how we will display the game data to a user that's joined. To show the total active users, we could simply return all users, as offline users have already been removed. In order to return an array of just the user names, rather than an array of the entire user object, we could use the Promise.map() method to convert each user object in the array into a user name.

Since User.find returns an array of users, we use the Promise.map()method to return the values from the name key. This effectively turns the array of user objects into an array of user names. Again, notice that we use the promise.resolve()method to obtain a Promise from our input. This will allow us to display a list of the currently logged in users by their user name.

schema.statics.findAllUsers = function() {
  return Promise.resolve(User.find({}).exec())
    .map(function(user) {
      return user.name;
    });
};

The words – Subdocuments

We have gone through the game logic involving creating, displaying, and deleting users but what about the meat of the game itself—the words?

In app/models/stat.js, we see how we model our word data. The word field shows the current word, and the used field saves the game's history.

We embedded the used list as subdocuments into the Stat document, so that we can update stats atomically. We will explain this later.

{
  'word': 'what',
  'used': [
    { 'user': 'admin', 'word': 'what' },
    { 'user': 'player1', 'word': 'tomorrow' },
    { 'user': 'player2', 'word': 'when' },
    { 'user': 'player2', 'word': 'nice' },
    { 'user': 'player1', 'word': 'egg' },
  ]
}

The preceding code gives you an overview of what we will store in the database.

We first create a model for our word inputs, new word (word) and used words (used), in a similar method to our user's model, by defining a type (string for new and array for old). The old words are stored in an array so that they can be accessed when we check whether or not a new word has been used before.

var schema = new mongoose.Schema({word: {type: String,required: true},
  used: {type: Array
  },
});

Further logic about validating word inputs and scoring will be described after we create a new game.

When we create a new game, we want to make sure that no old game data exists and that all values in our database are reset, so we will first remove the existing game, and then create a new one, as shown in the following code:

schema.statics.newGame = function() {return Promise.resolve(Stat.remove().exec())
  .then(function() {return Stat.create({word: 'what',used: [{word: 'what',user: 'admin'}]
    });
  });
};

In the preceding example, we use Stat.remove() to remove all old game data and when the Promise is fulfilled, we create a new game using Stat.create() by passing a new word, 'what', to start off the new round and also submit both the word and the user who submitted the word into the used array. We want to submit the user in addition to the word so that other users can see who submitted the current word and also use that information to calculate scores.

Validate input

We can't just accept any word a user might input; users might enter an invalid word (as determined by our internal dictionary), a word that can't chain with the current word or a word that has already been used before in this game.

Our internal dictionary model is found in models/dictionary.js and consists of the dictionary json. Requests with an invalid word should be ignored and should not change the game's state (see app/controllers/game.js); if the word is not in the dictionary, the Promise will be rejected and will not go to Stat.chain().

In the following code example, we illustrate how to check whether the submitted word chains with the current word:

schema.statics.chain = function(word, user) {var first = word.substr(0, 1).toLowerCase();

  return Promise.resolve(Stat.findOne({}).exec())
  .then(function(stat) {var currentWord = stat.word;
    if (currentWord.substr(-1) !== first) {throw Helper.build400Error('not match'),
    }

    return currentWord;
  })
  .then(function(currentWord ) {return Promise.resolve(Stat.findOneAndUpdate({word: currentWord,}, {$push: {used: { 'word': word, 'user': user }}
    }, {upsert: false}).exec();
  });
});

The first step is to query the Stat collection to get the current game state. From the game state, we know the current word that needs to be matched by calling stat.word and assigning it to the variable currentWord.

We then compare the current word with the user's input. First we determine the first letter of the submitted word using calling substr(0, 1) and then we compare it to the last letter of the current word (currentWord ) by calling substr(-1). If the first character of the user's input doesn't match with the last character of the current word of the game, we throw a 400 error. The Promise will catch this error, and call the catch callback to handle the error.

Here, in the model's method, we let the model object return a Promise object. Later on, we will introduce how to catch this error in the controller's method and return a 400 response to the user.

throw Helper.build400Error('not match'),

The Helper.build400Error() function is a utility function that returns a 400 Error with an error message:

exports.build400Error = function(message) {var error = new Error(message);
  error.status = 400;
  return error;
};

If the word can chain with the current word, it's a valid request. We will get back a successful Promise, which allows us to chain with the next then and save the word along with the player's username to the database.

To save the data into the database, we use Mongoose's findOneAndUpdate method, which takes three arguments. The first is a query object to find the document to be updated. We find the stat document where the word is currentWord we get from Stat.findOnequery. The second argument is the update object. This defines what to update.

We use Mongo's modifier $push to push a word chain history into the used field, which is an array. The last argument is options.

We use the { upsert: false } option, which means if we can't find the document with the query defined in the first argument, we won't update or insert a new document. This makes sure no other operation occurs in between the time it takes to find the document and update the document, that is, we don't insert a new word if the current word cannot be found. Therefore, the game status doesn't change because the current word is assigned to word and is still the same.

If we successfully find the word, we add a new used word object to the used word array consisting of the new valid word and the username that submitted it.

Stat.findOneAndUpdate({word: currentWord,}, {$push: {used: { 'word': word, 'user': user }
  }
}, {upsert: false
}).exec();

Dealing with race conditions

You might have questions about the preceding code. Finding a document and updating a document seem like two separate operations; what if two users send the same request? It may cause a race condition.

For example, if the current word is Today, Player 1 submits yes, and Player 2 submits yellow; both players chain a valid word. While both these words are valid, we can't accept both of them for two reasons; only one player can win each round, and if we had two or more winning words, the words could end with different letters, which would affect the next word chain.

If yes arrives at the server first and gets accepted, then the next word should start with an s, and yellow from Player 2 should become invalid and be rejected. This is called a race condition.

How do we solve this? We need to combine the two database operations, finding a document and updating a document, into one operation. We could use Mongoose model's findOneAndUpdate method. This method will actually call the findAndModify method of MongoDB, which is an isolated update and return operation. Since it becomes one database operation, MongoDB will update the document atomically.

schema.statics.chain = function(word, user) {var first = word.substr(0, 1);

  return Promise.resolve(Stat.findOne({}).exec())
  .then(function(stat) {var currentWord = stat.word;

    if (currentWord.substr(-1).toLowerCase() !== first.toLowerCase()) {throw Helper.build400Error('not match'),
    }

    return currentWord;
  })
  .then(function(currentWord) {return Promise.resolve(Stat.findOneAndUpdate({word: currentWord,'used.word': { $ne: word }
    }, {word: word,$push: {used: { 'word': word, 'user': user }}
    }, {upsert: false,
    })
    .exec());
  })
  .then(function(result) {if (!result) {throw Helper.build404Error('not found'),
    }

    return result;
  });
};

When a user submits a word, we first query the current game state, when the Promise is resolved and successful, and then check that the first letter of our submitted word (first) and last letter of the current word (currentWord) are the same.

If they are the same, we call findOneAndUpdate() to search for the submitted word and make sure that it is not present in the array of previously used words. used.word: { $ne: word } then returns a Promise object.

If the Promise comes back fulfilled, then we push the submitted word and user to the used words array.

If the Promise is rejected and/or the conditions are not satisfied, then no data will be pushed into the array (upsert: false).

The last then statement returns the new result; if none was returned, then the not found error will be thrown.

Test case to test race conditions

Now that we implemented the logic, we want to test it out. The test case is shown as follows:

 describe('when player1 and player2 send different valid word together', function() {it('should accept player1's word, and reject player2's word', function(done) {Game.chain('geoffrey', 'hello')
        .then(function(state) {expect(state.used.length).to.equal(4);
          expect(state.used[3].word).to.equal('hello'),
          expect(state.used[3].user).to.equal('geoffrey'),

          expect(state.word).to.equal('hello'),
        });

      Game.chain('marc', 'helium')
        .then(function(state) {done(new Error('should return Error'));
        })
        .catch(function(err) {expect(err.status).to.equal(400);
          done();
        });

    });
  });

As the word by player 1 goes in first, the hello word by player 1 should increase the length of the used array to 4, the current word position in the array should be equal to hello, and the successful user who submitted it should be updated to be geoffrey.

When marc submits a word beginning with h, it should return an error because the last letter of the current word, hello, is o and helium does not begin with o.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.218.157.34