Despite what people might think, NPM has a huge advantage over other systems - it uses semantic versions when installing dependencies. If your module depends on another module "foo", you have to specify which particular version or range of versions is acceptable.
1 | { |
I suppose that your project has unit tests, and once they all pass, you assume that "foo@1.0.0" is the dependency you know works reliably. The "foo" package can have other releases, of course, and you can look them up, I advise using my available-versions for this
1 | $ npm i -g available-versions |
Just by looking at the new versions, we can see that there are 2 patch releases 1.0.1 and 1.0.2 that probably have bug fixes. Then there are two new feature releases that preserve backward compatability 1.1.0 and 1.2.0 - in fact I look at the versions not as absolute, but as difference from the current one
changed.same.same
= breaking API change, danger!same.changed.same
= new feature, yum!same.same.changed
= bug fix, thank you, kind developer!
I can easily control which version of "foo" I use - I list its version in the package.json
file,
I can use strict version (without any ^
, ~
or *
ranges), and I can test new versions at my
leisure before switching (I recommend using next-update).
Service dependencies
The "static" installed dependencies above are simple. But we have another type of dependencies
that are much much harder to control - the external 3rd party services we call at runtime.
For example, if your application calls the GitHub API, then you are using a 3rd party service.
My next-update
tool calls next-update.herokuapp.com to
display the update statistics for a package. If you split your application into a pool of
micro services, each one is likely to call others many many times.
Versioning service dependencies is an open field; there are no standards and people have very strong opinions what method is the best. Main methods are: including API version in the request URL, or setting a special header or even including the desired version in the "Accept" header. All these schemes in my opinion make the problem much harder by being the opposite of the NPM's solution and be giving the service too much work to do. Let us take a look at how we can solve the service versioning in the similar fashion to NPM.
- The dependency package "foo" should NOT implement multiple API versions simultaneously.
Instead, it publishes a new version to the registry. It is up to NPM installer to pick the
correct version depending on what is specified in the
package.json
. Similarly, it is a burden on the service author to support multiple versions simultaneously. Instead a deploy process should allow running multiple service versions in parallel. - Usually our module gets the service URL injected somehow instead of hardcoding it. Maybe it is a config file or an environment variable - all we have is an URL to call. How do we know if this is the right service? We cannot know, instead the service can return its own version with the response. Then the client can check the received version against the expected service version or range.
The second point is important - we do not have the control over the deployment in a distributed
heterogeneous environment. The most we can do is to make a request, hope it gets to the right
service, and check the response - is the executor service the right one? How do we know it is a
right one for us? By testing of course! Just like we did in unit testing,
once we have tested and confirmed
that [email protected]
is acceptable, we set its version in the package.json
. Same with the external
services. Once we have tested GitHub API and confirmed that v3 works for us, we should validate
every GitHub response to make sure that we are getting v3 responses. If we all of the sudden
get v2 or v4 response - something is wrong!
A service can embed name and version in the response headers, here is my koa-version-header for example. Any request gets the information necessary to validate if the service is acceptable.
1 | const koa = require('koa') |
1 | $ http localhost:3000 |
From an application you can validate the version using semantic versioning package
1 | // list run time service dependencies |
The above example is from this demo
To simplify checking I implemented the version validation for requests that use the axios library. Other libraries allow other interceptors, and if the excellent nock implements andCallThrough feature, then you could validate the service requests from any library.
If the service does NOT satisfy our requirement - well, a runtime exception should probably be sent to a crash monitoring service. Point is that something went wrong - maybe the deploy failed and an old external service is still responding to the calls, or maybe the external service has been upgraded but not the client application.
Conclusion
During npm install
you have the power to tell NPM which version of a dependency is acceptable.
If a specific version is unavailable, the install fails. Similarly, during execution, if a
semantically acceptable version of an external service is unavailable, you should be notified and
fail. By embedding the version information in the response as opposed to request you
decouple the client from the service itself; the service should not have the burden of implementing
multiple versions.
If testing and upgrading NPM module versions is like switching parts when assembling a car, validating versions in a deployed system is like replacing a wheel on a moving car - hard, but might be the only way to keep all the parts the same except for a specific one.
Resources
- axios library for promise-returning Ajax calls
- axios-version validates version in interceptor
- koa-version-header middleware for Koa server that adds name and version to the response headers
- semver
- Seneca microservices does NOT talk about versions
- Your API versioning is wrong
- THE ULTIMATE SOLUTION TO VERSIONING REST APIS: CONTENT NEGOTIATION