Architecture de plugins en JavaScript et Node.js avec Plug and Play

WORMS David

By WORMS David

28 août 2020

Plug and Play aide les auteurs de bibliothèques et d’applications à introduire une architecture de plugins dans leur code. Il simplifie l’exécution de code complexe avec des points d’interception bien définis, également appelés hooks, et offre aux utilisateurs de la bibliothèque la possibilité d’étendre, de corriger et de modifier l’exécution d’un code sans avoir à le modifier.

Cette bibliothèque vient d’être publiée sur NPM sous la licence MIT. Elle est disponible et open source depuis un certain temps dans le cadre d’autres bibliothèques. La première implémentation a été créée pour Node Parameters, une bibliothèque d’arguments CLI avancée. Elle a ensuite été améliorée pour Nikita, un outil d’automatisation de déploiement avant d’être finalement isolée dans le package Plug and Play.

Une architecture de plugins a plusieurs objectifs. Lorsque les hooks sont soigneusement sélectionnés et configurés, les utilisateurs de l’application et de la bibliothèque peuvent étendre et ajuster la bibliothèque en fonction de leurs besoins. Mais pas seulement les utilisateurs, les auteurs originaux bénéficient également de l’architecture pour composer leur code lorsque celui-ci devient complexe.

Un code complexe peut être décomposé en plusieurs composants. Cela facilite le développement, le débogage et les tests. Par exemple, le noyau de Nikita, notre outil d’automatisation de déploiement pour Node.js, était autrefois basé sur un module de 600 lignes qui ne fonctionnait que parce qu’il était sauvegardé par plus plus de 300 tests. Toute nouvelle fonctionnalité était complexes, voire impossibles à introduire. Des bugs subtils et connus subsistaient. Dans son dernier refactor, le même module de base fait environ 140 lignes et seulement 100 lignes une fois le code associé à Plug and Play supprimé. Une compréhension claire du code est désormais possible, même par son auteur d’origine. Les vieux bugs désagréables ont été supprimés (de nouveaux peuvent avoir été introduits :). Un tas de nouvelles fonctionnalités sont apparues. Plus important encore, l’expérience développeur est passée d’un cauchemar à plaisir lorsque de nouvelles idées surgissent.

Ceci est un tutoriel pour vous familiariser avec les principales fonctionnalités de Plug and Play. Ce n’est pas si compliqué et nous allons parcourir lentement les usages suivants.

  • ccéder et modifier les arguments des hooks
  • Modification du comportement des handlers
  • Insertion de code après l’exécution d’un handler
  • Ordenancement des hooks et dépendances obligatoires
  • Passation des résultats dans une chaîne des handlers
  • Handlers asynchrones
  • Architecture imbriquée / hiérarchique

1. Application simple sans plugin

Le module sample/1/app.js est un simple code pour démarrer et arréter un serveur web. Il correspond à une library fictive que nous rendrons par la suite extensible avec des plugins.

const http = require('http')

module.exports = (config = {}) => {
  if(!config.mesage){
    config.message = 'hello'
  }
  const server = http.createServer((req, res) => {
    res.end(config.message)
  })
  return {
    start: () => {
      server.listen(config.port)
    },
    stop: () => {
      server.close()
    }
  }
}

Le code est exporte une function d’initiation qui accept un paramètre config et retourne deux fonctions que sont start et stop.

Le module sample/1/index.js est une interface CLI qui invite l’utilisateur à lancer les commande start et stop.

const readline = require('readline')
const app = require('./app')()

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});
rl.prompt();
rl.on('line', (line) => {
  switch (line.trim()) {
    case 'start':
      app.start()
      break;
    case 'stop':
      app.stop()
      break;
    default:
      process.stdout.write('Only `start` and `stop` are supported\n')
      break;
  }
  rl.prompt();
})

Pour exécuter cet exemple, clonez le dépôt du tutorial. Depuis la racine du projet, lancez la commande node tutorial/1. À l’invite du shell, entrez start puisstop. Il démarrera et arrêtera le serveur Web sur un port aléatoire choisi par Node.js. Vous pouvez modifier le code pour afficher la valeur renvoyée par server.address().port ou utiliser la commande Linux netcat pour connaître le port utilisé.

2. Accéder et modifier les arguments des hooks

Améliorons notre application avec une architecture orienté plugin. Notre premier plugin modifiera l’objet de configuration pour que le port d’écoute par défaut soit la valeur 3000. Avant d’enregistrer de nouveaux plugins, nous mettrons à jour notre bibliothèque d’origine, le fichier app.js.

Dans le module sample/2/app.js, nous importons le module Plug and Play et l’initialisons. L’instance créée nommée plugins est retournée et exposée de telle sorte que les utilisateurs peuvent enregistrer de nouveaux hooks en appelant la fonction plugins.register.

Nous définissons ensuite un premier hook nommé server:start. Son rôle est de fournir une opportunité aux utilisateurs pour intercepter la commande start. La fonction handler fournit l’implémentation par défaut. Le premier argument de la fonction handler est défini par la propriété args. Nous passons la configuration via la propriété config.

Les utilisateurs ont maintenant la possibilité de modifier, enrichir et altérer la fonction handler ainsi que ses arguments. Dans notre cas, nous avons accès à la configuration pour définir la valeur par défaut de la propriété config.port.

const http = require('http')
// Import the Plug and Play moduleconst plugandplay = require('plug-and-play')
module.exports = (config = {}) => {
  // Initialize our plugin architecture  const plugins = plugandplay()  const server = http.createServer((req, res) => {
    res.end(config.message)
  })
  return {
    // Return and expose the plugin instance    plugins: plugins,    start: () => {
      // Defined the `start` hook      plugins.call({        name: 'server:start',        args: {          config: config        },        handler: ({config}) => {          server.listen(config.port)        }      })    },
    stop: () => {
      server.close()
    }
  }
}

Un nouveau plugin est créé en appelant la fonction register. Pour être efficace, un plugin doit intercepter au moins un hook et y brancher sa logique.

Le nom du hook est associé à une fonction écrite par l’utilisateur. Il peut également s’agir d’un objet avec la propriété handler associée à la fonction. Lorsqu’un seul argument est défini, ce handler sera exécuté avant celui écrit dans la library d’origine. Nous verrons plus loin comment accéder au handler d’origine pour modifier son exécution ou injecter de la logique après son exécution en déclarant un deuxième argument.

Lorsqu’un nouveau hook est enregistré, la fonction utilisateur reçoit les mêmes arguments que ceux définis dans args. Dans notre cas, nous avons accès à la propriété config. Le module sample/2/index.js enregistre et implémente notre premier hook pour modifier la configuration.

const readline = require('readline')
const myapp = require('./app')()

myapp.plugins.register({  hooks: {    'server:start': ({config}) => {      // Set the default port value      if( !config.port ){        config.port = 3000      }    }  }})
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});
rl.prompt();
rl.on('line', (line) => {
  switch (line.trim()) {
    case 'start':
      myapp.start()
      break;
    case 'stop':
      myapp.stop()
      break;
    default:
      process.stdout.write('Only `start` and `stop` are supported\n')
      break;
  }
  rl.prompt();
})

3. Modification du comportement des handlers

Tenter d’exécuter deux fois start entraînera une erreur pas si amicale qui ressemble à :

> start
> start
readline.js:1150
            throw err;
            ^

Error [ERR_SERVER_ALREADY_LISTEN]: Listen method has been called more than once without closing.
    at Server.listen (net.js:1399:11)
    at Object.start (/home/david/plug-and-play/sample/app.js:10:14)
    at Interface.<anonymous> (/home/david/plug-and-play/sample/index.js:14:11)
    at Interface.emit (events.js:315:20)
    at Interface._onLine (readline.js:337:10)
    at Interface._line (readline.js:666:8)
    at Interface._ttyWrite (readline.js:1006:14)
    at ReadStream.onkeypress (readline.js:213:10)
    at ReadStream.emit (events.js:315:20)
    at emitKeys (internal/readline/utils.js:335:14) {
  code: 'ERR_SERVER_ALREADY_LISTEN'
}

Le serveur Web écoute déjà sur le port 3000 et un nouveau serveur ne peut pas être démarré. Il serait plus intéressant d’éviter de démarrer le serveur HTTP deux fois en n’appelant pas sa fonction server.listen si elle est déjà lancée. Pour cela, nous allons exposer la variable server dans le modulesample/3/app.js :

const http = require('http')
const plugandplay = require('plug-and-play')

module.exports = (config = {}) => {
  const plugins = plugandplay()
  const server = http.createServer((req, res) => {
    res.end(config.message)
  })
  return {
    plugins: plugins,
    start: () => {
      plugins.call({        name: 'server:start',        // Expose the server argument        args: {          config: config,          server: server        },        // Grab the server argument        handler: ({config, server}) => {          server.listen(config.port)        }      })    },
    stop: () => {
      server.close()
    }
  }
}

Nos hooks peuvent désormais accéder à la propriété server.listening pour savoir si le serveur est opérationnel. En fonction de sa valeur, nous désactiverons l’exécution du handler dans le hook d’origine.

Dans notre handler, nous déclarons un deuxième argument, le handler précédemment appelé qui est également le handler d’origine. Les hooks peuvent choisir de modifier ou de désactivter les autres handlers en fournissant une implémentation alternative ou null. Dans notre cas, nous retournons null lorsque le serveur écoute déjà. Une approche alternative et similaire aurait été de renvoyer une fonction vide function(){}.

const readline = require('readline')
const myapp = require('./app')()

myapp.plugins.register({
  hooks: {
    // Declare the `handler` second argument    'server:start': ({config, server}, handler) => {      if( !config.port ){        config.port = 3000      }      // Return null when the server is already listening      return server.listening ? null : handler    }  }
})

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});
rl.prompt();
rl.on('line', (line) => {
  switch (line.trim()) {
    case 'start':
      myapp.start()
      break;
    case 'stop':
      myapp.stop()
      break;
    default:
      process.stdout.write('Only `start` and `stop` are supported\n')
      break;
  }
  rl.prompt();
})

4. Insertion de code après l’exécution d’un handler

Il serait un ajout intéressant d’imprimer un message une fois que le serveur a démarré.

Un nouveau plugin sera créé uniquement pour cet effet. Il affiche aux utilisateurs des informations sur le cycle de vie du serveur. Il est enregistré juste après celui créé précédemment.

Le hook est branché sur le point d’interception server:start. Il renvoie une nouvelle fonction de gestionnaire, remplaçant ainsi la fonction d’origine. Il appelle d’abord le gestionnaire d’origine, imprime un message et retourne ce que le gestionnaire d’origine renvoyait. Dans un tel cas, notre nouveau gestionnaire se comporte exactement comme l’ancien, il imprime simplement un message une fois que le serveur a démarré.

const readline = require('readline')
const myapp = require('./app')()

myapp.plugins.register({
  hooks: {
    'server:start': ({config, server}, handler) => {
      if( !config.port ){
        config.port = 3000
      }
      return server.listening ? null : handler
    }
  }
})

myapp.plugins.register({  hooks: {    'server:start': {      handler: (args, handler) => {        // Return a new handler function        return () => {          // Call the original handler          const info = handler.call(null, args)          // Print a message          process.stdout.write('Server is started\n')          // Return whatever the original handler was returning          return info        }      }    }  }})
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});
rl.prompt();
rl.on('line', (line) => {
  switch (line.trim()) {
    case 'start':
      myapp.start()
      break;
    case 'stop':
      myapp.stop()
      break;
    default:
      process.stdout.write('Only `start` and `stop` are supported\n')
      break;
  }
  rl.prompt();
})

5. Ordenancement des hooks et dépendances obligatoires

Exécuter deux fois la commande start entraînera l’erreur :

> start
> Server is started
start
> (node:1358) UnhandledPromiseRejectionWarning: TypeError: Cannot read property 'call' of null
    at Object.<anonymous> (/home/david/plug-and-play/sample/4/index.js:21:34)
    at Object.hook (/home/david/plug-and-play/lib/index.js:200:24)
    at processTicksAndRejections (internal/process/task_queues.js:97:5)
(node:1358) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
(node:1358) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.

Lors du deuxième appel, le handler est null au lieu d’être une fonction, déclenchant ainsi l’erreur ci-dessus. Ceci est attendu puisque notre premier plugin retourne null si le serveur est déjà démarré.

Une solution est de vérifier la valeur du handler et d’imprimer un message comme “le serveur est déjà en cours d’exécution” lorsqu’il est égal à null. Pour illustrer le séquencement des hooks, nous choisissons une approche alternative. Nous allons plutôt nous assurer que le deuxième hook est toujours exécuté avant le premier hook.

Donnons un nom à nos plugins. Le premier est nommé plugin:enhancer et le second est nomméplugin:reporter. Le valeur du hook server:start n’est plus une fonction. Au lieu de cela, c’est un objet contenant le handler ainsi qu’une propriété before pointant vers le premier plugin appeléplugin:enhancer. De cette façon, le hook server:start deplugin:reporter sera toujours exécuté avant celui de plugin:enhancer et l’argumenthandler ne sera jamais null.

Notez que les propriétés before etafter définissent l’ordre d’exécution entre le hook de plusieurs plugins. Elles n’imposent pas l’existance du plugin. Les dépendances sont par défaut facultatives. Pour définir une dépendance obligatoire, vous devez lister son nom dans la propriété required du plugin sous la forme d’un string ou d’une array de string représentant les noms des plugins.

const readline = require('readline')
const myapp = require('./app')()

myapp.plugins.register({  name: 'plugin:enhancer',  hooks: {    'server:start': ({config, server}, handler) => {      if( !config.port ){        config.port = 3000      }      return server.listening ? null : handler    }  }})
myapp.plugins.register({  name: 'plugin:reporter',  required: 'plugin:enhancer',  hooks: {    'server:start': {      before: 'plugin:enhancer',      handler: (args, handler) => {        return () => {          const info = handler.call(null, args)          process.stdout.write('Server is started\n')          return info        }      }    }  }})
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});
rl.prompt();
rl.on('line', (line) => {
  switch (line.trim()) {
    case 'start':
      myapp.start()
      break;
    case 'stop':
      myapp.stop()
      break;
    default:
      process.stdout.write('Only `start` and `stop` are supported\n')
      break;
  }
  rl.prompt();
})

6. Passation des résultats dans une chaîne des handlers

Lorsque les plugins enregistrent de nouveaux hooks, plusieurs handlers commençant par celui d’origine sont appelés les uns après les autres. L’ordre des séquences est basé sur l’enregistrement du plugin ainsi que sur les propriétés des hooks «before» et «after».

Lors de l’implémentation d’un handler, les auteurs obtiennent le résultat renvoyé par la chaîne d’exécution. La fonction plugins.call renvoie la valeur du dernier handler exécuté. L’auteur de la bibliothèque est libre d’utiliser et de renvoyer la valeur ultérieurement dans la fonction. Dans le module sample/6/app.js, le gestionnaire renvoie le numéro de port. Le port est utilisé pour enrichir l’objet info qui est retourné à l’utilisateur final à la fin de la fonction start.

const http = require('http')
const plugandplay = require('plug-and-play')

module.exports = (config = {}) => {
  const plugins = plugandplay()
  const server = http.createServer((req, res) => {
    res.end(config.message)
  })
  return {
    plugins: plugins,
    start: async () => {
      const info = {
        time: new Date()
      }
      // Get the result returned by the last called handler      info.port = await plugins.call({        name: 'server:start',
        args: {
          config: config,
          server: server
        },
        // Handler returning the port number        handler: ({config, server}) => {          server.listen(config.port)          return server.address().port        }      })
      return info
    },
    stop: () => {
      server.close()
    }
  }
}

Dans le module sample/6/index.js, l’objet info est utilisé pour afficher le numéro de port.

const readline = require('readline')
const myapp = require('./app')()

myapp.plugins.register({
  name: 'plugin:enhancer',
  hooks: {
    'server:start': ({config, server}, handler) => {
      if( !config.port ){
        config.port = 3000
      }
      return server.listening ? null : handler
    }
  }
})

myapp.plugins.register({
  name: 'plugin:reporter',
  required: 'plugin:enhancer',
  hooks: {
    'server:start': {
      before: 'plugin:enhancer',
      handler: (args, handler) => {
        return () => {
          const info = handler.call(null, args)
          process.stdout.write('Server is started\n')
          return info
        }
      }
    }
  }
})

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});
rl.prompt();
rl.on('line', async (line) => {
  switch (line.trim()) {
    case 'start':
      // Get the info object and print the port number      const info = await myapp.start()      process.stdout.write(`Port is ${info.port}\n`)      break;
    case 'stop':
      myapp.stop()
      break;
    default:
      process.stdout.write('Only `start` and `stop` are supported\n')
      break;
  }
  rl.prompt();
})

Notez que les valeurs sont toujours renvoyées sous forme de Promise. Tout autre type de valeur est enveloppé dans une Promise.

7. Handlers asynchrones

Il y a un petit problème avec le code ci-dessus. Le message informe l’utilisateur que le serveur a démarré mais ce n’est pas tout à fait vrai. La fonction de démarrage est une fonction asynchrone. Au moment de l’impression du message, le serveur n’est ni démarré, ni prêt à traiter les demandes.

Un handler asynchrone doit retourner et résoudre une Promise.

Le module sample/7/app.js corrige ce problème. Son handler renvoie maintenant une Promise. La fonction start elle-même doit également attendre la résolution de la Promise avant d’être résolue. Notez que la fonction setTimeout n’est présente dans le code que pour simuler un temps de démarrage lent.

const http = require('http')
const plugandplay = require('plug-and-play')

module.exports = (config = {}) => {
  const plugins = plugandplay()
  const server = http.createServer((req, res) => {
    res.end(config.message)
  })
  return {
    plugins: plugins,
    // Return a promise    start: async () => {      const info = {        time: new Date()      }      // Wait for the promise to resolve      info.port = await plugins.call({        name: 'server:start',
        args: {
          config: config,
          server: server
        },
        handler: ({config, server}) => {
          // Return a Promise          return new Promise( (accept, reject) => {            server.listen({port: config.port}, (err) => {              // Simulate a perceptible delay              setTimeout( () => {                err ? reject(err) : accept(server.address().port)              }, 1000)            })          })        }
      })
      return info
    },
    stop: () => {
      server.close()
    }
  }
}

Les auteurs de plugins doivent également respecter la nature asynchrone des handlers et gérer d’éventuelles Promises retournées comme dans le module sample/7/index.js.

const readline = require('readline')
const myapp = require('./app')()

myapp.plugins.register({
  name: 'plugin:enhancer',
  hooks: {
    'server:start': ({config, server}, handler) => {
      if( !config.port ){
        config.port = 3000
      }
      return server.listening ? null : handler
    }
  }
})

myapp.plugins.register({
  name: 'plugin:reporter',
  required: 'plugin:enhancer',
  hooks: {
    'server:start': {
      before: 'plugin:enhancer',
      handler: (args, handler) => {
        // Return a promise        return async () => {          // Obtain a promise          const info = await handler.call(null, args)          process.stdout.write('Server is started\n')
          return info
        }
      }
    }
  }
})

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});
rl.prompt();
rl.on('line', async (line) => {
  switch (line.trim()) {
    case 'start':
      const info = await myapp.start()
      process.stdout.write(`Port is ${info.port}\n`)
      break;
    case 'stop':
      myapp.stop()
      break;
    default:
      process.stdout.write('Only `start` and `stop` are supported\n')
      break;
  }
  rl.prompt();
})

8. Architecture imbriquée / hiérarchique

Les instances de plug-in peuvent être imbriquées, ce qui signifie qu’un point d’interception peut appeler des hooks enregistrés dans l’instance actuelle ainsi que dans les instances parentes.

Le projet Nikita exploite cette fonctionnalité au plus profond de son architecture. Nikita est un outil d’infrastructure en tant que code (IaC) utilisé pour automatiser les déploiements. Il est composé d’actions imbriquées. Une action parent peut définir la procédure d’installation d’un composant, par exemple l’installation de MariaDB. Cette action parente est composée de plusieurs actions enfants, telles que l’installation des packages YUM ou APT, la gestion des fichiers de configuration et le téléchargement des certificats SSL. Encore une fois, chacune de ces actions consiste en plusieurs actions enfants. L’action de configuration écrit un fichier mais vérifie également ses autorisations et ses propriétés. L’action package exécute des commandes shell pour installer le package en fonction de la distribution Linux actuelle. À tous les niveaux, Nikita offre à l’utilisateur la possibilité d’enregistrer des plugins. Une fois enregistré, le plugin est disponible et actif pour chaque action enfant. Par exemple, le plugin debug imprime les informations de log dans stdout. Une fois enregistré et activé, il s’appliquera à une action et à ses enfants.

Une autre utilisation courante est d’offrir aux utilisateurs la possibilité d’enregistrer des plugins au niveau de l’instance ainsi qu’au niveau global. L’enregistrement d’un plugin au niveau global le rend disponible pour chaque nouvelle instance crée.

Le sample/8/app.js implémente le dernier modèle. Une nouvelle instance de plugins est exportée globalement par le module Plug and Play principal. Cette instance globale est transmise à chaque instance Plug and Play locale créée. Les instances parentes sont exposée à leur enfants lors de l’initialisation de ces derniers avec la propriété parent.

const http = require('http')
const plugandplay = require('plug-and-play')

module.exports = (config = {}) => {
  const plugins = plugandplay({    // Pass the global plugins instance    parent: module.exports.plugins  })  const server = http.createServer((req, res) => {
    res.end(config.message)
  })
  return {
    plugins: plugins,
    start: async () => {
      const info = {
        time: new Date()
      }
      info.port = await plugins.call({
        name: 'server:start',
        args: {
          config: config,
          server: server
        },
        handler: ({config, server}) => {
          return new Promise( (accept, reject) => {
            server.listen({port: config.port}, (err) => {
              setTimeout( () => {
                err ? reject(err) : accept(server.address().port)
              }, 1000)
            })
          })
        }
      })
      return info
    },
    stop: () => {
      server.close()
    }
  }
}

// Export global pluginsmodule.exports.plugins = plugandplay()

Le module sample/8/index.js illustre l’enregistrement global et local. Le plugin:enhancer est enregistré globalement tandis que leplugin:reporter est enregistré localement.

const readline = require('readline')
const app = require('./app')// Global registrationapp.plugins.register({  name: 'plugin:enhancer',
  hooks: {
    'server:start': ({config, server}, handler) => {
      if( !config.port ){
        config.port = 3000
      }
      return server.listening ? null : handler
    }
  }
})

// Local registrationconst myapp = app()myapp.plugins.register({  name: 'plugin:reporter',
  required: 'plugin:enhancer',
  hooks: {
    'server:start': {
      before: 'plugin:enhancer',
      handler: (args, handler) => {
        return async () => {
          const info = await handler.call(null, args)
          process.stdout.write('Server is started\n')
          return info
        }
      }
    }
  }
})

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});
rl.prompt();
rl.on('line', async (line) => {
  switch (line.trim()) {
    case 'start':
      const info = await myapp.start()
      process.stdout.write(`Port is ${info.port}\n`)
      break;
    case 'stop':
      myapp.stop()
      break;
    default:
      process.stdout.write('Only `start` and `stop` are supported\n')
      break;
  }
  rl.prompt();
})

Conclusion

Nous utilisons Plug and Play depuis quelques années maintenant et cela s’est avéré utile. Son usage a permit l’émergence de nouvelles opportunités dans nos bibliothèques et applications, y compris l’extensibilité et la composabilité. Nous espérons que cela vous servira bien et accueillons vos futures fonctionnalités et correctifs.

Canada - Morocco - France

International locations

10 rue de la Kasbah
2393 Rabbat
Canada

Nous sommes une équipe passionnées par l'Open Source, le Big Data et les technologies associées telles que le Cloud, le Data Engineering, la Data Science le DevOps…

Nous fournissons à nos clients un savoir faire reconnu sur la manière d'utiliser les technologies pour convertir leurs cas d'usage en projets exploités en production, sur la façon de réduire les coûts et d'accélérer les livraisons de nouvelles fonctionnalités.

Si vous appréciez la qualité de nos publications, nous vous invitons à nous contacter en vue de coopérer ensemble.