BT

Diffuser les Connaissances et l'Innovation dans le Développement Logiciel d'Entreprise

Contribuez

Sujets

Sélectionner votre région

Accueil InfoQ Articles Manipulation De Données Avec Programmation Fonctionnelle Et Requêtes Dans Ballerina

Manipulation De Données Avec Programmation Fonctionnelle Et Requêtes Dans Ballerina

Points Clés

  • Ballerina a été conçu comme un langage de programmation orienté données et prend en charge un style de codage de programmation fonctionnel
  • Exprimer une logique de manipulation de données avec des fonctions peut être puissant
  • L'utilisation de fonctions fléchées avec inférence de type peut rendre le code compact et clair
  • Le langage de requête de Ballerina est similaire à SQL dans le sens où une expression de requête est composée de clauses. La manipulation de données exprimée avec le langage de requête de Ballerina est plus facile à lire en comparaison avec d'autres expressions de programmation fonctionnelles
  • La structure de données Ballerina "Table" peut être plus efficace que les maps pour représenter les collections de données indexées

En tant qu'adepte de la programmation fonctionnelle (FP), je me sens à l'aise pour exprimer ma logique de manipulation de données en enchaînant des fonctions de rang supérieur comme map, filter et sort fonctionnant sur des tableaux et des maps. Comme nous l'avons vu dans notre article précédent, Ballerina, étant conçu comme un langage de programmation orienté données, prend en charge ce style de codage FP.

Dans cet article, j'aimerais approfondir les capacités en FP dans Ballerina et également explorer une manière innovante d'exprimer la logique de manipulation de données fournie par le langage, via le langage de requête Ballerina et une structure de données appelée "table".

Manipulation de données avec la programmation fonctionnelle

Supposons que nous voulions récupérer des résultats de recherche enrichis à partir de collections en mémoire de livres et d'auteurs que nous avons récupérés à partir d'un service comme OpenLibrary via son API JSON.

Un livre (book) a trois champs :

  • title
  • isbn
  • author_id 

Un auteur (author) a trois champs :

  • id
  • firstName
  • lastName

Un livre correspond à la requête si son titre contient la requête en tant que sous-chaîne.

Chaque résultat de recherche doit contenir les champs suivants :

  • title (champ de livre)
  • authorName (champ calculé)

Les résultats de livres doivent être triés par noms d'auteurs de livres.

Afin de répondre aux exigences, je crée :

  • un record Book,
  • un record Author,
  • un record BookResult,
  • un tableau de records Book,
  • et une map des records Author, où les clés de la map sont les identifiants d'auteur.

Pour implémenter la logique de recherche, je vais écrire une fonction avec la signature suivante :

function searchBooks(Book[] books, map<Author> authorMap, string query) returns BookResult[] {}

Le code de la fonction comporte trois étapes :

  1. Recherche des records Book correspondants dans le tableau de records Book
  2. Conversion de chaque record Book en un record BookResult (enrichissement des données)
  3. Trier les records BookResult

La partie la plus intéressante est l'enrichissement des données, où nous devons « joindre » les livres et les auteurs. Pour cela, j'ai besoin d'écrire deux fonctions :

  • fullName : prend un record Author et renvoie le nom complet de l'auteur
  • enrichBook : prend une map Author et un tableau Book et renvoie un BookResult :
function fullName(Author? author) returns string {
    if (author is null) {
        return "N/A";
    }
    return author.firstName + " " + author.lastName;
}

function enrichBook(map<Author> authorMap, Book book) returns BookResult {
    return {
        title: book.title,
        authorName: fullName(authorMap[book.author_id])
    };
}

Notez que le type d'argument passé à fullName est Author? avec en suffixe un point d'interrogation pour exprimer le fait que sa valeur pourrait être nil. La raison est que nous devons faire face à la possibilité que le author_id ne soit pas trouvé dans la map d'auteur.

Maintenant, je vais enchaîner filter, map et sort avec des fonctions anonymes pour implémenter ma logique métier :

function searchBooks(Book[] books, map<Author> authorMap, string query) returns BookResult[] {
    return books
    .filter(book => book.title.includes(query))
    .map(book => enrichBook(authorMap, book))
    .sort(array:DESCENDING, b => b.authorName);
}

Notez que le système de types de Ballerina est suffisamment intelligent pour déduire les types d'arguments des fonctions anonymes passées à filter, map et sort.

Si, par exemple, j'essaie d'accéder au champ isbn à l'intérieur de la fonction anonyme passée à sort, le système de type se plaindra que ce champ n'existe pas dans le type anonyme du record :

undeclared field 'isbn' in record 'record {| string title; string authorName; |}'

Les capacités d'inférence de type de Ballerina facilitent l'écriture de code en fonctionnel

Pour les personnes ayant de l'expérience en FP, un code comme celui-ci est probablement facile à lire et également facile à écrire. Mais pour les personnes issues d'un milieu OOP, cela peut être difficile. De plus, même pour un développeur FP expérimenté, écrire le code d'une fonction comme searchBooks nécessite une attention aux détails de bas niveau :

  1. Nous devons créer des fonctions anonymes
  2. Nous devons appeler filter avant d'appeler map et sort afin d'éviter des calculs inutiles
  3. Nous devons joindre manuellement les auteurs et les livres en accédant au champ book.author_id à l'intérieur de la map authorMap
  4. La collection d'auteurs doit être une map, tandis que la collection de livres est un tableau

En bref, nous devons écrire du code afin d'exprimer quelque chose qui pourrait être exprimé de manière déclarative.

Ballerina prend en charge une manière innovante d'exprimer la manipulation de données, via un langage de requête. Voyons-le en action.

Le langage de requête de Ballerina

Le langage de requête de Ballerina est similaire à SQL dans le sens où une expression de requête est composée de clauses, comme select, from, where, order by, join, etc... Mais, contrairement à SQL, nous ne sommes pas limités aux opérateurs SQL pour exprimer notre logique métier personnalisée : nous sommes autorisés à utiliser n'importe quelle fonction dans nos requêtes. De plus, la syntaxe du langage de requête de Ballerina facilite la manipulation des données lors du traitement des records.

Commençons notre exploration du langage de requête de Ballerina en écrivant une requête qui trouve les titres de livres contenant une chaîne de requête :

function searchBooksSimple(Book[] books, string query) returns string[] {
    var res = from var book in books // operate on books
        where book.title.includes(query) // filter books whose title include the query
        select book.title; // return the book title
    return res;
}

La requête est composée de 3 clauses :

  1. La clause from définit les données sur lesquelles la requête opère
  2. La clause where définit la condition à laquelle un record doit correspondre pour être renvoyé par la requête
  3. La clause select décide quels champs du record doivent être retournés (la projection)

Une description complète de toutes les clauses est disponible dans la documentation officielle du langage de requête.

Notez qu'à l'intérieur de la requête, nous pouvons utiliser n'importe quel morceau du langage Ballerina, par exemple, en appelant la méthode includes de String à l'intérieur de la clause where.

Mais les capacités de requête vont plus loin que l'appel de méthodes : par exemple, nous pouvons utiliser la syntaxe de déstructuration de Ballerina pour déstructurer le champ de titre dans un livre et rendre le code plus compact en réécrivant notre requête comme ceci :

function searchBooksSimple(Book[] books, string query) returns string[] {
    return from var {title} in books // operate on books
        where title.includes(query) // filter books whose title include the query
        select title; // return the book title
}

En plus de cette commodité, le langage de requête de Ballerina apporte une fonctionnalité qui tue, à savoir la jonction de sources de données, comme dans SQL.

Dans l'implémentation FP de searchBooks, à partir de la section précédente, nous avons dû "joindre" manuellement les records Book et Author en recherchant le record Author correspondant dans la map authorMap.

Avec Ballerina, nous n'avons pas besoin d'avoir une map d'Author. Nous pouvons laisser les records Author dans un tableau et tirer parti de la puissance de la clause join, comme ceci :

function searchBooks(Book[] books, Author[] authors, string query) returns map<anydata>[] {
    return from var {author_id, title} in books // destructuring two fields
        join var author in authors  // joining with authors
        on author_id equals author.id // the joining condition
        where title.includes(query) // filter books whose title include the query
        select {  // select some fields 
            authorFirstName: author.firstName,
            authorLastName: author.lastName,
            title
        }; 
}

Remarque : le type de retour de la fonction est un tableau de maps, car pour l'instant, le résultat n'est pas un record BookResult. Nous verrons dans un instant comment insérer le nom complet de l'auteur dans les résultats.

La syntaxe de la clause join est similaire à SQL, avec une légère différence : nous créons une variable locale avec var author pour contenir le record correspondant et le référencer dans le reste de la requête.

Nous avons maintenant tous les éléments en place pour implémenter la logique de recherche complète à l'aide du langage de requête de Ballerina :

function searchBooks(Book[] books, Author[] authors, string query) returns BookResult[] {
    return from var {author_id, title} in books // destructuring two fields
        join var author in authors  // joining with authors
        on author_id equals author.id // the join condition
        let string authorName = fullName(author) // creating a variable to calculate the author full name
        where title.includes(query) // filter books whose title include the query
        order by authorName descending // sorting according to authorName field
        select {authorName, title}; // select some fields 
}

Notez à quel point il est naturel de créer un champ calculé comme authorName et de l'utiliser plus tard dans la requête dans la clause order by et dans la clause select.

Un mot sur les performances de la requête. Le moteur de requête de Ballerina est suffisamment intelligent pour créer un index temporaire afin de rendre la jointure efficace. Dans la section suivante, nous verrons une manière plus idiomatique de représenter les données à manipuler par des requêtes qui atténuent le besoin de cette optimisation des performances.

Les tables comme composants de premier ordre

Les deux grandes familles de collections de données que j'utilise au quotidien dans mes programmes sont les collections séquentielles et les collections indexées. Par exemple, en JavaScript, j'utilise des tableaux pour les collections séquentielles et des objets pour les collections indexées (ce que JavaScript appelle les objets sont en fait des hash maps avec des clés sous la forme de chaîne).

Ballerina a des tableaux typés et des maps de chaînes typées. Mais en plus d'eux, il a un type de collection intéressant appelé table : c'est une collection de données hybride qui combine les caractéristiques des collections séquentielles et indexées.

Voyons comment représenter une collection du records Book avec des tableaux, des maps et des tables. Comme auparavant, un record Book a 3 champs : title, isbn et author_id.

type Book record {
    string isbn;
    string title;
    string author_id;
};

Supposons maintenant que l'API OpenLibrary renvoie une chaîne JSON, comme celle-ci :

[
  {
    "isbn": "978-0736056106",
    "title": "The Volleyball Handbook",
    "author_id": "bob-miller"
  },
  {
    "isbn": "978-0345525345",
    "title": "Friendship Bread",
    "author_id": "darien-gee"
  },
  ...
]

En fonction des besoins de notre application, nous pouvons décider de représenter cette collection de livres dans notre programme sous forme de tableau ou de map. Si nous devons parcourir les livres, nous utiliserons un tableau, tandis que si nous devons accéder au hasard à un livre, nous utiliserons une map.

Ballerina fournit un moyen simple de convertir une chaîne JSON en tableau :

Book[] bookArray = check apiResponse.fromJsonStringWithType();

Si nous voulons transformer ce tableau en map, nous devons écrire du code personnalisé, en utilisant forEach ou reduce.

function mapifyBooks(Book[] books) returns map<Book> {
    map<Book> res = {};
    foreach Book book in books {
        res[book.isbn] = book;
    }
    return res;
}

function mapifyBooks(Book[] books) returns map<Book> {
    return books.reduce(function(map<Book> res, Book book) returns map<Book> {
        res[book.isbn] = book;
        return res;
    },
    {});
}

En tant qu'adepte de la programmation fonctionnelle, j'ai tendance à préférer l'implémentation avec reduce, mais les deux implémentations transforment un tableau de livres en une map de livres comme prévu :

mapifyBooks(bookArray)
{
  "978-0736056106": {
    "isbn": "978-0736056106",
    "title": "The Volleyball Handbook",
    "author_id": "bob-miller"
  },
  "978-0345525345": {
    "isbn": "978-0345525345",
    "title": "Friendship Bread",
    "author_id": "darien-gee"
  },
  ...
}

Dans un langage typé dynamiquement comme JavaScript, on pourrait écrire une fonction mapify générique qui fonctionne sur n'importe quel type du record (voir par exemple keyBy), mais dans Ballerina, nous devons écrire une fonction spécifique pour chaque type du record : mapifyBooks pour les records Book, mapifyAuthors pour les records Author, etc.

À titre d'exemple, voici l'implémentation de mapifyAuthors, en utilisant la clé d'identification de Author :

{
  "bob-miller": {
    "firstName": "Bob",
    "lastName": "Miller"
  },
  "darien-gee": {
    "firstName": "Darien",
    "lastName": "Gee"
  },
  ...
}

Remarque : si un jour Ballerina introduit la prise en charge des types génériques, nous pourrons peut-être éviter ce type de duplication de code. C'est difficile car le nom du champ (isbn pour Book, id pour Author) doit également être dynamique.

Le premier inconvénient des maps est qu'elles nécessitent un code personnalisé pour les créer à partir de tableaux. Un autre inconvénient des maps est que la clé par laquelle la map est indexée ne fait pas nécessairement partie du record. Très souvent, lorsqu'une map est renvoyée à partir d'une demande d'API JSON, la clé n'est pas à l'intérieur des données elles-mêmes. Par exemple, une map d'auteur JSON pourrait ressembler à ceci :

{
  "bob-miller": {
    "firstName": "Bob",
    "lastName": "Miller"
  },
  "darien-gee": {
    "firstName": "Darien",
    "lastName": "Gee"
  },
  ...
}

Encore une fois, la conversion d'une chaîne JSON comme celle-ci en une map d'Author (avec un champ id) nécessite un code personnalisé.

Ballerina prend en charge une collection de données qui ressemble à une table de base de données avec un index de clé primaire, où au lieu de lignes, nous avons des records où le champ utilisé comme clé primaire dans l'index doit être un champ en lecture seule. Ajustons nos types de record Book et Author en conséquence :

type Book record {
    readonly string isbn;
    string title;
    string author_id;
};

type Author record {
    readonly string id;
    string firstName;
    string lastName;
};

Voici les définitions de type pour :

  • BookTable : une table de records de Book avec isbn comme clé primaire
  • AuthorTable : une table de records Author avec id comme clé primaire
type BookTable table<Book> key(isbn);
type AuthorTable table<Author> key(id);

Ballerina fournit un moyen simple de créer une table à partir d'une chaîne JSON, en tirant parti de l'inférence de type, de la même manière que nous avons créé un tableau à partir d'une chaîne JSON :

BookTable bookTable = check apiResponse.fromJsonStringWithType();

Une autre façon de créer une table consiste à partir d'un tableau de records, en utilisant une requête simple, comme celle-ci :

AuthorTable authorTable = check table key(id) from var author in authorArray select author;

Si certains records ont la même valeur pour le champ indexé, la création de la table échoue au moment de l'exécution. C'est pourquoi nous devons utiliser la syntaxe check pour capturer les erreurs d'exécution. Une fois la table créée, nous ne sommes pas autorisés à modifier la valeur du champ indexé. C'est pourquoi le champ indexé doit être marqué en lecture seule.

Une fois que nous avons une table en main, nous pouvons récupérer efficacement un record par sa clé primaire, en utilisant la notation entre crochets, comme dans une map :

authorTable["bob-miller"]

Les tables apportent un autre avantage par rapport aux maps : le champ indexé n'a pas besoin d'être une chaîne, comme dans les maps. De plus, on peut utiliser la combinaison de plusieurs champs pour l'index.

 

Map

Table

Localisation de la clé

externe

interne

Type pour la clé

string

n'importe quel

Clé mutable

oui

lecture seule

Champs dans la clé

1

plusieurs

Ordre

non

oui

Nous opérons sur des tables avec des requêtes, exactement comme nous opérions sur des tableaux. Nous pouvons copier/coller notre implémentation de searchBooks et remplacer les types d'argument :

  • BookTable au lieu de Book[]
  • AuthorTable au lieu de Author[] 
function searchBooks(BookTable books, AuthorTable authors, string query) returns map<anydata>[] {
    return from var {author_id, title} in books // destructuring two fields
        join var author in authors  // joining with authors
        on author_id equals author.id // we must respect left and right!
        let string authorName = fullName(author) // creating a variable calculate the author full name
        where title.includes(query) // filter books whose title include the query
        order by authorName descending
        select {authorName, title}; // select some fields 
}

Cela fonctionne exactement de la même manière qu'avec les tableaux, sauf que l'optimisation de la jointure que nous avons mentionnée précédemment n'est plus nécessaire, car les tables sont déjà indexées.

En bref, les tables Ballerina sont la voie à suivre lors de la manipulation de données avec des requêtes.

Permettez-moi de conclure cet article en mentionnant certaines limitations du langage de requête de Ballerina.

Les limites du langage de requête de Ballerina

Le langage de requête de Ballerina n'est pas encore complètement implémenté, et certaines fonctionnalités importantes viendront dans un futur (proche) :

De plus, je pense qu'il existe une limitation fondamentale lors de l'expression de la manipulation de données avec une requête, et cela a à voir avec la composabilité. Lorsque nous utilisons la FP, les étapes de manipulation de données étant des appels de fonction sont composables, tandis que les clauses à l'intérieur d'une expression de requête ne sont pas composables.

Laissez-moi vous donner un exemple : supposons que nous voulions ajouter un argument à notre searchBooks, pour contrôler si nous voulons ou non trier les résultats. Avec la FP, il suffit d'ajouter une condition dans le code :

function searchBooksWithCondSort(Book[] books, string query, boolean shouldSort) returns Book[] {
    var filteredBooks = books.filter(book => book.title.includes(query));
    var res = shouldSort? filteredBooks.sort(array:DESCENDING, b => b.title) : filteredBooks;
    return res;
}

Mais à l'intérieur d'une requête, nous devons ignorer une clause en fonction de la valeur d'exécution d'un argument booléen. La seule façon de le faire est d'avoir deux requêtes différentes :

function searchBooksWithCondSort(BookTable books, string query, boolean shouldSort) returns Book[] {
    if (shouldSort) {
        return from var book in books
            where book.title.includes(query)
            order by book.title descending
            select book;
    } else {
        return from var book in books
            where book.title.includes(query)
            select book;
    }
}

Cela peut être acceptable lorsque nous avons un seul argument booléen, mais que se passe-t-il si nous voulons ajouter un autre argument, par exemple limiter ou non le nombre de résultats ?
Maintenant, nous devrions écrire quatre requêtes différentes qui traitent des quatre combinaisons d'arguments :

  1. avec sort et avec limit
  2. avec sort et sans limit
  3. sans sort et avec limit
  4. sans sort et sans limit
function searchBooks(BookTable books, string query, boolean shouldSort, boolean shouldLimit) returns Book[] {
    if (shouldSort) {
        if (shouldLimit) {
            return from var book in books
                where book.title.includes(query)
                order by book.title descending
                limit 100
                select book;
        } else {
            return from var book in books
                where book.title.includes(query)
                order by book.title descending
                select book;
        }
    } else {
        if (shouldLimit) {
            return from var book in books
                where book.title.includes(query)
                limit 100
                select book;
        } else {
            return from var book in books
                where book.title.includes(query)
                select book;
        }
    }
}

Je dois admettre que dans la plupart des cas d'utilisation réels, je trouverais ce manque de composabilité acceptable, en fait. Je pense que dans l'ensemble, la rigidité du langage de requête est un avantage.

 

Programmation fonctionnelle

Langage de requête

Unités logiques

Fonctions

Clauses

Connaissances requises

Fonction de rang supérieur

SQL

Structure

Flexible

Rigide

Composabilité

Elevée

Limitée

Conclusion

Le système de type flexible de Ballerina rend naturel l'écriture d'une logique de manipulation de données dans un style FP, en modifiant les fonctions de rang supérieur telles que filter, map et sort. De plus, grâce au puissant langage de requête de Ballerina, nous sommes en mesure d'exprimer la logique de manipulation des données d'une manière facile à lire, même pour les développeurs sans expérience en programmation fonctionnelle.

Les requêtes sont faciles à écrire, car nous avons la possibilité d'utiliser des clauses de type SQL, comme select, where, order by et join, et combinez-les avec des fonctions régulières et une syntaxe Ballerina avancée (par exemple, la déstructuration). La façon naturelle de représenter les collections de données dans Ballerina est via une table, une structure de données qui combine les avantages des tableaux et des maps.

 

Au sujet de l’Auteur

Evaluer cet article

Pertinence
Style

Contenu Éducatif

BT