使用 EF Core Azure Cosmos DB Provider 配置模型

容器和实体类型

在 Azure Cosmos DB 中,JSON 文档存储在容器中。 与关系数据库中的表不同,Azure Cosmos DB 容器可以包含具有不同形状的文档 - 容器不会对其文档施加统一架构。 但是,各种配置选项在容器级别定义,因此会影响其中包含的所有文档。 有关详细信息,请参阅有关容器的 Azure Cosmos DB 文档

默认情况下,EF 将所有实体类型映射到同一容器;就性能和定价而言,这通常是一个很好的默认值。 默认容器以 .NET 上下文类型命名(在本例中为 OrderContext)。 要更改默认容器名称,请使用 HasDefaultContainer

modelBuilder.HasDefaultContainer("Store");

要将实体类型映射到其他容器,请使用 ToContainer

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

在将实体类型映射到不同的容器之前,请确保了解潜在的性能和定价影响(例如,与专用吞吐量和共享吞吐量有关):有关详细信息,请参阅 Azure Cosmos DB 文档

ID 和密钥

Azure Cosmos DB 要求所有文档都具有唯一标识它们的 id JSON 属性。 与其他 EF 提供程序一样,EF Azure Cosmos DB 提供程序将尝试查找名为 Id<type name>Id 的属性,并将该属性配置为实体类型的键,将其映射到 id JSON 属性。 可以使用 HasKey 将任何属性配置为键属性;有关详细信息,请参阅关于键的 EF 通用文档

从其他数据库访问 Azure Cosmos DB 的开发人员有时希望自动生成键 (Id) 属性。 例如,在 SQL Server 上,EF 将数值键属性配置为 IDENTITY 列,其中会在数据库中生成自动递增值。 相比之下,Azure Cosmos DB 不支持自动生成属性,因此必须显式设置键属性。 插入具有未设置键属性的实体类型只会插入该属性的 CLR 默认值(例如,0 表示 int),第二次插入将失败;如果尝试执行此操作,EF 会发出警告。

如果想要将 GUID 用作键属性,可以将 EF 配置为在客户端生成唯一的随机值:

modelBuilder.Entity<Session>().Property(b => b.Id).HasValueGenerator<GuidValueGenerator>();

分区键

Azure Cosmos DB 使用分区来实现水平缩放;适当的建模和仔细选择分区键对于实现良好的性能和降低成本至关重要。 强烈建议阅读关于分区的 Azure Cosmos DB 文档,并提前规划分区策略。

若要使用 EF 配置分区键,请调用 HasPartitionKey,在实体类型上传递常规属性:

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

任何属性都可以成为分区键,只要它被转换成字符串。 配置后,分区键属性应始终具有非 null 值;尝试插入具有未设置分区键属性的新实体类型将导致错误。

请注意,Azure Cosmos DB 允许两个具有相同 id 属性的文档存在于一个容器中,只要它们位于不同的分区中;这意味着,为了唯一标识容器中的文档,必须同时提供 id 和分区键属性。 因此,EF 的实体主键的内部概念按约定包含这两个元素,这与没有分区键概念的关系数据库不同。 这意味着,例如,FindAsync 需要键和分区键属性(请参阅更多文档),查询必须在其 Where 子句中指定这些属性,才能从高效且经济高效的 point reads 中受益。

请注意,分区键是在容器级别定义的。 这显然意味着同一容器中的多个实体类型不可能具有不同的分区键属性。 如果需要定义不同的分区键,请将相关的实体类型映射到不同的容器。

分层分区键

Azure Cosmos DB 还支持分层分区键,以进一步优化数据分布;有关更多详细信息,请参阅文档。 EF 9.0 添加了对分层分区键的支持;若要配置这些属性,只需将最多 3 个属性传递给 HasPartitionKey

modelBuilder.Entity<Order>().HasPartitionKey(o => new { e.TenantId, e.UserId, e.SessionId });

使用此类分层分区键,查询可以很容易地只发送到相关的子分区子集。 例如,如果查询特定租户的订单,则这些查询将仅针对该租户的子分区执行。

如果不使用 EF 配置分区键,启动时将记录警告;EF Core 将创建分区键设置为 __partitionKey 的容器,并且在插入项目时不会为其提供任何值。 如果未设置分区键,则容器将限制为 20 GB 的数据,这是单个逻辑分区的最大存储空间。 虽然这适用于小型开发/测试应用程序,但强烈建议不要在没有配置良好的分区键策略的情况下部署生产应用程序。

正确配置分区键属性后,可以在查询中为它们提供值;有关详细信息,请参阅使用分区键进行查询

鉴别器

由于多个实体类型可能映射到同一个容器,EF Core 总是为你保存的所有 JSON 文档添加一个 $type 鉴别器属性(此属性在 EF 9.0 之前称为 Discriminator);这允许 EF 识别从数据库加载的文档,并具体化正确的 .NET 类型。 来自关系数据库的开发人员可能熟悉每个层次结构一个表 (TPH) 上下文中的鉴别器;在 Azure Cosmos DB 中,鉴别器不仅用于继承映射方案,还因为同一容器可以包含完全不同的文档类型。

可以使用标准 EF API 配置鉴别器属性名称和值,有关详细信息,请参阅这些文档。 如果你正在将一个实体类型映射到一个容器,并且确信你永远不会映射另一个实体类型,并且想删除鉴别器属性,请调用 HasNoDiscriminator

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

由于同一容器可以包含不同实体类型,并且 JSON id 属性在容器分区中必须是唯一的,因此同一容器分区中的不同类型实体不能具有相同的 id 值。 将其与关系数据库进行比较,在关系数据库中,每种实体类型都映射到不同的表,因此有自己的单独键空间。 因此,你有责任确保插入到容器中的文档的 id 唯一性。 如果需要使用相同主键值的不同实体类型,可以指示 EF 自动将鉴别器插入 id 属性,如下所示:

modelBuilder.Entity<Session>().HasDiscriminatorInJsonId();

虽然这可能使使用 id 值更容易,但它可能会使与使用你的文档的外部应用程序进行互操作变得更加困难,因为它们现在必须知道 EF 的串联 id 格式,以及默认情况下派生自 .NET 类型的鉴别器值。 请注意,这是 EF 9.0 之前的默认行为。

另一个选项是指示 EF 仅将根鉴别器(层次结构的根实体类型的鉴别器)插入到 id 属性中:

modelBuilder.Entity<Session>().HasRootDiscriminatorInJsonId();

这类似,但允许 EF 在更多方案中使用高效的点读取。 如果需要将鉴别器插入 id 属性,请考虑插入根鉴别器以获得更好的性能。

预配的吞吐量

如果使用 EF Core 创建 Azure Cosmos DB 数据库或容器,可以通过调用 CosmosModelBuilderExtensions.HasAutoscaleThroughputCosmosModelBuilderExtensions.HasManualThroughput 为数据库配置预配的吞吐量。 例如:

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

要为容器配置预配吞吐量,请调用 CosmosEntityTypeBuilderExtensions.HasAutoscaleThroughputCosmosEntityTypeBuilderExtensions.HasManualThroughput。 例如:

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

生存时间

Azure Cosmos DB 模型中的实体类型可以配置默认生存时间。 例如:

modelBuilder.Entity<Hamlet>().HasDefaultTimeToLive(3600);

或者,对于分析存储:

modelBuilder.Entity<Hamlet>().HasAnalyticalStoreTimeToLive(3600);

可以使用映射到 JSON 文档中的“ttl”的属性来设置各个实体的生存时间。 例如:

modelBuilder.Entity<Village>()
    .HasDefaultTimeToLive(3600)
    .Property(e => e.TimeToLive)
    .ToJsonProperty("ttl");

注意

必须在实体类型上配置默认生存时间,才能使“ttl”产生任何影响。 有关详细信息,请参阅 Azure Cosmos DB 中的生存时间 (TTL)

然后,在保存实体之前设置生存时间属性。 例如:

var village = new Village { Id = "DN41", Name = "Healing", TimeToLive = 60 };
context.Add(village);
await context.SaveChangesAsync();

生存时间属性可以是影子属性,以避免因数据库问题而污染域实体。 例如:

modelBuilder.Entity<Hamlet>()
    .HasDefaultTimeToLive(3600)
    .Property<int>("TimeToLive")
    .ToJsonProperty("ttl");

然后,通过访问跟踪的实体来设置影子“生存时间”属性。 例如:

var hamlet = new Hamlet { Id = "DN37", Name = "Irby" };
context.Add(hamlet);
context.Entry(hamlet).Property("TimeToLive").CurrentValue = 60;
await context.SaveChangesAsync();

嵌入的实体

备注

相关实体类型配置为默认拥有。 若要防止特定实体类型出现这种情况,请调用 ModelBuilder.Entity

对于 Azure Cosmos DB,从属实体嵌入到所有者所在的项中。 要更改属性名称,请使用 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");
    });

对于此配置,以上示例中的顺序存储如下:

{
    "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
}

还嵌入了从属实体的集合。 对于下一个示例,我们将使用具有 StreetAddress 集合的 Distributor 类:

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

从属实体不需要提供要存储的显式键值:

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();
}

它们将以这种方式持久保存:

{
    "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
}

在内部而言,EF Core 始终需要对所有被跟踪实体提供唯一键值。 默认情况下,为从属类型集合创建的主键包含指向所有者的外键属性和与 JSON 数组中的索引对应的 int 属性。 要检索这些值,可使用以下条目 API:

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();
}

提示

必要时,可更改从属实体类型的默认主键,但应显式提供键值。

基元类型的集合

将自动发现和映射支持的基元类型(如 stringint)的集合。 支持的集合是所有实现 IReadOnlyList<T>IReadOnlyDictionary<TKey,TValue>的类型。 例如,请考虑以下实体类型:

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; }
}

可以填充 IListIDictionary,并将其持久化到数据库中:

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);
await context.SaveChangesAsync();

这会生成以下 JSON 文档:

{
    "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
}

然后可以再次以正常方式更新这些集合:

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

await context.SaveChangesAsync();

的限制:

  • 仅支持包含字符串键的字典。
  • 在 EF Core 9.0 中添加了对查询基元集合的支持。

使用 eTag 的乐观并发

若要配置实体类型以使用乐观并发,请调用 UseETagConcurrency。 此调用将在阴影状态中设置一个 _etag 属性,并将它设置为并发令牌。

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

为了更轻松地解决并发性错误,可使用 IsETagConcurrency 将 eTag 映射到 CLR 属性。

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