Blog Arolla

Requêtes hors-processus avec IQueryable, traduction de l’article de Jon Skeet

Avant-propos du traducteur

La traduction présentée ici est celle de l’article Reimplementing LINQ to Objects: Part 43 - Out-of-process queries with IQueryable, effectuée bien évidemment avec l’autorisation de l’auteur, Jon Skeet. L’article original s’inscrit dans la série EDULINQ, dans laquelle l’auteur réimplémente un à un tous les opérateurs de LINQ to Objects, dans un objectif éducatif. Il s’agit ici d’un des tous derniers articles, qui sort un peu du périmètre initial de la série et présente le fonctionnement des requêtes hors processus avec l’interface IQueryable.

Avant propos de l’auteur

J’ai repoussé l’écriture de ceci depuis un moment déjà, principalement parce que c’est un sujet vraiment énorme. Je ne vais pas essayer de donner plus qu’une brève introduction ici - n’espérez pas pouvoir construire votre propre implémentation  de LINQ to SQL après ça - mais cela vaut le coup d’avoir au moins une idée de ce qui se passe lorsque vous utilisez quelque chose comme LINQ to SQL, NHibernate, ou Entity Framework.

Expression Trees

Pour résumer, les arbres d’expressions encapsulent la logique sous forme de donnée plutôt que de code. Même si vous pouvez introspecter du code .NET grâce à MethodBase.GetMethodBody et ensuite MethodBody.GetILAsByteArray, ce n’est pas une approche vraiment pratique. Les types dans System.Linq.Expressions définissent des expressions d’une manière plus facile à traiter. Quand les arbres d’expressions ont été introduits en .NET 3.5, ils étaient uniquement pour les expressions, mais le Dynamic Language Runtime utilise aussi les arbres d’expressions pour représenter des opérations, et la gamme de logique représentée a eu à s’étendre en fonction, pour inclure des choses telles que des blocs.

Bien que vous puissiez certainement construire des arbres d’expressions vous-même (en général via les méthodes factory de la classe Expression non générique), et bien que ce soit amusant de le faire par moments, la façon la plus commune de les créer est d’utiliser le support par le compilateur C# des lambda expressions. Jusqu’à présent nous avons toujours vu les lambda expressions converties en délégués, mais il peut aussi convertir des lambdas en instances de Expression<TDelegate>, où TDelegate est un type délégué compatible avec la lambda expression. Un exemple concret va aider ici. L’instruction :

Expression<Func<int, int>> addOne = x => x + 1;

va être compilée en code qui est en réalité quelque chose comme ça :

var parameter = Expression.Parameter(typeof(int), "x");
var one = Expression.Constant(1, typeof(int));
var addition = Expression.Add(parameter, one);
var addOne = Expression.Lambda<Func<int, int>>(addition, new ParameterExpression[] { parameter });

Le compilateur a des tours dans ses manches, qui lui permettent de faire référence à des méthodes, des évènements et autres d’une manière plus simple que nous ne le pouvons à partir du code, mais en gros vous pouvez regarder la transformation comme juste une manière de rendre la vie beaucoup plus simple que si vous aviez du construire l’arbre d’expressions vous-même à chaque fois.

IQueryable, IQueryable<T> and IQueryProvider

Maintenant que nous avons l’idée de pouvoir être capable d’inspecter la logique assez aisément au moment de l’exécution, voyons comment cela s’applique à LINQ.

Il y a trois interfaces à introduire, et il est probablement plus facile de commencer par la façon dont elles apparaissent sur un diagramme de classes :

La plupart du temps, les requêtes sont représentées en utilisant l’interface générique IQueryable<T>, mais cela n’ajoute en réalité pas grand chose par rapport à l’interface IQueryable qu’elle étend, à part le fait d'étendre également IEnumerable<T> - ainsi vous pouvez itérer sur le contenu d’un IQueryable<T> comme sur n’importe quelle autre séquence.

IQueryable contient les parties intéressantes, sous la forme de trois propriétés : ElementType qui indique le type des éléments de la requête (en d’autres termes, une forme dynamique du T de IQueryable<T>), Expression renvoie l’arbre d’expression pour la requête jusqu’à ce point, et Provider renvoie le fournisseur de requête qui est responsable de créer de nouvelles requêtes et d’exécuter l’existante. Nous n’avons pas besoin d’utiliser la propriété ElementType nous-même, mais nous aurons besoin à la fois des propriétés Provider et Expression.

La classe statique Queryable

Nous n’allons implémenter aucune des interfaces nous-même, mais j’ai un court exemple de programme pour démontrer comment elles fonctionnent, en imaginant que nous implémentions la majeure partie de Queryable nous-même. La classe statique contient des méthodes d’extension pour IQueryable<T>, tout comme Enumerable le fait pour IEnumerable<T>. La plupart des opérateurs de requête de LINQ to Objects apparait dans Queryable également, mais il y a quelques omissions notables, comme les méthodes {Lookup, Array, List, Dictionary}. Si vous appelez une de ces méthodes sur un IQueryable<T>, les implémentations de Enumerable seront utilisées à la place (IQueryable<T> étend IEnumerable<T>, donc les méthodes d’extensions d’Enumerable sont applicables aux séquences IQueryable<T> également).

La grande différence entre les méthodes de Queryable et d’Enumerable, en termes de leurs déclarations, est dans les paramètres :

  • Le paramètre “source” dans Queryable est toujours de type IQueryable<TSource> à la place de IEnumerable<TSource> (les autres paramètres de types séquences comme par exemple la séquence à concaténer pour Queryable.Concat sont exprimées en tant que IEnumerable<T>, de manière assez intéressante. Cela vous permet d’exprimer des requêtes SQL en utilisant des données “locales” également ; les méthodes de requêtes déterminent si la séquence est en réalité un IQueryable<T> et agissent en fonction).
  • Tous les paramètres qui étaient des délégués dans IEnumerable sont des arbres d’expressions dans Queryable ; donc alors que le paramètres selector dans Enumerable.Select est du type Func<TSource, TResult>, l’équivalent dans Queryable.Select est de type Expression<Func<Tsource, TResult>>

La grande différence entre les méthodes en termes de ce qu’elles font et qu’alors que les méthodes d’Enumerable font réellement le travail (tôt ou tard - potentiellement après une exécution différée bien évidement), les méthodes de Queryable elles-même ne font aucun travail : elles demandent juste au fournisseur de requêtes de créer une requête indiquant qu’ils ont été appelés.

Jetons un coup d’œil à Where par exemple. Si nous voulions implémenter Queryable.Where, nous devrions :

  • Effectuer la vérification des arguments
  • Récupérer l’Expression actuelle de la requête
  • Créer une nouvelle expression représentant un appel à Queryable.Where, en utilisant l’expression courante, et l’expression du prédicat en tant que prédicat
  • Demander au fournisseur de requêtes de construire un nouvel IQueryable<T>, basé sur cet appel d’expression, et le retourner.

Ca a l’air un peu récursif dans l’ensemble, je m’en rends compte - l’appel à Where doit tracer qu’un Where a eu lieu… mais c’est tout. Vous pouvez très bien vous demander où tout le travail a réellement lieu. Nous allons y venir.

Maintenant construire une expression d’appel est un peu fastidieux parce que vous avez besoin d’avoir la bonne MethodInfo - et comme Where est surchargé, cela veux dire faire la distinction entre deux méthodes Where, ce qui est plus facile à dire qu’à faire. J’ai en réalité utilisé une requête LINQ pour trouver la bonne surcharge - celle dont le paramètre predicate est une Expression<Func<T, bool>> plutôt qu’une Expression<Func<T, int, bool>>. Dans l’implémentation .NET, les méthodes peuvent utiliser MethodBase.GetCurrentMethod() à la place… bien qu’ils auraient pu également créer un certain nombre de variables statiques calculées au moment de l’initialisation de la classe. Nous ne pouvons pas utiliser GetCurrentMethod() pour le but de notre expérimentation, parce que le fournisseur de requêtes est susceptible d’attendre la méthode exacte de System.Linq.Queryable de l’assembly System.Core.

Voici notre exemple d’implémentation, relativement découpée pour la rendre plus facile à comprendre :

public static IQueryable<TSource> Where<TSource>(
    this IQueryable<TSource> source,
    Expression<Func<TSource, bool>> predicate)
{
    if (source == null)
    {
        throw new ArgumentNullException("source");
    }
    if (predicate == null)
    {
        throw new ArgumentNullException("predicate");
    }
        
    Expression sourceExpression = source.Expression;
    Expression quotedPredicate = Expression.Quote(predicate);
        
    // Ceci récupère la méthode "ouverte", sans argument de type spécifique. Le second paramètre
    // de la méthode que nous voulons est de type Expression<Func<TSource, bool>>, donc le seul argument 
    // de type générique pour l’Expression<T> elle-même a deux arguments de types génériques.
    // Avouons-le, la réflexion sur les méthodes génériques est un bazar. 
    MethodInfo method = typeof(Queryable).GetMethods()
                                         .Where(m => m.Name == "Where")
                                         .Where(m => m.GetParameters()[1]
                                                      .ParameterType
                                                      .GetGenericArguments()[0]
                                                      .GetGenericArguments().Length == 2)
                                         .First();
        
    // Ceci récupère la méthode avec le même type d’argument que le notre 
    MethodInfo closedMethod = method.MakeGenericMethod(new Type[] { typeof(TSource) });
        
    // Maintenant nous créons un *représentation* de cet appel de méthode précis 
    Expression methodCall = Expression.Call(closedMethod, sourceExpression, quotedPredicate);
        
    // ... et nous demandons à notre fournisseur de requêtes de créer une requête pour celle-ci 
    return source.Provider.CreateQuery<TSource>(methodCall);
}

Il n’y a qu’une partie de ce code dont je ne comprends pas vraiment le besoin, et c’est l’appel à Expression.Quote sur l’arbre d’expression prédicat. Je suis sûr qu’il y a une bonne raison pour cela, mais cet exemple particulier marcherait sans, de ce que je peux en voir. L’implémentation réelle l’utilise cependant, donc j’ose dire que c’est nécessaire d’une certaine façon.

EDIT : le commentaire de Daniel m’a rendu ceci un peu plus clair. Chacun des arguments de l’appel à Expression.Call après la récupération du MethodInfo est prévu pour être une expression qui représente l’argument de l’appel de la méthode. Dans notre exemple nous avons besoin d’une expression qui représente un argument de type Expression<Func<TSource, bool>>. Nous avons déjà la valeur, mais nous devons fournir une couche “enveloppe”… tout comme nous l’avons fait avec Expression.Constant dans la toute première expression que j’ai montrée au début. Pour envelopper cette cette valeur d’expression que nous avons déjà, nous utilisons Expression.Quote. Il n’est pas encore exactement clair pour moi, pourquoi nous pouvons utiliser Expression.Quote et pas Expression.Constant, mais au moins pourquoi nous avons besoin de quelque chose est plus clair…

EDIT : J’y arrive progressivement. Cette réponse sur Stack Overflow par Eric Lippert a beaucoup à dire sur le sujet. J’essaie toujours de me la rentrer dans la tête, mais je suis sûr que quand j’aurai lu la réponse d’Eric plusieurs fois, j’y arriverai.

Nous pouvons même tester que cela fonctionne, en utilisant la méthode Queryable.AsQueryable depuis là vraie implémentation .NET. Elle crée un IQueryable<T> à partir de n’importe quel IEnumerable<T>, en utilisant le fournisseur de requêtes intégré. Voici le programme de test, où FakeQueryable est une classe statique contenant la méthode d’extension précédente :

using System;
using System.Collections.Generic;
using System.Linq;

class Test
{
    static void Main()
    {        
        List<int> list = new List<int> { 3, 5, 1 };
        IQueryable<int> source = list.AsQueryable();
        IQueryable<int> query = FakeQueryable.Where(source, x => x > 2);
        
        foreach (int value in query)
        {
            Console.WriteLine(value);
        }
    }
}

Ca fonctionne, en écrivant juste 3 et 5, et en filtrant le 1. Yay! (J’appelle explicitement FakeQueryable.Where plutôt que de laisser la résolution de la méthode d’extension la trouver, juste pour rendre les choses plus claires).

Hum, mais qu’est-ce qui fait réellement le travail ? Nous avons implémenté la clause Where sans fournir aucun filtrage nous-même. C’est réellement le fournisseur de requête qui a construit une implémentation appropriée de IQueryable<T>. Lorsque nous appelons GetEnumerator() implicitement dans la boucle foreach, la requête peut examiner tout ce qui est construit dans l’arbre d’expression (qui peut contenir plusieurs opérateurs - il s’agit d’imbrication de requêtes dans des requêtes, essentiellement) et déterminer ce qu’il faut faire. Dans le cas de notre IQueryable<T> construit à partir d’une liste, il fait juste le filtrage dans le processus… mais si nous utilisions LINQ to SQL, c’est là que le SQL serait généré. Le fournisseur reconnait les méthodes spécifiques de Queryable, et applique les filtres, les projections, etc. C’est pourquoi il était important que notre Where de démonstration prétende que le Queryable.Where réel avait été appelé - dans le cas contraire le fournisseur de requêtes ne saurait pas quelle est l’expression à appeler.

Juste pour enfoncer un le clou un peu plus… Queryable lui-même ne sait ni ne s’intéresse au type de source de données que vous utilisez. Son travail n’est pas de réaliser les opérations de requêtes lui-même ; son travail est d’enregistrer les opérations de requêtes d’une manière indépendante de la source, et de laisser le fournisseur source les gérer quand il en a besoin.

Exécution immédiate avec IQueryProvider.Execute

Tous les opérateurs utilisant l’exécution différée dans IQueryable sont implémentés à peu près de la même façon que notre méthode Where de démo. Cependant, cela ne couvre pas la situation où nous avons besoin d’exécuter une requête immédiatement, parce qu’elle doit retourner une valeur plutôt qu’une autre requête.

Cette fois je vais utiliser ElementAt pour l’exemple, simplement parce que c’est le seul opérateur qui n’a qu’une surcharge, ce qui rend très simple la récupération de la bonne MethodInfo. La procédure générale est exactement la même que pour construire une nouvelle requête, sauf que cette fois nous appelons la méthode Execute du provider plutôt que CreateQuery.

public static TSource ElementAt<TSource>(this IQueryable<TSource> source, int index)
{
    if (source == null)
    {
        throw new ArgumentNullException("source");
    }
        
    Expression sourceExpression = source.Expression;
    Expression indexExpression = Expression.Constant(index);
        
    MethodInfo method = typeof(Queryable).GetMethod("ElementAt");        
    MethodInfo closedMethod = method.MakeGenericMethod(new Type[] { typeof(TSource) });
        
    // Maintenant un crée une *représentation* de cet appel de méthode précis
    Expression methodCall = Expression.Call(closedMethod, sourceExpression, indexExpression);
        
    // ... et on demande à notre fournisseur de requête de l’exécuter 
    return source.Provider.Execute<TSource>(methodCall);
}

L’argument de type que l’on fournit à Execute est le type souhaité de retour - donc pour Count, on appellerait Execute<int> par exemple. A nouveau, c’est au fournisseur de requête de déterminer ce que la requête signifie en réalité.

Cela vaut le coup de mentionner qu’à la fois CreateQuery et Execute ont des surcharges génériques et non-génériques. Je n’ai personnellement pas rencontré d’usage pour les non-génériques, mais j’ai entendu qu’elles étaient utiles pour nombre de situations dans du code généré, particulièrement si vous ne savez pas le type d’élément - ou au moins si vous ne le connaissez que dynamiquement, et ne voulez pas avoir à utiliser de la réflexion pour générer l’appel de la méthode générique appropriée.

Support transparent dans le code source

Un des aspects de LINQ qui le hisse au statut de “génie” (et “légèrement effrayant” en même temps) est que la plupart du temps, la majeure partie des développeurs n’a pas besoin de faire le moindre changement dans le code source pour utiliser Enumerable ou Queryable. Prenez cette expression de requête et sa traduction :

var query = from person in family
            where person.LastName == "Skeet"
            select person.FirstName;

// Translation
var query = family.Where(person => person.LastName == "Skeet")
                  .Select(person => person.FirstName);

Quel ensemble de méthodes de requêtes va-t-elle utiliser ? Cela dépend entièrement du type de la variable “family” au moment de la compilation. Si c’est un type qui implémente IQueryable<T>, elle utilisera les méthodes d’extensions de Queryable, la lambda expression sera convertie en arbres d’expressions, et le type de “query” sera un IQueryable<string>. Dans le cas contraire (et en partant du principe que le type qui implémente IEnumerable<T> n’est pas un autre type intéressant comme ParallelEnumerable) elle utilisera les méthodes d’extension d’Enumerable, les lambda expressions seront converties en délégués, et le type de “query” sera IEnumerable<string>.

La partie de la spécification qui concerne la traduction de la requête d’expression n’a pas besoin de se soucier de ça, car elle traduit simplement dans une forme qui utilise des lambda expressions - le reste de la résolution de surcharge et de la conversion de lambda expressions s’occupe de ces détails.

Génie… bien que cela signifie que vous devez faire attention que vous sachiez vraiment où votre évaluation de requête va avoir lieu - vous ne voulez pas exécuter accidentellement votre requête complète dans le processus après avoir servi le contenu complet d’une base de données à travers une connexion réseau…

Conclusion

Ce n’était réellement qu’un tour très rapide de l’autre coté de LINQ - et sans aller trop dans les détails des fournisseurs réels comme LINQ to SQL. Cependant, j’espère que cela vous a donné assez de gout pour ce qui ce passe, pour apprécier la conception générale. Rapidement :

  • Les arbres d’expressions sont utilisés pour capturer la logique sous forme de structure de données, qui peut être examinée assez facilement à l’exécution
  • Les lambda expressions peuvent être converties en arbres d’expressions ou en délégués
  • IQueryable<T> et IQueryable forment une sorte de hiérarchie d’interface parallèle à IEnumerable<T> et IEnumerable - bien que les formes queryables étendent les formes énumérables.
  • IQueryProvider permet à une requête d’être construite à partir d’une autre, ou exécutée immédiatement quand approprié
  • Queryable fournit des méthodes d’extension équivalentes à presque toutes celles des opérateurs Enumerable de LINQ, sauf qu’elle utilise des sources IQueryable<T> et des arbres d’expressions à la place de délégués
  • Queryable de gère pas du tout lui-même les requêtes ; il enregistre seulement ce qui a été appelé et délègue le traitement réel au fournisseur de requête.

<

p align="justify">Je pense que j’ai désormais couvert la plupart des sujets que je voulais mentionner après avoir fini l’implémentation réelle d’Edulinq. La prochaine fois je parlerai au sujet de problèmes de conceptions (dont la plupart ont déjà été mentionnés, mais que je tolère de répéter) et puis j’écrirai un bref billet “conclusion de la série” avec une liste de liens vers toutes les autres parties.

Site Web | Plus de publications

Contributeur enthousiaste

Comments are closed.