使用 SOLID 原则开发 MVC 应用程序






4.96/5 (45投票s)
我们在开发 MVC 应用程序时,应考虑设计原则。
主题
引言
在 ASP.NET 中,我们可以采用两种不同的方法来构建 Web 应用程序。
- WebForms
- MVC
Webforms
Webforms 是开发 Web 应用程序的传统方式。它允许我们使用与传统桌面应用程序相同的事件处理模型来开发 Web 应用程序。这提供了一些优势,例如:
快速应用程序开发 WebForms 使我们能够非常快速地开发 Web 应用程序。它提供了服务器控件和其他实用程序,我们可以使用它们在相对较短的时间内开发 Web 应用程序。
HTTP 抽象 Webforms 提供了对 HTTP 协议的抽象,使我们开发 Web 应用程序的体验就像开发 Windows 应用程序一样。
尽管以上是明显的优势,但这种传统方法也存在一些问题:
- 由于应用程序不同组件之间紧密耦合,因此难以维护和更改。
- 由于不同组件之间的依赖关系,单元测试很困难。
- 由于服务器控件和视图状态等抽象,无法直接与 HTTP 协议进行交互。
MVC
MVC 解决了上述问题。
关注点分离 在 MVC 应用程序中,它由具有不同职责的独立组件组成。由于这种关注点分离,我们可以非常轻松地更改一个组件,而不会影响应用程序的其余部分,因为组件之间的耦合度很低。
单元测试 由于请求直接调用控制器中的操作方法,因此我们可以轻松地测试控制器中的功能,而无需调用请求管道。这与 ASP.NET Web Forms 不同,后者需要运行请求管道才能测试单个 Web 窗体。
但是,当我们开始开发应用程序,特别是大型企业应用程序时,我们可能会遇到一些反模式,这些反模式可能会抵消 MVC 架构的优势。以下是一些常见示例。
如果我们要在控制器中使用某些功能,并希望在控制器之间重用该功能,那么我们倾向于采取的一种方法是将代码复制到不同的控制器中。如果我们通过复制代码在控制器和模型之间重用功能,那么我们就重复了代码。这是称为“复制粘贴编程”的反模式的一个示例。
如果我们从数据库中需要某些值,而现有的模型类未提供这些值,那么我们可能会直接将数据访问代码放在控制器中。下面是控制器中一个使用 EF 访问数据库并更新模型对象的方法。
public ActionResult UpdateUser(User user)
{
//A DBContext concrete object is created
UserContext db = new UserContext();
//it is updated in the database using the EF
db.Users.Add(user);
db.SaveChanges();
return RedirectToAction("Index");
}
上述控制器中的问题是:
- 对数据访问技术 Entity Framework 存在强依赖。
- 控制器直接访问数据库,这属于模型对象的职责。
因此,正如我们现在所理解的,使用 MVC 框架确实需要使用健全的设计原则来创建灵活且易于维护的应用程序。这就是为什么我们使用 SOLID 原则。
SOLID 用于良好面向对象设计的原则
SOLID 是一组原则,有助于实现良好的面向对象设计并避免上述问题。SOLID 的原则是:
第一个原则,单一职责原则中的 S,规定应用程序中的每个模块应该只因一个原因而改变。
如果我们从逻辑上来看,我们可以很容易地理解这一点。我们在应用程序中有不同的模块,所以如果我们在一个模块中承担多个职责,就很难维护,并且很容易破坏应用程序的其余部分。由于一个模块中有多个职责,因此一个模块中的问题会影响其他模块。
为了理解这个模式的必要性,我们可以举上面操作方法的例子。
上面的方法执行两个职责:
- 返回视图
- 访问数据库。
假设明天我们更改了数据访问技术,并使用 ADO.NET 来访问数据库。现在,如果我们的代码有问题并出现错误,它将传播到我们的视图,视图将通知用户我们的应用程序有问题。这绝对不是我们希望发生的事情。
解决方案是单一职责模式,它将职责分离到不同的模块中。
public ActionResult UpdateUser(User user)
{
UserRepository repository = new UserRepository();
repository.Save(user);
return RedirectToAction("Index");
}
正如您在上面的代码中看到的,我们将数据访问职责分离到一个存储库类中。因此,现在我们的操作方法唯一的职责就是返回视图(这是它应该做的)。
在大多数应用程序中,一个常见的场景是验证用户,只有在验证后才允许他们访问应用程序。一个常见的方法是在 Login 操作方法本身中包含验证逻辑。但是,如果我们还在应用程序的其他地方使用验证逻辑,这可能会导致维护噩梦。例如,如果用户从其他站点重定向到我们的应用程序,而我们在不同的控制器中验证用户。
public ActionResult Login(User user)
{
UserRepository repository = new UserRepository();
if(repository.IsValidUser(user))
return RedirectToAction("Home");
else
return RedirectToAction("Index")
}
处理这种情况的一种更优雅的方法是实现 MVC 提供的 IAuthenticationFilter 接口。因此,如果验证逻辑发生更改,我们现在只需要更改一个类,即实现IAuthenticationFilter 接口的类,而不是在整个应用程序中查找和更改验证逻辑。
因此,在此更改后,我们的方法仅包含一个重定向语句。
[CustomAuthenticationAttribute ]
public ActionResult Login(User user)
{
return RedirectToAction("Home");
}
该原则指出,软件实体(类、模块、函数等)应开放扩展,封闭修改。规则的“封闭”部分说明,一旦开发和测试了一个模块,代码就不应更改,除非是为了修复错误。“开放”部分则表示您应该能够扩展现有代码以引入新功能。
不提倡更改现有类的代码的原因是,它可能会破坏现有代码,并且如果我们更改类,还需要重新测试代码。如果我们有一些客户端代码在使用该类,那么该客户端也需要经过测试。
因此,如果我们需要向现有类添加一些功能,我们可以通过继承来实现。下面是一个模型类的示例,该类包含计算图书价格的业务规则。
enum Category
{
student,
corporate
}
class Book
{
public double CalculatePrice(double price,Category category)
{
if (category == Category.corporate)
{
price = price- (price * 10);
}
else if (category == Category.student)
{
price = price - (price * 20);
}
return price;
}
}
上面的 Book 模型类包含根据买家所属的类别计算图书价格的逻辑,并据此提供折扣。现在,如果添加了一个新的折扣类别,我们实现它的唯一方法就是更改 Book 类。
开放/封闭原则规定类应封闭修改,但开放扩展。所以,让我们看看如何使用这个原则来实现折扣功能。
在下面的代码中,我们创建了一个带有 CalculatePrice 方法的抽象类。StudentBook 和 CorporateBook 类继承了这个抽象类。一个明显的优点是,如果我们必须添加新的折扣类型,而不是修改现有类(这不是一个很好的设计),我们可以创建一个扩展 Book 类的类。
abstract class Book
{
public abstract double CalculatePrice(double price);
}
class StudentBook : Book
{
public override double CalculatePrice(double price)
{
return price - (price * 20);
}
}
class CorporateBook : Book
{
public override double CalculatePrice(double price)
{
return price - (price * 10);
}
}
现在,我们可以根据需要轻松添加折扣规则,而不会影响现有代码。
该原则指出派生类型必须可以替换其基类型。
根据此原则,我们可以在客户端代码中用派生类对象替换基类对象,并且应用程序应按预期工作。
如果我们认为子类和基类之间存在“is a”关系,那么这一点很容易理解。子类也是基类的一个实例。因此,子类对象也应该能在基类的位置工作。
我们可以通过一个例子来理解这一点。
我们有承包商和员工类,其中承包商继承自员工基类。
class Employee
{
int _sal, _empId;
public int CalculateSalary()
{
//calculate salary
return _sal;
}
public int GetEmployeeId()
{
//fetch values from database
return _empId;
}
}
class Contractor : Employee
{
int _contractDuration;
public int ContractDuration()
{
return _contractDuration;
}
public int GetEmployeeId()
{
throw new NotImplementedException();
}
}
起初,这可能看起来是一个合理的类层次结构。但是这里有一个问题。虽然承包商在公司工作,但他们没有员工 ID。所以,如果我们的客户端代码调用 GetEmployeeId() 方法,他可能会感到惊讶,因为该方法没有被客户端实现。
所以,这里的父子类关系不正确,两者都应该是独立的类。
该原则指出,客户端不应被迫依赖于它们不使用的接口。这意味着对依赖类可见的接口的成员数量应尽可能少。
换句话说,我们可以说“暴露给客户端的接口应只包含客户端所需的方法,而不是所有方法”。
根据此原则,我们应该创建多个具有少量方法的接口,而不是拥有一个包含所有方法的单个接口。这样,我们就不会强迫客户端实现不必要的方法。
在下面的示例中,我们有一个 IOrganization 接口,其中包含组织不同部门的三个方法。如果任何类想要实现该接口,它将不得不实现所有三个方法。这就像我们说“如果任何类想实现我们的接口,它要么实现所有功能(无论是否需要),要么无需实现我们的接口”。
如果我们能将接口功能分解为三个独立的接口,那不是很好吗?
在下面的示例中,我们有一个实现 IEmployee 接口的模型类。Employee 接口包含以下方法:
- Manage
- 薪资
- 部门
我们还有两个实现了IEmployee 接口的类。
- 管理器
- 执行官
interface IEmployee
{
public void Manage();
public void Salary();
public void Department();
}
class Manager : IEmployee
{
public void Manage()
{
//Manage employees
}
public void Salary()
{
//return salary
}
public void Department()
{
//return department
}
}
class Executive : IEmployee
{
public void Manage()
{
//no employees to manage. No implementation ?
}
public void Salary()
{
//return salary
}
public void Department()
{
//return department
}
}
上述设计的问题在于,IEmployee 包含可能不属于每个员工的方法。其中一个方法是 Manage() 方法,该方法仅由经理员工实现。但上述设计强制所有员工实现 manage 方法,无论他们是否管理员工。因此,该设计违反了接口隔离原则。
因此,在重新设计后,我们得到了如下所示的接口和类结构。
interface IManager
{
public void Manage();
}
interface IEmployee
{
public void Salary();
public void Department();
}
class Manager : IEmployee, IManager
{
public void Manage()
{
//Manage employees
}
public void Salary()
{
//return salary
}
public void Department()
{
//return department
}
}
class Executive : IEmployee
{
public void Salary()
{
//return salary
}
public void Department()
{
//return department
}
}
因此,我们的新设计不会强制每个员工实现 manage 方法。但同时,经理员工可以实现特定的 IManager 接口。
依赖倒置原则定义为: "高层模块不应依赖于低层模块。两者都应依赖于抽象"。
让我们用一个非常基本的例子来理解这一点。有 Manager 和 Organization 类。Organization 类依赖于 Manager 类,因为它引用了 Manager 类的具体实例。
class Manager
{
public string Name { get; set; }
public string Designation { get; set; }
public string Salary { get; set; }
}
public class Organization
{
List<Manager> lst;
public void Add(Manager mgr)
{
lst.Add(mgr);
}
}
正如您所看到的,Organization 类有一个 Add() 方法。该方法允许我们将 Manager 添加到组织中的 Manager 列表中。上面的代码有一个问题。
Organization 类与 Manager 类紧密耦合。因此,更改 Manager 类也会迫使我们更改 Organization 类。这违反了 DI 原则。
这里是同一个类的另一个版本,但现在我们已经消除了 Organization 类对具体类的依赖。我们创建了一个 IEmployee 接口,Organization 类引用了它。因此,即使 Manager 类发生更改,我们的 Organization 也将不受影响,因为依赖关系已被移除。
interface IEmployee
{
public string Name{get;set;}
public string Salary { get; set; }
public string Department { get; set; }
}
class Manager :IEmployee
{
public string Name { get; set; }
public string Designation { get; set; }
public string Salary { get; set; }
}
public class Organization
{
List<IEmployee> lst;
public void Add(IEmployee emp)
{
lst.Add(emp);
}
}
使用我们控制器最初的例子,在其中我们使用存储库访问数据库,到目前为止,控制器依赖于 UserRepository 类型的具体实例。这不符合依赖倒置原则,并且在 UserRepository 发生更改时,我们的控制器需要进行更改。
在下面的示例中,我们创建了一个 IRepository 接口,并将 IRepository 类型的实例传递给了构造函数。这消除了我们控制器的依赖,即使 UserRepository 发生更改,我们的控制器也无需更改。只要它实现了UserRepository 接口,我们甚至可以用任何其他对象替换 UserRepository。
这是用于实现依赖倒置原则的依赖注入的一个示例。
IRepository _repository;
public void ApplController(IRepository repository)
{
_repository = repository;
}
public ActionResult UpdateUser(User user)
{
_repository.Save(user);
return RedirectToAction("Index");
}
interface IRepository
{
void Save(User user);
}
如果我们考虑一些最佳实践,我们可以创建良好的应用程序设计。以下是开发 MVC 应用程序时需要考虑的一些事项:
- 使用具有仅常见成员的细粒度接口。
- 尽可能使用抽象类来封装通用功能。
- 设计轻量级类,只承担单一职责,并在识别出新职责时创建新类。
- 尽量减少对外部实体的依赖,使用依赖注入,在大多数情况下,构造函数注入是一个不错的选择。 如果我们创建一个解耦的体系结构,那么更改一个实体或类就很少会破坏整个应用程序的风险。
- 有许多 IOC 容器可用于在我们的应用程序中实现依赖注入。常见示例包括 Unity、Castle Windsor、StructureMap。