Who among us does not want to make a great application with the right architecture? Everyone wants.
That there was flexibility, reusability and clarity of logic. That there were domains, services, their interaction.
And even sometimes you want it to be almost like in Erlang.
The idea of creating a microservice framework for NodeJs has been successfully implemented more than once - this is how we at least have Seneca and Studio.js , which are certainly good, but they define large logical units. On the other hand, we have ordinary objects that are shared in the system by means of Dependency Injection or similar technology, but they do not give a clear definition of the boundaries.
Sometimes you need "nano-services".
For the term "nano-service" we will fix this definition - "independent components that interact with each other within the same process under the contract, while not using the network . "
In fact, these are the most common objects, but the differences are still there - the “nano-service” component clearly describes what functions of other services it needs and uniquely lists all the exported functions. A component cannot request from another component to perform something outside the contract.
Resolving all dependencies will be dealt with by the framework, which will be indifferent to what and in what sequence was requested, the only condition is that the dependency graph should not be cyclical and all required services should be registered in total before the system starts.
"- Stop! - you say. - And what's wrong with microservices?"
Microservices are an excellent solution for separating a monolithic application and they can be useful for posting tasks into several processes. However, using the network for interoperability reduces performance, adds overhead, imposes restrictions on the style of interaction, and may be unsafe.
Next - the service inaccessible situations require handlers in each consumer. In addition, the question immediately arises of who will serve the infrastructure of dozens of services (processes), track their statuses, what is the scenario of their restarting, utilization, etc.
For example, microservices will not work if a regular application is to be divided into components - allocating the logging module, accessing the database, the validator to separate microservices looks like the wrong solution to the problem. Microservices are too large and expensive for such tasks.
"- Well, then why not the classic DI?"
Yes, dependency inversion allows you to shift the work of creating objects to the framework, but can in no way limit their use. In addition, quite often in the heat of the moment, instead of a specific service, the container itself may depend on, from which anything can be dynamically requested. Such a style rigidly blocks the component in the system, making its removal and reuse virtually impossible.
"- And the alternative is nano-services?"
True, nano-service may well be a tool of the right size.
First, I briefly describe the Antinite framework created to implement this approach, and then give the code.
Schematically, the concept is as follows:
There are Foo and Bar components, which are respectively located in the Services and Shared domains. Components export the doFoo and getBar methods , which may be requested by other components.
Domains, in turn, are registered by the framework and become available within the process, all interactions occur through the core.
In addition, the framework provides a method for accessing components "externally", allowing the central application launch point to interact with the components.
We also mention that there is a mechanism for separating access rights to the methods of components, about it later.
// first service file aka 'foo_service' class FooService { getServiceConfig () { return ({ require: { BarService: ['getBar'] }, export: { execute: ['doFoo'] }, options: { injectRequire : true } }) } doFoo (where) { let bar = this.BarService.getBar() return `${where} ${bar} and foo` } } export default FooService
// first layer file aka 'services_layer' import { Layer } from 'antinite' import FooService from './foo_service' const LAYER_NAME = 'service' const SERVICES = [ { name: 'FooService', service: new FooService(), acl: 711 } ] let layerObj = new Layer(LAYER_NAME) layerObj.addServices(SERVICES)
// second service file aka 'bar_service' class BarService { getServiceConfig () { return ({ export: { read: ['getBar'] } }) } getBar () { return 'its bar' } } export default BarService
// second layer file aka 'shared_layer' import { Layer } from 'antinite' import BarService from './bar_service' const LAYER_NAME = 'shared' const SERVICES = [ { name: 'BarService', service: new BarService(), acl: 764 } ] let layerObj = new Layer(LAYER_NAME) layerObj.addServices(SERVICES)
// main start point aka 'index' import { System } from 'antinite' // load layers, in ANY orders import './services_layer' import './shared_layer' let antiniteSys = new System('mainSystem') antiniteSys.onReady() .then(function() { let res = antiniteSys.execute('service', 'FooService', 'doFoo', 'here') console.log(res) // -> `here its bar and foo` })
As can be seen from the code, components are normal objects, with several additional methods, domains use instances of components, and the central point imports layers and accesses a system call to a specific component in a specific domain.
In addition, calling methods in component dependencies is a simple method call of an object, if it is synchronous — it can be called synchronously, if asynchronous — then, in accordance with the implementation of the method, the framework does not impose any restrictions in this regard.
In the repository there are additional examples of services.
"- And what about the overhead?"
The main work of the framework occurs at the moment of launching the application, when all dependencies are resolved. The delay time will depend on the size of the system, but in general it is imperceptible. Additional delays are possible with asynchronous initialization of components, here the delay will be determined by the speed of the task execution (connection to the base, opening of the port, etc.).
The overhead of the launched system is minimal. The execution of the method of another component occurs as the search for a wrapper function by a key in the dictionary, and then the component method itself is executed from the wrapper function.
Now about the mechanisms of access rights. First, the component, by exporting methods, explicitly indicates the export category - 'read', 'write', 'execute' , so you can separate them according to the degree of impact on the system. Secondly, registering the component, specifies the component access mask, for example, '174' - says that only system methods of 'execute' are accessible to system calls, components located in the same domain - full set of permissions ' read ',' write ',' execute ' and components from other domains are only methods of the ' read ' category.
Therefore, a write method exported by a component in one domain cannot be called by a component in another domain. If you mistakenly write a similar scheme depending - the framework will refuse to allow it.
The framework has a helper for legacy code that can ease the process of porting code.
In addition, the framework design can help simplify debugging of the system. There is a debugger of the process of resolving all dependencies, with its help it will become clear where the resolution of dependencies gives an error.
An important feature of the framework is the fact that it is possible at any time to enable the system audit, getting detailed information about which components interact with each other and what parameters are transmitted.
And in addition to everything, the system can provide a current dependency graph that is easy to visualize.
There is a simple visualization assistant, Antinite Visual Toolkit . This library was made as an example of visualization possible, perhaps not the most successful.
This is how the concept of nano-services, the implementation of the framework and the toolkit to it looks like in brief.
If you have questions, requests, additions, criticism and suggestions - please reflect this in the comments. In addition, the project has a gitter chat . At this point, I really need feedback to improve the prototype.
Antinite is available on github , and for installation via npm under the MIT license . The project has detailed documentation and a set of tests.
Ps. At present, a working draft is being actively developed using this framework, I cannot, alas, disclose details, but no problems have been identified at this stage.
The architecture made it possible to carry out a resource-intensive task into a separate process, completely re-using part of the common code and introducing the service motif hiding interprocess communication depending on the process.
Source: https://habr.com/ru/post/346976/
All Articles