.Net

Utiliser EF Core 6 avec Azure Cosmos DB dans une API ASP.NET

Sep 7, 2022

Romain LAPREE-KAMINSKI

Les bases de données relationnelles représentent, pour les développeurs ayant comme moi quelques années d’expérience, le modèle avec lequel nous avons démarré notre métier. Encore aujourd’hui, la plupart des applications web utilisent un modèle relationnel, et cela reste une option tout à fait cohérente. Cependant, un modèle non-relationnel tel qu’Azure Cosmos DB est une alternative crédible mais souvent laissée de côté. Il offre plus de souplesse, de meilleures performances et se prête davantage à des solutions « cloud native ».

Dans cet article, je reviendrai dans un premier temps sur les principales caractéristiques d’Azure Cosmos DB. J’utiliserai ensuite une API en .NET 6 avec Entity Framework Core 6 pour montrer comment facilement intégrer cette base de données dans vos futurs développements.

Vous pouvez trouver les sources de l’API en lien avec cet article sur le GitHub de dcube

Qu’est-ce qu’Azure Cosmos DB ?

Azure Cosmos DB est le service proposé par Azure pour l’utilisation d’une base de données NoSQL managée. Il permet, entre autres :

  • une haute disponibilité (jusqu’à 99.999% contre 99.995% pour Azure SQL Database)
  • une distribution mondiale facilement paramétrable avec plusieurs niveaux de cohérence
  • une solution agnostique à un schéma (table, colonne)
  • de hautes performances (recherche facilitée par les index/partition, auto-scaling, temps de réponse)
  • une utilisation sur un mode de tarification serverless ou avec un débit provisionné

Plusieurs APIs permettent d’interagir avec la base de données Azure Cosmos DB : Core SQL, MongoDB API, Cassandra API, Azure Table et Gremlin.

Avec une application .NET, deux choix se dégagent pour communiquer avec Azure Cosmos DB: le SDK Azure ou Entity Framework Core. Seule l’API Core SQL permet d’utiliser Entity Framework Core 6. Elle permet de requêter les documents JSON avec une syntaxe SQL.

Les principales caractéristiques d’Azure Cosmos DB

Au niveau de sa structure hiérarchique, un compte (account) Cosmos DB peut contenir plusieurs base de données (database). Chaque base de données contient un ou plusieurs conteneurs (containers) dans lesquels sont stockés les données/les documents. Au sein d’un conteneur se trouvent également des procédures stockées, des fonctions et des déclencheurs. Tous les documents d’un conteneur ne suivent pas forcément le même schéma mais ils doivent partager une propriété commune : la clé de partition (partition key). Un conteneur « Vehicule » peut par exemple contenir des données sur des « Moto » et des « Voiture » avec des propriétés différentes mais une clé de partition commune qui pourrait être la plaque d’immatriculation ou la marque. La clé de partition permet de regrouper les données ayant la même valeur au sein d’une partition logique, facilitant la recherche.

Le choix de la clé de partition est très important dans Azure Cosmos DB. Sa valeur ne peut pas changer dans le temps. Il est nécessaire de disposer d’une grande variété de valeurs pour permettre d’avoir des partitions logiques pas trop volumineuses (la limite est à 20 Go).

Exemple de données d’un container Azure Cosmos DB depuis Azure Cosmos DB Emulator

Dans l’exemple ci-dessus, le document Person figure dans le conteneur Persons avec pour propriétés « firstname » et « age ». Toutes les autres propriétés sont propres à Azure Cosmos DB, y compris l’id. La partition key du containeur Persons est la propriété « firstname ».

Autre point important, Azure Cosmos DB n’est pas une base relationnelle, il est donc impossible de faire des jointures. Imaginons alors que l’on souhaite ajouter un « Job » à notre « Person ». Plusieurs solutions sont possibles :

  • Dupliquer l’entité, un document dans un conteneur Job, une propriété dans les documents Person => une seule lecture en base permet de récupérer toutes les informations mais il faudra gérer la cohérence (mettre à jour les documents Person lors d’un changement sur le document Job)
  • Ajouter un identifiant et idéalement la valeur de la clé de partition d’une entité liée => cela à l’avantage de ne pas dupliquer les données et d’avoir toujours une cohérence, mais il faudra deux lectures en base pour récupérer les informations sur le Job d’une Person
  • Dupliquer uniquement les propriétés nécessaires dans l’item Person => on limite le nombre de mises à jour liées à la cohérence aux seules propriétés nécessaires
Attention, Cosmos DB n’est pas la meilleure solution pour stocker des données volumineuses. La taille maximum d’un document est de 2MB et les lectures/écritures ne sont pas optimisées (préférez plutôt Azure Blob Storage).

Mise en place de l’API et intégration du provider Cosmos DB pour EF Core

Le projet utilisé est le template par défaut de Visual Studio pour une API WEB ASP.NET Core 6 pour laquelle j’ai supprimé la classe et le controller automatiquement générés. L’application cible est une API de vote pour le choix d’un restaurant pour les déjeuners d’entreprise.

1. Intégration du provider

Après avoir ajouté le package NuGet Microsoft.EntityFrameworkCore.Cosmos à l’application, je crée mon LunchVotingDbContext et j’enregistre le provider grâce à l’injection de dépendance. J’essaie de garder le code le plus simple possible ici pour mettre le focus sur ce qui nous intéresse, donc pas de service Azure Key Vault par exemple.

EF Core dispose de quatre providers officiels : SQL Server, SQLite, InMemory et CosmosDB. D’autres providers sont disponibles comme MySQL et Oracle mais ils ont été créés à l’extérieur du projet EF Core.
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddDbContextFactory<LunchVotingContext>(optionBuilder =>
    optionBuilder.UseCosmos(
        connectionString: "AccountEndpoint=https://<ACCOUNT_ENDPOINT>",
        databaseName: "LunchVotingDb")
);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();;

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();
using Microsoft.EntityFrameworkCore;

public class LunchVotingContext : DbContext
{
    public LunchVotingContext(DbContextOptions<LunchVotingContext> options)
        : base(options)
    {

    }
}

2. Création et configuration des models

Nous avons besoin de deux entités pour notre application :

  • Restaurant qui permet de lister les différents choix possibles pour le vote du jour
  • Vote qui enregistre le choix d’un utilisateur pour un restaurant à une date donnée

Avec EF Core, la liaison de données peut se faire soit par des attributs directement sur les modèles, soit en mode fluent en surchargeant la méthode OnModelCreating de notre DbContext. Je préfère la deuxième méthode parce qu’elle permet d’avoir toute la configuration des liaisons au même endroit. Cela permet également, si vous séparez votre DbContext et vos modèles dans deux projets distincts, de ne pas inclure la package EF Core pour vos modèles. Enfin, l’utilisation de la Fluent API permet de disposer de plus d’options de configuration.

La configuration des liaisons peut agir au niveau de la base de données (définition du débit auto/manuel, choix du conteneur par défaut), au niveau des conteneurs/entités et au niveau des propriétés (mapping dans le cas d’un nommage différents entre le document dans la base et la propriété dans la classe C# par exemple). Dans notre cas, nous allons uniquement configurer nos deux entités dans deux conteneurs séparés.

using Microsoft.EntityFrameworkCore;

public class LunchVotingContext : DbContext
{
    public LunchVotingContext(DbContextOptions<LunchVotingContext> options)
        : base(options)
    {

    }

    public DbSet<Vote> Votes { get; set; }
    public DbSet<Restaurant> Restaurants { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Vote>()
            .ToContainer(nameof(Vote))
            .HasNoDiscriminator()
            .HasDefaultTimeToLive(60*60*24*30)
            .HasPartitionKey(vote => vote.ShortDate)
            .HasKey(vote => vote.VoteId);

        modelBuilder.Entity<Restaurant>()
            .ToContainer(nameof(Restaurant))
            .HasNoDiscriminator()
            .HasPartitionKey(restaurant => restaurant.RestaurantId)
            .HasKey(restaurant => restaurant.RestaurantId);
    }
}
public class Restaurant
{
    public string RestaurantId { get; set; }
    public string Name { get; set; } = null!;
    public string Address { get; set; } = null!;
    public List<string> FoodTypes { get; set; } = new();
    public int AveragePriceInEuros { get; set; }
    public int TimeToServeInMinutes { get; set; }
}
public class Vote
{
    public Guid VoteId { get; set; }
    public string RestaurantId { get; set; }
    public Restaurant? Restaurant { get; set; }
    public string RestaurantName { get; set; } = null!;
    public string ShortDate { get; set; }
    public string User { get; set; }
}

Quelques explications sur les méthodes utilisées pour le binding :

  • Entity<T>() spécifie que la définition de liaison suivante concerne l’entité T
  • ToContainer(« <ContainerName> ») spécifie à quel conteneur est liée cette entité T
  • HasNoDiscriminator spécifie que la liaison ne nécessite pas de discriminateur (utile pour les entités avec héritage). Nous avons vu plus haut que le container était agnostique au schéma. C’est grâce au discriminateur que l’on peut lier une donnée d’un conteneur avec une classe C#
  • HasDefaultTimeToLive(seconds) spécifie la durée de vie de l’objet dans le container. Cosmos DB permet un nettoyage automatique des données. Pour l’entité Vote, nous décidons de la stocker pendant 30 jours
  • HasPartitionKey spécifie la clé de partition du conteneur
  • HasKey spécifie l’id unique d’une donnée au sein d’une partition logique. Par défaut, la liaison se fait automatiquement sur la propriété Id ou <Entity>Id. La propriété choisie ne peut être qu’un string ou un type disposant d’un string converter (Guid par exemple)
  • Les liaisons simples ou multiples entre entités, par exemple le Vote qui a une liaison avec un Restaurant, n’ont pas besoin d’être spécifiées. Nous verrons lors de la récupération d’un vote, comment cela se passe mais gardez une chose en tête : il n’y a pas de lien direct entre deux documents dans une base Cosmos DB

Nous allons dans la suite nous concentrer sur l’entité Vote. Les Restaurants pourront, dans un premier temps, être statiques au sein de notre API. EF Core permet d’injecter des données directement depuis notre code C#. Pour cela, il faut utiliser la méthode HasData.

using Microsoft.EntityFrameworkCore;

public class LunchVotingContext : DbContext
{
    public LunchVotingContext(DbContextOptions<LunchVotingContext> options)
        : base(options)
    {

    }

    public DbSet<Vote> Votes { get; set; }
    public DbSet<Restaurant> Restaurants { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Vote>()
            .ToContainer(nameof(Vote))
            .HasNoDiscriminator()
            .HasDefaultTimeToLive(60*60*24*30)
            .HasPartitionKey(vote => vote.ShortDate)
            .HasKey(vote => vote.VoteId);

        modelBuilder.Entity<Restaurant>()
            .ToContainer(nameof(Restaurant))
            .HasNoDiscriminator()
            .HasPartitionKey(restaurant => restaurant.RestaurantId)
            .HasKey(restaurant => restaurant.RestaurantId);

        modelBuilder.Entity<Restaurant>()
            .HasData(new Restaurant[] { 
                new Restaurant { 
                    RestaurantId = "1",
                    Name = "Bistronomique",
                    Address = "1 rue de Paris, 75014 Paris",
                    FoodTypes = new List<string> { "Bistro" }, 
                    AveragePriceInEuros = 15,
                    TimeToServeInMinutes = 60
                },
                new Restaurant { 
                    RestaurantId = "2", 
                    Name = "XL Burger", 
                    Address = "2 rue de Paris, 75014 Paris",
                    FoodTypes = new List<string> { "Burger" },
                    AveragePriceInEuros = 11,
                    TimeToServeInMinutes = 20 
                },
                new Restaurant { 
                    RestaurantId = "3", 
                    Name = "Le Fast",
                    Address = "3 rue de Paris, 75014 Paris",
                    FoodTypes = new List<string> { "Burger", "Bistro", "Française" }, 
                    AveragePriceInEuros = 9,
                    TimeToServeInMinutes = 25 
                },
            });
    }
}

3. Création et récupération de données

L’application doit pouvoir ajouter un vote, récupérer un vote et chercher tous les votes d’une journée en filtrant éventuellement sur un restaurant spécifique. Pour manipuler les données de vote, je crée un service VoteService et son interface IVoteService que j’injecte en tant que Transient.

public interface IVoteService
{
    Task<Vote> AddVote(CreateVote createVote);
    Task<Vote?> GetVote(Guid id);
    Task<IEnumerable<Vote>> GetAllVotesForDay(string shortDate,
                                              string? restaurantId = null);
}
public class CreateVote
{
    public string RestaurantId { get; set; } = null!;
    public string RestaurantName { get; set; } = null!;
    public DateTime Date { get; set; }
    public string User { get; set; } = null!;
}
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddTransient<IVoteService, VoteService>();

builder.Services.AddDbContextFactory<LunchVotingContext>(optionBuilder =>
    optionBuilder.UseCosmos(
        connectionString: "AccountEndpoint=https://<ACCOUNT_ENDPOINT>",
        databaseName: "LunchVotingDb")
);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();;

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Pour interagir avec notre base de données Azure Cosmos DB grâce à EF Core 6, il faut tout d’abord récupérer une instance de LunchVotingContext à partir de IDbContextFactory que nous avons injecté précédemment. Ce contexte implémente IDisposable et doit donc être utilisé autour d’un using. Il va nous permettre de réaliser les différentes opérations (lecture/écriture) sur notre base de données. Enfin, si des données ont changé dans notre contexte, il faudra les persister dans la base Cosmos DB. Sans trop entrer dans les détails, le contexte permet, au moment de sa création, de prendre une photo de notre base. Ainsi, si je veux modifier mon entité Vote, je vais modifier les données issues de cette photo en mémoire dans mon API puis persister ces changements dans ma base de données.

EF Core dispose de méthodes CRUD pour interagir avec la base de données. Un élément qui peut amener de la confusion est que certaines méthodes sont disponibles dans leur version asynchrone (FindAsync, AddAsync…) et d’autres non (Update, Remove). Dites vous que l’asynchronisme est important lorsqu’il y a un échange réseau vers la base. Ainsi les méthodes FindAsync, ToListAsync, SaveChangesAsync doivent par exemple être utilisées. En revanche, celles qui modifient uniquement le contexte en mémoire peuvent être synchrones (Add, AddRange, Update, Remove).

public class VoteService : IVoteService
{
    private readonly IDbContextFactory<LunchVotingContext> contextFactory;

    public VoteService(IDbContextFactory<LunchVotingContext> contextFactory)
    {
        this.contextFactory = contextFactory;
    }

    public async Task<Vote> AddVote(CreateVote createVote)
    {
        using var context = await contextFactory.CreateDbContextAsync(); 

        var vote = await context.Votes
                .AddAsync(new Vote {
                    VoteId = Guid.NewGuid(),
                    RestaurantId = createVote.RestaurantId,
                    RestaurantName = createVote.RestaurantName,
                    User = createVote.User,
                    ShortDate = createVote.Date.ToShortDateString(),
                });

        await context.SaveChangesAsync();

        return vote.Entity;
    }

    public async Task<Vote?> GetVote(Guid id)
    {
        using var context = await contextFactory.CreateDbContextAsync();

        var vote = await context.Votes
                .FindAsync(id);

        if (vote == null) return null;

        await context.Entry(vote)
                .Reference(vote => vote.Restaurant)
                .LoadAsync();

        return vote;
    }

    public async Task<IEnumerable<Vote>> GetAllVotesForDay(string shortDate,
                                                           string? restaurantId = null)
    {
        using var context = await contextFactory.CreateDbContextAsync();

        var votesQuery = context.Votes
            .WithPartitionKey(shortDate);

        if (restaurantId != null)
        {
            votesQuery = votesQuery
            .Where(vote => vote.RestaurantId == restaurantId);
        }

        var votes = await votesQuery
            .AsNoTracking()
            .ToListAsync();

        return votes;
    }
}
using dcubeLunchVotingApi.Services;
using Microsoft.AspNetCore.Mvc;

namespace dcubeLunchVotingApi.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class VotesController : ControllerBase
    {
        private readonly IVoteService voteService;

        public VotesController(IVoteService voteService)
        {
            this.voteService = voteService;
        }

        [HttpGet]
        [ProducesResponseType(typeof(Vote), StatusCodes.Status200OK)]
        public async Task<IActionResult> GetAll([FromQuery] string shortDate,
                                             [FromQuery] string? restaurantId)
        {
            IEnumerable<Vote> votes = await voteService.GetAllVotesForDay(
                shortDate,
                restaurantId);

            return Ok(votes);
        }

        [HttpGet]
        [Route("{voteId}", Name = "GetVoteById")]
        [ProducesResponseType(StatusCodes.Status404NotFound)]
        [ProducesResponseType(typeof(Vote), StatusCodes.Status200OK)]
        public async Task<IActionResult> Get([FromRoute] Guid voteId)
        {
            Vote? vote = await voteService.GetVote(voteId);

            return vote == null ? NotFound() : Ok(vote);
        }

        [HttpPost]
        [ProducesResponseType(StatusCodes.Status201Created, Type = typeof(string))]
        [ProducesResponseType(StatusCodes.Status400BadRequest)]
        public async Task<ActionResult> CreateVote([FromBody] CreateVote createVote)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest();
            }
            Vote vote = await voteService.AddVote(createVote);

            return CreatedAtRoute("GetVoteById", new { voteId = vote.VoteId }, vote);
        }
    }
}

Point important dans la méthode GetVote, l’entité Restaurant liée au vote dans ma classe C# n’est pas implicitement récupérée. Si je souhaite la rapatrier, je dois la charger explicitement (LoadAsync()) depuis l’entité suivie (tracked) par le contexte (Entry()). Deux lectures en base sont nécessaires, à utiliser avec modération donc.

Lors d’une recherche, il faut quasi-systématiquement ajouter la clé de partition soit via la méthode WithPartitionKey soit dans la méthode Where. Les performances n’en seront que meilleures car la recherche ciblera directement la bonne partition logique.

Avec le provider Cosmos DB, plusieurs choses ne sont pas permises sur les filtres. Cela est notamment lié à la nature de Azure Cosmos DB qui est une base de données non relationnelle. Ainsi, on ne peut :

  • Filtrer sur la valeur d’une propriété d’une entité liée
  • Inclure explicitement la liaison avec Include
  • Filtrer sur la valeur d’un élément d’une collection, même une collection de type primitifs

Astuces et bonnes pratiques

1. Maitrisez les performances et les coûts

Comme pour tout provider EF Core, il est important de regarder ce qui est fait sous le capot par Entity Framework. Pour le provider Cosmos DB, il est vivement conseillé de regarder le coût en RU de chacune des requêtes exécutées. Pour cela, vous pouvez logger directement dans la console pendant la phase de développement. Des requêtes mal écrites entrainent un coût en RU trop grand et donc une facturation importante.

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddTransient<IVoteService, VoteService>();

builder.Services.AddDbContextFactory<LunchVotingContext>(optionBuilder =>
    optionBuilder
    .EnableSensitiveDataLogging()
    .LogTo(Console.WriteLine, (eventId, logLevel) => logLevel > LogLevel.Information
                                   || eventId == CoreEventId.QueryCompilationStarting
                                   || eventId == CosmosEventId.ExecutedReadNext
                                   || eventId == CosmosEventId.ExecutedReadItem
                                   || eventId == CosmosEventId.ExecutedCreateItem
                                   || eventId == CosmosEventId.ExecutedReplaceItem
                                   || eventId == CosmosEventId.ExecutedDeleteItem
    ).UseCosmos(
        connectionString: "AccountEndpoint=https://<ACCOUNT_ENDPOINT>",
        databaseName: "LunchVotingDb")
);

builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();;

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

2. « Trackez » vos entités seulement si nécessaire

Pour les scénarios « read-only », pensez à spécifier à EF Core que le suivi de l’entité n’est pas nécessaire grâce à la méthode AsNoTracking().

3. Gérez les accès concurrentiels

Gérer les accès concurrentiels (deux modification simultanée d’un même document) de manière optimiste avec l’Etag. Pour activer cette option, il suffit de le spécifier dans la méthode OnModelCreating(). S’il y a une différence d’Etag lors d’une écriture en base, une exception de type DbUpdateConcurrencyException est levée par le provider. Il est donc possible de gérer le conflit en retournant par exemple une erreur 412 au client de l’API.

modelBuilder.Entity<Restaurant>()
     .ToContainer(nameof(Restaurant))
     .HasNoDiscriminator()
     .UseETagConcurrency()
     .HasPartitionKey(restaurant => restaurant.RestaurantId)
     .HasKey(restaurant => restaurant.RestaurantId);

4. Ecrivez vos propres requêtes SQL pour contourner certaines limitations

Pour les requêtes complexes, non réalisables par le provider, vous pouvez exécuter des commandes SQL grâce à la méthode FromSqlRaw() d’un DbSet. Par exemple, pour filtrer les restaurants selon le type de nourriture proposé.

public async Task<IEnumerable<Restaurant>> GetAllRestaurantsForFoodType(string foodType)
{
    using var context = await contextFactory.CreateDbContextAsync();

    var sql = "SELECT * FROM c " +
                "WHERE EXISTS (" +
                "SELECT VALUE FoodType " +
                "FROM FoodType IN c.FoodTypes " +
                "WHERE FoodType={0})";

    var restaurants = await context.Restaurants
        .FromSqlRaw(sql, foodType)
        .ToListAsync();

    return restaurants;
}

4. Utilisez le SDK pour contourner d’autres limitations

Vous pouvez également accéder directement à l’objet CosmosClient du SDK Azure. Il est par exemple possible d’exécuter une procédure stockée ou de faire des lectures paginées.

public async Task ExecuteCosmosClientRequest()
{
    using var context = await contextFactory.CreateDbContextAsync();

    var container = context.Database.GetCosmosClient()
        .GetContainer("LunchVotingDb", "Vote");

    await container.Scripts.ExecuteStoredProcedureAsync<string>(
        storedProcedureId: "myStoredProcedure", 
        partitionKey: new PartitionKey("partitionKey"),
        parameters: null);
}

J’espère avoir levé le voile sur Azure Cosmos DB et son utilisation avec EF Core. Sachez que courant novembre 2022 sort la version 7 d’Entity Framework Core. Vous pouvez retrouver la liste des nouveautés attendues pour le provider Cosmos DB à cette adresse.

0 commentaires

Soumettre un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Découvrez nos autres articles

git et la face cachée du Rebase

git et la face cachée du Rebase

"Faire une rebase ? *sight* heu... ok..." Jean-Michel Fullstack - Développeur fébrile Jean-Michel est inquiet. En effet, lorsque nous collaborons à plusieurs sur un projet, quelque soit les technologies utilisées, il est important de garder à l'esprit que notre...

lire plus
Aller au contenu principal