Earlier, we discussed that in microservices architecture, we create a separate service for every component, so you must be wondering what's the point of using a framework for creating services that can be done by simply writing some code to listen to a port and reply to requests. Well, writing code to make requests, send responses, and parse data requires a lot of time and work, but a framework like Seneca makes all this easy. Also, converting the components of a monolithic core to services is also a cumbersome task as it requires a lot of code refactoring, but Seneca makes it easy by introducing the concepts of actions and plugins. Finally, services written in any other programming language or framework will be able to communicate with Seneca services.
In Seneca, these operations of a component of monolithic core are written using actions, which we may later want to move from monolithic core to a service and expose it to other services and monolithic cores via a network.
A Seneca service exposes actions to other services and monolithic cores. While making requests to a service, we need to provide a pattern matching an action's pattern to be called in the service.
Seneca also has a concept of plugins. A seneca
plugin is actually a set of actions that can be easily distributed and plugged in to a service or monolithic core.
As our monolithic core becomes larger and complex, we can convert components to services. That is, move actions of certain components to services.
Creating your first Seneca application
Let's create a basic application using Seneca to demonstrate how to use it. We will create an application that allows users to log in and register. This will be a demo application just to demonstrate how actions, plugins, and services can be created, and not how login and registration functionality works.
Before you proceed further, create a directory named seneca-example
and place a file named package.json
in it. Inside the seneca-example
directory, we will place our services and monolithic core. Inside the package.json
file, place the following code so that npm will be able to download the dependencies for our application:
Here we are using Seneca version 0.6.5. Make sure that you are also using the same version to avoid code incompatibility.
Now run the npm install
command inside the seneca-example
directory to install Seneca and other packages locally.
Now create a file named main.js
that will be the monolithic core of our server side application.
The monolithic core and services are all seneca
instances programmatically. Place this code in the main.js
file to create a seneca
instance:
Now using this seneca
object, we are going to add actions, call actions, add plugins, and everything.
Let's create actions for login and registration functionality and place them in the main.js
file. To create actions, we need to use the add
method of the seneca
object. The first argument of the add
method takes a JSON string or object that is the action identifier (called pattern to identify the action). The second argument is a callback that will be executed when the action is invoked.
Place this code in the main.js
file that creates two actions for login and registration, as follows:
We will see the code for the body of the actions later in this chapter.
There is nothing special about role
and cmd
properties. You can use any property names you wish too.
The second argument is a callback, which will be invoked when the action is called.
If there are multiple actions with the same pattern, then the later overrides the others.
We need to use the act
method of the seneca
object to invoke an action that's local to the instance or resides on some other service. The first argument of the act
method is a pattern to match an action, and the second argument is a callback that will be executed once the action has been invoked.
Here is an example code that shows how to call the preceding two actions:
The callback passed to the act
method is executed asynchronously once the result of the action arrives.
Here, the object we passed to the act
method has two extra properties than the action's pattern it is supposed to match. However, the action is still matched and invoked because in case the pattern passed to the act
method has more properties than the action's pattern it is supposed to match, Seneca finds all the action's patterns whose properties are in the pattern passed to the act
method and invokes the one that has the highest number of matching properties.
If Seneca finds multiple action patterns with equal number of matching properties, then they are matched in ascending alphabetical order.
A Seneca plugin is just a set of related actions packed together. Programmatically, a seneca
plugin can be created using a function or module.
A plugin makes it easy to distribute a set of actions among applications. You will also find seneca
plugins in online public package registry maintained by npm. For example, there is a seneca
plugin that provides actions to work with the MongoDB database. This plugin can be inserted into monolithic cores or services with just a single line of code.
By default, Seneca installs four built-in plugins when we create a seneca
instance. These plugins are basic, transport, web, and mem-store.
Let's first create a plugin using a function. The function name is the plugin name, and a plugin can also have an initialization action, which will be invoked as soon as the plugin is attached to the seneca
instance.
So, let's create a plugin named account
and place the login and register actions in that, as later on, we will be creating a service and moving the actions there. Remove the actions we defined earlier in the main.js
file and place the following code instead:
Here we defined a function named account
and attached it using the use
method of the seneca
object. To attach multiple plugins, we can call the use
method multiple times.
The init:account
action is the initialization action invoked by Seneca once the plugin is added. This can be used to do things such as establishing database connection or other things that the actions of the plugin depend on.
The this
keyword inside the plugin refers to the seneca
instance.
Let's create the same plugin using a module so that it's easily distributable and can be put up in the npm
registry. Create a file named account.js
and place it in the seneca-example
directory. account.js
is the plugin module. Place this code inside the account.js
file:
Here is the plugin name in the string returned by the anonymous function.
Remove the plugin code that we previously defined in the main.js
file and place the following code instead:
Here, to attach the plugin, we are providing the module path.
A service is a seneca instance that exposes some actions via network. Let's create a service that exposes the login and register actions.
Create an account-service.js
file in the seneca-example
directory that will act as the service. Then place the following code in it to create a service that exposes the login and register actions:
Here, we first created a seneca
instance. Then we added actions via a plugin. You can also manually add actions using the add
method of the seneca
object. Finally, we exposed the actions via an HTTP protocol. Seneca also supports other protocols, but we will stick to HTTP, as it's the most commonly used one.
seneca.listen
creates an HTTP server to listen to requests. We also provided the port number and pin, which are optional. The default port is 10101
, and by default, there is no pin if not provided.
You must be wondering what is a pin and what is it used for? Well, you may not always want to expose all the actions of the service via a network. In that case, you can provide a pattern to the pin
property and the server will handle these requests that match the pin
pattern.
Now, for other services or monolithic cores to be able to call the actions of this service, they need to register this service.
Remove the previous plugin attachment code from the main.js
file and add the following code to register the service:
Here we are registering the service by providing the port
number and pin
. Both of them are optional. In case if we don't use any port number, then it defaults to 10101
. In case the service is on different server, then you should use the host
property to provide the IP address.
The pin
attached to the client
method is used to tell the seneca
instance about what actions are exposed by the service. It's completely optional. Seneca won't send requests to a service that doesn't match the pin
pattern.
You can add as many services as you want by calling the client method multiple times.
When you call the act
method to invoke an action, the seneca
instance first looks for the action locally before requesting services. If it's not found locally, then it checks for the services that have a pin to see if it matches any. If a pin matches, then it sends request to this particular service. Finally, if any of the pin doesn't match, it sends the requests one by one to all other services that don't have a pin till it gets a valid response from one of them.
You can also manually call an action of a service by sending the GET request to these types of URL:
You can also call a service by using the POST request. Here is how to do it using CURL:
Seneca provides a built-in mechanism to store data. Seneca provides some built-in actions that allow us to store data. The built-in actions use mem-store
to store data by default. mem-store is an asynchronous in-memory storage system.
You can create your application using the default storing mechanism. In case you want to change the underlying store system, you just need to install plugin for this particular storage system that will overwrite the built-in storage actions, therefore you will not have to refactor any code.
The built-in actions to do the CRUD operations are as follows:
role:entity,cmd:load,name:<entity-name>
: This is used to retrieve an entity using its ID. An entity can be thought of as a row in MySQL. Every entity gets a unique ID.role:entity,cmd:save,name:<entity-name>
: This is used to update (if you provide entity ID) or add an entity if it does not exist. Entities are stored and retrieved in form of objects.role:entity,cmd:list,name:<entity-name>
: This is used to list all the entities that are matching a query.role:entity,cmd:remove,name:<entity-name>
: This is used to remove an entity using its ID.
Seneca also provides some wrapper functions that extract these actions and make it easy to call these actions. These functions are load$
, save$
, list$
, and remove$
.
Let's implement the login and register actions to allow us to log in and also register new accounts.
Here is the implementation of the account action. Update this code in the account.js
file:
The first argument of the callback passed to the add
method holds reference to the object that matched against the pattern of the action.
Here we are first calling the make
method of the seneca
object. It's used to get reference of an entity's store. For example, in case of MySQL, the make
method gets reference to a table.
Then, we will find whether there are any entities with the username and password passed by the act
method. As entities are added as objects, to query for entities, we need to pass an object. Now list$
looks for all entities with the same username and password.
We are passing a callback to the $list
method that will be invoked asynchronously once the data is retrieved. This callback takes two parameters, that is, the first parameter is an error
object if there is an error, otherwise null
.
Similarly, the second parameter is an array of entities found matching the given object.
For the action to respond back, it needs to call the second parameter of the action callback by passing it an object as the second argument. In case an error has occurred, we need to pass the error in the first argument.
Similarly, now let's write the code for the register action, as follows:
Here, most of the code is understandable as it works the same way as the previous action. To store data, we are creating a new entity store reference using the data$
method by passing the entity we want to store. Then we are calling the save$
method to save the entity.
Integrating Express and Seneca
We have completed creating our login and register actions. Now, as our backend will be used by an app or it may represent as a website, we need to provide URLs to the clients who will use them to talk to the server.
Monolithic core is the part of our server-side application that the client interacts with for most of the functionality. Clients can also interact with services directly for some specific functionality if required.
So, we need to use some sort of website development framework in the monolithic core and services of our server-side application. We will be using Express, as it's the most popular one.
Seneca also provides a built-in way to map the URLs to actions, that is, requests made to an HTTP server can be automatically mapped to a particular action to invoke them. This is done using a definition object whose properties define a route mapping from URLs to action patterns. This built-in method defines route mapping independent of the framework being used. Once we have defined the definition objects, we need a plugin specific to the web server framework that will capture and resolve the URLs to action patterns using the definition objects. Definition object allows you to attach callbacks that will get the response of the action via a parameter, and then the callbacks can return the data to the user in whatever format they want. This can be useful in case you are creating a plugin for distribution that exposes a few actions that need to be called for specific URL requests, then you will have to use the built-in method, as it defines route mapping independent of the framework being used.
Add the following code to the main.js
file to start the Express server in it:
On the second line, we are exporting a middleware function provided by the seneca-web
plugin. seneca-web
is the plugin to integrate Seneca and Express directly, that is, to translate URLs into action patterns using the definition object for Express framework. This is only required if we use the definition object to define route mapping. We won't be using definition objects, but we should still use seneca-web
, as some third-party plugins may use definition objects if we are using these plugins. For example, if you are using the seneca-auth
plugin, then you will have to include second line.
We want the user to be able to log in using the /account/login
path and register using the /account/register
path. The user will provide a username and password via query string. Here is the code to define routes to handle HTTP requests for login and registration:
Here we are calling the appropriate actions depending on the path of the URL request.
Here, instead of using seneca.act
, we are using httpRequest.seneca.act
as the middleware function that we exported earlier adds a new seneca
property to request the object of every HTTP requests. This property inherits the actual seneca
instance. Actions in the third-party plugins add information in form of properties to the seneca
property in order to share information related to a particular HTTP request with other route handers. The preceding code will behave in the same way even if we use seneca.act
, but it's a good practice to use httpRequest.seneca.act
as we may use such types of plugins. Your own route handlers can also use httpRequest.seneca.act
to pass information related to seneca
to each other.
Now, to run the application, first run the account-service.js
file and then the main.js
file. You can then log in and register using the following URLs:
http://localhost:8080/account/login?username=narayan&password=mypassword
http://localhost:8080/account/register?username=x&password=mypassword
Here, we saw how to create a web interface, which can be used by an app or to serve HTML pages in case it's a website.
We can also move the routes of Express to a service if we want a different service to handle certain requests.