Aller au contenu

Hexagone · épisode 02

La nuit où l'open data a changé de format

Ports et adaptateurs : survivre au changement de format d'une source

Houssem Blibech 3 min de lecture

épisode 02 · La nuit où l'open data a changé de format · la version animée
Écouter l'épisode · version audio

Hexagone, épisode 2 sur 8 : O, développeur chez AJLabs, remet d'aplomb la plateforme de RestoSafe, qui publie les contrôles d'hygiène de 207 000 restaurants. Précédemment : O a regroupé la règle du niveau d'hygiène au centre du code, dans un domaine qui ne dépend de rien.

Chaque nuit à 2 h, RestoSafe télécharge le fichier des contrôles sanitaires publié en open data, le découpe et met à jour les fiches des restaurants. Ce mardi-là, à 2 h 04, l’import plante. Puis il replante à 3 h, à 4 h, à 5 h. À 8 h, quand O arrive, le site affiche encore les données de la veille et Hajer a déjà envoyé trois messages.

Le fournisseur des données a changé son export pendant le week-end. Les colonnes ont été renommées, les dates sont passées du format dd/mm/yyyy au format ISO, et le niveau d’hygiène n’est plus un texte mais un code chiffré. Rien d’anormal : c’est son fichier, il en fait ce qu’il veut.

Adam ouvre le code de l’import et soupire. Les noms des colonnes du fichier apparaissent dans 17 fichiers : le script d’import, mais aussi les entités de la base, deux services, le build du site et même un écran de l’application. « Ça va prendre la semaine, dit-il. Et la prochaine fois qu’ils changent, rebelote. »

Daly arrive avec la question qu’O redoutait : « Hajer veut aussi brancher le flux d’une métropole qui publie ses propres contrôles. On peut l’avoir pour la fin du mois ? »

O retourne au tableau blanc. Sur un côté de l’hexagone, il dessine une petite prise électrique. « Le métier a besoin de recevoir des inspections. Il se moque de savoir si elles viennent d’un CSV national, d’une API régionale ou d’un fichier envoyé par e-mail. Ce besoin, on va l’écrire dans ses mots à lui. »

C’est ce qu’on appelle un portPort Interface définie par le domaine, dans ses propres mots, qui décrit ce dont il a besoin sans dire comment l'obtenir. : une interface définie par le domaine, dans le langage du domaine, qui dit ce dont il a besoin sans dire comment l’obtenir.

// domain/ports/inspection-source.port.ts
export interface InspectionSource {
  readonly name: string;
  fetchSince(date: Date): AsyncIterable<RawInspection>;
}

export interface RawInspection {
  siret: string;
  establishmentName: string;
  inspectedAt: Date;
  level: HygieneLevel;
}

Jana fronce les sourcils. « Mais le fichier, il faut bien le lire quelque part ? »

« Bien sûr. Dans un adaptateurAdaptateur Code qui branche un outil réel (base, API, fichier) sur un port, en traduisant ses données dans les mots du domaine.. Son seul travail, c’est de traduire le monde extérieur dans les mots du domaine. Toute la connaissance du format du fichier vit là, et nulle part ailleurs. »

O écrit le premier adaptateur pour le nouveau format national :

// infrastructure/sources/national-open-data.source.ts
const LEVEL_CODES: Record<string, HygieneLevel> = {
  '1': 'VERY_SATISFACTORY',
  '2': 'SATISFACTORY',
  '3': 'TO_IMPROVE',
  '4': 'URGENT_CORRECTION',
};

export class NationalOpenDataSource implements InspectionSource {
  readonly name = 'national-open-data';

  constructor(private readonly http: HttpClient, private readonly url: string) {}

  async *fetchSince(date: Date): AsyncIterable<RawInspection> {
    for await (const row of this.http.streamCsv(this.url, { delimiter: ';' })) {
      const inspectedAt = new Date(row.date_inspection);
      if (inspectedAt < date) continue;
      yield {
        siret: row.siret,
        establishmentName: row.app_libelle_etablissement,
        inspectedAt,
        level: LEVEL_CODES[row.code_niveau_hygiene],
      };
    }
  }
}

Puis, presque sans effort, un second pour le flux de la métropole, qui publie en JSON avec ses propres noms de champs. Le script d’import ne connaît plus aucun format. Il parcourt simplement toutes les sources branchées :

// application/import-inspections.ts
export class ImportInspections {
  constructor(private readonly sources: InspectionSource[], private readonly restaurants: RestaurantRepository) {}

  async run(since: Date): Promise<ImportReport> {
    const report = new ImportReport();
    for (const source of this.sources) {
      for await (const raw of source.fetchSince(since)) {
        await this.restaurants.recordInspection(raw);
        report.count(source.name);
      }
    }
    return report;
  }
}

La dernière pièce du puzzle, c’est de décider quelles sources sont branchées. O le fait à un seul endroit, dans le module NestJS, grâce à l’injection de dépendancesInjection de dépendances Technique qui fournit à un objet les dépendances dont il a besoin, au lieu qu'il les crée lui-même ; c'est elle qui choisit quel adaptateur brancher. :

{
  provide: INSPECTION_SOURCES,
  useFactory: (http: HttpClient) => [
    new NationalOpenDataSource(http, process.env.NATIONAL_FEED_URL),
    new MetropolisSource(http, process.env.METROPOLIS_FEED_URL),
  ],
  inject: [HttpClient],
}

Il faut deux jours, au lieu de la semaine annoncée. Le premier sert surtout à retirer les noms de colonnes des 16 autres fichiers. C’est le moment le moins glorieux de l’histoire, et le plus utile : chaque fichier nettoyé devient plus court.

Le jeudi soir, l’import tourne à nouveau, avec deux sources au lieu d’une. Le lendemain matin, 3 412 inspections de la métropole apparaissent sur le site. Adam regarde les logs, puis le code de l’adaptateur. « La prochaine fois qu’ils changent de format, on touche un seul fichier ? »

« Un seul. Et on peut même faire tourner l’ancien et le nouveau côte à côte pendant la transition. »

Ce que O retient

  • Un port s’écrit dans la langue du métier, jamais dans celle du fournisseur.
  • L’adaptateur est le seul endroit qui a le droit de connaître un format, un SDK ou une URL.
  • Le coût d’un changement d’outil est proportionnel au nombre d’endroits qui le connaissent. L’objectif, c’est un seul.