Original text: http://onmr.com/press/getting-started-seneca.html
Seneca It's a toolkit that allows you to quickly build a message-based micro-service system. You don't need to know where services are deployed, how many services exist, or what they do. Any service outside your business logic (such as database, caching, third-party integration, etc.) is hidden behind the micro-service.
This decoupling makes your system easy to build and update continuously. Seneca can do this because of its three core functions:
Pattern matching: Unlike fragile service discovery, pattern matching is designed to tell the world what news you really care about.
Dependent Transport: You can send messages between services in many ways, all of which are hidden behind your business logic.
Componentization: Functions are represented as a set of plug-ins that together make up microservices.
In Seneca, a message is a JSON object that can have any internal structure you like. They can be transmitted by HTTP/HTTPS, TCP, message queue, publish/subscribe service or any way that can transmit data. For you as a message producer, you just need to send the message out, and you don't need to care about which services to receive it. People.
Then, you want to tell the world that you want to receive some messages. It's also very simple. You just need to configure a matching pattern in Seneca. The matching pattern is also very simple. It's just a list of key-value pairs, which are used to match the extreme group properties of JSON messages.
In the next part of this article, we will build some micro services based on Seneca.
Patterns
Let's start with a very simple piece of code. We'll create two micro services, one for mathematical computation and the other for invoking it:
const seneca = require('seneca')(); seneca.add('role:math, cmd:sum', (msg, reply) => { reply(null, { answer: ( msg.left + msg.right )}) }); seneca.act({ role: 'math', cmd: 'sum', left: 1, right: 2 }, (err, result) => { if (err) { return console.error(err); } console.log(result); });
Save the above code into a js file and execute it. You may see messages like the following in console:
{"kind":"notice","notice":"hello seneca 4y8daxnikuxp/1483577040151/58922/3.2.2/-","level":"info","when":1483577040175} (node:58922) DeprecationWarning: 'root' is deprecated, use 'global' { answer: 3 }
So far, all of this has happened in the same process, no network traffic has been generated, and the function calls in the process are also based on message transmission.
The seneca.add method adds a new action pattern (_Action Pattern_) to the Seneca instance with two parameters:
Pattern: A pattern used to match the JSON message body in Seneca instances;
Action: An action performed when a pattern is matched
The seneca.act method also has two parameters:
msg: Inbound messages to be matched as pure objects;
Respons: A callback function used to receive and process response information.
Let's go through all the code again:
seneca.add('role:math, cmd:sum', (msg, reply) => { reply(null, { answer: ( msg.left + msg.right )}) });
The Action function in the code above calculates the sum of the values of the two attributes left and right in the matched message body. Not all messages will be created a response, but in most cases, a response is required. Seneca provides a callback function to respond to the message.
In the matching pattern, role:math, cmd:sum matches the following message body:
{ role: 'math', cmd: 'sum', left: 1, right: 2 }
The results are taken into account.
{ answer: 3 }
There's nothing special about role s and cmd attributes, but they just happen to be used for matching patterns.
Next, the seneca.act method sends a message with two parameters:
msg: the body of the message sent
Respons_callback: If the message has any response, the callback function will be executed.
The callback function of the response receives two parameters: error and result. If any error occurs (for example, the message sent is not matched by any pattern), the first parameter will be an Error object. If the program executes in the desired direction, the second parameter will receive the response result. In our example, we only have It simply prints the result of the received response to console.
seneca.act({ role: 'math', cmd: 'sum', left: 1, right: 2 }, (err, result) => { if (err) { return console.error(err); } console.log(result); });
sum.js Sample files show you how to define and create an Action and how to call an Action, but they all happen in a process, and then we'll show you how to split different code and multiple processes very quickly.
How does the matching pattern work?
Patterns -- rather than network addresses or sessions -- make it easier for you to expand or enhance your system, making it easier to add new microservices.
Now let's add a new function to the system - calculating the product of two numbers.
The message we want to send looks like the following:
{ role: 'math', cmd: 'product', left: 3, right: 4 }
The results then look like the following:
{ answer: 12 }
You know what to do? You can create a role: math, cmd: product operation like role: math, cmd: sum mode.
seneca.add('role:math, cmd:product', (msg, reply) => { reply(null, { answer: ( msg.left * msg.right )}) });
Then, the operation is invoked:
seneca.act({ role: 'math', cmd: 'product', left: 3, right: 4 }, (err, result) => { if (err) { return console.error(err); } console.log(result); });
Function product.js You will get the results you want.
Put these two methods together, and the code looks like the following:
const seneca = require('seneca')(); seneca.add('role:math, cmd:sum', (msg, reply) => { reply(null, { answer: ( msg.left + msg.right )}) }); seneca.add('role:math, cmd:product', (msg, reply) => { reply(null, { answer: ( msg.left * msg.right )}) }); seneca.act({role: 'math', cmd: 'sum', left: 1, right: 2}, console.log) .act({role: 'math', cmd: 'product', left: 3, right: 4}, console.log)
Function sum-product.js After that, you will get the following results:
null { answer: 3 } null { answer: 12 }
In the code merged above, we find that seneca.act can be called in a chain. Seneca provides a chain API. The call is executed sequentially, but not serially, so the order of the results returned may not be the same as the call order.
Extend the schema to add new functionality
Patterns make it easier for you to extend the program's functionality, unlike if...else... grammar, you can achieve the same functionality by adding more matching patterns.
Let's expand the role: math, cmd: sum operation, which only receives integer numbers. So, how?
seneca.add({role: 'math', cmd: 'sum', integer: true}, function (msg, respond) { var sum = Math.floor(msg.left) + Math.floor(msg.right) respond(null, {answer: sum}) })
Now, here's the message:
{role: 'math', cmd: 'sum', left: 1.5, right: 2.5, integer: true}
The following results will be obtained:
{answer: 3} // == 1 + 2, the decimal part has been removed
Code available sum-integer.js Check in.
Now that both of your patterns exist in the system and there are intersections, which pattern will Seneca eventually match messages to? The principle is: more matching items are matched first, and the more attributes are matched, the higher the priority is.
pattern-priority-testing.js Can give us a more intuitive test:
const seneca = require('seneca')() seneca.add({role: 'math', cmd: 'sum'}, function (msg, respond) { var sum = msg.left + msg.right respond(null, {answer: sum}) }) // The following two messages match roles: math, cmd: sum seneca.act({role: 'math', cmd: 'sum', left: 1.5, right: 2.5}, console.log) seneca.act({role: 'math', cmd: 'sum', left: 1.5, right: 2.5, integer: true}, console.log) setTimeout(() => { seneca.add({role: 'math', cmd: 'sum', integer: true}, function (msg, respond) { var sum = Math.floor(msg.left) + Math.floor(msg.right) respond(null, { answer: sum }) }) // The following message also matches role: math, cmd: sum seneca.act({role: 'math', cmd: 'sum', left: 1.5, right: 2.5}, console.log) // However, it also matches role:math,cmd:sum,integer:true // But because more attributes are matched, it has a higher priority. seneca.act({role: 'math', cmd: 'sum', left: 1.5, right: 2.5, integer: true}, console.log) }, 100)
The output should look like the following:
null { answer: 4 } null { answer: 4 } null { answer: 4 } null { answer: 3 }
In the above code, because only role: math, cmd: sum mode exists in the system, so it is matched, but when 100ms later, we add a role: math, cmd: sum, integer: true mode to the system, the result is different, matching more operations will have a higher priority.
This design can make it easier for our system to add new functions. Whether in the development environment or in the production environment, you can update new services without modifying the existing code. You just need to start the new services first.
Pattern-based code reuse
Schema operations can also call other operations, so that we can meet the requirements of code reuse:
const seneca = require('seneca')() seneca.add('role: math, cmd: sum', function (msg, respond) { var sum = msg.left + msg.right respond(null, {answer: sum}) }) seneca.add('role: math, cmd: sum, integer: true', function (msg, respond) { // Reuse role:math, cmd:sum this.act({ role: 'math', cmd: 'sum', left: Math.floor(msg.left), right: Math.floor(msg.right) }, respond) }) // Match role:math,cmd:sum seneca.act('role: math, cmd: sum, left: 1.5, right: 2.5',console.log) // Match role:math,cmd:sum,integer:true seneca.act('role: math, cmd: sum, left: 1.5, right: 2.5, integer: true', console.log)
In the example code above, we used this.act instead of seneca.act, because in the action function, the context relation variable this refers to the current Seneca instance so that you can access the entire context of the action call in any action function.
In the above code, we use JSON abbreviations to describe patterns and messages. For example, the following is the object literal quantity:
{role: 'math', cmd: 'sum', left: 1.5, right: 2.5}
The abbreviation mode is:
'role: math, cmd: sum, left: 1.5, right: 2.5'
jsonic This format provides a simple way to express objects in string literals, which allows us to create simpler patterns and messages.
The above code is saved in sum-reuse.js In the document.
Patterns are unique
The Action patterns you define are unique. They can only trigger one function. The parsing rules of the patterns are as follows:
More My Attributes Priority is Higher
If the pattern has the same number of attributes, it matches alphabetically
The rules are designed very simply, which makes it easier for you to know which pattern is matched.
The following examples can make it easier for you to understand:
a: 1, b: 2 takes precedence over a: 1 because it has more attributes;
a: 1, b: 2 takes precedence over a: 1, c: 3, because B is in front of the letter c;
a: 1, b: 2, d: 4 takes precedence over a: 1, c: 3, d:4, because B is in front of the letter c;
a:1, b: 2, c: 3 takes precedence over a:1, b: 2, because it has more attributes;
a: 1, b:2, c:3 takes precedence over a:1, c:3 because it has more attributes.
Many times, it's necessary to provide a way to add functionality without altering the code of an existing Action function. For example, you might want to add more custom attribute validation methods to a message, capture message statistics, add additional database results, or control message flow.
In my example code below, the addition operation expects a finite number of left and right attributes. In addition, for debugging purposes, it is useful to attach the original input parameters to the output results. You can use the following code to add validation checks and debugging information:
const seneca = require('seneca')() seneca .add( 'role:math,cmd:sum', function(msg, respond) { var sum = msg.left + msg.right respond(null, { answer: sum }) }) // Rewrite role:math,cmd:sum with, add additional functionality .add( 'role:math,cmd:sum', function(msg, respond) { // bail out early if there's a problem if (!Number.isFinite(msg.left) || !Number.isFinite(msg.right)) { return respond(new Error("left and right The value must be a number.")) } // Call the previous operation function role:math,cmd:sum this.prior({ role: 'math', cmd: 'sum', left: msg.left, right: msg.right, }, function(err, result) { if (err) return respond(err) result.info = msg.left + '+' + msg.right respond(null, result) }) }) // Additional roles: math, cmd: sum .act('role:math,cmd:sum,left:1.5,right:2.5', console.log // Print {answer: 4, info:'1.5 + 2.5'} )
The seneca instance provides a method called prior that allows you to call old operation functions that are overridden in the current action method.
The prior function accepts two parameters:
msg: message body
response_callback: Callback function
In the example code above, we have demonstrated how to modify the input and output parameters. It is optional to modify these parameters and values. For example, new rewriting can be added to increase the logging function.
In the example above, we also demonstrate how to do better error handling. Before we do the real operation, we verify the correctness of the data. If the parameters passed in are wrong, then we will return the error information directly without waiting for the system to report the error when the real calculation is made.
Error messages should only be used to describe incorrect input or internal failure information. For example, if you execute some database queries and return no data, this is not an error, but only a feedback of the facts of the database, but if the connection to the database fails, it is an error.
The above code can be used in sum-valid.js Find it in the file.
Using plug-in organization mode
A Seneca instance is actually just a collection of multiple Action Patters. You can use namespaces to organize operation patterns. For example, in the previous example, we all used role: math. Seneca also supports a simple plug-in support to help log recording and debugging.
Similarly, the Seneca plug-in is just a set of modes of operation. It can have a name for annotating log entries, and it can also give the plug-in a set of options to control their behavior. The plug-in also provides a mechanism for executing initialization functions in the correct order. For example, you want to establish a database connection before trying to read data from the database.
Simply put, the Seneca plug-in is just a function with a single parameter option. You pass the plug-in definition function to the seneca.use method. Here's the smallest Seneca plug-in (it doesn't actually do anything!). :
function minimal_plugin(options) { console.log(options) } require('seneca')() .use(minimal_plugin, {foo: 'bar'})
The seneca.use method takes two parameters:
plugin: A plug-in defines a function or a plug-in name;
Options: Plug-in configuration options
After the example code above is executed, the printed log looks like this:
{"kind":"notice","notice":"hello seneca 3qk0ij5t2bta/1483584697034/62768/3.2.2/-","level":"info","when":1483584697057} (node:62768) DeprecationWarning: 'root' is deprecated, use 'global' { foo: 'bar' }
Seneca also provides detailed logging, which can provide more log information for development or production. Usually, the log level is set to INFO. It does not print too much log information. If you want to see all log information, try the following way to start your service:
node minimal-plugin.js --seneca.log.all
Would you be shocked? Of course, you can also filter log information:
node minimal-plugin.js --seneca.log.all | grep plugin:define
From the log, we can see that seneca loads many built-in plug-ins, such as basic, transport, web and mem-store. These plug-ins provide us with the basic function of creating micro services. Similarly, you should also see the minimal_plugin plug-in.
Now let's add some modes of operation for this plug-in:
function math(options) { this.add('role:math,cmd:sum', function (msg, respond) { respond(null, { answer: msg.left + msg.right }) }) this.add('role:math,cmd:product', function (msg, respond) { respond(null, { answer: msg.left * msg.right }) }) } require('seneca')() .use(math) .act('role:math,cmd:sum,left:1,right:2', console.log)
Function math-plugin.js File, get the following information:
null { answer: 3 }
Look at a printed log:
{ "actid": "7ubgm65mcnfl/uatuklury90r", "msg": { "role": "math", "cmd": "sum", "left": 1, "right": 2, "meta$": { "id": "7ubgm65mcnfl/uatuklury90r", "tx": "uatuklury90r", "pattern": "cmd:sum,role:math", "action": "(bjx5u38uwyse)", "plugin_name": "math", "plugin_tag": "-", "prior": { "chain": [], "entry": true, "depth": 0 }, "start": 1483587274794, "sync": true }, "plugin$": { "name": "math", "tag": "-" }, "tx$": "uatuklury90r" }, "entry": true, "prior": [], "meta": { "plugin_name": "math", "plugin_tag": "-", "plugin_fullname": "math", "raw": { "role": "math", "cmd": "sum" }, "sub": false, "client": false, "args": { "role": "math", "cmd": "sum" }, "rules": {}, "id": "(bjx5u38uwyse)", "pattern": "cmd:sum,role:math", "msgcanon": { "cmd": "sum", "role": "math" }, "priorpath": "" }, "client": false, "listen": false, "transport": {}, "kind": "act", "case": "OUT", "duration": 35, "result": { "answer": 3 }, "level": "debug", "plugin_name": "math", "plugin_tag": "-", "pattern": "cmd:sum,role:math", "when": 1483587274829 }
All plug-in logs are automatically added with plugin attributes.
In the world of Seneca, we organize various modes of operation through plug-ins, which makes logging and debugging easier. Then you can merge multiple plug-ins into various micro services. In the next chapter, we will create a math service.
Plug-ins need some initialization work, such as connecting to databases, etc. But you don't need to perform these initializations in the definition function of the plug-in. The definition function is designed to be executed synchronously because all its operations are to define a plug-in. In fact, you shouldn't call the seneca.act method in the definition function, just the seneca.add method. .
To initialize a plug-in, you need to define a special matching mode init: <plugin-name>. For each plug-in, this mode of operation will be called sequentially. The init function must call its callback function, and no error can occur. If the plug-in initialization fails, Seneca will immediately exit the Node process. So plug-in initialization must be completed before any operation is executed.
To demonstrate initialization, let's add a simple custom log record to the math plug-in. When the plug-in starts, it opens a log file and writes the log of all operations to the file. The file needs to be opened and written successfully. If this fails, the start of the microservice should fail.
const fs = require('fs') function math(options) { // Logging function, created by init function var log // Putting all the patterns together at the meeting makes it easier for us to find them. this.add('role:math,cmd:sum', sum) this.add('role:math,cmd:product', product) // This is the special initialization operation. this.add('init:math', init) function init(msg, respond) { // Logging to a close-up file fs.open(options.logfile, 'a', function (err, fd) { // If the file cannot be read or written, an error is returned, which will cause Seneca to fail to start. if (err) return respond(err) log = makeLog(fd) respond() }) } function sum(msg, respond) { var out = { answer: msg.left + msg.right } log('sum '+msg.left+'+'+msg.right+'='+out.answer+'\n') respond(null, out) } function product(msg, respond) { var out = { answer: msg.left * msg.right } log('product '+msg.left+'*'+msg.right+'='+out.answer+'\n') respond(null, out) } function makeLog(fd) { return function (entry) { fs.write(fd, new Date().toISOString()+' '+entry, null, 'utf8', function (err) { if (err) return console.log(err) // Ensure that log entries are refreshed fs.fsync(fd, function (err) { if (err) return console.log(err) }) }) } } } require('seneca')() .use(math, {logfile:'./math.log'}) .act('role:math,cmd:sum,left:1,right:2', console.log)
In the code of the plug-in above, matching patterns are organized at the top of the plug-in so that they can be seen more easily. Functions are defined a little bit in these patterns. You can also see how to use options to provide the location of custom log files (which is not a production log, to be sure).
Initialization function init performs some asynchronous filesystem work, so it must be done before any operation is performed. If it fails, the entire service will not be initialized. To see what happens when a failure occurs, you can try to change the location of the log file to invalid, such as / math.log.
The above code can be used in math-plugin-init.js Find it in the file.
Creating Micro Services
Now let's turn the math plug-in into a real micro service. First, you need to organize your plug-ins. The business logic of the math plug-in, that is, the functionality it provides, is separate from how it communicates with the outside world. You may expose a Web service or listen on the message bus.
It makes sense to put business logic (plug-in definitions) in its own files. Node.js module can be perfectly implemented, creating a name named math.js The document reads as follows:
module.exports = function math(options) { this.add('role:math,cmd:sum', function sum(msg, respond) { respond(null, { answer: msg.left + msg.right }) }) this.add('role:math,cmd:product', function product(msg, respond) { respond(null, { answer: msg.left * msg.right }) }) this.wrap('role:math', function (msg, respond) { msg.left = Number(msg.left).valueOf() msg.right = Number(msg.right).valueOf() this.prior(msg, respond) }) }
Then, we can add it to our microservice system as follows in the files that need to refer to it:
// The following two approaches are equivalent (remember the two parameters of the `seneca.use'method we mentioned earlier?) require('seneca')() .use(require('./math.js')) .act('role:math,cmd:sum,left:1,right:2', console.log) require('seneca')() .use('math') // Find `. / math.js in the current directory` .act('role:math,cmd:sum,left:1,right:2', console.log)
The seneca.wrap method can match a set of patterns, covering all matched patterns with the same action extension function, which can achieve the same effect as calling seneca.add manually for each group pattern. It requires two parameters:
pin: pattern matching pattern
Action: Extended action function
Pin is a pattern that can be matched to multiple patterns. For example, role:math pin can be matched to role:math, cmd:sum and role:math, cmd:product.
In the example above, in the wrap function at the end, we ensure that the left and right values in any message body passed to role:math are numbers, and that even if we pass a string, they can be automatically converted to numbers.
Sometimes it's useful to see which operations in Seneca instances have been rewritten. When you start an application, you can add the -- seneca.print.tree parameter. Let's create one first. math-tree.js Fill in the following:
require('seneca')() .use('math')
Then execute it:
❯ node math-tree.js --seneca.print.tree {"kind":"notice","notice":"hello seneca abs0eg4hu04h/1483589278500/65316/3.2.2/-","level":"info","when":1483589278522} (node:65316) DeprecationWarning: 'root' is deprecated, use 'global' Seneca action patterns for instance: abs0eg4hu04h/1483589278500/65316/3.2.2/- ├─┬ cmd:sum │ └─┬ role:math │ └── # math, (15fqzd54pnsp), │ # math, (qqrze3ub5vhl), sum └─┬ cmd:product └─┬ role:math └── # math, (qnh86mgin4r6), # math, (4nrxi5f6sp69), product
From above you can see a lot of key / value pairs and show rewriting in a tree structure. All Action functions are shown in # plugin, (action-id), function-name format.
However, up to now, all operations still exist in the same process. Next, let's create a name named math-service.js Fill in the following documents:
require('seneca')() .use('math') .listen()
Then start the script, we can start our micro service, it will start a process, and listen for HTTP requests through port 10101, it is not a Web server, at this time, HTTP only as a message transmission mechanism.
You can visit now. http://localhost:10101/act?ro... You can see the results, or use the curl command:
curl -d '{"role":"math","cmd":"sum","left":1,"right":2}' http://localhost:10101/act
The results can be seen in both ways:
{"answer":3}
Next, you need a microservice client math-client.js:
require('seneca')() .client() .act('role:math,cmd:sum,left:1,right:2',console.log)
Open a new terminal and execute the script:
null { answer: 3 } { id: '7uuptvpf8iff/9wfb26kbqx55', accept: '043di4pxswq7/1483589685164/65429/3.2.2/-', track: undefined, time: { client_sent: '0', listen_recv: '0', listen_sent: '0', client_recv: 1483589898390 } }
In Seneca, we create micro services by seneca.listen method, and then communicate with micro services by seneca.client. In the above example, we used Seneca's default configuration, such as HTTP protocol listening on port 10101, but seneca.listen and seneca.client methods can accept the following parameters to achieve the function of decimation:
Port: An optional number representing the port number;
Host: A preferable string representing the host name or IP address;
spec: Optional object, complete custom object
Note: In Windows, if no host is specified, the default connection will be 0.0.0. This is of no use. You can set host to localhost.
As long as the port numbers of client and listen are the same as the host, they can communicate:
seneca.client(8080) → seneca.listen(8080)
seneca.client(8080, '192.168.0.2') → seneca.listen(8080, '192.168.0.2')
seneca.client({ port: 8080, host: '192.168.0.2' }) → seneca.listen({ port: 8080, host: '192.168.0.2' })
Seneca provides you with a dependency-free transport feature that allows you to enter business logic development without knowing how messages are transmitted or which services will receive them, but specifying in service settings or configurations, such as the code in math.js plug-in that never needs to be changed, we can arbitrarily change the mode of transmission.
Although HTTP protocol is very convenient, but not all the time is appropriate. Another commonly used protocol is TCP. We can easily use TCP protocol for data transmission. Try the following two files:
require('seneca')() .use('math') .listen({type: 'tcp'})
require('seneca')() .client({type: 'tcp'}) .act('role:math,cmd:sum,left:1,right:2',console.log)
By default, client/listen does not specify where messages will be sent, but if the schema is locally defined, it will be sent to the local schema. Otherwise, it will all be sent to the server. We can define which messages will be sent to which services through some configuration. You can use a pin parameter to do this.
Let's create an application that will send all role:math messages to the service through TCP and all other messages to the local:
require('seneca')() .use('math') // Listen for role:math messages // Important: The client must be matched .listen({ type: 'tcp', pin: 'role:math' })
require('seneca')() // Local mode .add('say:hello', function (msg, respond){ respond(null, {text: "Hi!"}) }) // Send role:math mode to service // Note: The server must match .client({ type: 'tcp', pin: 'role:math' }) // Remote operation .act('role:math,cmd:sum,left:1,right:2',console.log) // Local operation .act('say:hello',console.log)
You can customize the printing of logs through various filters to track the flow of messages, using the -- seneca... Parameter to support the following configuration:
date-time: When is the log entry created?
seneca-id: Seneca process ID;
level: any of DEBUG, INFO, WARN, ERROR and FATAL;
type: Entry coding, such as act, plugin, etc.
plugin: The name of the plug-in, not the operation in the plug-in, will be expressed as root$;
case: Item events: IN, ADD, OUT, etc.
action-id/transaction-id: Tracking identifier, Always consistent in the network;
pin: action matching mode;
Message: incoming / outgoing message body
If you run the above process and use - seneca.log.all, you will print all the logs. If you only want to see the logs printed by the math plug-in, you can start the service as follows:
node math-pin-service.js --seneca.log=plugin:math
Web Services Integration
Seneca is not a Web framework. However, you still need to connect it to your Web Services API. You should always remember that it is not a good safe practice to expose your internal behavior patterns. Instead, you should define a set of API patterns, such as attribute role: api, and then you can connect them to your internal micro services.
Here's our definition api.js Plug-in unit.
module.exports = function api(options) { var validOps = { sum:'sum', product:'product' } this.add('role:api,path:calculate', function (msg, respond) { var operation = msg.args.params.operation var left = msg.args.query.left var right = msg.args.query.right this.act('role:math', { cmd: validOps[operation], left: left, right: right, }, respond) }) this.add('init:api', function (msg, respond) { this.act('role:web',{routes:{ prefix: '/api', pin: 'role:api,path:*', map: { calculate: { GET:true, suffix:'/{operation}' } } }}, respond) }) }
Then we used hapi as the Web framework and built it hapi-app.js Application:
const Hapi = require('hapi'); const Seneca = require('seneca'); const SenecaWeb = require('seneca-web'); const config = { adapter: require('seneca-web-adapter-hapi'), context: (() => { const server = new Hapi.Server(); server.connection({ port: 3000 }); server.route({ path: '/routes', method: 'get', handler: (request, reply) => { const routes = server.table()[0].table.map(route => { return { path: route.path, method: route.method.toUpperCase(), description: route.settings.description, tags: route.settings.tags, vhost: route.settings.vhost, cors: route.settings.cors, jsonp: route.settings.jsonp, } }) reply(routes) } }); return server; })() }; const seneca = Seneca() .use(SenecaWeb, config) .use('math') .use('api') .ready(() => { const server = seneca.export('web/context')(); server.start(() => { server.log('server started on: ' + server.info.uri); }); });
After starting hapi-app.js, access http://localhost:3000/routes You can see the following information:
[ { "path": "/routes", "method": "GET", "cors": false }, { "path": "/api/calculate/{operation}", "method": "GET", "cors": false } ]
This means that we have successfully updated the pattern matching to the routing of hapi applications. Visit http://localhost:3000/api/cal... The results will be as follows:
{"answer":3}
In the example above, we directly loaded the math plug-in into the seneca instance, but we could do this more reasonably, such as hapi-app-client.js The file shows:
... const seneca = Seneca() .use(SenecaWeb, config) .use('api') .client({type: 'tcp', pin: 'role:math'}) .ready(() => { const server = seneca.export('web/context')(); server.start(() => { server.log('server started on: ' + server.info.uri); }); });
Instead of registering math plug-ins, we use the client method to send role:math to math-pin-service.js services and use tcp connections. That's how your micro-services are shaped.
Note: Never use external input to create the message body of the operation, and always create it internally, which can effectively avoid injection attacks.
In the above initialization function, a role:web schema operation is invoked and a routes attribute is defined, which defines a matching rule between the URL address and the operation mode. It has the following parameters:
Prefix: URL prefix
pin: The set of schemas that need to be mapped
map: A list of pin wildcard attributes to be used as URL Endpoint
Your URL address will start at / api /.
rol:api, path:* pin means mapping any pair of keys with role="api" and the path attribute is defined. In this case, only role:api,path:calculate conforms to this pattern.
The map attribute is an object with a calculate attribute, and the corresponding URL address starts at: / api/calculate.
Press, the value of calculate is an object, which indicates that HTTP's GET method is allowed, and that the URL should have a parameterized suffix (the suffix is similar to hapi's route rule).
So your full address is / api/calculate/{operation}.
Then, other message attributes will be obtained from the URL query object or JSON body, in this case, because the GET method is used, there is no body.
Seneca Web will describe a request through msg.args, which includes:
body: payload part of HTTP request;
query: querystring of requests;
params: The requested path parameter.
Now, start the microservice we created earlier:
node math-pin-service.js --seneca.log=plugin:math
Then start our application:
node hapi-app.js --seneca.log=plugin:web,plugin:api
Visit the following address:
http://localhost:3000/api/cal... Get {answer":6}
http://localhost:3000/api/cal... Get {answer":5}
Data persistence
A real system definitely needs persistent data. In Seneca, you can perform any operation you like and use any type of database layer. But why not use the power of pattern matching and micro services to make your development easier?
Patterns matching also means that you can postpone arguments about microservice data, such as whether a service should "own" data, whether a service should access a shared database, etc. Patterns matching means that you can reconfigure your system at any later time.
seneca-entity A simple data abstraction layer (ORM) is provided based on the following operations:
load: load an entity according to its identity.
save: Create or update (if you provide an identity) an entity;
list: Lists all entities that match query conditions;
remove: Delete an entity that identifies the specified entity.
Their matching patterns are:
load: role:entity,cmd:load,name:<entity-name>
save
:role:entity,cmd:save,name:<entity-name>
list
:role:entity,cmd:list,name:<entity-name>
remove
:role:entity,cmd:remove,name:<entity-name>
Any plug-in that implements these patterns can be used to provide databases (for example MySQL ) access.
When data persistence and everything else are provided based on the same mechanism, the development of microservices will become easier, and this mechanism is pattern matching messages.
Because direct use of data persistence patterns can be tedious, Seneca entities also provide a more familiar ActiveRecord-style interface. To create record objects, call the seneca.make method. Recording objects have methods load $, save $, list $, and remove $(all methods have a $suffix to prevent conflicts with data fields), and data fields are just object properties.
Install seneca-entity through npm, and then load it into your Seneca instance using the seneca.use() method in your application.
Now let's create a simple data entity that holds the details of the book.
file book.js
const seneca = require('seneca')(); seneca.use('basic').use('entity'); const book = seneca.make('book'); book.title = 'Action in Seneca'; book.price = 9.99; // Send role:entity,cmd:save,name:book message book.save$( console.log );
In the example above, we also used seneca-basic It is a seneca-entity dependent plug-in.
After executing the above code, we can see the following log:
❯ node book.js null $-/-/book;id=byo81d;{title:Action in Seneca,price:9.99}
Seneca is built-in mem-store This allows us to complete database persistence without any other database support in this example (although it is not really persistent).
Because data persistence always uses the same set of message schemas, you can interact with databases very simply, for example, you may use the same set of message schemas in the development process. MongoDB Then, when the development is complete, it is used in the production environment Postgres.
Let's let him create a simple online bookstore through which we can quickly add new books, get detailed information about books and buy a book:
module.exports = function(options) { // From the database, we query a book with an ID of `msg.id'. We use the `load$` method. this.add('role:store, get:book', function(msg, respond) { this.make('book').load$(msg.id, respond); }); // Add a book to the database with data of `msg.data'. We use the `data $` method. this.add('role:store, add:book', function(msg, respond) { this.make('book').data$(msg.data).save$(respond); }); // Create a new payment order (in a real system, it is often touched by the * Buy * button in the commodity details cloth) // The first thing is to query the book whose ID is `msg.id'. If the query is wrong, it will return the error directly. // Otherwise, copy the information from the book to the `purchase'entity and save the order, and then we send it. // A `role:store,info:purchase'message (but we don't receive any response) // This message just informs the whole system that we have a new order coming up, but I don't care who will. // Need it. this.add('role:store, cmd:purchase', function(msg, respond) { this.make('book').load$(msg.id, function(err, book) { if (err) return respond(err); this .make('purchase') .data$({ when: Date.now(), bookId: book.id, title: book.title, price: book.price, }) .save$(function(err, purchase) { if (err) return respond(err); this.act('role:store,info:purchase', { purchase: purchase }); respond(null, purchase); }); }); }); // Finally, we implemented the `role:store, info:purchase'mode, which is simply to put information into practice. // Printed out, `seneca.log'object provides `debug', `info', `warn', `error', // ` The fatal `method is used to print logs at the corresponding level. this.add('role:store, info:purchase', function(msg, respond) { this.log.info('purchase', msg.purchase); respond(); }); };
Next, we can create a simple unit test to validate the program we created earlier:
// Use Node's built-in `assert'module const assert = require('assert') const seneca = require('seneca')() .use('basic') .use('entity') .use('book-store') .error(assert.fail) // Add a Book addBook() function addBook() { seneca.act( 'role:store,add:book,data:{title:Action in Seneca,price:9.99}', function(err, savedBook) { this.act( 'role:store,get:book', { id: savedBook.id }, function(err, loadedBook) { assert.equal(loadedBook.title, savedBook.title) purchase(loadedBook); } ) } ) } function purchase(book) { seneca.act( 'role:store,cmd:purchase', { id: book.id }, function(err, purchase) { assert.equal(purchase.bookId, book.id) } ) }
Execute the test:
❯ node book-store-test.js ["purchase",{"entity$":"-/-/purchase","when":1483607360925,"bookId":"a2mlev","title":"Action in Seneca","price":9.99,"id":"i28xoc"}]
In a production application, we may have a separate service to monitor the order data above, instead of printing a log as above, so let's create a new service to collect the order data:
const stats = {}; require('seneca')() .add('role:store,info:purchase', function(msg, respond) { const id = msg.purchase.bookId; stats[id] = stats[id] || 0; stats[id]++; console.log(stats); respond(); }) .listen({ port: 9003, host: 'localhost', pin: 'role:store,info:purchase' });
Then, update the book-store-test.js file:
const seneca = require('seneca')() .use('basic') .use('entity') .use('book-store') .client({port:9003,host: 'localhost', pin:'role:store,info:purchase'}) .error(assert.fail);
At this point, when a new order is generated, the order monitoring service is notified.
Integrate all services together
Through all the above steps, we now have four services:
book-store-stats.js Used to collect Bookstore order information;
book-store-service.js Provide functions related to bookstores;
math-pin-service.js To provide some mathematics related services;
app-all.js Web services
We already have book-store-stats and math-pin-service, so start it directly:
node math-pin-service.js --seneca.log.all node book-store-stats.js --seneca.log.all
Now, we need a book-store-service:
require('seneca')() .use('basic') .use('entity') .use('book-store') .listen({ port: 9002, host: 'localhost', pin: 'role:store' }) .client({ port: 9003, host: 'localhost', pin: 'role:store,info:purchase' });
The service receives any role:store message, but sends any role:store,info:purchase message to the network at the same time. Always remember that the pin configuration of client and listen must be exactly the same.
Now we can start the service:
node book-store-service.js --seneca.log.all
Then, create our app-all.js, preferred, copy the api.js file to api-all.js This is our API.
module.exports = function api(options) { var validOps = { sum: 'sum', product: 'product' } this.add('role:api,path:calculate', function(msg, respond) { var operation = msg.args.params.operation var left = msg.args.query.left var right = msg.args.query.right this.act('role:math', { cmd: validOps[operation], left: left, right: right, }, respond) }); this.add('role:api,path:store', function(msg, respond) { let id = null; if (msg.args.query.id) id = msg.args.query.id; if (msg.args.body.id) id = msg.args.body.id; const operation = msg.args.params.operation; const storeMsg = { role: 'store', id: id }; if ('get' === operation) storeMsg.get = 'book'; if ('purchase' === operation) storeMsg.cmd = 'purchase'; this.act(storeMsg, respond); }); this.add('init:api', function(msg, respond) { this.act('role:web', { routes: { prefix: '/api', pin: 'role:api,path:*', map: { calculate: { GET: true, suffix: '/{operation}' }, store: { GET: true, POST: true, suffix: '/{operation}' } } } }, respond) }) }
Last, app-all.js:
const Hapi = require('hapi'); const Seneca = require('seneca'); const SenecaWeb = require('seneca-web'); const config = { adapter: require('seneca-web-adapter-hapi'), context: (() => { const server = new Hapi.Server(); server.connection({ port: 3000 }); server.route({ path: '/routes', method: 'get', handler: (request, reply) => { const routes = server.table()[0].table.map(route => { return { path: route.path, method: route.method.toUpperCase(), description: route.settings.description, tags: route.settings.tags, vhost: route.settings.vhost, cors: route.settings.cors, jsonp: route.settings.jsonp, } }) reply(routes) } }); return server; })() }; const seneca = Seneca() .use(SenecaWeb, config) .use('basic') .use('entity') .use('math') .use('api-all') .client({ type: 'tcp', pin: 'role:math' }) .client({ port: 9002, host: 'localhost', pin: 'role:store' }) .ready(() => { const server = seneca.export('web/context')(); server.start(() => { server.log('server started on: ' + server.info.uri); }); }); // Create a sample book seneca.act( 'role:store,add:book', { data: { title: 'Action in Seneca', price: 9.99 } }, console.log )
Start the service:
node app-all.js --seneca.log.all
From the console we can see the following message:
null $-/-/book;id=0r7mg7;{title:Action in Seneca,price:9.99}
This means that a book with an ID of 0r7mg7 has been successfully created. Now, let's visit it. http://localhost:3000/api/store/get?id=0r7mg7 You can view the book details of the ID (the ID is random, so the ID you generate may not be like this).
http://localhost:3000/routes You can view all routes.
Then we can create a new purchase order:
curl -d '{"id":"0r7mg7"}' -H "content-type:application/json" http://localhost:3000/api/store/purchase {"when":1483609872715,"bookId":"0r7mg7","title":"Action in Seneca","price":9.99,"id":"8suhf4"}
Visit http://localhost:3000/api/calculate/sum?left=2&right=3 You can get {answer":5}.
Best Seneca Application Architecture Practice
It is recommended that you do so.
It is possible to separate business logic from execution in separate plug-ins, such as different Node modules, different projects and even different files under the same project.
-
Write your application using execution scripts. Don't be afraid to use different scripts for different contexts. They should look short, like the following:
var SOME_CONFIG = process.env.SOME_CONFIG || 'some-default-value' require('seneca')({ some_options: 123 }) // Existing Seneca plug-ins .use('community-plugin-0') .use('community-plugin-1', {some_config: SOME_CONFIG}) .use('community-plugin-2') // Business Logic Plug-in .use('project-plugin-module') .use('../plugin-repository') .use('./lib/local-plugin') .listen( ... ) .client( ... ) .ready( function() { // Custom scripts when Seneca starts successfully })
The order of plug-in loading is very important, which is of course a good thing. You have absolute control over the formation of messages.
It is not recommended that you do so.
Put the start and initialization of Seneca application together with the start and initialization of other frameworks. Always remember to keep the transaction simple.
Seneca instances are passed around as variables.