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

在 ASP.NET Core 中编写博客引擎

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (31投票s)

2017 年 3 月 24 日

MIT

32分钟阅读

viewsIcon

74862

downloadIcon

230

在 ASP.NET Core 中使用 Entity Framework Core 编写简单博客引擎的思考

引言

这是我在 CodeProject 上的第一篇文章。我的背景是网络/系统工程和软件开发的结合。我还在初中时第一次学习 QBasic 编程,从那时起,我用 JavaScript、C 和 C# 等语言编写了一些琐碎和一些不那么琐碎的程序。我个人觉得编程很有意义,喜欢解决技术问题。不幸的是,有时我觉得我读到的编程知识比我实际编程的要多,所以我决定稍微扭转一下这种趋势,最终编写了一个博客引擎,这样我就可以写关于编程的文章了。

大约两三个月前,我开始编写我的博客引擎。在此过程中,它发展成一个相当庞大的项目,我得出的结论是,与社区分享它可能会有所帮助。它远非完美,但它有一些不错的功能,例如能够对博客文章进行分类和功能性搜索能力,并且它已经达到了“足够好”的程度,可以分享了。

本文是对我使用 C# 和 ASP.NET Core 编写博客引擎的经验的回顾。我将回顾一些设计考虑因素,这些考虑因素影响了我选择的技术、它的运行方式、我犯了一些错误的地方、我计划更改的内容以及我在此过程中学到的东西。

我并不认为自己是 ASP.NET Core 的专家,尽管完成这个项目后,我确实学到了一些。我希望通过阅读本文,您可以看到我选择这项技术时遇到的一些问题,并看到一些设计决策的影响。

我把博客引擎命名为 Bagombo,因为我很久以前就注册了域名 bagombo.org,而且我读过《Bagombo Snuff Box》。在软件开发领域工作了一段时间后,命名事物有时似乎和编写代码一样困难,能够随便取一个名字而无需咨询太多人是件好事。 :)

Bagombo 现在的状态还不是一个真正的完成项目,就像任何软件一样。但它已经到了我开始使用它的程度,我认为发布源代码并将其发布出去可能会帮助那些刚开始使用 ASP.NET Core 和 Entity Framework Core 的人开始编写他们自己的博客引擎。我根据 MIT 许可证发布了它,所以人们可以随意使用它。

以下是博客引擎的一些功能:

  • 支持按特性或类别对帖子进行分类
  • 支持多作者
  • 本地或 Twitter 身份验证
  • 博客文章全文搜索
  • 内置编辑器,用于以 Markdown 格式编辑帖子并预览

我将讨论这些的设计和实现以及我在此过程中学到的东西。我将重点关注后端 ASP.NET Core 和 EF Core 部分的代码,除了我使用了基本的 HTML、Bootstrap 和一些 JavaScript 之外,不会涉及任何前端设计。它不依赖于任何花哨的 JavaScript 库,如 Angular 或 Backbone,它只使用一点 jQuery 和 highlight.js 库进行语法高亮。

首先,我将回顾我是如何最终确定我的技术栈的。

技术栈

我决定用 ASP.NET Core 编写我的博客引擎,因为当我听说它时,我非常兴奋。.NET Core 在 Mac 上运行,我不需要使用 Mono 就能让它工作。它直接来自微软,加上漂亮的新 Visual Studio Code 编辑器,我觉得它很棒,而且我在工作中也做一些 C# 编程,所以我认为我应该抓住这个机会,使用最新最好的技术来编写我的博客引擎,并提高我的技能。

以下是我最初选择用 ASP.NET Core 而不是 Node.js 等编写博客的一些原因和想法。

  • 首先,我喜欢 C#。
  • 其次,ASP.NET Core MVC 以开源形式发布,因此可以深入研究源代码,并根据我的时间和意愿深入研究。我认为这是一个很大的吸引力。
  • 第三,最初我计划使用 Dapper 作为我的 ORM 和 MySQL,这样我就可以在我的 Mac 上进行所有开发,然后在投入生产时,我可以使用 Linux 服务器。然而,这一切在稍后发生了一些变化。
  • 第四,我有没有提到我认为我可以用我的 Mac 来完成所有开发?

因此,我根据对软件的了解,最初的设计思路如下。我将在 Mac 上完成所有开发,然后在我想投入生产时将其移动到 Ubuntu 或 CentOS Linux 服务器。我甚至考虑过 Docker,但还没有做到那一步。我可以在 Mac 上使用 Homebrew 运行 MySQL,当需要迁移时,在 Linux 上运行它会很容易。

我喜欢 MySQL,因为它是我很久以前用过的一些项目,我也喜欢开源软件,尤其是当它是免费且运行良好时。我工作中也使用 Oracle 11g,我不得不说它也运行得很好,既然这是一个教育项目,我想使用免费的 Oracle XE,但当时 Oracle 甚至还没有宣布他们是否会支持 .NET Core。据我所知,现在情况已经改变,据我所读,他们将支持 .NET Core。但我喜欢 MySQL,这只是一个个人项目,所以我决定使用它。据我所知,SQL Server 还没有在 Linux 上发布……也许有测试版或类似的东西,但我可以在我的 Mac 上运行 MySQL!!

所以我已经准备好了,ASP.NET Core,Dapper,MySQL,NGINX 作为反向代理,Linux 作为服务器,在 AWS 上运行。我出发了……

然后我遇到了第一个“陷阱”。我的博客需要身份验证。现在,这可能是我从一开始就走对或走错方向的地方。身份验证是那种我倾向于选择安全路线的事情,而且由于我是 ASP.NET Core 的新手,我不太敢自己实现身份验证,因为我真的不知道我在做什么。我的意思是,最糟糕的情况是有人入侵我的个人博客并摧毁它,我必须从备份中恢复它,这并不是世界末日。但这毕竟是一个学习练习。

于是我开始查阅 Microsoft 关于 ASP.NET Core 的文档,虽然我觉得大部分都很好,但我发现关于身份验证的部分有所欠缺。但它谈到了使用 Identity,我认为他们称之为“成员资格系统”,它允许您使用本地身份验证,并且可以相当容易地插入和使用 Twitter、Facebook、OAuth2 等进行身份验证。我有一个 Twitter 帐户,我用来关注新闻 @itcheeze,我想设置 Twitter 身份验证会很好。我还计划为博客实现评论功能,如果我有读者,让他们注册并记住另一个密码是我不想做的事情。我还有 Adam Freeman 的《Pro ASP.NET Core MVC》一书,大约有 1000 页长,其中有三章专门讲 Identity。

所以,我想,使用 Identity 似乎是个好主意。总的来说,我认为这仍然是正确的选择。最终,我能够将其插入并使其正常工作,没有遇到太多麻烦。我甚至让 Twitter 身份验证正常工作,没有花费太多精力,这很好。我还没有插入任何其他外部身份验证,但根据我使用 Twitter 的经验,我认为让它们正常工作不会太难。

但这确实给我的最初计划带来了第一个麻烦。Identity,对于任何想要开箱即用并直接使用它的人来说,都依赖于 Entity Framework Core,或 EF Core。哎呀。我没打算使用 EF Core。我的那本 1000 页的《Pro ASP.NET Core》书中,恰好没有一章专门介绍 EF Core。我不想在我的博客中使用两个 ORM 库,而且我之前已经用 Dapper 和 MySQL 进行了小规模的概念验证,并且对这种方法很满意。现在我必须处理 EF Core。尽管还有另一个选择,Identity 是开源的!所以我花了大约 3-4 个小时深入研究 Identity 的源代码。我得出的结论是,可以实现一些关键的用户存储和角色存储接口,从而有效地将 EF Core 排除在外。

我认真考虑了一下。最终,我认为要做到这一点,我必须比我想象的更深入地掌握 Identity。而且,查看那些实现了这些关键接口并使用 EF Core 的类,我决定很难拆解它所做的一切,因为我完全不懂 EF Core。该怎么办?这确实是我设计中所有我选择的技术开始稍微改变方向的地方。我当时并不知道我的基于 Mac 的开发计划将会改变。不过,这并非全是坏事。我喜欢我的 2014 年款 Macbook Pro。它是一台很棒的电脑……正如我目前正在 VMware Fusion 上运行 Windows 编写这篇文章一样……

我将在设计部分更深入地探讨我的经验和博客模型/数据层的设计。我想继续探讨我最终如何采用与最初设想截然不同的技术栈。诚然,影响这些变化的一些因素现在可能已经改变了,但我正在取得进展,我想要我的博客软件。Bagombo 不会自己编写。

所以,要使用 Identity,我必须使用 EF Core(据我所知,这是唯一的方法,除非我实现它所需的后台存储。我花了很多时间在 Google 上搜索,试图找到有人这样做过,但没有找到,我正在考虑有一天为某个项目这样做。)

好吧,所以我的项目有了一个新的依赖。此时,我对 EF Core 做了一些研究,并开始接受这个想法。我找不到大量的在线文档,而且我仍然不声称我理解它所做的一切以及它是如何工作的。此外,使用它所谓的“Code First”方法来建模您的数据,它会根据您的代码神奇地创建数据库和您需要的所有模式。好吧,它并不完全是魔法,事实证明,作为一个新软件,并不是每个数据库提供商都有一个完成的 EF Core 提供商。我尝试了官方的 MySQL 提供商,以及一家名为 Sapient 的公司(我想),花了一段时间与 Nuget 包作斗争,并尝试使用不同的版本来尝试让 EF Core 发挥其魔力并创建我的数据库和表。我没有得到任何喜爱。我用 SQLite 试了一下,它运行得非常漂亮。嗯,至少在那时我知道这不是我的代码或我输入的东西的问题。我将坚持使用它,我读到有人在互联网上某个地方让它与 Sapient 合作,但我不能。

这是我的第一个障碍。说实话,我可能可以在我的项目中使用 SQLite,而且会一切顺利。但我其实对 SQLite 不太了解,我真的不想使用它。我的意思是,总有一天我的博客会有很多读者,我会靠它赚很多钱等等,然后我该怎么办?我认真考虑过,因为我有点懒惰。但这与我想做的事情不太符合。嗯……所以这意味着……SQL Server。这意味着……Windows。嗯,并非一切都失去了。我仍然可以在我的 Mac 上进行所有开发,只需将我的数据库连接指向 Windows 虚拟机,或者花时间运行 Docker 或类似的东西,一切都会好起来的。我现在已经到了对它在生产环境中运行什么不太挑剔的程度,只要它运行良好。

但随后我遇到了第二个问题,其实不算大,但如果与第一个问题结合起来,那就……

Visual Studio Code (我仍然喜欢并用于 Go 编程和 PowerShell 脚本) 没有 Razor Pages 的智能感知功能!!!!!

所以我尝试了 Visual Studio 2015,当然,它在那里运行得很好。我很高兴我做了这个切换,因为我把它看作是开发的一个要求。如果 MySQL 与 EF Core 配合得更好一点,我可能会坚持在 Mac 上开发。但我想我只是试一试,结果最终在那里继续我的开发。当我项目进行到一半时,Visual Studio 2017 刚刚发布,所以我甚至冒险将我的 package.json 项目升级到基于新 csproj 的项目,并用它完成了我的编码。我发现它是一个很好的开发环境,实际上我很喜欢用它进行编程。不过我非常喜欢 Visual Studio Code,并且发现我仍然有它的空间。

所以,最初的计划:ASP.NET Core,Dapper,MySQL,NGINX 作为反向代理,Linux 作为服务器,在 AWS 上运行。在我的 Mac 上开发。实际计划:ASP.NET Core,EF Core,SQL Server 2016 Express,IIS 作为反向代理,Windows Server 2016,在 AWS 上运行。在 Windows 上开发。

设计

我发现此时,后续的大部分设计主要是由我的第一个选择——使用 ASP.NET Core MVC 驱动的。如果你打算使用它,我认为遵循其约定是有意义的。否则,你将事倍功半,而且可能不会运行得那么好。我以前从未使用过它,我不会逐行审查我编写的代码或类似的东西。老实说,我不得不阅读一整本书并大量搜索互联网才能让它工作,而且需要一整本书才能描述所有工作原理。

不过,关于它,我会说。我非常了解 C#,但 ASP.NET Core 中的其他一切对我来说都是全新的。我很快就能掌握新技术,虽然这只是一个基本的博客引擎,但它花了 2-3 个月的兼职工作才启动。我现在开玩笑地称它为 0.2 Alpha 版本。

我写这篇文章是为了回顾我编写 Bagombo 的经验,但我也想介绍一些代码以及我在此过程中发现的东西。

ASP.NET Core MVC 之所以带有 MVC,是有原因的。它代表模型-视图-控制器,是它用于操作的编码设计范例。它不会阻止你直接在 Razor 页面中编写大量可能不应该编写的代码,或者在控制器中格式化大量原始 HTML 并将其转储到浏览器中。我不想自称专家,但我想如果你使用 MVC 然后做这种事情,你会显得很傻。实际上,我想,你就没有在使用 MVC。

因此,Bagombo 的代码被分解成尽可能遵循 MVC 约定的方式。实现任何功能的核心代码都在控制器中。目前源代码中有五个控制器,其中一个 BlogApiController,我计划删除,因为我在早期基本上停止了任何类似 SPA 的操作。它现在所做的只是有一个删除博客文章的方法。现在我的设置有点傻,但它响应页面表单的帖子以删除博客文章,然后重定向回该页面。它有效,但我需要将该代码移动到 Admin Controller 中,这会更有意义,因为该调用是从 Admin Manage Posts 页面发出的。我实际上不打算过多地谈论如何使用博客引擎,因为如果你尝试一下,我认为很容易弄清楚。但作为旁注,我规定只有管理员才能删除帖子,因为我没有编写“您确定要删除此帖子吗?”的确认,而且我不想意外删除帖子。

因此,Bagombo 中的控制器主要负责从数据库获取数据,将其放入 ViewModel POCO 中,然后将其返回给正确的视图。应用程序的大部分逻辑都发生在控制器中。我早期做出的一项设计考虑是,不使用像 Repository 模式或 CQRS 这样的模式。因此,我目前对 EF Core 有很强的依赖性,并且所有查询都编码在控制器的操作方法中。对于这样的项目,我这样做并没有什么问题,但我正在考虑在将其视为 1.0 版本发布之前,我将采用 Repository 或 CQRS 这样的模式,并将这些代码分解出来,以便我减少对 EF Core 的依赖。当时我没有考虑(我确实是临时搞出来的),而且无论如何,使用 Identity,我对 EF Core 有很强的依赖性,除非我真的雄心勃勃地实现所需的接口,以便我可以在不使用 EF Core 的情况下使用 Identity。

我的模型真的很简单。我将代表 Identity 框架中用户的 `IdentityUser` 类扩展为 `ApplicationUser` 类,并添加了与 `Author` 类的一对一关系。我有博客文章、类别和功能的类。Bagombo 中的功能是组织博客文章的另一种方式。我见过一些博客,我发现很难找到较旧的博客或仅按类别排序。对于 Bagombo,我想到功能,它们类似于章节或主题,作为作者组织其文章的另一种方式。博客文章和类别之间,以及博客文章和功能之间存在多对多关系。如果你查看 Models 文件夹下这些类的代码,你会看到它们在 C# 代码中的表示方式。还有一些是链接表的类,`BlogpostCategory` 和 `BlogpostFeature`。EF Core 需要这些来建立多对多关系,但根据我的理解,在未来的软件版本中,它将支持这种关系而无需创建这些类来表示链接表。

下面是 Bagombo 数据模型的图片

从这个到使用 Code First,对于 EF Core 来说,是一个很短的步骤。我还有另一个类 `BlogDbContext`,在 Data 文件夹下,它代表数据库上下文。它有类型为 `DbSet` 的成员变量,这些成员变量代表数据库中的表。在 Bagombo 的案例中,`BlogDbContext` 继承自 `IdentityDbContext`,因为它使用了 `Identity`。这就是 `Identity` 在您的数据库中创建的其他一些表被隐藏起来的地方,但如果您好奇,可以在线找到代码。就设置 `Identity` 的数据库而言,这实际上是所需的一切。

在 `BlogDbContext` 中,有一个调用其基构造函数的构造函数。然后是另一个函数 `OnModelCreating`,它允许您使用 EF Core 的 Fluent API 来配置表在数据库中的设置方式和某些属性。我用它来设置一些东西,比如表之间的关系,以及删除条目时要采取的操作。我发现 API 非常可读,如果您了解数据库设计的基础知识,您可能会看到它是如何协同工作的。

下面是我的 `BlogDbContext` 类的代码。在 `OnModelCreating` 方法中,使用 EF Core Fluent API 描述了类之间的关系。您还可以看到它继承自 `IdentityDbContext`,这也带来了 `Identity` 的表。我有两个 `static` 方法,`CreateAuthorRole` 和 `CreateAdminAccount`。这些方法从 `Startup` 类的 `Configure` 方法中调用,并使用 appsettings.json 中指定的管理员帐户播种数据库,如果数据库中尚不存在,则创建作者角色。如果您忘记了最初指定的密码,这也是创建另一个管理员帐户的便捷方式。您还可以在 `OnModelCreating` 中看到我如何指定删除作者记录时的操作。在这里,我将级联行为设置为 `SetNull`,这样在删除作者时,任何现有的博客文章都不会被删除。您还可以看到如何使用链接表配置博客文章与类别和功能之间的多对多关系。

// BlogDbContext.cs  -- under the Data Folder in the solution
namespace blog.Data
{
  public class BlogDbContext : IdentityDbContext<ApplicationUser>
  {
    public DbSet<BlogPost> BlogPosts { get; set; }
    public DbSet<Author> Authors { get; set; }
    public DbSet<Feature> Features { get; set; }
    public DbSet<BlogPostFeature> BlogPostFeature { get; set; }
    public DbSet<BlogPostCategory> BlogPostCategory { get; set; }
    public DbSet<Category> Categories { get; set; }

    public BlogDbContext(DbContextOptions<BlogDbContext> options) : base(options)
    {
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
      base.OnModelCreating(builder);
      // Customize the ASP.NET Identity model and override the defaults if needed.
      // For example, you can rename the ASP.NET Identity table names and more.
      // Add your customizations after calling base.OnModelCreating(builder);
      builder.Entity<Author>().ToTable("Author");
      builder.Entity<Author>().HasAlternateKey(e => new { e.FirstName, e.LastName });
      builder.Entity<Author>().HasOne(e => e.ApplicationUser)
                              .WithOne(au => au.Author)
                              .OnDelete(Microsoft.EntityFrameworkCore.
                                        Metadata.DeleteBehavior.SetNull);
      //.OnDelete(Microsoft.EntityFrameworkCore.Metadata.DeleteBehavior.);

      builder.Entity<ApplicationUser>().HasOne(e => e.Author)
                                       .WithOne(a => a.ApplicationUser)
                                       .HasForeignKey<Author>(a => a.ApplicationUserId);

      builder.Entity<Author>().HasIndex(a => a.ApplicationUserId)
                              .IsUnique(false);

      builder.Entity<Author>().HasMany(a => a.BlogPosts)
                              .WithOne(bp => bp.Author)
                              .IsRequired(false)
                              .OnDelete(Microsoft.EntityFrameworkCore.Metadata.
                                        DeleteBehavior.SetNull);

      builder.Entity<BlogPost>().ToTable("BlogPost");

      builder.Entity<BlogPost>().HasOne(bp => bp.Author)
                                .WithMany(a => a.BlogPosts)
                                .HasForeignKey("AuthorId")
                                .IsRequired(false);
                                
      builder.Entity<Feature>().ToTable("Feature");
      builder.Entity<Category>().ToTable("Category");

      builder.Entity<BlogPostFeature>().HasKey(bpf => new { bpf.FeatureId, bpf.BlogPostId });
      builder.Entity<BlogPostFeature>().HasOne(bpf => bpf.BlogPost)
                                       .WithMany(bp => bp.Features)
                                       .HasForeignKey(bpf => bpf.BlogPostId);
      builder.Entity<BlogPostFeature>().HasOne(bpf => bpf.Feature)
                                       .WithMany(f => f.BlogPosts)
                                       .HasForeignKey(bpf => bpf.FeatureId);

      builder.Entity<BlogPostCategory>().HasKey(bpc => new { bpc.BlogPostId, bpc.CategoryId });
      builder.Entity<BlogPostCategory>().HasOne(bpc => bpc.BlogPost)
                                        .WithMany(bp => bp.Categories)
                                        .HasForeignKey(bpc => bpc.BlogPostId);
      builder.Entity<BlogPostCategory>().HasOne(bpc => bpc.Category)
                                        .WithMany(c => c.BlogPosts)
                                        .HasForeignKey(bpc => bpc.CategoryId);
    }

    public static async Task CreateAuthorRole(IServiceProvider serviceProvider)
    {
      RoleManager<IdentityRole> roleManager = 
                  serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();

      if (await roleManager.FindByNameAsync("Authors") == null)
      {
        IdentityResult result = await roleManager.CreateAsync(new IdentityRole("Authors"));
        if (!result.Succeeded)
        {
          throw new Exception("Error creating authors role!");
        }
      }
    }
    public static async Task CreateAdminAccount
           (IServiceProvider serviceProvider, IConfiguration configuration)
    {
      UserManager<ApplicationUser> userManager = 
          serviceProvider.GetRequiredService<UserManager<ApplicationUser>>();
      RoleManager<IdentityRole> roleManager = 
          serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();

      string userName = configuration["Data:AdminUser:Name"];
      string email = configuration["Data:AdminUser:Email"];
      string password = configuration["Data:Adminuser:Password"];
      string role = configuration["Data:AdminUser:Role"];

      if (await userManager.FindByNameAsync(userName) == null)
      {
        if (await roleManager.FindByNameAsync(role) == null)
        {
          await roleManager.CreateAsync(new IdentityRole(role));
        }
        ApplicationUser user = new ApplicationUser
        {
          UserName = userName,
          Email = email
        };
        IdentityResult result = await userManager.CreateAsync(user, password);
        if (result.Succeeded)
        {
          await userManager.AddToRoleAsync(user, role);
        }
      }
    }
  }
}

然而,这是我在使用 EF Core 时遇到麻烦的一个地方。那就是多对多关系。问题不在于使用 Fluent API 定义关系。EF Core 网站上关于此的文档非常直接。问题是后来让查询流畅地工作,就像网上代码所说的那样。很有可能我的代码中存在一个错误,这就是它没有像应有的那样流畅地工作的原因。但是在一个我使用多对多关系的情况下,例如查找分配给特定博客文章的类别,当包含类别时,我收到 `null` 值。我能够通过进行两次查询并手动执行来解决这个问题。所以我可能做错了什么,但如果你正在使用 EF Core 并且在多对多查询方面遇到问题,你可能需要查看我在 HomeController.cs 中名为 `FeaturePosts` 的方法中的代码,如下所示。有一行被注释掉了:

    //var categories = p.Categories.Select(c => c.Category).ToList();

这是大多数参考代码建议我应该能够通过链接表检索数据的方式,但我发现实际上我必须进行几次查询,因为这返回了 `null` 值。诚然,很难总是在 Stack Exchange 或其他人的博客上找到你试图进行的查询的精确副本,所以很可能我只是需要稍微调整一下才能使其正常工作。但这是我目前发现的。我将 `FeaturePosts` 的代码放在下面,这样你就可以看到我在说什么,以及我是如何最终编写查询来通过链接表访问类别的。

// FeaturePosts -- from HomeController.cs
public async Task<IActionResult> FeaturePosts(long id)
{
    var feature = await _context.Features.FindAsync(id);

    var bps = await _context.BlogPostFeature
                            .Where(bpf => bpf.FeatureId == feature.Id && 
                             bpf.BlogPost.Public == true && 
                             bpf.BlogPost.PublishOn < DateTime.Now)
                            .Select(bpf => bpf.BlogPost)
                            .ToListAsync();

    var posts = await _context.BlogPosts
                .Include(bp => bp.Author)
                .Include(bp => bp.Categories)
                .Where(bp => bps.Contains(bp))
                .ToListAsync();

    List<ViewBlogPostViewModel> viewPosts = new List<ViewBlogPostViewModel>();

    foreach (var p in posts)
    {
        //var categories = p.Categories.Select(c => c.Category).ToList();

        var categoryIds = p.Categories.Select(c => c.CategoryId);

        var categories = await (from cat in _context.Categories
                                where categoryIds.Contains(cat.Id)
                                select cat).ToListAsync();

        var bpView = new ViewBlogPostViewModel()
        {
            Author = $"{p.Author.FirstName} {p.Author.LastName}",
            Title = p.Title,
            Description = p.Description,
            Categories = categories,
            ModifiedAt = p.ModifiedAt,
            Id = p.Id
        };
        viewPosts.Add(bpView);
    }

    ViewFeaturePostsViewModel vfpvm = new ViewFeaturePostsViewModel()
    {
        Feature = feature,
        BlogPosts = viewPosts
    };

    return View(vfpvm);
}

我将稍微介绍一下 `FeaturePosts` 中的这段代码。这是一个控制器动作,返回与通过 `id` 参数传入的特性相对应的博客文章。实际上有两处我必须进行额外的查询才能获取数据。第一个查询是我通过 `BlogPostFeature` 表查找博客文章的地方。我能够通过这个查询获取文章,但当我试图在这个查询中包含其对应的作者和类别时,它不起作用。所以我不得不直接从 `BlogPosts` 表进行下一个查询,并在其中包含文章的 `Author` 和 `Categories`。

但遗憾的是,即使这样也并未完全包含其结果中的“类别”,这部分是通过多对多链接表实现的。它确实很好地返回了“作者”的结果,并且我能够直接访问它,无需进行任何其他查询来检索它。

因此,我最终不得不获取每个相应帖子的 `Category` ID,这些 ID 幸运地通过上面的查询(出于某种原因,它没有正确链接对象)可用。之后,我不得不查询 `Category` 表目录以包含上面查询中存在的类别。你可以看到我是如何使用 `Contains` 方法在 `categoryIds` 列表上做到这一点的。

最终,我发现这是一个可行的解决方案,但它确实导致了许多额外的查询,如果我没错的话,我应该基本上能够通过在我的第一个 `BlogPostFeatures` 查询中包含 `Author` 和 `Categories` 来获得我需要的一切。我的流量不多,这只是一个小项目,所以我没有过多地为此烦恼,但这是我正在努力改进的地方之一。

更新 - 2017 年 3 月 27 日

原来我查询的方式不对,有点过于依赖智能感知来指导我。结果是,无需额外查询,就可以获取我需要的所有信息。正确的方法是使用 `Include`,然后两次使用 `ThenInclude`,通过链接表获取类别。我在微软 Entity Framework Core Issues 页面上的支持人员的帮助下发现了这一点。正确的方法是:
    public async Task<iactionresult> FeaturePosts(long id)
    {
      var feature = await _context.Features.FindAsync(id);

      if (feature == null)
      {
        return NotFound();
      }

      var bpfs = await _context.BlogPostFeature
                              .Where(bpf => bpf.FeatureId == feature.Id && 
                               bpf.BlogPost.Public == true && 
                               bpf.BlogPost.PublishOn < DateTime.Now)
                              .Include(bpf => bpf.BlogPost)
                                .ThenInclude(bp => bp.Author)
                              .Include(bpf => bpf.BlogPost)
                                .ThenInclude(bp => bp.BlogPostCategory)
                                .ThenInclude(bpc => bpc.Category)
                              .ToListAsync();

      List<ViewBlogPostViewModel> viewPosts = new List<ViewBlogPostViewModel>();

      foreach (var bpf in bpfs)
      {
        var bpView = new ViewBlogPostViewModel()
        {
          Author = $"{bpf.BlogPost.Author.FirstName} {bpf.BlogPost.Author.LastName}",
          Title = bpf.BlogPost.Title,
          Description = bpf.BlogPost.Description,
          Categories = bpf.BlogPost.BlogPostCategory.Select(c => c.Category).ToList(),
          ModifiedAt = bpf.BlogPost.ModifiedAt,
          Id = bpf.BlogPost.Id
        };
        viewPosts.Add(bpView);
      }

      ViewFeaturePostsViewModel vfpvm = new ViewFeaturePostsViewModel()
      {
        Feature = feature,
        BlogPosts = viewPosts
      };

      return View(vfpvm);
    }
</iactionresult>

最终,我发现 EF Core 的性能似乎足够好,我已经在我的服务器(在 AWS 上运行)上对这个页面和其他页面进行了一些初步的负载测试。它能够在数据库访问页面上处理每秒约 100 个请求(它不在某个超强大的专用服务器上),并且似乎没有崩溃,这比我可能获得的流量还要多,所以我认为它在大多数情况下都是可以的。

MVC 的视图部分基本上就是我所有的 Razor 页面。我不得不说,这是我最不喜欢的 Web 编程部分,尽管像 Bootstrap 这样的框架使其变得更容易,并且使用 Highlight.js,我能够为代码示例插入一些非常酷的语法高亮。所以我认为大部分看起来还不错。我还能够为撰写页面组建一个编辑器,您可以在其中以 Markdown 格式编写您的帖子。这使用了一点 Ajax 来实现预览功能,它将 Markdown 发送到一个控制器,该控制器解析 Markdown 并将 HTML 返回给页面。这可能是 Web 应用程序中我唯一使用 Ajax 从客户端与服务器通信的地方,但让它工作起来却出奇地容易。我使用了一个名为 `CommonMark.Net` 的库(可作为 Nuget 包使用)来实现这一点。它只需要一个函数调用就能让它工作。代码在 `AuthorController` 中,客户端代码位于 EditPost.cshtmlAddEditPostPage.js 文件中,这些文件执行实际的 Ajax。

然而,我发现一旦我习惯了在 Razor 中编码,将所有东西整合并显示出来就变得很容易了。我仍在努力使其看起来更好一点,但使用 Bootstrap 使博客具有相对不错的外观和感觉变得相当容易。使用 Bootstrap 的网格布局系统也使页面在移动设备上显示得非常好,我发现这是一个真正的优点。我最终实现了一个标签助手,这是一个允许您自定义与其匹配的元素的一些 HTML 的类。我制作的那个标签助手查找具有特定属性集的 `DIV` 元素,然后将其内容设置为通过 `ViewContext` 传递的消息。您可以在 TagHelpers/DivEditSaveUpdateTagHelper.cs 中看到它是如何工作的以及如何从标签助手中访问 `ViewContext`。

所以,我并没有直接深入讲解代码,但如果你想浏览代码并了解它是如何协同工作的,我建议从 Startup.cs 文件开始。这是 Program.cs 中指定供 Web Host Builder 使用的启动类,其中包含配置服务和 MVC 的代码,并告诉它如何响应 HTTP 请求。从那里,我可能会建议通过 Visual Studio 2017 运行博客,这是项目现在配置的 Visual Studio 版本。

如果您想运行它,我在本地系统上测试时发现最简单的方法是创建一个具有在我的工作站上创建数据库权限的用户,这样如果我搞砸了什么,EF Core 就能够从头开始创建数据库。设置连接字符串后(您需要使用 appsettings.json、用户机密工具或环境变量之一指定一个),您应该能够运行

    dotnet ef migrations add initial
    dotnet ef database update

如果您正确设置了用户,它应该根据您的连接字符串为您创建数据库。这需要从项目文件夹的命令行运行才能工作。在运行应用程序之前,您应该将 appsettings.json 中的设置更改为您自己的电子邮件地址或虚拟地址,否则您将使用我的电子邮件和密码登录。它在应用程序启动时读取此文件,如果数据库中尚未找到管理员用户,则会创建一个新的管理员用户……我可能应该更改它。如果您希望全文搜索正常工作,您需要在数据库的 `BlogPost` 表上创建全文索引。这就像右键单击表并按照 SQL Server Management Studio 中的简短向导一样简单。我在开发过程中使用了 SQL Server 2016 开发版,部署时最终使用了 Express 版进行许可。

摘要

嗯,我学到了很多关于 ASP.NET Core MVC 以及它是如何协同工作的。我发现它使用起来相对简单,而且尽管它是一个新产品,但网上有很多关于它的信息。我还发现,在大多数情况下,微软关于 ASP.NET Core 和 EF Core 的文档都相当不错,尽管我希望他们能更深入地讲解 EF Core。那个项目也是开源的,如果你习惯使用 EF Core 以外的 EF 版本,学习曲线可能不会那么陡峭。话虽如此,我在开始这个项目之前并没有太多使用 LINQ,我发现使用 LINQ 和 EF Core 感觉很好。我确实在 EF Core 中遇到了一个 bug,并且能够确认它是一个 bug,这要归功于他们在 Github 上有问题列表,这让我松了一口气,因为我没有花太多时间为为什么有些东西不工作而抓狂。我仍然希望多对多关系能更好地工作一点,我感觉如果它们能更好地工作,我就能够减少在 LINQ 中编写的查询数量。

最终,我认为使用 `Identity` 进行身份验证对我的博客来说是有意义的。我最终确实在网上找到了其他一些可以使用简单 Cookie 身份验证的代码,但我已经开始为项目使用 `Identity`,并且那样做并没有将 Twitter 身份验证集成到项目中的便捷性。使用 `Identity` 的代码包含在处理登录和注销的 `Account` 类中,以及具有编辑用户操作方法的 `Admin` 类中。我编写的最混乱的函数绝对是编辑用户的函数,我需要回来清理并尽可能缩短它。

回想起来,我想如果我不使用 `Identity`,我本可以使用 MySQL。对 EF Core 的依赖很大程度上驱动了我的应用程序设计,并最终决定我选择 SQL Server 而不是 MySQL。这基本上都是由于使用 `Identity` 而不想使用两个 ORM。

如果你打算启动一个 Web 应用程序并计划使用 ASP.NET Core,我认为总体而言,它是一个不错的开发环境。如果你不打算使用 `Identity` 进行身份验证,我认为很容易使用另一个 ORM,或者只是简单的数据库访问来管理你的数据,或者如果你不需要数据库,那么你就不必担心所有这些。如果你确实打算使用 `Identity`,那么你就必须稍微熟悉一下 EF Core。我非常确定,如果我再多玩一点 MySQL 和可用的 Nuget 包,我可能可以通过编写迁移脚本并使用脚本应用模式更新来使其工作。还有一种方法可以从现有的数据库模型中获取 EF Core 为你创建类,但我根本没有玩过这个,所以我无法评论它的工作原理。

我唯一尚未掌握的部分是如何处理 EF Core 从开发数据库到生产数据库的流程。EF Core 允许您输出它应用的迁移的 SQL 脚本,然后您可以手动针对目标数据库运行该脚本,

我之所以开始写 Bagombo,是因为我当时还没有遇到很多 ASP.NET Core 的开源博客,所以我决定自己写一个。总的来说,我计划改进的地方有很多,但这是一个有趣的项目,现在基本上可以使用了。我接下来要添加的功能是分页支持,这样当返回大量博客文章时,可以更容易地翻页,而不是滚动。这还没有实现,因为我根本没有那么多博客文章。我还计划更改博客文章的链接,以便链接使用标题而不是博客 ID,就 Google 而言,这可能对 SEO 更好。

我还认为,如果我再做一次,我只会把它设置为系统中只有一个用户。我猜大多数博客都只由一个作者使用,尽管目前,Bagombo 允许你拥有任意数量的作者。

感谢您抽出时间阅读本文,如果您正在考虑在 ASP.NET Core 中编写自己的博客引擎,无论是作为学习练习还是实际使用,我都强烈推荐您尝试一下。我之前基本上没有任何经验,发现一切都进展得相当顺利。最棘手的部分有时是让查询与 EF Core 协同工作,但即使经过一段时间,这也并不算太糟糕,而且随着时间的推移,网上有越来越多的资源可以学习如何操作。我的大部分查询都在 Home、Author 和 Admin 控制器中,因此您可以查看这些代码,了解我是如何实现的。如果您发现我遗漏了什么或者有更好的方法,我很乐意听取您的意见。如果您真的制作了自己的博客引擎,祝您好运,这是一个有趣的项目,有很多不同的实现方式。

历史

  • 2017年3月24日:初始版本
  • 2017年3月27日:找到了一些关于如何正确执行查询的信息,并更新了文章以展示在 EF Core 中执行此类多对多查询的正确方法。
© . All rights reserved.