65.9K
CodeProject 正在变化。 阅读更多。
Home

在 Blazor Server 上使用 EF Core 的 Azure Cosmos DB

starIconstarIconstarIconstarIconstarIcon

5.00/5 (1投票)

2021 年 7 月 21 日

CPOL

20分钟阅读

viewsIcon

8128

本文将带您了解我的项目 Planetary Docs,这是一个展示完整应用程序的存储库,该应用程序使用 Blazor (Server)、Entity Framework Core 和 Azure Cosmos DB 支持创建、读取、更新和删除操作 (CRUD)。

和许多其他 .NET 开发人员一样,当我在 2020 年初加入 EF Core 团队时,我对 NoSQL “ORM” 持非常怀疑的态度。毕竟,“R” 不代表“关系型”吗?

🤣 老爸(或烂)笑话: 海盗最喜欢的字母是什么?所有人:“RRRrrrrrrrr。”我:“不,是 B the C we love!”

当我开始深入研究细节时,我很快就学会了欣赏它的潜力。我不是想向您推销这个想法,但确实想分享我所学到的。

  • 它已在生产环境中用于大容量和高速工作负载。 例如,Microsoft 的一个团队使用它来摄取数据并“扇出”到 SQL Server 和 Cosmos DB 实例。
  • 开发人员欣赏其易于设置的特点…… 直到他们不再欣赏。入门指南很棒,但我们还有很多工作要做,特别是关于更改假设和约定的方面。稍后会详细介绍。
  • EF Core 最受请求但尚不存在的提供程序是 MongoDB。 这表明人们渴望将 EF Core API 用于文档数据库。

我已经收到了一些请求,希望展示一个包含更新和查询的“完整”应用程序是什么样子。最近的 Azure Cosmos DB 会议给了我构建一个应用程序的机会。我在这里介绍了参考应用程序并进行了高层解释(这篇文章比我的会话详细得多)

 

我很乐意与您分享“Planetary Docs”演示。它包括

  • EF Core 特性
    • 一个 Azure Cosmos DB DbContext
    • 实体关系配置
    • 容器和鉴别器配置
  • Azure Cosmos DB 特性
    • 分区键管理
    • 处理“相关实体”
  • Blazor 特性
    • 键盘输入
    • 书签查询页面
    • 将 Markdown 转换为 HTML
    • 使用一个很酷的“技巧”渲染 HTML
    • 使用另一个“技巧”在 Blazor Server 中处理大字段

它在 GitHub 上可用

 JeremyLikness/PlanetaryDocs

这篇博文将引导您了解有关该项目的所有内容!

快速入门

最好的入门方法是遵循 Planetary Docs 快速入门。步骤包括

  1. 克隆仓库
  2. 设置 Azure Cosmos DB 和/或使用模拟器
  3. 克隆 ASP.NET Core 文档仓库
  4. 使用控制台应用程序安装并填充数据库
  5. 运行并开始使用 Blazor Server 应用程序

如果您不耐烦并已经拉取代码以启动和运行,我理解。但是,如果您不介意,我想对应用程序背后的概念进行温和的介绍。

介绍 Planetary Docs

您可能(或可能不)知道 Microsoft 的 官方文档 完全基于开源运行。它使用 Markdown 和一些元数据增强功能来构建 .NET 开发人员日常使用的交互式文档。Planetary Docs 的假设场景是提供一个基于 Web 的工具来编写文档。它允许设置标题、描述、作者别名、分配标签、编辑 Markdown 和预览 HTML 输出。

它是“行星级的”,因为 Azure Cosmos DB 是“行星级规模”的。我最近还培养了一个新爱好:深空天文摄影。这是一个很好的机会,让我可以随意放入一张 M42(猎户座大星云)的风格化照片。我也展示了一些我的 Blazor 技能。

该应用程序提供了搜索文档的能力。文档以别名和标签存储,以便快速查找,但也提供全文搜索。该应用程序会自动审计文档(它在每次编辑文档时都会创建快照,并提供历史记录视图)。截至撰写本文时,删除和恢复功能尚未实现。我提交了 issue #3 以添加删除功能,并提交了 issue #4 以提供恢复功能,供感兴趣的任何人使用。

这是文档 Document 的外观

public class Document
{
    public string Uid { get; set; }
    public string Title { get; set; }
    public string Description { get; set; }
    public DateTime PublishDate { get; set; }
    public string Markdown { get; set; }
    public string Html { get; set; }
    public string AuthorAlias { get; set; }
    public List<string> Tags { get; set; }
        = new List<string>();
    public string ETag { get; set; }
    public override int GetHashCode() => Uid.GetHashCode();
    public override bool Equals(object obj) =>
        obj is Document document && document.Uid == Uid;
    public override string ToString() =>
        $"Document {Uid} by {AuthorAlias} with {Tags.Count} tags: {Title}.";
}

💡 编码技巧: 我总是为我的领域对象实现一个有意义的哈希码并覆盖 Equals,使其行为符合我的领域对象的意义。这样,列表查找和像 HashSet 这样的有用容器就能“正常工作”。我也喜欢有一个好的 ToString() 覆盖,这样我的调试视图就能提供良好的“一目了然”信息。

这是在 Visual Studio 调试会话期间查看文档列表的样子

为了加快查找速度,我创建了一个包含文档基本信息的 DocumentSummary 类。

public class DocumentSummary
{
    public DocumentSummary()
    {
    }

    public DocumentSummary(Document doc)
    {
        Uid = doc.Uid;
        Title = doc.Title;
        AuthorAlias = doc.AuthorAlias;
    }

    public string Uid { get; set; }
    public string Title { get; set; }
    public string AuthorAlias { get; set; }

    public override int GetHashCode() => Uid.GetHashCode();

    public override bool Equals(object obj) =>
        obj is DocumentSummary ds && ds.Uid == Uid;

    public override string ToString() =>
        $"Summary for {Uid} by {AuthorAlias}: {Title}.";
}

这被 AuthorTag 使用。它们看起来非常相似。这是 Tag 代码

public class Tag : IDocSummaries
{
    public string TagName { get; set; }
    public List<DocumentSummary> Documents { get; set; }
        = new List<DocumentSummary>();
    public string ETag { get; set; }

    public override int GetHashCode() => TagName.GetHashCode();

    public override bool Equals(object obj) =>
        obj is Tag tag && tag.TagName == TagName;

    public override string ToString() =>
        $"Tag {TagName} tagged by {Documents.Count} documents.";
}

你立刻会问:“Jeremy,ETag 是什么?”

谢谢提问!为了简单起见,我在模型上实现了该属性,这样它就会随模型而动。这在 Azure Cosmos DB 中用于 并发。我在示例应用程序中实现了并发支持(尝试在两个选项卡中打开同一个文档,然后更新一个并保存,最后更新另一个并保存)。

因为人们经常在 EF Core 中遇到 断开连接的实体 问题,所以我选择在此应用程序中使用该模式。这在 Blazor Server 中不是必需的,但它使扩展应用程序变得更容易。另一种方法是使用 EF Core 令人难以置信的 更改跟踪器 来跟踪实体的状态。更改跟踪器将使我能够删除 ETag 属性,并改为使用 影子属性

最后是 DocumentAudit 文档。

public class DocumentAudit
{
     public DocumentAudit()
    {
    }

    public DocumentAudit(Document document)
    {
        Id = Guid.NewGuid();
        Uid = document.Uid;
        Document = JsonSerializer.Serialize(document);
        Timestamp = DateTimeOffset.UtcNow;
    }

    public Guid Id { get; set; }
    public string Uid { get; set; }
    public DateTimeOffset Timestamp { get; set; }
    public string Document { get; set; }

    public Document GetDocumentSnapshot() =>
        JsonSerializer.Deserialize<Document>(Document);
}

理想情况下,Document 快照应该是一个适当的属性(是的,我就是这么想的),而不是一个字符串。这是 EF Core 目前存在的一些 EF Core Azure Cosmos DB 提供程序限制 之一。目前还没有一种方法可以让 Document 同时充当独立实体和“拥有”实体。如果我想让用户能够搜索历史文档中的属性,我可以在 DocumentAudit 类中添加这些属性以自动索引,或者创建一个 DocumentSnapshot 类,它共享相同的属性,但被配置为由 DocumentAudit 父级“拥有”。

我的领域已准备就绪。让我们创建将这些文档存储在文档数据库中的策略。

Azure Cosmos DB 设置

我的数据存储策略是使用三个容器。

一个名为 Documents 的容器专门用于存储文档。它们按 ID 分区。是的,每个文档有一个分区。我为什么要这么做?答案在此。

审计记录包含在一个名为 Audits 的容器中(哇,这个命名我们做得真好)。分区键是文档 ID,因此所有历史记录都存储在同一个分区中。对我来说,这似乎是一个合理的策略,因为我只会要求单个文档的历史记录。

最后,有一些元数据存储在 Meta 中(我知道,这很元)。分区键是元数据类型,可以是 AuthorTag。元数据包含相关文档的摘要。如果我想搜索带有标签 x 的文档,我不需要扫描所有文档。相反,我读取标签 x 的文档,它包含一个相关文档的集合,这些文档被标记在其中。这显然意味着要保持摘要是最新的。稍后会详细介绍。

这是“cookie”标签的一瞥。

尽管这是数据库的计划,但我实际上没有在门户中动一根手指来创建任何东西。相反,我在 EF Core 中配置了一个模型,并设置应用程序根据该模型生成文档。

Entity Framework Core

这一切都始于 DbContext。这是您的应用程序告知 EF Core 哪些内容需要跟踪以及您的领域模型如何映射到底层数据存储的方式。Planetary Docs 的 DbContextPlanetaryDocs.DataAccess 项目中名为 DocsContext。对于上下文,我使用我最喜欢的反魔术字符串“技巧”来定义分区键字段的名称和将保存元数据的容器的名称。

public const string PartitionKey = nameof(PartitionKey);
private const string Meta = nameof(Meta);

我定义了一个接受 DbContextOptions<DocsContext> 参数并将其传递给基类的构造函数,以启用运行时配置。

public DocsContext(DbContextOptions<DocsContext> options)
    : base(options) =>
            SavingChanges += DocsContext_SavingChanges;

那是什么?我刚刚挂接了一个事件吗?我做了!稍后会详细介绍。接下来,我使用 DbSet<> 泛型类型来指定应该持久化的类。

public DbSet<DocumentAudit> Audits { get; set; }
public DbSet<Document> Documents { get; set; }
public DbSet<Tag> Tags { get; set; }
public DbSet<Author> Authors { get; set; }

我在 DbContext 上放置了一些辅助方法,以便更轻松地搜索和分配元数据。这两个元数据项都使用基于字符串的键,并将类型指定为分区键。这使得通用的查找记录策略成为可能

public async ValueTask<T> FindMetaAsync<T>(string key)
    where T : class, IDocSummaries
{
    var partitionKey = ComputePartitionKey<T>();
    try
    {
        return await FindAsync<T>(key, partitionKey);
    }
    catch (CosmosException ce)
    {
        if (ce.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        throw;
    }
}

FindAsync(EF Core 中基类 DbContext 上的现有方法)的优点是它不需要关闭类型来指定键。它将其作为 object 参数,并根据模型的内部表示应用它。

“杰里米,你说的这个模型在哪里?”

我们马上就会讲到。等等……好的。

OnModelCreating 重载中,我们配置实体并流畅地断言它们应该如何持久化。这是 DocumentAudit 的第一个配置。

modelBuilder.Entity<DocumentAudit>()
    .HasNoDiscriminator()
    .ToContainer(nameof(Audits))
    .HasPartitionKey(da => da.Uid)
    .HasKey(da => new { da.Id, da.Uid });

此配置通知 EF Core,即…

  • 表中只存储一种类型,因此不需要鉴别器来区分类型。
  • 文档应存储在名为 Audits 的容器中。
  • 分区键是文档 ID。
  • 访问键是审计的唯一标识符与分区键(文档的唯一标识符)的组合。

接下来,我们配置 Document

var docModel = modelBuilder.Entity<Document>();

docModel.ToContainer(nameof(Documents))
    .HasNoDiscriminator()
    .HasKey(d => d.Uid);

docModel.HasPartitionKey(d => d.Uid)
    .Property(p => p.ETag)
    .IsETagConcurrency();

docModel.Property(d => d.Tags)
    .HasConversion(
        t => ToJson(t),
        t => FromJson<List<string>>(t));

这里我们指定了一些更多细节。

  • ETag 属性应映射为并发。
  • 使用转换来序列化和反序列化标签列表。这是 EF Core 不处理 原始类型集合 的一种变通方法。

TagAuthor 配置类似。这是 Tag 的定义

var tagModel = modelBuilder.Entity<Tag>();
tagModel.Property<string>(PartitionKey);
tagModel.HasPartitionKey(PartitionKey);
tagModel.ToContainer(Meta)
    .HasKey(nameof(Tag.TagName), PartitionKey);
tagModel.Property(t => t.ETag)
    .IsETagConcurrency();
tagModel.OwnsMany(t => t.Documents);

几点说明

  • 分区键被配置为影子属性。与 ETag 属性不同,分区键是固定的,因此不必存在于模型上。
  • OwnsMany 用于通知 EF Core DocumentSummary 不存在于其自己的文档中,而应始终作为父 Tag 文档的一部分包含。

最后一点很重要。在关系型数据库中,您可能会将摘要规范化到一个表中,并使用关系定义它们。这在文档数据库中是一种反模式,因为与将其包含在文档中相比,额外的查找会增加显着的开销。这是一个 DbContext 的示例,它要么不能在提供程序之间共享,要么需要一些条件逻辑。在文档数据库中,所有权应该是隐含的。如果您同意,请点赞 此问题 以增加您的投票!

阅读此内容以了解有关 EF Core 中模型的更多信息:创建和配置模型

别担心,我没有忘记 SaveChanges 事件。我用它来在文档插入或更新时自动插入文档快照。每次保存更改并触发事件时,我都会利用 EF Core 强大的 ChangeTracker,要求它给我任何已添加或更新的 Document 实体。然后我为每个实体插入一个审计条目。

private void DocsContext_SavingChanges(
    object sender,
    SavingChangesEventArgs e)
{
    var entries = ChangeTracker.Entries<Document>()
        .Where(
            e => e.State == EntityState.Added ||
            e.State == EntityState.Modified)
        .Select(e => e.Entity)
        .ToList();

    foreach (var docEntry in entries)
    {
        Audits.Add(new DocumentAudit(docEntry));
    }
}

这样做可以确保即使您构建了共享相同 DbContext 的其他应用程序,也会生成审计。

数据服务

我经常被问到开发人员是否应该将存储库模式与 EF Core 一起使用,我的答案总是“视情况而定”。在 DbContext 可测试并可以接口化以进行模拟的范围内,在许多情况下直接使用它是完全可以的。无论您是否专门使用存储库模式,当有 EF Core 功能之外的数据库相关任务要做时,添加数据访问层通常是有意义的。在这种情况下,存在与数据库相关的逻辑,将其隔离比膨胀 DbContext 更合理,所以我实现了一个 DocumentService

该服务使用 DbContext 工厂构建。EF Core 提供此工厂,以便使用您首选的配置轻松创建新上下文。该应用程序使用“每个操作一个上下文”,而不是使用长期存在的上下文和更改跟踪。这是用于获取设置并告知工厂创建连接到 Azure Cosmos DB 的上下文的配置。然后,工厂会自动注入到服务中。

services.Configure<CosmosSettings>(
    Configuration.GetSection(nameof(CosmosSettings)));
services.AddDbContextFactory<DocsContext>(
    (IServiceProvider sp, DbContextOptionsBuilder opts) =>
    {
        var cosmosSettings = sp
            .GetRequiredService<IOptions<CosmosSettings>>()
            .Value;
        opts.UseCosmos(
            cosmosSettings.EndPoint,
            cosmosSettings.AccessKey,
            nameof(DocsContext));
    });
services.AddScoped<IDocumentService, DocumentService>();

使用这种模式,我能够演示分离的实体,并且还为 Blazor SignalR 电路 可能中断的情况构建了一些弹性。

加载文档

文档加载旨在获取一个未跟踪更改的快照,因为这些更改将在单独的操作中发送。主要要求是设置分区键。

private static async Task<Document> LoadDocNoTrackingAsync(
DocsContext context, Document document) =>
    await context.Documents
        .WithPartitionKey(document.Uid)
        .AsNoTracking()
        .SingleOrDefaultAsync(d => d.Uid == document.Uid);

查询文档

文档查询允许用户搜索文档中的任何文本,并进一步按作者和/或标签进行过滤。伪代码如下所示

  • 如果存在标签,则加载标签并使用文档摘要列表作为结果集
    • 如果同时存在作者,则加载作者并过滤结果以获得标签和作者结果的交集
      • 如果存在文本,则加载与文本匹配的文档,然后根据作者和标签交集过滤结果
    • 如果同时存在文本,则加载与文本匹配的文档,然后根据标签结果过滤结果
  • 否则,如果存在作者,则加载作者并过滤结果以获得文档摘要列表作为结果集
    • 如果存在文本,则加载与文本匹配的文档,然后根据作者结果过滤结果
  • 否则加载与文本匹配的文档

就性能而言,基于标签和/或作者的搜索只需要加载一两个文档。文本搜索总是加载匹配的文档,然后根据现有文档进一步过滤列表,因此速度明显较慢(但仍然很快)。

这是实现。请注意,HashSet “正常工作”,因为我重写了 EqualsGetHashCode

public async Task<List<DocumentSummary>> QueryDocumentsAsync(
    string searchText,
    string authorAlias,
    string tag)
{
    using var context = factory.CreateDbContext();

    var result = new HashSet<DocumentSummary>();

    bool partialResults = false;

    if (!string.IsNullOrWhiteSpace(authorAlias))
    {
        partialResults = true;
        var author = await context.FindMetaAsync<Author>(authorAlias);
        foreach (var ds in author.Documents)
        {
            result.Add(ds);
        }
    }

    if (!string.IsNullOrWhiteSpace(tag))
    {
        var tagEntity = await context.FindMetaAsync<Tag>(tag);

        IEnumerable<DocumentSummary> resultSet =
            Enumerable.Empty<DocumentSummary>();

        if (partialResults)
        {
            resultSet = result.Intersect(tagEntity.Documents);
        }
        else
        {
            resultSet = tagEntity.Documents;
        }

        result.Clear();

        foreach (var docSummary in resultSet)
        {
            result.Add(docSummary);
        }

        partialResults = true;
    }

    if (string.IsNullOrWhiteSpace(searchText))
    {
        return result.OrderBy(r => r.Title).ToList();
    }

    if (partialResults && result.Count < 1)
    {
        return result.ToList();
    }

    var documents = await context.Documents.Where(
        d => d.Title.Contains(searchText) ||
        d.Description.Contains(searchText) ||
        d.Markdown.Contains(searchText))
        .ToListAsync();

    if (partialResults)
    {
        var uids = result.Select(ds => ds.Uid).ToList();
        documents = documents.Where(d => uids.Contains(d.Uid))
            .ToList();
    }

    return documents.Select(d => new DocumentSummary(d))
            .OrderBy(ds => ds.Title).ToList();
}

现在我们可以查询文档了,但我们如何**创建**它们呢?

创建文档

通常,使用 EF Core 创建文档就像这样简单

context.Add(document);
await context.SaveChangesAsync();

然而,对于 PlanetaryDocs,文档可以有相关的标签和作者。这些都有必须明确更新的摘要,因为没有正式的关系。

📝 注意: 这个例子使用代码来保持文档同步。如果数据库被多个应用程序和服务使用,那么在数据库层面实现逻辑并使用 触发器和存储过程 可能更合理。

一个通用方法处理保持文档同步。无论是作者还是标签,伪代码都是相同的

  • 如果文档被插入或更新
    • 新文档将导致“作者已更改”和“标签已添加”
    • 如果作者已更改或标签已删除
      • 加载旧作者或已删除标签的元数据文档
      • 从摘要列表中删除文档
    • 如果作者已更改
      • 加载新作者的元数据文档
      • 将文档添加到摘要列表
        • 加载模型的所有标签
        • 更新每个标签摘要列表中的作者
    • 如果标签被添加
      • 如果标签存在
        • 加载标签的元数据文档
        • 将文档添加到摘要列表
      • Else
        • 创建一个新标签,并在摘要列表中包含该文档
    • 如果文档已更新且标题已更改
      • 加载现有作者和/或标签的元数据
      • 更新摘要列表中的标题

这个算法是 EF Core 闪光的一个例子。所有这些操作都可以在一次通过中完成。如果一个标签被多次引用,它只会被加载一次。最终的保存更改调用将提交所有更改,包括插入。

这是作为插入过程一部分调用的处理标签更改的代码

private static async Task HandleTagsAsync(
    DocsContext context,
    Document document,
    bool authorChanged)
{
    var refDoc = await LoadDocNoTrackingAsync(context, document);
   var updatedTitle = refDoc != null && refDoc.Title != document.Title;
    if (refDoc != null)
    {
        var removed = refDoc.Tags.Where(
            t => !document.Tags.Any(dt => dt == t));
        foreach (var removedTag in removed)
        {
            var tag = await context.FindMetaAsync<Tag>(removedTag);
            if (tag != null)
            {
                var docSummary =
                    tag.Documents.FirstOrDefault(
                        d => d.Uid == document.Uid);
                if (docSummary != null)
                {
                    tag.Documents.Remove(docSummary);
                    context.Entry(tag).State = EntityState.Modified;
                }
            }
        }
    }
    var tagsAdded = refDoc == null ?
        document.Tags : document.Tags.Where(
            t => !refDoc.Tags.Any(rt => rt == t));
    if (updatedTitle || authorChanged)
    {
        var tagsToChange = document.Tags.Except(tagsAdded);
        foreach (var tagName in tagsToChange)
        {
            var tag = await context.FindMetaAsync<Tag>(tagName);
            var ds = tag.Documents.SingleOrDefault(ds => ds.Uid == document.Uid);
            if (ds != null)
            {
                ds.Title = document.Title;
                ds.AuthorAlias = document.AuthorAlias;
                context.Entry(tag).State = EntityState.Modified;
            }
        }
    }
    foreach (var tagAdded in tagsAdded)
    {
        var tag = await context.FindMetaAsync<Tag>(tagAdded);
        if (tag == null)
        {
            tag = new Tag { TagName = tagAdded };
            context.SetPartitionKey(tag);
            context.Add(tag);
        }
        else
        {
            context.Entry(tag).State = EntityState.Modified;
        }
        tag.Documents.Add(new DocumentSummary(document));
    }
}

所实现的算法适用于插入、更新和删除。

更新文档

现在元数据同步已经实现,更新代码很简单

public async Task UpdateDocumentAsync(Document document)
{
    using var context = factory.CreateDbContext();
    await HandleMetaAsync(context, document);
    context.Update(document);
    await context.SaveChangesAsync();
}

在这种情况下,并发性有效,因为我们将实体加载的版本持久化在 ETag 属性中。

删除文档

删除代码使用简化的算法来移除现有标签和作者引用。

public async Task DeleteDocumentAsync(string uid)
{
    using var context = factory.CreateDbContext();
    var docToDelete = await LoadDocumentAsync(uid);
    var author = await context.FindMetaAsync<Author>(docToDelete.AuthorAlias);
    var summary = author.Documents.Where(d => d.Uid == uid).FirstOrDefault();
    if (summary != null)
    {
        author.Documents.Remove(summary);
        context.Update(author);
    }

    foreach (var tag in docToDelete.Tags)
    {
        var tagEntity = await context.FindMetaAsync<Tag>(tag);
        var tagSummary = tagEntity.Documents.Where(d => d.Uid == uid).FirstOrDefault();
        if (tagSummary != null)
        {
            tagEntity.Documents.Remove(tagSummary);
            context.Update(tagEntity);
        }
    }

    context.Remove(docToDelete);
    await context.SaveChangesAsync();
}

搜索元数据(标签或作者)

查找与文本字符串匹配的标签或作者是一个简单的查询。关键是通过将其设为单个分区查询来提高性能并降低查询成本(字面上,以美元计)。

public async Task<List<string>> SearchAuthorsAsync(string searchText)
{
    using var context = factory.CreateDbContext();
    var partitionKey = DocsContext.ComputePartitionKey<Author>();
    return (await context.Authors
        .WithPartitionKey(partitionKey)
        .Select(a => a.Alias)
        .ToListAsync())
        .Where(
            a => a.Contains(searchText, System.StringComparison.InvariantCultureIgnoreCase))
        .OrderBy(a => a)
        .ToList();
}

ComputePartitionKey 方法返回简单的类型名称作为分区。作者列表不长,所以我首先拉取别名,然后应用内存中的过滤器进行**包含**逻辑。

处理文档审计

最后一组 API 处理自动生成的审计。此方法加载文档审计,然后将其投影到摘要上。我不在查询中进行投影,因为它需要反序列化快照。相反,我获取审计列表,然后反序列化快照并提取相关数据以显示,例如标题和作者。

public async Task<List<DocumentAuditSummary>> LoadDocumentHistoryAsync
    (string uid)
{
    using var context = factory.CreateDbContext();
    return (await context.Audits
        .WithPartitionKey(uid)
        .Where(da => da.Uid == uid)
        .ToListAsync())
        .Select(da => new DocumentAuditSummary(da))
        .OrderBy(das => das.Timestamp)
        .ToList();
}

ToListAsync 实体化查询结果,之后的所有操作都在内存中进行。

该应用程序还允许您使用与实时文档相同的查看器控件查看审计记录。一个方法加载审计,实体化快照并返回一个 Document 实体供视图使用。

public async Task<Document> LoadDocumentSnapshotAsync(
    System.Guid guid, 
    string uid)
{
    using var context = factory.CreateDbContext();
    try
    {
        var audit = await context.FindAsync<DocumentAudit>(guid, uid);
        return audit.GetDocumentSnapshot();
    }
    catch (CosmosException ce)
    {
        if (ce.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        throw;
    }
}

最后,尽管您可以删除一条记录,但审计记录仍然保留。Web 应用程序尚未实现此功能(尽管应该),但我已在数据服务中实现了它。步骤是简单地反序列化请求的版本并插入它。

public async Task<Document> RestoreDocumentAsync(
    Guid id, 
    string uid)
{
    var snapshot = await LoadDocumentSnapshotAsync(id, uid);
    await InsertDocumentAsync(snapshot);
    return await LoadDocumentAsync(uid);
}

到目前为止,我们已经从数据库通过 EF Core 到应用程序服务逆向工作。接下来,让我们实现 Blazor Server 应用程序!

Blazor

我是 Blazor 的忠实粉丝。我能够使用 C# 代码和逻辑快速构建 Web 应用程序,这比我在 JavaScript 中解决和/或实现要花费更长的时间。对于这篇博文的其余部分,我假设您熟悉 Blazor 基础知识,并将重点关注应用程序的具体实现细节。

JavaScript 标题和导航

让我们把 JavaScript(大部分)搞定。我知道宣传手册上说**没有 JavaScript**,但实际上它很有用。例如,这段代码获取 title 标签的引用并更新浏览器标题以提供上下文

window.titleService = {
    titleRef: null,
    setTitle: (title) => {
        var _self = window.titleService;
        if (_self.titleRef == null) {
            _self.titleRef = document.getElementsByTagName("title")[0];
        }
        setTimeout(() => _self.titleRef.innerText = title, 0);
    }
}

当然,我不会让你直接调用这个。相反,我把它封装在一个服务中。该服务会在导航后自动刷新标题,并提供一个方法来动态设置它

public class TitleService
{
    private const string DefaultTitle = "Planetary Docs";
    private readonly NavigationManager navigationManager;
    private readonly IJSRuntime jsRuntime;

    public TitleService(
        NavigationManager manager,
        IJSRuntime jsRuntime)
    {
        navigationManager = manager;
        navigationManager.LocationChanged += async (o, e) =>
            await SetTitleAsync(DefaultTitle);
        this.jsRuntime = jsRuntime;
    }

    public string Title { get; set; }

    public async Task SetTitleAsync(string title)
    {
        Title = title;
        await jsRuntime.InvokeVoidAsync("titleService.setTitle", title);
    }
}

从代码设置标题就像这样简单

await TitleService.SetTitleAsync($"Editing '{Uid}'");

我还想提供一个自然的“取消”功能,可以返回到上一页,而无需保留自己的访问页面日志/列表。事实证明,JavaScript History API 非常适合这一点。服务的一个包装器看起来像这样

public HistoryService(IJSRuntime jsRuntime)
{
    goBack = () => jsRuntime.InvokeVoidAsync(
        "history.go", 
        "-1");
}

public ValueTask GoBackAsync() => goBack();

用户看到的第一个页面是搜索页面。

搜索是一个常见的功能。我经常在拥有完美搜索结果却无法分享链接时感到沮丧,因为 URL 强制用户重新输入搜索参数。我设计了该应用程序,使其易于书签,因此您可以导航到几乎任何页面。NavigationHelper 服务使在应用程序中生成链接变得容易。例如,编辑链接像这样公开

public static string EditDocument(string uid) =>
    $"/Edit/{Web.UrlEncode(uid)}";

这使得在应用程序的任何地方引用编辑导航变得容易,并提供了一个地方,如果我需要重构,可以更新它。

书签支持

该助手还提供了一项服务,用于读取和写入查询字符串参数。每当搜索参数更新时,查询字符串都会重新生成,并且应用程序会调用导航

var queryString =
    NavigationHelper.CreateQueryString(
        (nameof(Text), WebUtility.UrlEncode(Text)),
        (nameof(Alias), WebUtility.UrlEncode(Alias)),
        (nameof(Tag), WebUtility.UrlEncode(Tag)));

navigatingToThisPage = false;
NavigationService.NavigateTo($"/?{queryString}");

这不会强制重新加载,因此导航标志设置为 false,表示该事件旨在更新浏览器 URL。这允许您将搜索书签。当您导航到完整 URL 时,参数将被解析并调用搜索。

var queryValues = NavigationHelper.GetQueryString(
    NavigationService.Uri);

var hasSearch = false;

foreach (var key in queryValues.Keys)
{
    switch (key)
    {
        case nameof(Text):
            Text = queryValues[key];
            hasSearch = true;
            break;
        case nameof(Alias):
            Alias = queryValues[key];
            hasSearch = true;
            break;
        case nameof(Tag):
            Tag = queryValues[key];
            hasSearch = true;
            break;
    }
}

navigatingToThisPage = false;
if (hasSearch)
{
    InvokeAsync(async () => await SearchAsync());
}

键盘支持

键盘支持不仅仅是方便项。它对可访问性很重要,因为并非所有人都能使用鼠标,而对于那些无法使用键盘的人,语音软件通常会模仿键盘手势。我为此实现了一些特定功能。第一个是更新 tabindex 属性,以便在字段之间进行自然导航。当我创建一个包装 HTML 表单元素的自定义控件时,我也会在其中公开一个名为 TabIndex 的参数。

我预计用户最需要的输入元素是文本搜索。我用 autofocus 装饰它,以便在表单加载时自动将焦点放在那里。我还使用 @ref="InputElement" 将 HTML 元素绑定到将其定义为

public ElementReference InputElement { get; set; }

搜索完成后,我使用新的 Blazor 功能设置焦点

await InputElement.FocusAsync();

因此用户只需键入并按 ENTER 即可优化搜索。自动提交功能使得只需在任何地方按 ENTER 键即可轻松提交表单。我挂接到父 HTML 元素上的键盘事件

<div @onkeypress="HandleKeyPress">

处理按键就像这样简单

protected void HandleKeyPress(KeyboardEventArgs key)
{
    if (key.Key == KeyNames.Enter)
    {
        InvokeAsync(SearchAsync);
    }
}

标签和作者都具有自动完成功能,因此我创建了一个通用的 AutoComplete.razor 控件。该控件还通过挂钩 HandleKeyDown 事件来处理键盘。该代码跟踪可能值的索引并突出显示当前选定的项目。按向上或向下箭头会相应地增加或减少索引。

protected void HandleKeyDown(KeyboardEventArgs e)
{
    var maxIndex = Values != null ?
        Values.Count - 1 : -1;

    switch (e.Key)
    {
        case KeyNames.ArrowDown:
            if (index < maxIndex)
            {
                index++;
            }

            break;

        case KeyNames.ArrowUp:
            if (index > 0)
            {
                index--;
            }

            break;

        case KeyNames.Enter:
            if (Selected)
            {
                InvokeAsync(
                    async () =>
                    await SetSelectionAsync(string.Empty, true));
            }
            else if (index >= 0)
            {
                InvokeAsync(
                    async () =>
                    await SetSelectionAsync(Values[index]));
            }

            break;
    }
}

一点点代码就能发挥大作用!

视图

视图页面显示相关的文档信息。它还支持 HTML 预览。如果您尝试将 HTML 绑定到 Blazor 中的控件,出于安全原因,它默认会转义 HTML。在 Blazor 的早期版本中,需要一个变通方法,涉及使用一些客户端 JavaScript 和一个 textarea 元素。幸运的是,Blazor 现在具有 MarkupString 类型,它将渲染为原始 HTML。

我实现了一个 HtmlPreview.razor 控件来简化将 HTML 文本转换为 MarkupString 的过程。该组件相当简单,所以在这里我不会花太多时间。让我们编辑我们的文档!

编辑

编辑控件使用 Blazor 内置的 EditForm 和自定义验证引擎来呈现表单。我对当前的实现不满意,并计划进行重构。虽然它有效,但跟踪多个验证状态很繁琐,而有一个单一的服务来管理它会简单得多。大多数验证都很直接。例外是 Markdown 字段。

我遇到的第一个挑战是字段的大小。每次我尝试编辑它时,都会出现“连接丢失”和超时错误。我尝试调整 SignalR 的消息大小,但没有奏效。所以,我采用了另一种方法。我无法解释**为什么**它有效,因为它应该仍然使用 SignalR,但它确实有效。

本质上,我不是直接数据绑定字段,而是完全绕过数据绑定。我创建了一个特殊的 MultiLineEdit.razor 控件,它与 JavaScript 协作手动数据绑定。当字段初始化时,会调用此 JavaScript 来渲染文本区域中的文本并监听用户输入时的更改

target.value = txt;
target.oninput = () => window.markdownExtensions.getText(id, target);

MultiLineEditService 生成一个唯一的 ID 来跟踪会话,并调用 JavaScript 传递初始字段值。

var id = Guid.NewGuid().ToString();
await jsRuntime.InvokeVoidAsync(
    "markdownExtensions.setText",
    id,
    text,
    component.TextArea);
components.Add(id, component);
Services.Add(id, this);
return id;

当用户输入时,JavaScript 监听器调用服务

getText: (id, target) => DotNet.invokeMethodAsync(
    'PlanetaryDocs',
    'UpdateTextAsync',
    id,
    target.value)

该服务将方法公开为 JsInvokable 并将更改路由到相应的控件。

[JSInvokable]
public static async Task UpdateTextAsync(string id, string text)
{
    var service = Services[id];
    var component = service.components[id];
    await component.OnUpdateTextAsync(text);
}

当字段更改时,编辑器会将其标记为无效,等待用户预览。链接按钮允许用户生成预览,这将清除验证错误。

我很想知道为什么这种方法有效,而不是直接数据绑定。有什么想法吗?

结论

我分享这些的希望是为使用 EF Core Azure Cosmos DB 提供程序提供一些指导,并展示它闪光的地方。还有工作要做,但好消息是我们在 EF Core 6.0 版本中优先更新了提供程序。您可以通过查看 问题列表 并投票(单击“赞”标志)对您影响最大的问题来提供帮助。

这个参考项目还有很多工作要做。如果您正在寻找为 开源做贡献 的机会,请考虑抓取一个 开放问题 或提出 您自己的问题。我随时乐意提供帮助!

© . All rights reserved.