Product Manager is the core of our system. I know what you are thinking: microservices should be small (micro) and distributed (no central point), but you need to set the conceptual centre somewhere, otherwise you will end up with a fragmented system and traceability problems (we will talk about it later).
Building a dual API with Seneca is fairly easy, as it comes with a quite straightforward integration with Express. Express is going to be used to expose some capabilities of the UI such as editing products, adding products, deleting products, and so on. It is a very convenient framework, easy to learn, and it integrates well with Seneca. It is also a de-facto standard on Node.js for web apps, so it makes it easy to find information about the possible problems.
It is going to also have a private part exposed through Seneca TCP (the default plugin in Seneca) so that our internal network of microservices (specifically, the UI) will be able to access the list of products in our catalogue.
Product Manager is going to be small and cohesioned (it will only manage products), as well as scalable, but it will hold all the knowledge required to deal with products in our e-commerce.
First thing we need to do is to define our Product Manager microservice, as follows:
id
).Our product will be a data structure having four fields: name, category, description, and price. As you can see, it is a bit simplistic, but it will help us to understand the complicated world of microservices.
Our Product Management microservice is going to use MongoDB (https://www.mongodb.org/). Mongo is a document-oriented schema-less database that allows an enormous flexibility to store data such as products (that, at the end of the day, are documents). It is also a good choice for Node.js as it stores JSON objects, which is a standard, created for JavaScript (JSON stands for JavaScript Object Notation), so that looks like the perfect pairing.
There is a lot of useful information on the MongoDB website if you want to learn more about it.
Let's start coding our functions.
To fetch products, we go to the database and dump the full list of products straight to the interface. In this case, we won't create any pagination mechanism, but in general, paginating data is a good practice to avoid database (or applications, but mainly database) performance problems.
Let's see the following code:
/** * Fetch the list of all the products. */ seneca.add({area: "product", action: "fetch"}, function(args, done) { var products = this.make("products"); products.list$({}, done); });
We already have a pattern in Seneca that returns all the data in our database.
The products.list$()
function will receive the following two parameters:
Seneca uses the $
symbol to identify the key functions such as list$
, save$
, and so on. Regarding the naming of the properties of your objects, as long as you use alphanumeric identifiers, your naming will be collision free.
We are passing the done
function from the seneca.add()
method to the list$
method. This works as Seneca follows the callback with error-first approach. In other words, we are creating a shortcut for the following code:
seneca.add({area: "product", action: "fetch"}, function(args, done) { var products = this.make("products"); products.list$({}, function(err, result) { done(err, result); }); });
Fetching by category is very similar to fetching the full list of products. The only difference is that now the Seneca action will take a parameter to filter the products by category.
Let's see the code:
/** * Fetch the list of products by category. */ seneca.add({area: "product", action: "fetch", criteria: "byCategory"}, function(args, done) { var products = this.make("products"); products.list$({category: args.category}, done); });
One of the first questions that most advanced developers will now have in their mind is that isn't this a perfect scenario for an injection attack? Well, Seneca is smart enough to prevent it, so we don't need to worry about it any more than avoid concatenating strings with user input.
As you can see, the only significant difference is the parameter passed called category
, which gets delegated into Seneca data abstraction layer that will generate the appropriate query, depending on the storage we use. This is extremely powerful when talking about microservices. If you remember, in the previous chapters, we always talked about coupling as if it was the root of all evils, and now we can assure it is, and Seneca handles it in a very elegant way. In this case, the framework provides a contract that the different storage plugins have to satisfy in order to work. In the preceding example, list$
is part of this contract. If you use the Seneca storage wisely, switching your microservice over to a new database engine (have you ever been tempted to move a part of your data over MongoDB?) is a matter of configuration.
Fetching a product by ID is one of the most necessary methods, and it is also a tricky one. Not tricky from the coding point of view, as shown in the following:
/** * Fetch a product by id. */ seneca.add({area: "product", action: "fetch", criteria: "byId"}, function(args, done) { var product = this.make("products"); product.load$(args.id, done); });
The tricky part is how id
is generated. The generation of id
is one of the contact points with the database. Mongo creates a hash to represent a synthetic ID; whereas, MySQL usually creates an integer that auto-increments to uniquely identify each record. Given that, if we want to switch MongoDB to MySQL in one of our apps, the first problem that we need to solve is how to map a hash that looks something similar to the following into an ordinal number:
e777d434a849760a1303b7f9f989e33a
In 99% of the cases, this is fine, but we need to be careful, especially when storing IDs as, if you recall from the previous chapters, the data should be local to each microservice, which could imply that changing the data type of the ID of one entity, requires changing the referenced ID in all the other databases.
Adding a product is trivial. We just need to create the data and save it in the database:
/** * Adds a product. */ seneca.add({area: "product", action: "add"}, function(args, done) { var products = this.make("products"); products.category = args.category; products.name = args.name; products.description = args.description; products.category = args.category; products.price = args.price products.save$(function(err, product) { done(err, products.data$(false)); }); });
In this method, we are using a helper from Seneca, products.data$(false)
. This helper will allow us to retrieve the data of the entity without all the metadata about namespace (zone), entity name, and base name that we are not interested in when the data is returned to the calling method.
The removal of a product is usually done by id
: We target the specific data that we want to remove by the primary key and then remove it, as follows:
/** * Removes a product by id. */ seneca.add({area: "product", action: "remove"}, function(args, done) { var product = this.make("products"); product.remove$(args.id, function(err) { done(err, null); }); });
In this case, we don't return anything aside from an error if something goes wrong, so the endpoint that calls this action can assume that a non-errored response is a success.
We need to provide an action to edit products. The code for doing that is as follows:
/** * Edits a product fetching it by id first. */ seneca.edit({area: "product", action: "edit"}, function(args, done) { seneca.act({area: "product", action: "fetch", criteria: "byId", id: args.id}, function(err, result) { result.data$( { name: args.name, category: args.category, description: args.description, price: args.price } ); result.save$(function(err, product){ done(product.data$(false)); }); }); });
Here is an interesting scenario. Before editing a product, we need to fetch it by ID, and we have already done that. So, what we are doing here is relying on the already existing action to retrieve a product by ID, copying the data across, and saving it.
This is a nice way for code reuse introduced by Seneca, where you can delegate a call from one action to another and work in the wrapper action with the result.
As we agreed earlier, the product manager is going to have two faces: one that will be exposed to other microservices using the Seneca transport over TCP and a second one exposed through Express (a Node.js library to create web apps) in the REST way.
Let's wire everything together:
var plugin = function(options) { var seneca = this; /** * Fetch the list of all the products. */ seneca.add({area: "product", action: "fetch"}, function(args, done) { var products = this.make("products"); products.list$({}, done); }); /** * Fetch the list of products by category. */ seneca.add({area: "product", action: "fetch", criteria: "byCategory"}, function(args, done) { var products = this.make("products"); products.list$({category: args.category}, done); }); /** * Fetch a product by id. */ seneca.add({area: "product", action: "fetch", criteria: "byId"}, function(args, done) { var product = this.make("products"); product.load$(args.id, done); }); /** * Adds a product. */ seneca.add({area: "product", action: "add"}, function(args, done) { var products = this.make("products"); products.category = args.category; products.name = args.name; products.description = args.description; products.category = args.category; products.price = args.price products.save$(function(err, product) { done(err, products.data$(false)); }); }); /** * Removes a product by id. */ seneca.add({area: "product", action: "remove"}, function(args, done) { var product = this.make("products"); product.remove$(args.id, function(err) { done(err, null); }); }); /** * Edits a product fetching it by id first. */ seneca.add({area: "product", action: "edit"}, function(args, done) { seneca.act({area: "product", action: "fetch", criteria: "byId", id: args.id}, function(err, result) { result.data$( { name: args.name, category: args.category, description: args.description, price: args.price } ); result.save$(function(err, product){ done(err, product.data$(false)); }); }); }); } module.exports = plugin; var seneca = require("seneca")(); seneca.use(plugin); seneca.use("mongo-store", { name: "seneca", host: "127.0.0.1", port: "27017" }); seneca.ready(function(err){ seneca.act('role:web',{use:{ prefix: '/products', pin: {area:'product',action:'*'}, map:{ fetch: {GET:true}, edit: {GET:false,POST:true}, delete: {GET: false, DELETE: true} } }}); var express = require('express'); var app = express(); app.use(require("body-parser").json()); // This is how you integrate Seneca with Express app.use( seneca.export('web') ); app.listen(3000); });
Now let's explain the code:
We have created a Seneca plugin. This plugin can be reused across different microservices. This plugin contains all the definitions of methods needed by our microservice that we have previously described.
The preceding code describes the following two sections:
seneca.ready()
callback is doing is taking care of the fact that Seneca might not have connected to Mongo before the calls start flowing into its API. The seneca.ready()
callback is where the code for integrating Express with Seneca lives.The following is the package.json
configuration of our app:
{ "name": "Product Manager", "version": "1.0.0", "description": "Product Management sub-system", "main": "index.js", "keywords": [ "microservices", "products" ], "author": "David Gonzalez", "license": "ISC", "dependencies": { "body-parser": "^1.14.1", "debug": "^2.2.0", "express": "^4.13.3", "seneca": "^0.8.0", "seneca-mongo-store": "^0.2.0", "type-is": "^1.6.10" } }
Here we control all the libraries needed for our microservice to run, as well as the configuration.
Integrating with Express is quite straightforward. Let's take a look at the code:
seneca.act('role:web',{use:{ prefix: '/products', pin: {area:'product',action:'*'}, map:{ fetch: {GET:true}, edit: {PUT:true}, delete: {GET: false, DELETE: true} } }}); var express = require('express'); var app = express(); app.use(require("body-parser").json()); // This is how you integrate Seneca with Express app.use( seneca.export('web') ); app.listen(3000);
This code snippet, as we've seen in the preceding section, provides the following three REST endpoints:
/products/fetch
/products/edit
/products/delete
Let's explain how.
First, what we do is tell Seneca to execute the role:web
action, indicating the configuration. This configuration specifies to use a /products
prefix for all the URLs, and it pins the action with a matching {area: "product", action: "*"}
pattern. This is also new for us, but it is a nice way to specify to Seneca that whatever action it executes in the URL, it will have implicit area: "product"
of the handler. This means that /products/fetch
endpoint will correspond to the {area: 'products', action: 'fetch'}
pattern. This could be a bit difficult, but once you get used to it, it is actually really powerful. It does not force use
to fully couple our actions with our URLs by conventions.
In the configuration, the attribute map specifies the HTTP actions that can be executed over an endpoint: fetch will allow GET
, edit will allow PUT
, and delete will only allow DELETE
. This way, we can control the semantics of the application.
Everything else is probably familiar to you. Create an Express app and specify using the following two plugins:
This is all. Now, if we add a new action to our Seneca list of actions in order to expose it through the API, the only thing that needs to be done is to modify the map attribute to allow HTTP methods.
Although we have built a very simplistic microservice, it captures a big portion of the common patterns that you find when creating a CRUD (Create Read Update Delete) application. We have also created a small REST API out of a Seneca application with little to no effort. All we need to do now is configure the infrastructure (MongoDB) and we are ready to deploy our microservice.