ASP.NET Core非常适合在云上部署,因为它对内存的占用很小,并且具有很高的吞吐量。所以不需要强大的服务器即可流畅运行,非常适合云的环境特点。特别是近来随着.net的开源以及对Linux平台的支持和Docker Container的支持,.Net也越来越在国外流行起来成为主流开发技术和平台。

在具体的前端架框架上,我们可以选择传统的Web Apps (又分为MVC和Razor)、SPA(如Angular)和Blazor(仍然在不断完善中)。

当我们设计应用程序架构时,需要注意以下几点:

  1. Seperation of concerns: 也就是需要根据做的事情的不同来划分模块或功能,比如如果要选择并按照一定格式显示某种商品,那么选择的逻辑和格式化的逻辑应该封装在不同的模块里。
  2. Encapsulation(封装):模块的完整逻辑应该都封装在内部,和外部的交互仅依赖于external contract。这个理论上很好理解,但真正做到并不容易,典型的反例是在DDD中,为了保证领域层的纯粹性,往往会把一些仓储操作放到仓储层,造成部分业务逻辑也随着搬移到仓储层。比如确保用户修改的邮件地址需要在整个系统中唯一,那么这个查询邮件是否重复的函数是定义在User里呢,还是定义在Repository里?前者会造成User对仓储的访问从而影响领域模型的纯洁性,后者则相当于部分业务逻辑分布到了领域层。
  3. Dependency inversion (依赖反转):这个又和依赖注入密切相关。传统上我们的User Class会直接调用UserRepository, 但如果我们在User Class所在的Layer定义IRepository接口,然后将该接口注入到User中,并且基础架构层里的UserRepository也实现该接口,这样依赖的方向就反转了。如此一来可以极大的提高程序的可维护性、模块化和可测试性。

如果我们按照依赖反转的原则和Domain-Driven Design设计我们的架构,那么最后的架构往往是非常相似的,这样的架构近来被称为Clean Architecture。这样的架构把业务逻辑和应用模型作为整个架构的核心。它的依赖反转体现在infrastructure层依赖于Application Core. 其实也就是在Application层定义该层需要的接口,然后infrastructure层来实现。UI和基础架构层都依赖于Application Core.

 

Controller的调停者模式
在开发ASP.NET页面时,我们可以选择Razor,也可以选择MVC. 使用MVC时,由于单个controller可以包含多个View和Action, 那我们需要注意不要让单个Controller变得太臃肿。(这一点和在设计Repository时要避免方法过多类似)。具体方法,除了换成Razor Page外,还可以考虑使用mediator模式(调停者)。我们的问题可以进一步描述如下:

controller中action太多,导致太多的dependency, 从而又导致需要把大量的关联类注入到controller中。

引入mediator后,controller只需要依赖一个mediator的实例,然后在controller的action中需要通过该mediator发送message, 该message再有单独的handler处理。所以该模式的实质是把controller的责任优雅的通过mediator分配到不同的handler中处理,下面是一个例子:

public class OrderController Controller
{
    private readonly IMediator mediator;
    public OrderController(IMediator mediator)
    {
        mediator mediator;
    }

[HttpGet]
public async Task<IActionResult> MyOrders()
{
    var viewModel await mediator.Send(new GetMyorders(User.Identity.Name));
    return View(viewModel);
}

//other actions implemented similarly

在这个例子中,action MyOrders用于查询当前用户的order.该请求封装在GetMyOrders类中,传递给mediator, 并由下面的GetMyOrdersHandler处理。

public class GetMyordersHandler : IRequestHandler<GetMyorders, IEnumerable<OrderViewModel>>
{
    private readonly IOrderRepository orderRepository;
    public GetMyordersHandler(IOrderRepository orderRepository)
    {
        _orderRepository orderRepository;
    }

    public async Task<IEnumerable<OrderViewModel>> Handle(GetMyOrders request, CancellationToken cancellationToken)
    {
        var specification = new CustomerOrdersWithItemsSpecification(request.UserName);
        var orders = await orderRepository.ListAsync(specification);
        return orders.Select(o = new OrderViewModel
        {
            OrderDate = o.OrderDate,
            OrderItems = o.OrderItems?.Select(oi = new OrderItemViewModel()
            {
                PictureUrl = oi.Itemordered.PictureUri,
                ProductId = oi.Itemordered.CatalogItemId,
                ProductName = oi.ItemOrdered.ProductName,
                UnitPrice = oi.UnitPrice
            })
        }        
    }

 

这样controller可以重新聚焦在它的本质工作:路由和模型绑定上。而处理每个end point请求的重任则落在在每个handler身上。

最后我们再来看看依赖注入。

  1. 陷阱之一:Static Cling

在设计系统的依赖时,我们要避免Static Cling。所谓的Static Cling,就是代码直接调用静态方法产生强耦合。这样因为无法替换具体实现而对测试造成影响。特别是当这段代码并不是核心业务逻辑不需要测试时,因为难以绕开而产生干扰。比如下面的例子。

public class CheckoutController
{
    public void Checkout(Order order)
    {
        // verify payment
        // verify inventory
        LogHelper.LogOrder(order);
    }
}
public static class LogHelper
{
    public static void LogOrder(Order order)
    {
        using (System.IO.StreamWriter file =
        new System.IO.StreamWriter(@"C:\\Users\\Steve\\OrderLog.txt", true))
        {
            file.WriteLine("{0} checked out.", order.Id);
        }
    }
}
public class Order
{
    public int Id { get; set; }
}

 

这段代码如何重构?当然是把静态方法改成实例方法并注入,但是如果该静态方法无法修改呢?那我们可以通过再封装一层adaptor来调用,进而实现重构,代码如下:

public class CheckoutController
{
    private readonly IOrderLoggerAdapter _orderLoggerAdapter;
    public CheckoutController(IOrderLoggerAdapter orderLoggerAdapter)
    {
        _orderLoggerAdapter = orderLoggerAdapter;
    }
    public CheckoutController()
    : this(new FileOrderLoggerAdapter())
    {
    }
    public void Checkout(Order order)
    {
        // verify payment
        // verify inventory
        _orderLoggerAdapter.LogOrder(order);
    }
}
public static class LogHelper
{
    public static void LogOrder(Order order)
    {
        using (System.IO.StreamWriter file =
        new System.IO.StreamWriter(@"C:\\Users\\Steve\\OrderLog.txt", true))
        {
            file.WriteLine("{0} checked out.", order.Id);
        }
    }
}
public interface IOrderLoggerAdapter
{
    void LogOrder(Order order);
}
public class FileOrderLoggerAdapter : IOrderLoggerAdapter
{
    public void LogOrder(Order order)
    {
        LogHelper.LogOrder(order);
    }
}
public class Order
{
    public int Id { get; set; }
}

 

 

  1. 陷阱之二:new is glue

也就是避免使用new,从而产生耦合。new就像一纸婚约,把两个class牢牢的绑定在了一起。

在.net 6和Visual Studio 2022中,我们默认在Program.cs中声明我们所有的dependencies.




在具体的前端架框架上,我们可以选择传统的Web Apps (又分为MVC和Razor)、SPA(如Angular)和Blazor(仍然在不断完善中)。

当我们设计应用程序架构时,需要注意以下几点:

  1. Seperation of concerns: 也就是需要根据做的事情的不同来划分模块或功能,比如如果要选择并按照一定格式显示某种商品,那么选择的逻辑和格式化的逻辑应该封装在不同的模块里。
  2. Encapsulation(封装):模块的完整逻辑应该都封装在内部,和外部的交互仅依赖于external contract。这个理论上很好理解,但真正做到并不容易,典型的反例是在DDD中,为了保证领域层的纯粹性,往往会把一些仓储操作放到仓储层,造成部分业务逻辑也随着搬移到仓储层。比如确保用户修改的邮件地址需要在整个系统中唯一,那么这个查询邮件是否重复的函数是定义在User里呢,还是定义在Repository里?前者会造成User对仓储的访问从而影响领域模型的纯洁性,后者则相当于部分业务逻辑分布到了领域层。
  3. Dependency inversion (依赖反转):这个又和依赖注入密切相关。传统上我们的User Class会直接调用UserRepository, 但如果我们在User Class所在的Layer定义IRepository接口,然后将该接口注入到User中,并且基础架构层里的UserRepository也实现该接口,这样依赖的方向就反转了。如此一来可以极大的提高程序的可维护性、模块化和可测试性。

如果我们按照依赖反转的原则和Domain-Driven Design设计我们的架构,那么最后的架构往往是非常相似的,这样的架构近来被称为Clean Architecture。这样的架构把业务逻辑和应用模型作为整个架构的核心。它的依赖反转体现在infrastructure层依赖于Application Core. 其实也就是在Application层定义该层需要的接口,然后infrastructure层来实现。UI和基础架构层都依赖于Application Core.

 

Controller的调停者模式
在开发ASP.NET页面时,我们可以选择Razor,也可以选择MVC. 使用MVC时,由于单个controller可以包含多个View和Action, 那我们需要注意不要让单个Controller变得太臃肿。(这一点和在设计Repository时要避免方法过多类似)。具体方法,除了换成Razor Page外,还可以考虑使用mediator模式(调停者)。我们的问题可以进一步描述如下:

controller中action太多,导致太多的dependency, 从而又导致需要把大量的关联类注入到controller中。

引入mediator后,controller只需要依赖一个mediator的实例,然后在controller的action中需要通过该mediator发送message, 该message再有单独的handler处理。所以该模式的实质是把controller的责任优雅的通过mediator分配到不同的handler中处理,下面是一个例子:

public class OrderController Controller
{
    private readonly IMediator mediator;
    public OrderController(IMediator mediator)
    {
        mediator mediator;
    }

[HttpGet]
public async Task<IActionResult> MyOrders()
{
    var viewModel await mediator.Send(new GetMyorders(User.Identity.Name));
    return View(viewModel);
}

//other actions implemented similarly

在这个例子中,action MyOrders用于查询当前用户的order.该请求封装在GetMyOrders类中,传递给mediator, 并由下面的GetMyOrdersHandler处理。

public class GetMyordersHandler : IRequestHandler<GetMyorders, IEnumerable<OrderViewModel>>
{
    private readonly IOrderRepository orderRepository;
    public GetMyordersHandler(IOrderRepository orderRepository)
    {
        _orderRepository orderRepository;
    }

    public async Task<IEnumerable<OrderViewModel>> Handle(GetMyOrders request, CancellationToken cancellationToken)
    {
        var specification = new CustomerOrdersWithItemsSpecification(request.UserName);
        var orders = await orderRepository.ListAsync(specification);
        return orders.Select(o = new OrderViewModel
        {
            OrderDate = o.OrderDate,
            OrderItems = o.OrderItems?.Select(oi = new OrderItemViewModel()
            {
                PictureUrl = oi.Itemordered.PictureUri,
                ProductId = oi.Itemordered.CatalogItemId,
                ProductName = oi.ItemOrdered.ProductName,
                UnitPrice = oi.UnitPrice
            })
        }        
    }

 

这样controller可以重新聚焦在它的本质工作:路由和模型绑定上。而处理每个end point请求的重任则落在在每个handler身上。

最后我们再来看看依赖注入。

  1. 陷阱之一:Static Cling

在设计系统的依赖时,我们要避免Static Cling。所谓的Static Cling,就是代码直接调用静态方法产生强耦合。这样因为无法替换具体实现而对测试造成影响。特别是当这段代码并不是核心业务逻辑不需要测试时,因为难以绕开而产生干扰。比如下面的例子。

public class CheckoutController
{
    public void Checkout(Order order)
    {
        // verify payment
        // verify inventory
        LogHelper.LogOrder(order);
    }
}
public static class LogHelper
{
    public static void LogOrder(Order order)
    {
        using (System.IO.StreamWriter file =
        new System.IO.StreamWriter(@"C:\\Users\\Steve\\OrderLog.txt", true))
        {
            file.WriteLine("{0} checked out.", order.Id);
        }
    }
}
public class Order
{
    public int Id { get; set; }
}

 

这段代码如何重构?当然是把静态方法改成实例方法并注入,但是如果该静态方法无法修改呢?那我们可以通过再封装一层adaptor来调用,进而实现重构,代码如下:

public class CheckoutController
{
    private readonly IOrderLoggerAdapter _orderLoggerAdapter;
    public CheckoutController(IOrderLoggerAdapter orderLoggerAdapter)
    {
        _orderLoggerAdapter = orderLoggerAdapter;
    }
    public CheckoutController()
    : this(new FileOrderLoggerAdapter())
    {
    }
    public void Checkout(Order order)
    {
        // verify payment
        // verify inventory
        _orderLoggerAdapter.LogOrder(order);
    }
}
public static class LogHelper
{
    public static void LogOrder(Order order)
    {
        using (System.IO.StreamWriter file =
        new System.IO.StreamWriter(@"C:\\Users\\Steve\\OrderLog.txt", true))
        {
            file.WriteLine("{0} checked out.", order.Id);
        }
    }
}
public interface IOrderLoggerAdapter
{
    void LogOrder(Order order);
}
public class FileOrderLoggerAdapter : IOrderLoggerAdapter
{
    public void LogOrder(Order order)
    {
        LogHelper.LogOrder(order);
    }
}
public class Order
{
    public int Id { get; set; }
}

 

 

  1. 陷阱之二:new is glue

也就是避免使用new,从而产生耦合。new就像一纸婚约,把两个class牢牢的绑定在了一起。

在.net 6和Visual Studio 2022中,我们默认在Program.cs中声明我们所有的dependencies.