Blazor 服务器上带有 EF Core 的 Azure Cosmos DB

目录

快速入门

介绍行星文档

Azure Cosmos DB设置

实体框架核心

数据服务

加载文档

查询文档

创建文档

更新文档

删除文档

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

处理文件审核

Blazor

JavaScript标题和导航

搜索

书签支持

键盘支持

View

编辑

结论


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

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

  • 它在生产中用于大容量和高速工作负载。例如,Microsoft的一个团队使用它来摄取数据并扇出SQL ServerCosmos DB实例。
  • 开发人员欣赏易于设置……直到他们不喜欢。有一个很好的入门故事,但我们仍然需要努力成长,特别是在改变假设和惯例方面。稍后再谈。
  • 请求最多的EF Core提供程序(尚不存在)是MongoDB这表明有兴趣将EF Core API用于文档数据库。

一段时间以来,我一直要求展示具有更新和查询功能的完整应用程序的外观。最近的Azure Cosmos DB大会让我有机会构建一个。

我很高兴与您分享“Planetary Docs”演示。这包括:

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

Blazor 服务器上带有 EF Core 的 Azure Cosmos DB_第1张图片

它可以在GitHub上找到:

  JeremyLikness/PlanetaryDocs

这篇博文介绍了您需要了解的有关该项目的所有信息!

快速入门

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

  1. 克隆repo
  2. 设置Azure Cosmos DB/或使用模拟器
  3. 克隆ASP.NET Core文档存储库
  4. 使用控制台应用程序安装和播种数据库
  5. 运行并开始使用Blazor服务器应用

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

介绍行星文档

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

它是行星级的,因为Azure Cosmos DB行星级。我也展示了我的一些 Blazor 技能。

该应用程序提供了搜索文档的功能。文档存储在别名和标签下以便快速查找,但也可以使用全文搜索。该应用程序会自动审核文档(它会在文档被编辑时拍摄快照并提供历史视图)。在撰写本文时,尚未实现删除和恢复。我提交了问题 #3以添加删除功能,并提交问题 #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 Tags { get; set; }
        = new List();
    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“just work”)。我也喜欢有一个很好的ToString()重写,所以我的调试视图给了我很好的一目了然的信息。

下面是在调试会话期间查看文档列表在Visual Studio中的样子:

Blazor 服务器上带有 EF Core 的 Azure Cosmos DB_第2张图片

 

为了更快地查找,我创建了一个包含有关文档的一些基本信息的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 Documents { get; set; }
        = new List();
    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.";
}

你马上就会问:“JeremyETag是干什么的?

谢谢提问!为简单起见,我在模型上实现了该属性,使其遵循模型。这用于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快照将是一个适当的属性(是的,我去了那里)而不是字符串。这是EF Core当前具有的EF Core Azure Cosmos DB提供程序限制之一。目前没有办法让Document同时作为独立实体和自有实体执行双重任务。如果我希望用户能够搜索属性的历史文档,我可以将这些属性添加到DocumentAudit类自动索引,或者让DocumentSnapshot类股票相同的属性,但由DocumentAudit配置为拥有的父级。

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

Azure Cosmos DB设置

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

一个名为Documents的容器专用于文档。它们按id进行分区。是的,每个文档有一个分区。我到底为什么要这样做?这是答案。

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

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

这是“cookie”标签的一瞥。

Blazor 服务器上带有 EF Core 的 Azure Cosmos DB_第3张图片

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

实体框架核心

这一切都始于DbContext。这就是您的应用程序如何通知EF Core哪些是重要的跟踪以及您的领域模型如何映射到基础数据存储。行星文档的DbContextPlanetaryDocs.DataAccess项目中被命名DocsContext。对于上下文,我使用我最喜欢的反魔法字符串“trick”来定义分区键字段的名称和将保存元数据的容器的名称。

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

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

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

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

public DbSet Audits { get; set; }
public DbSet Documents { get; set; }
public DbSet Tags { get; set; }
public DbSet Authors { get; set; }

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

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

        throw;
    }
}

关于FindAsync(作为EF Core的一部分提供的基础DbContext上的现有方法)的好处是它不需要关闭类型来指定键。它将它作为object参数并根据模型的内部表示应用它。

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

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

此配置通知EF Core……

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

接下来,我们配置Document

var docModel = modelBuilder.Entity();

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

在这里,我们指定了更多细节。

  • ETag属性应映射为并发。
  • 转换用于序列化和反序列化标签列表。这是EF Core不处理原始类型集合这一事实的解决方法。

TagAuthor结构是相似的。这是Tag的定义:

var tagModel = modelBuilder.Entity();
tagModel.Property(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内核,DocumentSummary不生活在自己的文件中,但应始终包含在父级Tag文档的部分中。

最后一点很重要。在关系数据库中,您可能会规范化表中的摘要并使用关系定义它们。这是文档数据库中的一种反模式,因为与将其包含在文档中相比,额外的查找会增加大量开销。这是一个不能在提供者之间共享或需要一些条件逻辑的DbContext示例。在文档数据库中,所有权应该是隐含的。

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

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

private void DocsContext_SavingChanges(
    object sender,
    SavingChangesEventArgs e)
{
    var entries = ChangeTracker.Entries()
        .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比膨胀DbContext更有意义,因此我实现了DocumentService

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

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

使用此模式,我可以演示断开连接的实体,并在Blazor SignalR电路可能中断的情况下建立一些弹性。

加载文档

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

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

查询文档

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

  • 如果有标签,则加载标签并使用文档摘要列表作为结果集
    • 如果也有作者,则加载作者并过滤结果到标签和作者结果的交集
      • 如果有文本,则加载与文本匹配的文档,然后将结果过滤到作者和标签交集
    • 如果还有文本,则加载与文本匹配的文档,然后将结果过滤到标签结果
  • 否则如果有作者,则加载作者并将结果过滤到文档摘要列表作为结果集
    • 如果有文本,则加载与文本匹配的文档,然后将结果过滤为作者结果
  • 否则加载与文本匹配的文档

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

这是实现。请注意HashSet 只是工作,因为我重写了EqualsGetHashCode

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

    var result = new HashSet();

    bool partialResults = false;

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

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

        IEnumerable resultSet =
            Enumerable.Empty();

        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,该文档可以有关联的标签和作者。由于没有正式关系,这些摘要必须明确更新。

注意:这个例子使用代码来保持文档同步。如果数据库被多个应用程序和服务使用,则在数据库级别实现逻辑并改用触发器和存储过程可能更有意义。

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

  • 如果文档被插入或更新
    • 新文档将导致作者更改添加标签
    • 如果作者被更改或标签被移除
      • 加载旧作者或删除标签的元数据文档
      • 从摘要列表中删除文档
    • 如果作者改了
      • 为新作者加载元数据文档
      • 将文档添加到摘要列表
        • 加载模型的所有标签
        • 更新每个标签的摘要列表中的作者
    • 如果添加了标签
      • 如果标签存在
        • 加载标签的元数据文档
        • 将文档添加到摘要列表
      • 否则
        • 使用摘要列表中的文档创建新标签
    • 如果文档已更新且标题已更改
      • 加载现有作者和/或标签的元数据
      • 更新摘要列表中的标题

该算法是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(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(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(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();
}
Concurrency works in this scenar

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

删除文档

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

public async Task DeleteDocumentAsync(string uid)
{
    using var context = factory.CreateDbContext();
    var docToDelete = await LoadDocumentAsync(uid);
    var author = await context.FindMetaAsync(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);
        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> SearchAuthorsAsync(string searchText)
{
    using var context = factory.CreateDbContext();
    var partitionKey = DocsContext.ComputePartitionKey();
    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> 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 LoadDocumentSnapshotAsync(
    System.Guid guid, 
    string uid)
{
    using var context = factory.CreateDbContext();
    try
    {
        var audit = await context.FindAsync(guid, uid);
        return audit.GetDocumentSnapshot();
    }
    catch (CosmosException ce)
    {
        if (ce.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        throw;
    }
}

最后,虽然您可以删除记录,但审核仍然存在。Web应用程序尚未实现此功能(尽管它应该)但我确实在数据服务中实现了它。这些步骤只是反序列化请求的版本并插入它。

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

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

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元素上的键盘事件:

处理按键操作非常简单:

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

一小段代码可以大有作为!

View

查看页面显示相关文档信息。它还启用HTML预览。如果您尝试将HTML绑定到Blazor中的控件,出于安全原因,默认情况下它将转义HTML。在较早版本的Blazor 中,需要一种涉及使用某些客户端JavaScripttextarea元素的解决方法。幸运的是,Blazor现在具有MarkupString将呈现为原始HTML的类型。

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

编辑

编辑控件使用Blazor的内置EditForm结合自定义验证引擎来呈现表单。我对实现和重构它的计划不满意。尽管它有效,但与使用单个服务来管理多个验证状态相比,跟踪多个验证状态是乏味的。大多数验证都很简单。例外是markdown字段。

我遇到的第一个挑战是场地的大小。每当我尝试编辑它时,我都会遇到连接丢失和超时错误。我尝试调整SignalR的消息大小,但没有奏效。于是,我采用了另一种方法。我无法解释它为什么起作用,因为它应该仍在使用SignalR,但确实如此。

本质上,我没有直接对字段进行数据绑定,而是完全绕过了数据绑定。我创建了一个特殊的MultiLineEdit.razor控件,可以与JavaScript一起手动数据绑定。当字段被初始化时,这个JavaScript被调用来呈现textarea中的文本并在用户键入时监听更改:

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版本的提供程序。您可以通过查看问题列表和投票(单击喜欢标志)对您影响最大的问题来提供帮助。

https://www.codeproject.com/Articles/5308539/Azure-Cosmos-DB-With-EF-Core-on-Blazor-Server

你可能感兴趣的:(ASP.NET,CORE,Blazor,EF,Core,Azure,Cosmos,DB)