Analyse statique et génération de code

Publié dans développement
le

Prenons de la hauteur et imaginons à quoi pourrait ressembler le développement d'applications si nous n'avions plus qu'à nous préoccuper du code métier.

Il y a quelques semaines, poussé par ma curiosité, j'ai assisté à un live d'Alexandre Soyer intitulé Coder avec l'IA - Comment gagner 4h par jour avec L'IA, un titre accrocheur s'il en est ! En tant que développeur·se, force est d'admettre que l'IA est effectivement un enjeu important et qu'il vaut mieux s'y préparer et s'adapter.

J'utilise d'ailleurs depuis quelques mois Github Copilot et il faut bien avouer qu'il me fait gagner un sacré paquet de temps (même si le flou autour des données collectées reste questionnable).

Bref, revenons à nos moutons. Alex a avancé pas mal de points sur le métier de développeur·se et le code que nous produisons et à quel point écrire une grosse partie de notre code est un jeu d'enfant pour une IA. Après tout il ne s'agit que de texte avec un vocabulaire restreint.

Malgré tout, je pense que nous sommes tous d'accord pour admettre qu'écrire du code ne représente qu'une fraction de notre métier. On passe tout autant de temps, voir plus, à comprendre le problème, élaborer une solution et anticiper les erreurs. Je partage l'avis d'Alex sur le fait que l'IA ne sera qu'un outil de plus pour optimiser une partie de notre travail et non pas le remplacer.

En revanche, il y'a un point qui me fait réfléchir et qui rejoint une réflexion que j'ai depuis quelques temps :

On fait souvent des trucs pas compliqué, mais complexe

— Alexandre Soyer

Le constat

Je ressens la même chose, comme il le dit « On fait souvent les mêmes trucs, projet après projet », « 50% de notre temps, c'est : CRUD, formulaires, requêtes HTTP, tableaux, API GET, POST », etc…

Ce qui reste paradoxal car en tant que développeur·se, nous sommes extrêmement friands de tout ce qui nous évite de nous répéter et nous fait gagner du temps. Nous aimons la simplicité, surtout quand il s'agit de glue code, du code qui est nécessaire mais dont on préférerait se passer et encore plus ne pas avoir à maintenir.

Du coup, une fois un problème considéré comme résolu, la solution devrait pouvoir être intégrée sans aucun effort. Gérer l'authentification, la pagination, l'affichage de tableaux, la validation des formulaires, l'exposition et la documentation d'une API REST, etc…, toutes ces choses qu'on retrouve de manière systématique.

Et pourtant, en 2023, on est encore loin d'avoir atteint quelque chose de satisfaisant.

Alors bien sûr, il existe des frameworks à la pelle, des librairies, des approches, des patterns mais commencer un nouveau projet reste quelque chose de laborieux et ce constat peut être fait côté back ou front, peu importe.

IA pas de soucis

Badum tssss

Du coup forcément, 2023 oblige, nous avons enfin la solution à tous nos maux : l'IA ! Avant ça nous avions des outils de scaffolding mais ce n'était pas assez disruptif.

L'IA va donc se charger d'écrire ce code laborieux pour nous, en voilà une bonne idée ! De cette manière, nous pourrons nous concentrer sur le code métier, propre à notre problème, augmenter notre vélocité et proposer plus de valeur à nos utilisateurs (vous n'avez pas une impression de déjà vu ?) !

Mais ne peut-on pas aller encore plus loin ?

Personnellement, je pense que concernant ce code si trivial, ce code nécessaire mais sans grande valeur fonctionnelle, nous pouvons faire mieux. De mon point de vue, on pourrait même s'en passer ou en tout cas le rendre invisible pour ne plus s'en préoccuper.

Prenons un exemple simple que je trouve assez parlant, mais gardez en tête que le principe s'applique quelque soit le langage. Nous avons donc ci-dessous un code classique tiré de l'exemple Swagger de NestJS.

import { Injectable, Body, Controller, Get, Param, Post } from "@nestjs/common";
import {
  ApiBearerAuth,
  ApiOperation,
  ApiResponse,
  ApiTags,
  ApiProperty,
} from "@nestjs/swagger";
import { IsInt, IsString } from "class-validator";

class Cat {
  /**
   * The name of the Cat
   * @example Kitty
   */
  name: string;

  @ApiProperty({ example: 1, description: "The age of the Cat" })
  age: number;

  @ApiProperty({
    example: "Maine Coon",
    description: "The breed of the Cat",
  })
  breed: string;
}

class CreateCatDto {
  @IsString()
  readonly name: string;

  @IsInt()
  readonly age: number;

  @IsString()
  readonly breed: string;
}

@Injectable()
class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: CreateCatDto): Cat {
    this.cats.push(cat);
    return cat;
  }

  findOne(id: number): Cat {
    return this.cats[id];
  }
}

@ApiBearerAuth()
@ApiTags("cats")
@Controller("cats")
export class CatsController {
  constructor(private readonly catsService: CatsService) {}

  @Post()
  @ApiOperation({ summary: "Create cat" })
  @ApiResponse({ status: 403, description: "Forbidden." })
  async create(@Body() createCatDto: CreateCatDto): Promise<Cat> {
    return this.catsService.create(createCatDto);
  }

  @Get(":id")
  @ApiResponse({
    status: 200,
    description: "The found record",
    type: Cat,
  })
  findOne(@Param("id") id: string): Cat {
    return this.catsService.findOne(+id);
  }
}

On a là quelque chose d'ultra commode. Un modèle, un service, un contrôleur. Le service nous retourne un DTO lors de la lecture et on expose tout ça via une API REST. Je vous passe toute la partie configuration du conteneur d'injection de dépendances.

D'ores et déjà, on peut remarquer des choses assez étonnantes : findOne(@Param("id") id: string), @IsString() readonly breed: string;, même si on comprend pourquoi ces annotations sont nécessaires, notre code est noyé sous tout un tas de considérations d'infrastructure, qui répète souvent des choses qu'on peut déduire des types eux-mêmes.

Si on prend de la hauteur, ce que nous voulons ici, en tant que développeur·se web, c'est bien exposer des cas d'utilisations métier et les rendre accessibles depuis l'extérieur, ici sous forme d'API REST mais cela pourrait prendre une autre forme, ça n'a pas d'importance.

Au final, ne serait-il pas plus logique d'avoir quelque chose dans ce genre :

class Cat {
  /**
   * The name of the Cat
   * @example Kitty
   */
  name: string;

  /**
   * The age of the Cat
   */
  age: number;

  /**
   * The breed of the Cat
   */
  breed: string;
}

class CreateCatDto {
  readonly name: string;
  readonly age: number;
  readonly breed: string;
}

class CatsService {
  private readonly cats: Cat[] = [];

  /**
   * Create cat.
   */
  create(cat: CreateCatDto): Cat {
    this.cats.push(cat);
    return cat;
  }

  /**
   * Find a cat by its id.
   */
  findOne(id: number): Cat {
    return this.cats[id];
  }
}

Nous pourrions nous contenter d'écrire ce code strictement nécessaire à l'accomplissement de notre métier et ensuite, s'appuyer sur un générateur pour produire la partie manquante en se basant à la fois sur les composants du langage utilisé (ici Typescript), comme par exemple les types, les commentaires de code, quand c'est nécessaire quelques annotations ou doc tags et pourquoi pas des conventions utilisées dans l'équipe (nommage, emplacement des fichiers, etc…).

Analyse statique et parsing du code

Ce qu'il faut bien comprendre, c'est que quand je parle de générer du code, il ne s'agit pas de le faire écrire par une IA, je parle bien d'une génération déterministe à base d'analyse statique du code comme on sait le faire depuis longtemps. Ce code généré ne ferait plus réellement parti de notre base de code comme avec les outils de scaffolding et pourrait évoluer au gré du temps, nécessitant uniquement une mise à jour puis re-génération sur notre projet facilitant ainsi sa maintenance.

Dans mon exemple au dessus, je pourrai par exemple demander à cet outil de me générer soit une API REST, soit un outil en ligne de commande permettant d'appeler les méthodes create et findOne de mon service, soit totalement autre chose.

Via l'analyse statique et le parsing du code, je peux déduire quels sont les paramètres attendus, les retours, la documentation et même les dépendances de mon service et comment les construire.

Adapter les outils à nos pratiques et non l'inverse

Le but serait de pouvoir configurer cette génération, non pas avec un fichier de configuration comme on en voit souvent (yaml, toml, json, …), mais avec un fichier qui utiliserai le même langage que la cible de génération, permettant ainsi de tirer parti des fonctionnalités de l'IDE.

Cette configuration de la génération pourrait permettre à une équipe de s'approprier l'outil et mettre en place des conventions réutilisées de projet en projet pour un maximum d'efficacité.

Par exemple, une équipe pourrait configurer l'outil pour que toutes les classes terminant par Service et possédant une méthode publique create aboutissent à une méthode POST sur une route /api/{nom de la classe sans le suffixe Service} en utilisant la documentation du code pour générer les spécifications OpenAPI.

Suite à la commande de génération, le serveur web serait alors prêt, documenté, monitoré, configurable, les services correctement instanciés suivant leurs dépendances sans d'autre intervention de la part de l'équipe et sans au final avoir à se préoccuper de ce qui a été généré.

Formidable ! Je signe où ?

Cela peut sembler utopique mais il existe déjà pas mal d'outils qui font des choses dans le même état d'esprit. Par exemple, on a des générateurs pour construire du code à partir d'une spécification OpenAPI, ce qui nous permet d'être sûr que les URLs sont les bonnes et que les types utilisés aussi.

En Typescript, on sait générer un validateur Zod depuis des types Typescript. Prisma génère le code typé nécessaire aux interactions avec une base de données depuis un schéma défini.

Cependant, il s'agit toujours d'initiatives isolées pour des briques spécifiques.

Le seul projet qui se rapproche de ce que j'ai réellement en tête est Encore qui, en Go, vous permet d'écrire des cas d'utilisations, de les exposer, d'appeler d'autres services distribués facilement, de gérer la configuration, le monitoring, des tâches Cron et de provisionner toute votre infrastructure en se basant sur votre propre code et en générant tout ce qui est nécessaire pour que votre application fonctionne.

De mon côté, je me suis amusé à faire quelques expérimentations en Go juste pour valider la faisabilité technique mais je n'ai aucun doute sur le fait qu'on pourrait imaginer quelque chose de malléable et d'extensible pour couvrir tous les besoins triviaux qu'on a de projet en projet.

La tâche est bien évidemment colossale mais est-ce qu'il ne s'agirait pas là d'un futur souhaitable ?