EF Core Database First - Quelques astuces utiles (4/4)

Brouillon

À venir

0 commentaire

EF Core Database - Art4


Dans les articles précédents, nous avons vu comment générer un DbContext et des entités à partir d'une base de données existante ainsi que la possibilité de personnaliser le code généré.

Dans cet article, nous allons voir quelques astuces utiles :

  • Utiliser des chaines de connexion nommées.
  • Générer le DbContext et les entités dans un projet type "librairie".
  • Combiner les 2 points précédents


Avant de commencer, il est nécessaire de préciser qu'Entity Framework Core a été conçu pour une approche Code First. De ce fait, tout ce que nous allons voir dans cet article et les suivants, est susceptible d'être modifié voire supprimé. Cependant, il peut arriver que l'on ait besoin, pour une raison ou une autre (base de données d'une appli existante, ...), de faire du Database First avec EF Core.

Dans cette série d'articles EF Core Database First, nous allons aborder différentes thématiques autour du Database First avec Entity Framework Core, dont voici la liste :

  1. Visite guidée
  2. Les outils en ligne de commande
  3. Personnaliser le code généré
  4. Quelques astuces utiles ( cet article)

Chaines de connexion nommées

Si vous avez un peu utilisé EFCore_ en Database First, ou que vous avez lu les précédent articles, vous aurez remarqué que la chaine de connexion, utilisée lors de la génération, est insérée dans le DbContext.

Pour diverses raisons (sécurité, maintenance, ...), il n'est pas souhaitable que le detail de cette chaine de connexion soit directement intégrée dans le contexte.

Dans l'article précédent, nous avons vu qu'en utilisant un design time service, nous pouvions personnaliser le code du DbContext pour supprimer la chaine de connexion. Cette solution, bien que pratique, nécessite de mettre en place du code supplémentaire spécifiquement pour ce besoin. Ici, nous allons voir le fonctionnement des chaines de connexion nommées.

Au lieu de donner tout le détail de la chaine de connexion, nous allons donner un nom en paramètre de la commande dotnet ef dbcontext scaffold.

Pour commencer, je vous invite à récupérer la solution d'example présente sur Github : https://github.com/adrenalinedj/efcoredbfirstexample.

Une chose à savoir sur la commande scaffold, c'est qu'elle utilise la configuration du projet courant. Dans notre cas, elle va donc passer en revue les fichiers appsettings.json pour trouver une correspondance.

La première chose à faire est de créer notre chaine de connexion nommée dans le fichier appsettings.Development.json. Voici ce à quoi devrait ressembler le fichier:

{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "System": "Information",
      "Microsoft": "Information"
    }
  },
  "ConnectionStrings": {
    "MyStoreDatabase": "Server=(localdb)\\mssqllocaldb;Database=EFCoreDBFirstMyStore;Trusted_Connection=True;"
  }
}

Auparavant, pour générer le DbContext et les entités, nous utilisions la commande suivante : dotnet ef dbcontext scaffold "Server=(localdb)\mssqllocaldb;Database=EFCoreDBFirstMyStore;Trusted_Connection=True;" Microsoft.EntityFrameworkCore.SqlServer -o DAL -c MyStoreContext.

Ici, nous allons utiliser celle-ci : dotnet ef dbcontext scaffold "name=MyStoreDatabase" Microsoft.EntityFrameworkCore.SqlServer -o DAL -c MyStoreContext. Ici la connectionstring a été remplacé par son nom : name=MyStoreDatabase.

Le DbContextgénéré devrait ressembler à ceci, après avoir supprimer le code de la classe MyStoreDbContextGenerator créée dans l'article précédent :

using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata;

namespace EFCoreDBFirst.MyStore.DAL
{
    public partial class MyStoreContext : DbContext
    {
        public MyStoreContext()
        {
        }

        public MyStoreContext(DbContextOptions<MyStoreContext> options)
            : base(options)
        {
        }

        public virtual DbSet<Category> Category { get; set; }
        public virtual DbSet<Product> Product { get; set; }
        public virtual DbSet<ProductView> ProductView { get; set; }

        protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        {
            if (!optionsBuilder.IsConfigured)
            {
                optionsBuilder.UseSqlServer("name=MyStoreDatabase");
            }
        }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            var converter = new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<DateTime, DateTime>(
                v => DateTime.Now,
                v => v
            );

            modelBuilder.Entity<Category>(entity =>
            {
                entity.Property(e => e.Description).HasMaxLength(255);

                entity.Property(e => e.Name)
                    .IsRequired()
                    .HasMaxLength(50);
            });

            modelBuilder.Entity<Product>(entity =>
            {
                entity.Property(e => e.CreatedDate).HasColumnType("datetime");

                entity.Property(e => e.Description).IsRequired();

                entity.Property(e => e.ModifiedDate).HasColumnType("datetime").HasConversion(converter);

                entity.Property(e => e.Name)
                    .IsRequired()
                    .HasMaxLength(128);

                entity.Property(e => e.Price).HasColumnType("decimal(18, 3)");

                entity.HasOne(d => d.Category)
                    .WithMany(p => p.Product)
                    .HasForeignKey(d => d.CategoryId)
                    .OnDelete(DeleteBehavior.ClientSetNull)
                    .HasConstraintName("FK_Product_Category_Id");
            });

            modelBuilder.Entity<ProductView>(entity =>
            {
                entity.ToTable("ProductView", "stats");

                entity.HasOne(d => d.Product)
                    .WithMany(p => p.ProductView)
                    .HasForeignKey(d => d.ProductId)
                    .OnDelete(DeleteBehavior.ClientSetNull)
                    .HasConstraintName("FK_ProductView_Product_Id");
            });
        }
    }
}

A partir de là, il ne reste plus qu'à alimenter les fichiers appsettings.json pour chacun de vos environnements.

Séparation API et EFCore

Lorsque l'on démarre un projet, il est toujours utile de séparer les différentes couches de l'application dans plusieurs projets : API, métier, accès aux données, ...

Nous allons voir comment générer le DbContext et les entités dans un projet séparé de type libraire.

Dans la solution example, récupérable ici (https://github.com/adrenalinedj/efcoredbfirstexample), j'ai choisis de nommer le projet EFCoreDBFirst.MyStore.DAL pour Data Access Layer et j'ai supprimé le dossier DAL qui ne sert plus.

Pour commencer, il faut ajouter les paquets NuGet suivants :

  • Microsoft.EntityFrameworkCore.Design
  • Microsoft.EntityFrameworkCore.SqlServer

Attention, la version des paquets doit correspondre à la version des EF Core tools : la version 2.1.1 dans mon cas. Le message d'erreur obtenu ressemble à celui-ci : The EF Core tools version '2.1.1-rtm-30846' is older than that of the runtime '2.1.4-rtm-31024'. Update the tools for the latest features and bug fixes.

Ensuite, il faut se placer dans le dossier de ce nouveau projet et exécuter la commande suivante : dotnet ef dbcontext scaffold "Server=(localdb)\mssqllocaldb;Database=EFCoreDBFirstMyStore;Trusted_Connection=True;" Microsoft.EntityFrameworkCore.SqlServer -c MyStoreContext.

Tout devrait bien se passer et vous devriez obtenir le DbContext et les entités.

Cependant, les modifications apportées par les design time services mis en place dans l'article précédent, ne setrouvent plus dans le DbContext ni dans les entités.

Il suffit de déplacer les classes MyStoreDesignTimeService, MyStoreDbContextGenerator et MyStoreEntityTypeGenerator dans le nouveau projet et de modifier le namespace de chaque classe.

Une fois le projet compilé, on peut relancer la commande scaffold, sans oublier le flag -f ou --force. On obtient le DbContext et les entités avec les modifications souhaitées.

Chaines de connexion nommées et projet librairie

Maintenant, que nous avons séparé notre couche d'accès aux données de notre API, il nous reste toujours la chaine de connexion dans le DbContext.

Il existe 2 solutions :

  1. Remettre le code permettant de supprimer la chaine de connexion dans la classe MyStoreDbContextGenerator.
  2. Utiliser une chaine de connexion nommée.

Pour rappel, la commande scaffold utilise de la reflection pour générer le DbContext. C'est pour celan entre autres choses, qu'il faut que le projet compile.

Dans notre cas, cela implique d'ajouter les paquets Nuget

  • Microsoft.EntityFrameworkCore.Design et Microsoft.EntityFrameworkCore.SqlServer pour le reverser engineering de la base de données. Il est important d'installer la même version de paquet que la version des EF Core tools : 2.1.1 dans mon cas.
  • Microsoft.Extensions.Configuration.Json pour la partie configuration : nous y reviendrons.

Si vous exécutez la commande scaffold avec la chaine de connexion nommée, à savoir dotnet ef dbcontext scaffold "name=MyStoreDatabase" Microsoft.EntityFrameworkCore.SqlServer -c MyStoreContext, vous devriez obtenir l'erreur suivante :
System.InvalidOperationException: A named connection string was used, but the name 'MyStoreDatabase' was not found in the application's configuration. Note that named connection strings are only supported when using 'IConfiguration' and a service provider, such as in a typical ASP.NET Core application. See https://go.microsoft.com/fwlink/?linkid=850912 for more information.

Le message d'erreur indique que l'on utilise une chaine de connexion nommée et de ce fait, il s'attend à trouver dans les designtime services : un service provider ainsi qu'une configuration sous la forme de IConfiguration.

Nous allons donc, les lui fournir.

Fichier de configuration

Pour commencer, il nous faut un fichier de configuration que nous allons appeler appsettings.json, par souci de simplicité : le nom importe peu. Pensez à vérifier que dans les propriétés du fichier, il soit bien marqué en Copy always : il se trouvera dans le dossier bin lorsque la commande scaffold chargera l'assembly de notre projet DAL.

Si l'on regarde dans le code source de EF Core, on peut s'apercevoir que la résolution du nom de la chaine de connexion se fait à partir de 2 endroits :

  • à la racine du JSON, avec une propriété portant le nom de la chaine de connexion.
  • dans une propriété JSON nommée ConnectionStrings contenant elle-même une propriété portant le nom de la chaine de connexion.

Vous pouvez le voir dans la classe NamedConnectionStringResolverBase à la ligne 41 : https://github.com/aspnet/EntityFrameworkCore/blob/779d43731773d59ecd5f899a6330105879263cf3/src/EFCore.Relational/Storage/Internal/NamedConnectionStringResolverBase.cs

Dans notre cas et pour faire simple, nous allons reprendre ce que nous avions fait précédemment à savoir :

{
  "ConnectionStrings": {
    "MyStoreDatabase": "Server=(localdb)\\mssqllocaldb;Database=EFCoreDBFirstMyStore;Trusted_Connection=True;"
  }
}

Résolution du nom

En l'état actuel, ce n'est pas sufisant pour que la commande scaffold puisse résoudre le nom de la chaine de connexion. Nous allons devoir fournir un résolveur de nom.

Cela tombe que l'on ai vu la classe NamedConnectionStringResolverBase précédemment : elle va nus servir de base.

Commençons par créer une classe que l'on nommera MyStoreNamedConnectionStringResolver. Voici le code qu'elle va contenir :

using Microsoft.EntityFrameworkCore.Storage.Internal;
using System;

namespace EFCoreDBFirst.MyStore.DAL
{
    public class MyStoreNamedConnectionStringResolver : NamedConnectionStringResolverBase
    {
        private IServiceProvider _serviceProvider;
        protected override IServiceProvider ApplicationServiceProvider => _serviceProvider;

        public MyStoreNamedConnectionStringResolver(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
        }
    }
}

Elle hérite de la classe abstraite NamedConnectionStringResolverBase ce qui nous oblige à fournir l'implémentation de la propriété ApplicationServiceProvider.

Pour fournir cette proriété de type IServiceProvider, nous récupérons une instance de ce type par injection de dépendance : argument serviceProviderdu constructeur.

La raison sous-jacente de ce résolveur de nom, est que si l'on fourni seulement un IConfiguration dans les designtime service, le résolveur de nom par défaut n'arrive pas à résoudre la chaine de connexion nommée. Nous devons, donc, fournir un résolveur de nom qui utilisera le service provider que l'on aura configuré dans nos designtime services.

Modification des designtime services

Maintenant, il nous reste à :

  • Charger le fichier de configuration.
  • Fournir la configuration au service provider via un IConfiguration.
  • Fournir le résolveur de nom.

Le chargement du fichier de configuration se fait de la même manière que dans le projet de l'API, à savoir :

var config = new ConfigurationBuilder()
    .SetBasePath(Path.Combine(AppContext.BaseDirectory))
    .AddJsonFile("appsettings.json", false, true)
    .Build();

Pour fournir la configuration, nous allons passer la variable config come implémentation de IConfiguration :

serviceCollection.AddSingleton(typeof(IConfiguration), config);

Pour finir, il nous reste à fournir le résolveur de nom.

serviceCollection.AddSingleton<INamedConnectionStringResolver, MyStoreNamedConnectionStringResolver>();

Pour finir

Il ne reste plus qu'à compiler et à exécuter la commande suivante dotnet ef dbcontext scaffold "name=MyStoreDatabase" Microsoft.EntityFrameworkCore.SqlServer -c MyStoreContext, sans oublier l'option -f ou --force si vous avez déjà généré le DbContext et les entités.

Vous devriez obtenir un DbContext contenant toutes les personnalisations que nous avons implémenté tout en utilisant une chaine de connexion nommée.

Jérémy Larrieu

Titulaire d'une licence pro Systèmes Informatiques et Logiciels, Jérémy a d'abord travaillé en tant que développeur web freelance, puis développeur .NET dans une ESN avant de rejoindre SoftFluent.

Profil de l'auteur