向.NET核心过渡-为不断变化的现实进行架构

69 阅读6分钟

上一篇文章中,我讨论了我们从.NET Framework转移到.NET Core时发现的一般优点和缺点。在未来的文章中,我们将讨论我们面临的一些具体技术挑战,以及我们如何解决这些问题。然而,在我们对迁移到.NET Core进行技术分析之前,让我们花点时间回顾一下我们在尝试转换框架之前做出的架构选择。我们能够如此迅速地从.NET框架迁移的很大一部分原因是我们将我们的应用逻辑与框架的决定分开。

即使你从来没有用C#编码过,也不打算改变这一点,请坚持下去。我在这里讨论的原则是广泛适用的,不管你正在使用的框架的具体内容是什么。

框架的承诺

框架是强大的工具,它承诺让构建复杂软件的过程变得快速而简单(至少相比之下)。它们可以解决困难的问题,并且可以抽象出普通任务的复杂性。

在很大程度上,框架已经实现了它们的承诺。通过隐藏 "运行服务器和响应HTTP请求 "等任务的大量复杂性,框架使我们能够专注于使Pluralsight与谷歌不同的复杂性。框架还可以实现一些有趣的架构选择。如果每个服务都是由TCP连接建立起来的,那么微服务就不会像现在这样受欢迎。

框架的成本

虽然框架带来了很多好处,但它也有相应的成本。毕竟没有免费的午餐,就像没有完美的架构一样,也没有完美和不变的框架。我想用几分钟时间来谈谈其中的几个成本:抽象的成本和变化的成本。

抽象的代价

我对抽象化可能带来的问题的第一次介绍是Joel Spolsky的关于泄漏抽象化法则的文章。他很好地解释了为什么抽象,这些我们为使生活更容易而设计的奇妙的东西,有时最终反而使生活更难。框架中充满了抽象--它们通常包含多层抽象!在大多数情况下,这是个很好的例子。

大多数情况下,这是件好事。与其处理HTTP规范的所有复杂性,以及不同的人滥用和曲解它的所有方式,框架让我们处理相对简单、逻辑上一致的代码。但我们需要记住,框架程序员也是人。有时这将表现为我们所依赖的框架中的错误或安全漏洞。Headers 其他时候,你可能会发现Content-Type HTTP头没有被添加到HttpResponseHeaders 集合中,而是被添加到Content 对象上的HttpResponse 集合中。

抽象的泄漏这一事实不应该阻止我们使用它们。我们只需要记住,这样做是有隐藏成本的。

改变的成本

我们经常忽略的添加框架的第二个成本是改变框架的成本。像软件世界中的其他东西一样,框架也处于不断变化的状态。无论是因为当前框架的更新版本可用,还是因为一个新的框架出现,承诺解决你所面临的问题,几乎可以肯定的是,你最终会改变你的代码所建立的框架。突然间,那个使你能够快速完成许多工作的框架就阻碍了你。

如果你很幸运,这只是需要改变一些事情的工作方式那么简单。但在许多情况下,你会遇到你的框架的基本假设的变化。当这种情况发生时,你很可能会遇到一些隐蔽的小错误,最终会花费大量的时间和精力去追踪。

当你深陷杂草丛中与这种变化作斗争时,要记住一件重要的事情,那就是这是一个问题。你正在改变框架的事实意味着你的代码是有成效的。那些只是在工作的代码,那些不太符合你的理想化架构的代码,那些已经运行了多年的半遗忘的代码都是美妙的成功代码。

与我们的框架共存

那么,我们如何从框架中获得最大的价值,同时将它们带来的成本降到最低?

根据我的经验,这种工作的最佳工具之一是隔离

框架通常有一项工作要解决。给它们足够的空间来解决这个问题,然后建立坚固的墙,将它们与你的其他代码隔离开。这意味着,当使用ASP.NET这样的框架时,我们让框架解决响应HTTP请求的所有问题,但把所有的应用逻辑放在别的地方。在很多情况下,我甚至喜欢把这些逻辑放在一个单独的程序集里,甚至根本不引用ASP.NET,作为一个额外的保护层。

作为一个例子,让我们看一下ASP.NET CoreWeb API教程中的一个控制器例子。按照他们的指示,你最终会得到一个类似这样的控制器。

[Route("api/controller")]
[ApiController]
public class TodoController : ControllerBase
{
    private readonly TodoContext _context;
    
    // Lots of other endpoints omitted for brevity

    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteTodoItem(long id)
    {
        var todoItem = await _context.TodoItems.FindAsync(id);

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

        _context.TodoItems.Remove(todoItem);
        await _context.SaveChangesAsync();

        return NoContent();
    }
}

请注意,这段代码同时包含了对两个不同框架的引用。ASP.NET Core和Entity Framework。对于相当于 "Hello World "的应用程序,这可能是好的。但是,如果我们必须改变这些框架中的任何一个,所有围绕你是否可以删除项目以及如何实际删除它们的逻辑都与框架代码纠缠在一起。

一个简单的重构可以减少这种耦合,那就是把所有不涉及响应HTTP请求的逻辑从控制器中移除。然后我们就有了两个类。

[Route("api/controller")]
[ApiController]
public class TodoController : ControllerBase
{
    private readonly TodoList _todoList;
    
    // Lots of other endpoints omitted for brevity

    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteTodoItem(long id)
    {
        var deleted = await _todoList.DeleteById(id)
        if (!deleted) 
        {
            return NotFound();
        }
        return NoContent();
    }
}
public class TodoList
{
    private readonly TodoContext _context;

    public async Task<bool> DeleteById(long id)
    {
        var item = await _context.TodoItems.FindAsync(id);
        if (item == null)
        {
            return false;
        }

        _context.TodoItems.Remove(item);
        await _context.SaveChangesAsync();
        return true;
    }
}

如果这是我的项目,我可能也想从TodoList 中提取对实体框架的任何引用,这样它就只处理普通的老类对象。我会把为EF生成的实体当作DTO,把它们转换为我完全控制的领域对象,然后再把它们从存储库中传递出去。不过这对于一篇博客文章来说有点复杂。

这种工作方式是有代价的。框架试图解决的问题比我想要的要多,而且很容易让框架为我做架构上的决定。当我试图让框架远离我的领域对象时,也不可避免地出现了某种程度的重复。我经常会有一些看起来几乎相同的类,唯一的区别是一个为框架做了注释,而另一个则是严格的代码。除了这种明显的重复之外,创建这些不同的层最终会产生一些次要的工作。不过到最后,我发现与其在未来某个未知的时间处理灾难性的后果,还不如先支付一点维护费。

这是正在进行的关于我们向.NET核心过渡系列的第二部分。更多信息可以在第一部分第三部分找到。