paint-brush
Création d'une base de données de séries chronologiques avec TDEngine et GraphQLpar@patrickheneise
641 lectures
641 lectures

Création d'une base de données de séries chronologiques avec TDEngine et GraphQL

par Patrick Heneise13m2023/10/22
Read on Terminal Reader

Trop long; Pour lire

Dans cet article, nous passerons en revue la configuration d'une base de données et de tables TDEngine et comment créer un schéma GraphQL qui nous permet d'interroger les données de divers clients et applications.
featured image - Création d'une base de données de séries chronologiques avec TDEngine et GraphQL
Patrick Heneise HackerNoon profile picture
0-item
1-item

Motivation et introduction

En tant que membre de l'équipe logicielle de Nevados, nous construisons une plate-forme d'exploitation et de surveillance pour le Nevados All Terrain Tracker®. Un tracker solaire est un appareil qui oriente un panneau solaire vers le soleil. Chaque tracker solaire envoie en permanence des informations d'état et des relevés, tels que l'angle actuel, la température, les tensions, etc. à notre plateforme et nous devons stocker ces informations à des fins d'analyse et de visualisation. Si le tracker est configuré pour envoyer des données toutes les 5 secondes, nous avons 17 280 points de données par tracker par jour, 518 400 points de données par tracker et par mois. Cela résume beaucoup d’informations. Ce type de données est appelé « données de séries chronologiques » et comme pour tous les problèmes complexes liés aux logiciels, il existe plusieurs solutions (bases de données de séries temporelles). Les plus connus étant InfluxDB et TimescaleDB. Pour notre plateforme, nous avons décidé de travailler avec TDEngine , un produit relativement nouveau optimisé pour les applications IoT et fonctionnant avec le langage de requête SQL.


Il y avait plusieurs arguments pour cette décision : TDEngine

  • est open source
  • est optimisé pour les applications IoT
  • utilise SQL, qui est un langage que nous connaissons bien
  • est disponible en tant que service géré et nous pouvons nous concentrer sur la création de notre application
  • est facile à exécuter localement via Docker


Dans cet article, nous passerons en revue la configuration d'une base de données et de tables TDEngine et comment créer un schéma GraphQL qui nous permet d'interroger les données de divers clients et applications.

Premiers pas avec TDEngine

Le moyen le plus simple de démarrer avec TDEngine est d'utiliser leur service cloud. Accédez au TDEngine et créez un compte. Ils disposent de quelques bases de données publiques que nous pouvons utiliser, ce qui est idéal pour monter une démo ou expérimenter des requêtes.


Si vous souhaitez exécuter TDEngine localement, vous pouvez utiliser l'image Docker et Telegraf pour récupérer des données de diverses sources et les envoyer à la base de données, telles que des informations système, des statistiques ping, etc.

 version: '3.9' services: tdengine: restart: always image: tdengine/tdengine:latest hostname: tdengine container_name: tdengine ports: - 6030:6030 - 6041:6041 - 6043-6049:6043-6049 - 6043-6049:6043-6049/udp volumes: - data:/var/lib/taos telegraf: image: telegraf:latest links: - tdengine env_file: .env volumes: - ./telegraf.conf:/etc/telegraf/telegraf.conf


Consultez la documentation officielle de la configuration Telegraf et la documentation TDEngine sur Telegraf . En bref, cela ressemblerait à ceci pour se connecter à un sujet MQTT :

 [agent] interval = "5s" round_interval = true omit_hostname = true [[processors.printer]] [[outputs.http]] url = "http://127.0.0.1:6041/influxdb/v1/write?db=telegraf" method = "POST" timeout = "5s" username = "root" password = "taosdata" data_format = "influx" [[inputs.mqtt_consumer]] topics = [ "devices/+/trackers", ]

Au lieu de tout configurer localement et d'attendre que la base de données se remplisse d'informations, nous utiliserons la base de données publique pour cet article, qui contient les mouvements des navires en provenance des 5 principaux ports américains.

Utilisation de TDEngine avec les données publiques sur les mouvements des navires

Par défaut, les tables de TDEngine ont un schéma implicite, ce qui signifie que le schéma s'adapte aux données écrites dans la base de données. C'est idéal pour l'amorçage, mais à terme, nous souhaitons passer à un schéma explicite pour éviter les problèmes avec les données entrantes. Une chose à laquelle il faut un peu de temps pour s'habituer est leur concept de Super Tables (« STable » en abrégé). Dans TDEngine, il y a des balises (clés) et des colonnes (données). Pour chaque combinaison de touches, un "tableau" est créé. Toutes les tables sont regroupées dans le STable.

capture d'écran montrant les tables cloud tdengine


En regardant la base de données vessel , ils ont une STable appelée ais_data qui contient de nombreuses tables. Habituellement, nous ne souhaitons pas interroger table par table, mais utilisons toujours STable pour obtenir les données accumulées de toutes les tables.


TDEngine a une fonction DESCRIBE qui nous permet d'inspecter le schéma d'une table ou d'un STable. Le ais_data a le schéma suivant :

capture d'écran montrant le schéma de la table TDEngine


Le STable a deux clés et six colonnes de données. Les clés sont le mmsi et le name . Nous pouvons utiliser des instructions SQL classiques pour interroger les données :

 SELECT ts, name, latitude, longitude FROM vessel.ais_data LIMIT 100; ts name latitude longitude 2023-08-11T22:07:02.419Z GERONIMO 37.921673 -122.40928 2023-08-11T22:21:48.985Z GERONIMO 37.921688 -122.40926 2023-08-11T22:25:08.784Z GERONIMO 37.92169 -122.40926 ...


Gardez à l’esprit que les données de séries chronologiques sont généralement très volumineuses, nous devons donc toujours limiter l’ensemble des résultats. Il existe quelques fonctions spécifiques aux séries chronologiques que nous pouvons utiliser, comme PARTITION BY qui regroupe les résultats par clé et est utile pour obtenir la dernière mise à jour des clés individuelles. Par exemple:

 SELECT last_row(ts, name, latitude, longitude) FROM vessel.ais_data PARTITION BY name; ts name latitude longitude 2023-09-08T13:09:34.951Z SAN SABA 29.375961 -94.86894 2023-09-07T18:05:01.230Z SELENA 33.678585 -118.1954 2023-09-01T17:23:24.145Z SOME TUESDAY 33.676563 -118.230606 ... 


capture d'écran montrant la sortie de tdengine


Je recommande de lire leur documentation SQL pour plus d'exemples. Avant de continuer, rendez-vous dans « Programmation », « Node.js » et récupérez vos variables TDENGINE_CLOUD_URL et TDENGINE_CLOUD_TOKEN .

GraphQL avec Nexus.js, Fastify et Mercurius

GraphQL est assez connu de nos jours et il existe de nombreux bons articles à ce sujet. Nous avons choisi cette technologie car nous collectons et traitons des informations provenant de différentes sources et GraphQL nous permet de les combiner de manière transparente en une seule API.


Nous utiliserons l'incroyable framework Fastify (désormais le choix par défaut pour les applications Node.js) et l'adaptateur Mercurius . Les équipes de Mercurius et Fastify ont travaillé ensemble pour une expérience transparente et il s'agit d'un excellent choix d'API GraphQL axées sur les performances. GraphQL Nexus est un outil pour construire/générer le schéma et les résolveurs, nous n'avons donc pas besoin de tout écrire à la main.


Il y a un peu de code de configuration, etc. à faire, que je vais ignorer ici. Vous pouvez trouver un exemple complet sur GitHub - tdengine-graphql-example .


Je souhaite développer deux choses dans cet article qui sont plutôt spécifiques :

  1. la bibliothèque de requêtes TDEngine
  2. le schéma GraphQL avec Nexus

Bibliothèque de requêtes TDEngine

TDEngine dispose d'une bibliothèque Node.js qui nous permet d'interroger la base de données. Cela facilite la connexion et l'envoi de requêtes, malheureusement les réponses sont un peu difficiles à gérer. Nous avons donc écrit un petit wrapper :


 'use strict' import tdengine from '@tdengine/rest' import { tdEngineToken, tdEngineUrl } from '../config.js' import parseFields from 'graphql-parse-fields' const { options: tdOptions, connect: tdConnect } = tdengine tdOptions.query = { token: tdEngineToken } tdOptions.url = tdEngineUrl export default function TdEngine(log) { this.log = log const conn = tdConnect(tdOptions) this.cursor = conn.cursor() } TdEngine.prototype.fetchData = async function fetchData(sql) { this.log.debug('fetchData()') this.log.debug(sql) const result = await this.cursor.query(sql) const data = result.getData() const errorCode = result.getErrCode() const columns = result.getMeta() if (errorCode !== 0) { this.log.error(`fetchData() error: ${result.getErrStr()}`) throw new Error(result.getErrStr()) } return data.map((r) => { const res = {} r.forEach((c, idx) => { const columnName = columns[idx].columnName .replace(/`/g, '') .replace('last_row(', '') .replace(')', '') if (c !== null) { res[columnName] = c } }) return res }) }


Cela renvoie un objet TDEngine qui peut être transmis dans le contexte GraphQL. Nous utiliserons principalement la fonction fetchData où nous pouvons transmettre une requête SQL et récupérer les résultats sous forme de tableau d'objets. TDEngine renvoie les métadonnées (colonnes), les erreurs et les données séparément. Nous utiliserons les métadonnées pour mapper les colonnes dans une liste régulière d'objets. Un cas particulier ici est la fonction last_row . Les colonnes sont renvoyées sous la forme last_row(ts) , last_row(name) etc. et nous souhaitons supprimer la partie last_row afin que l'attribut soit mappé 1:1 au schéma GraphQL. Cela se fait dans la partie columnName.replace .

Schéma GraphQL

Malheureusement, il n'existe pas de générateur de schéma comme Postgraphile pour TDEngine et nous ne voulons pas écrire et maintenir un schéma GraphQL pur, nous utiliserons donc Nexus.js pour nous aider. Nous commencerons par deux types de base : VesselMovement et Timestamp (qui est un type scalaire). Timestamp et TDDate sont deux types différents pour afficher la date sous forme d'horodatage ou de chaîne de date. Ceci est utile pour l'application client (et pendant le développement), car elle peut décider quel format utiliser. asNexusMethod nous permet d'utiliser le type comme fonction dans le schéma VesselMovement . Nous pouvons résoudre le TDDate ici même dans la définition du type pour utiliser la valeur d'horodatage ts d'origine.


 import { scalarType, objectType } from 'nexus' export const Timestamp = scalarType({ name: 'Timestamp', asNexusMethod: 'ts', description: 'TDEngine Timestamp', serialize(value) { return new Date(value).getTime() } }) export const TDDate = scalarType({ name: 'TDDate', asNexusMethod: 'tdDate', description: 'TDEngine Timestamp as Date', serialize(value) { return new Date(value).toJSON() } }) export const VesselMovement = objectType({ name: 'VesselMovement', definition(t) { t.ts('ts') t.tdDate('date', { resolve: (root) => root.ts }) t.string('mmsi') t.string('name') t.float('latitude') t.float('longitude') t.float('speed') t.float('heading') t.int('nav_status') } })


Pour les types de séries temporelles, nous utilisons le suffixe Movement ou Series pour une séparation claire des types relationnels et chronologiques dans l'interface.


Nous pouvons maintenant définir la requête. Nous allons commencer par une simple requête pour obtenir les derniers mouvements de TDEngine :

 import { objectType } from 'nexus' export const GenericQueries = objectType({ name: 'Query', definition(t) { t.list.field('latestMovements', { type: 'VesselMovement', resolve: async (root, args, { tdEngine }, info) => { const fields = filterFields(info) return tdEngine.fetchData( `select last_row(${fields}) from vessel.ais_data partition by mmsi;` ) } }) } }) 


capture d'écran montrant la sortie graphique de la requête


GraphiQL est un excellent outil pour tester l'API et explorer le schéma, vous pouvez l'activer en passant graphiql.enabled = true dans Mercurius. Avec la requête, on peut voir les derniers mouvements des navires regroupés par mmsi . Allons cependant un peu plus loin. L'un des plus grands avantages de GraphQL est qu'il constitue une couche transparente pour le client ou l'application. Nous pouvons récupérer des données à partir de plusieurs sources et les combiner dans le même schéma.


Malheureusement, je n'ai pas pu trouver d'API simple/gratuite contenant des informations détaillées sur les navires. Il existe Sinay , mais ils ne fournissent que le name , mmsi et imo dans leur réponse Vessel (que nous avons déjà dans TDEngine). Pour les besoins de l'exemple, nous supposons que nous n'avons pas le name dans notre base de données et que nous devons le récupérer auprès de Sinay. Avec l' imo nous pourrions également interroger les émissions de CO2 d'un navire ou une autre API pourrait être utilisée pour récupérer une image, le drapeau ou d'autres informations, le tout pouvant être combiné dans le type Vessel .


 export const Vessel = objectType({ name: 'Vessel', definition(t) { t.string('mmsi') t.string('name') t.nullable.string('imo') t.list.field('movements', { type: 'VesselMovement' }) } })


Comme vous pouvez le voir ici, nous pouvons inclure une liste movements de champ avec les données de séries chronologiques de TDEngine. Nous ajouterons une autre requête pour récupérer les informations sur le navire et le résolveur nous permettra de combiner les données de TDEngine et Sinay :


 t.field('vessel', { type: 'Vessel', args: { mmsi: 'String' }, resolve: async (root, args, { tdEngine }, info) => { const waiting = [ getVesselInformation(args.mmsi), tdEngine.fetchData( `select * from vessel.ais_data where mmsi = '${args.mmsi}' order by ts desc limit 10;` ) ] const results = await Promise.all(waiting) return { ...results[0][0], movements: results[1] } } }) 


capture d'écran montrant la sortie graphique de la requête

🎉 et ici, nous avons une API GraphQL fonctionnelle renvoyant des lignes de TDEngine pour un navire spécifique que nous avons demandé. getVesselInformation() est un simple wrapper pour récupérer des données depuis Sinay. Nous ajouterons les résultats de TDEngine dans l'attribut movements et GraphQL s'occupera du reste et mappera tout au schéma.

Remarque : injection SQL

Comme pour toute base de données SQL, nous devons être prudents avec les entrées des utilisateurs. Dans l'exemple ci-dessus nous utilisons directement l'entrée mmsi , ce qui rend cette requête vulnérable aux injections SQL. Par souci d'exemple, nous ignorerons cela pour l'instant, mais dans les applications du « monde réel », nous devrions toujours nettoyer les entrées de l'utilisateur. Il existe plusieurs petites bibliothèques pour nettoyer les chaînes, dans la plupart des cas, nous nous appuyons uniquement sur les nombres (pagination, limite, etc.) et les énumérations (ordre de tri), que GraphQL vérifie pour nous.


Merci à Dmitry Zaets de l'avoir signalé !

Optimisations

Il y a quelques éléments qui dépassent le cadre de cet article, mais je tiens à les mentionner brièvement :

Pothos comme successeur spirituel de Nexus.js

Lorsque nous avons démarré le projet, Nexus.js était le meilleur choix pour générer notre schéma GraphQL. Bien que stable et quelque peu complet en fonctionnalités , il manque de maintenance et de mises à jour. Il existe un générateur de schéma GraphQL basé sur un plugin appelé Pothos , qui est un peu plus moderne et activement maintenu. Si vous démarrez un nouveau projet, je vous recommande probablement d'utiliser Pothos au lieu de Nexus.js.


Merci à Mo Sattler de l'avoir signalé !

Résolveurs de champ

Comme vous pouvez le voir dans le résolveur Vessel ci-dessus, les deux sources de données sont immédiatement récupérées et traitées. Cela signifie que si la requête porte uniquement sur le name , nous récupérons toujours les movements pour la réponse. Et si la requête concerne uniquement les movements , nous récupérons toujours le nom de Sinay et payons potentiellement pour la demande.


Il s'agit d'un anti-modèle GraphQL et nous pouvons améliorer les performances en utilisant les informations de champ pour récupérer uniquement les données demandées. Les résolveurs ont les informations de champ comme quatrième argument, mais ils sont assez difficiles à utiliser. Au lieu de cela, nous pouvons utiliser graphql-parse-fields pour obtenir un objet simple des champs demandés et ajuster la logique du résolveur.

Optimisations des requêtes SQL

Dans nos exemples de requêtes, nous utilisons select * pour récupérer toutes les colonnes de la base de données même si elles ne sont pas nécessaires. C'est évidemment assez mauvais et nous pouvons utiliser le même analyseur de champs pour optimiser les requêtes SQL :


 export function filterFields(info, context) { const invalidFields = ['__typename', 'date'] const parsedFields = parseFields(info) const fields = context ? parsedFields[context] : parsedFields const filteredFields = Object.keys(fields).filter( (f) => !invalidFields.includes(f) ) return filteredFields.join(',') }


Cette fonction renvoie une liste de champs séparés par des virgules à partir des informations GraphQL.

 const fields = filterFields(info) return tdEngine.fetchData( `select last_row(${fields}) from vessel.ais_data partition by mmsi;` )


Si nous demandons ts , latitude et longitude , la requête ressemblerait à ceci :

 select last_row(ts, latitude, longitude) from vessel.ais_data partition by mmsi;


Avec seulement quelques colonnes dans ce tableau, cela n'a peut-être pas beaucoup d'importance, mais avec plus de tables et de requêtes complexes, cela peut faire une énorme différence dans les performances des applications.

Fonctions de séries chronologiques

TDEngine possède des extensions spécifiques aux séries chronologiques qui doivent être utilisées pour améliorer les performances. Par exemple, pour récupérer la dernière entrée, une requête SQL traditionnelle :

 SELECT ts, name, latitude, longitude FROM vessel.ais_data order by ts desc limit 1;


L'exécution prend 653 ms, tandis que la requête "TDEngine" ne prend que 145 ms :

 SELECT last_row(ts, name, latitude, longitude) FROM vessel.ais_data;


Il existe des options de configuration pour chaque table afin d'optimiser les fonctions last_row/first_row et d'autres paramètres de cache. Je recommande de lire la documentation de TDEngine .

Conclusion

La version simple : dans cet article, nous avons mis en place une base de données de séries temporelles TDEngine et défini un schéma GraphQL pour permettre aux applications clientes de se connecter et d'interroger des données.


Il y a bien plus à faire. Nous avons un projet standard pour combiner des données de séries chronologiques complexes avec des données relationnelles dans une interface transparente. Chez Nevados, nous utilisons PostgreSQL comme base de données principale et récupérons les données de séries chronologiques de la même manière que dans l'exemple movement ci-dessus. C'est un excellent moyen de combiner des données provenant de plusieurs sources dans une seule API. Un autre avantage est que les données ne sont récupérées que sur demande, ce qui ajoute beaucoup de flexibilité à l'application client. Enfin et surtout, le schéma GraphQL fonctionne comme une documentation et un contrat, nous pouvons donc facilement cocher la case « Documentation API ».


Si vous avez des questions ou des commentaires , veuillez nous contacter sur BlueSky ou rejoindre la discussion sur GitHub .


Également publié ici .