Provider di Azure Cosmos DB per EF Core

Questo provider di database consente l'uso di Entity Framework Core con Azure Cosmos DB. Il provider viene gestito nell'ambito del progetto Entity Framework Core.

Prima di leggere questa sezione, è consigliabile acquisire familiarità con la documentazione di Azure Cosmos DB.

Nota

Questo provider funziona solo con Azure Cosmos DB per NoSQL.

Installazione

Installare il pacchetto NuGet Microsoft.EntityFrameworkCore.Cosmos.

dotnet add package Microsoft.EntityFrameworkCore.Cosmos

Introduzione

Suggerimento

È possibile visualizzare l'esempio di questo articolo in GitHub.

Analogamente agli altri provider, il primo passaggio consiste nel chiamare UseCosmos:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.UseCosmos(
        "https://localhost:8081",
        "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==",
        databaseName: "OrdersDB");

Avviso

L'endpoint e la chiave sono hardcoded qui per semplicità, ma in un'app di produzione devono essere archiviati in modo sicuro.

In questo esempio Order è un'entità semplice con un riferimento al tipo i proprietàStreetAddress.

public class Order
{
    public int Id { get; set; }
    public int? TrackingNumber { get; set; }
    public string PartitionKey { get; set; }
    public StreetAddress ShippingAddress { get; set; }
}
public class StreetAddress
{
    public string Street { get; set; }
    public string City { get; set; }
}

Per il salvataggio e l'esecuzione di query sui dati si segue il modello EF normale:

using (var context = new OrderContext())
{
    await context.Database.EnsureDeletedAsync();
    await context.Database.EnsureCreatedAsync();

    context.Add(
        new Order
        {
            Id = 1, ShippingAddress = new StreetAddress { City = "London", Street = "221 B Baker St" }, PartitionKey = "1"
        });

    await context.SaveChangesAsync();
}

using (var context = new OrderContext())
{
    var order = await context.Orders.FirstAsync();
    Console.WriteLine($"First order will ship to: {order.ShippingAddress.Street}, {order.ShippingAddress.City}");
    Console.WriteLine();
}

Importante

La chiamata di EnsureCreatedAsync è necessaria per creare i contenitori richiesti e inserire i dati di inizializzazione se presenti nel modello. Tuttavia, EnsureCreatedAsync deve essere chiamato solo durante la distribuzione, e non durante il normale funzionamento, perché potrebbe causare problemi di prestazioni.

Opzioni di Azure Cosmos DB

È anche possibile configurare il provider Azure Cosmos DB con un singolo stringa di connessione e specificare altre opzioni per personalizzare la connessione:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.UseCosmos(
        "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==",
        databaseName: "OptionsDB",
        options =>
        {
            options.ConnectionMode(ConnectionMode.Gateway);
            options.WebProxy(new WebProxy());
            options.LimitToEndpoint();
            options.Region(Regions.AustraliaCentral);
            options.GatewayModeMaxConnectionLimit(32);
            options.MaxRequestsPerTcpConnection(8);
            options.MaxTcpConnectionsPerEndpoint(16);
            options.IdleTcpConnectionTimeout(TimeSpan.FromMinutes(1));
            options.OpenTcpConnectionTimeout(TimeSpan.FromMinutes(1));
            options.RequestTimeout(TimeSpan.FromMinutes(1));
        });

Suggerimento

Per una descrizione dettagliata dell'effetto di ogni opzione indicata in precedenza, vedere la documentazione sulle opzioni per Azure Cosmos DB.

Personalizzazione del modello specifico di Cosmos

Per impostazione predefinita, viene eseguito il mapping di tutti i tipi di entità allo stesso contenitore, denominato in base al contesto derivato ("OrderContext" in questo caso). Per modificare il nome del contenitore predefinito, usare HasDefaultContainer:

modelBuilder.HasDefaultContainer("Store");

Per eseguire il mapping di un tipo di entità a un contenitore diverso, usare ToContainer:

modelBuilder.Entity<Order>()
    .ToContainer("Orders");

Per identificare il tipo di entità rappresentato da un determinato elemento, EF Core aggiunge un valore discriminatore anche se non sono presenti tipi di entità derivati. Il nome e il valore del discriminatore possono essere modificati.

Se nessun altro tipo di entità verrà archiviato nello stesso contenitore, il discriminatore può essere rimosso chiamando HasNoDiscriminator:

modelBuilder.Entity<Order>()
    .HasNoDiscriminator();

Chiavi di partizione

Per impostazione predefinita, EF Core creerà contenitori con la chiave di partizione impostata su "__partitionKey" senza specificare alcun valore per esso durante l'inserimento di elementi. Tuttavia, per sfruttare appieno le funzionalità di prestazioni di Azure Cosmos DB, è consigliabile usare una chiave di partizione accuratamente selezionata. Può essere configurata chiamando HasPartitionKey:

modelBuilder.Entity<Order>()
    .HasPartitionKey(o => o.PartitionKey);

Nota

La proprietà della chiave di partizione può essere di qualsiasi tipo, purché sia convertita in stringa.

Una volta configurata, la proprietà della chiave di partizione deve avere sempre un valore non Null. Una query può essere eseguita a partizione singola mediante l'aggiunta di una chiamata WithPartitionKey.

using (var context = new OrderContext())
{
    context.Add(
        new Order
        {
            Id = 2, ShippingAddress = new StreetAddress { City = "New York", Street = "11 Wall Street" }, PartitionKey = "2"
        });

    await context.SaveChangesAsync();
}

using (var context = new OrderContext())
{
    var order = await context.Orders.WithPartitionKey("2").LastAsync();
    Console.WriteLine($"Last order will ship to: {order.ShippingAddress.Street}, {order.ShippingAddress.City}");
    Console.WriteLine();
}

È in genere consigliabile aggiungere la chiave di partizione alla chiave primaria perché questo approccio rispecchia meglio la semantica del server e consente alcune ottimizzazioni, ad esempio in FindAsync.

Velocità effettiva con provisioning

Se si usa EF Core per creare il database o i contenitori di Azure Cosmos DB, è possibile configurare la velocità effettiva con provisioning per il database chiamando CosmosModelBuilderExtensions.HasAutoscaleThroughput o CosmosModelBuilderExtensions.HasManualThroughput. Ad esempio:

modelBuilder.HasManualThroughput(2000);
modelBuilder.HasAutoscaleThroughput(4000);

Per configurare la velocità effettiva con provisioning per un contenitore chiamare CosmosEntityTypeBuilderExtensions.HasAutoscaleThroughput o CosmosEntityTypeBuilderExtensions.HasManualThroughput. Ad esempio:

modelBuilder.Entity<Family>(
    entityTypeBuilder =>
    {
        entityTypeBuilder.HasManualThroughput(5000);
        entityTypeBuilder.HasAutoscaleThroughput(3000);
    });

Entità incorporate

Nota

I tipi di entità correlati sono configurati come di proprietà per impostazione predefinita. Per evitare questo problema per un di tipo di entità specifica, chiamare ModelBuilder.Entity.

Per Azure Cosmos DB, le entità di proprietà vengono incorporate nello stesso elemento del proprietario. Per modificare il nome di una proprietà, usare ToJsonProperty:

modelBuilder.Entity<Order>().OwnsOne(
    o => o.ShippingAddress,
    sa =>
    {
        sa.ToJsonProperty("Address");
        sa.Property(p => p.Street).ToJsonProperty("ShipsToStreet");
        sa.Property(p => p.City).ToJsonProperty("ShipsToCity");
    });

Con questa configurazione, l'ordine dell'esempio precedente viene archiviato come segue:

{
    "Id": 1,
    "PartitionKey": "1",
    "TrackingNumber": null,
    "id": "1",
    "Address": {
        "ShipsToCity": "London",
        "ShipsToStreet": "221 B Baker St"
    },
    "_rid": "6QEKAM+BOOABAAAAAAAAAA==",
    "_self": "dbs/6QEKAA==/colls/6QEKAM+BOOA=/docs/6QEKAM+BOOABAAAAAAAAAA==/",
    "_etag": "\"00000000-0000-0000-683c-692e763901d5\"",
    "_attachments": "attachments/",
    "_ts": 1568163674
}

Anche le raccolte di entità di proprietà sono incorporate. Per l'esempio successivo verrà usata la classe Distributor con una raccolta di StreetAddress:

public class Distributor
{
    public int Id { get; set; }
    public string ETag { get; set; }
    public ICollection<StreetAddress> ShippingCenters { get; set; }
}

Non è necessario che le entità di proprietà forniscano valori di chiave espliciti da archiviare:

var distributor = new Distributor
{
    Id = 1,
    ShippingCenters = new HashSet<StreetAddress>
    {
        new StreetAddress { City = "Phoenix", Street = "500 S 48th Street" },
        new StreetAddress { City = "Anaheim", Street = "5650 Dolly Ave" }
    }
};

using (var context = new OrderContext())
{
    context.Add(distributor);

    await context.SaveChangesAsync();
}

Verranno salvati in modo persistente in questo modo:

{
    "Id": 1,
    "Discriminator": "Distributor",
    "id": "Distributor|1",
    "ShippingCenters": [
        {
            "City": "Phoenix",
            "Street": "500 S 48th Street"
        },
        {
            "City": "Anaheim",
            "Street": "5650 Dolly Ave"
        }
    ],
    "_rid": "6QEKANzISj0BAAAAAAAAAA==",
    "_self": "dbs/6QEKAA==/colls/6QEKANzISj0=/docs/6QEKANzISj0BAAAAAAAAAA==/",
    "_etag": "\"00000000-0000-0000-683c-7b2b439701d5\"",
    "_attachments": "attachments/",
    "_ts": 1568163705
}

Internamente, EF Core deve avere sempre valori di chiave univoca per tutte le entità rilevate. La chiave primaria creata per impostazione predefinita per le raccolte di tipi di proprietà è costituita dalle proprietà di chiave esterna che puntano al proprietario e da una proprietà int corrispondente all'indice nella matrice JSON. Per recuperare questi valori, è possibile usare l'API Entry:

using (var context = new OrderContext())
{
    var firstDistributor = await context.Distributors.FirstAsync();
    Console.WriteLine($"Number of shipping centers: {firstDistributor.ShippingCenters.Count}");

    var addressEntry = context.Entry(firstDistributor.ShippingCenters.First());
    var addressPKProperties = addressEntry.Metadata.FindPrimaryKey().Properties;

    Console.WriteLine(
        $"First shipping center PK: ({addressEntry.Property(addressPKProperties[0].Name).CurrentValue}, {addressEntry.Property(addressPKProperties[1].Name).CurrentValue})");
    Console.WriteLine();
}

Suggerimento

Quando necessario, è possibile modificare la chiave primaria predefinita per i tipi di entità di proprietà, ma i valori di chiave devono poi essere specificati in modo esplicito.

Raccolte di tipi primitivi

Le raccolte di tipi primitivi supportati, ad esempio string e int, vengono individuate e mappate automaticamente. Le raccolte supportate sono tutti i tipi che implementano IReadOnlyList<T> o IReadOnlyDictionary<TKey,TValue>. Si consideri ad esempio questo tipo di entità:

public class Book
{
    public Guid Id { get; set; }
    public string Title { get; set; }
    public IList<string> Quotes { get; set; }
    public IDictionary<string, string> Notes { get; set; }
}

Sia l'elenco che il dizionario possono essere popolati e inseriti nel database in modo normale:

using var context = new BooksContext();

var book = new Book
{
    Title = "How It Works: Incredible History",
    Quotes = new List<string>
    {
        "Thomas (Tommy) Flowers was the British engineer behind the design of the Colossus computer.",
        "Invented originally for Guinness, plastic widgets are nitrogen-filled spheres.",
        "For 20 years after its introduction in 1979, the Walkman dominated the personal stereo market."
    },
    Notes = new Dictionary<string, string>
    {
        { "121", "Fridges" },
        { "144", "Peter Higgs" },
        { "48", "Saint Mark's Basilica" },
        { "36", "The Terracotta Army" }
    }
};

context.Add(book);
context.SaveChanges();

Si ottiene come risultato il documento JSON seguente:

{
    "Id": "0b32283e-22a8-4103-bb4f-6052604868bd",
    "Discriminator": "Book",
    "Notes": {
        "36": "The Terracotta Army",
        "48": "Saint Mark's Basilica",
        "121": "Fridges",
        "144": "Peter Higgs"
    },
    "Quotes": [
        "Thomas (Tommy) Flowers was the British engineer behind the design of the Colossus computer.",
        "Invented originally for Guinness, plastic widgets are nitrogen-filled spheres.",
        "For 20 years after its introduction in 1979, the Walkman dominated the personal stereo market."
    ],
    "Title": "How It Works: Incredible History",
    "id": "Book|0b32283e-22a8-4103-bb4f-6052604868bd",
    "_rid": "t-E3AIxaencBAAAAAAAAAA==",
    "_self": "dbs/t-E3AA==/colls/t-E3AIxaenc=/docs/t-E3AIxaencBAAAAAAAAAA==/",
    "_etag": "\"00000000-0000-0000-9b50-fc769dc901d7\"",
    "_attachments": "attachments/",
    "_ts": 1630075016
}

Queste raccolte possono quindi essere aggiornate, sempre in modo normale:

book.Quotes.Add("Pressing the emergency button lowered the rods again.");
book.Notes["48"] = "Chiesa d'Oro";

context.SaveChanges();

Limitazioni :

  • Sono supportati solo i dizionari con chiavi stringa
  • L'esecuzione di query nel contenuto delle raccolte primitive non è attualmente supportata. Se si ritiene che queste funzionalità siano importanti, votare per #16926, #25700 e #25701.

Uso delle entità disconnesse

Ogni elemento deve avere un valore id univoco per la chiave di partizione specificata. Per impostazione predefinita, EF Core genera il valore concatenando il discriminatore e i valori di chiave primaria usando '|' come delimitatore. I valori delle chiavi vengono generati solo quando un'entità entra nello stato Added. Questo potrebbe rappresentare un problema quando si collegano le entità se non hanno una proprietà id per il tipo .NET per archiviare il valore.

Per ovviare a questa limitazione, è possibile creare e impostare il valore id manualmente oppure contrassegnare prima l'entità come aggiunta, per poi modificarla nello stato desiderato:

using (var context = new OrderContext())
{
    var distributorEntry = context.Add(distributor);
    distributorEntry.State = EntityState.Unchanged;

    distributor.ShippingCenters.Remove(distributor.ShippingCenters.Last());

    await context.SaveChangesAsync();
}

using (var context = new OrderContext())
{
    var firstDistributor = await context.Distributors.FirstAsync();
    Console.WriteLine($"Number of shipping centers is now: {firstDistributor.ShippingCenters.Count}");

    var distributorEntry = context.Entry(firstDistributor);
    var idProperty = distributorEntry.Property<string>("__id");
    Console.WriteLine($"The distributor 'id' is: {idProperty.CurrentValue}");
}

Questo è il codice JSON risultante:

{
    "Id": 1,
    "Discriminator": "Distributor",
    "id": "Distributor|1",
    "ShippingCenters": [
        {
            "City": "Phoenix",
            "Street": "500 S 48th Street"
        }
    ],
    "_rid": "JBwtAN8oNYEBAAAAAAAAAA==",
    "_self": "dbs/JBwtAA==/colls/JBwtAN8oNYE=/docs/JBwtAN8oNYEBAAAAAAAAAA==/",
    "_etag": "\"00000000-0000-0000-9377-d7a1ae7c01d5\"",
    "_attachments": "attachments/",
    "_ts": 1572917100
}

Concorrenza ottimistica con ETag

Per configurare un tipo di entità per l'uso della concorrenza ottimistica, chiamare UseETagConcurrency. Questa chiamata creerà una proprietà _etag con stato shadow e la imposterà come token di concorrenza.

modelBuilder.Entity<Order>()
    .UseETagConcurrency();

Per semplificare la risoluzione degli errori di concorrenza, è possibile eseguire il mapping dell'ETag a una proprietà CLR usando IsETagConcurrency.

modelBuilder.Entity<Distributor>()
    .Property(d => d.ETag)
    .IsETagConcurrency();