原文 Contoso 大学 - 9 - 实现仓储和工作单元模式
By Tom Dykstra, Tom Dykstra is a Senior Programming Writer on Microsoft's Web Platform & Tools Content Team.
原文地址:http://www.asp.net/mvc/tutorials/getting-started-with-ef-using-mvc/implementing-the-repository-and-unit-of-work-patterns-in-an-asp-net-mvc-application
全文目录:Contoso 大学 - 使用 EF Code First 创建 MVC 应用
在上一次的教程中,你已经使用继承来消除在 Student 和 Instructor 实体之间的重复代码。在这个教程中,你将要看到使用仓储和工作单元模式进行增、删、改、查的一些方法。像前面的教程一样,你将要修改已经创建的页面中代码的工作方式,而不是新创建的页面。
9-1 仓储和工作单元模式
仓储和工作单元模式用来在数据访问层和业务逻辑层之间创建抽象层。实现这些模式有助于隔离数据存储的变化,便于自动化的单元测试或者测试驱动的开发 ( TDD )。
在这个教程中,你将要为每个实体类型实现一个仓储类。对于 Student 实体来说,你需要创建一个仓储接口和一个仓储类。当在控制器中实例化仓储对象的时候。你将会通过接口来使用它,当控制器在 Web 服务器上运行的时候,控制器将会接受任何实现仓储接口的对象引用。通过接收仓储对象进行数据的存储管理,使得你可以容易地控制测试,就像使用内存中的集合一样。
在教程的最后,你将要在 Course 控制器中对 Course 和 Department 实体使用多个仓储和一个工作单元类。工作单元类则通过创建一个所有仓储共享的数据库上下文对象,来组织多个仓储对象。如果你希望执行自动化的单元测试,你也应该对 Student类通过相同的方式创建和使用接口。不管怎样,为了保持教程的简单,你将不会通过接口创建和使用这些类。
下面的截图展示了在控制器和上下文之间的概念图,用来比较与不使用仓储或工作单元模式的区别。
在这个教程中不会创建单元测试,在 MVC 应用中使用仓储模式进行 TDD 的相关信息,可以查看 MSDN 网站中的 Walkthrough: Using TDD with ASP.NET MVC ,EF 团队博客中的 Using Repository and Unit of Work patterns with Entity Framework 4.0 ,以及 Julie Lerman 的博客 Agile Entity Framework 4 Repository 系列。
注意:有多种方式可以实现仓储和工作单元模式。配合工作单元类可以使用也可以不使用仓储类。可以对所有的实体类型实现一个简单的仓储,或者每种类型一个。如果为每种类型实现一个仓储,还可以通过分离的类,或者泛型的基类然后派生,或者抽象基类然后派生。可以将业务逻辑包含在仓储中,或者限制只有数据访问逻辑。也可以通过在实体中使用 IDbSet 接口代替 DbSet 类为数据库上下文类创建一个抽象层。在这个教程中展示的目标实现了抽象层,只是其中一种考虑,并不是针对所有的场景和环境都适用。
9-2 创建 Student 仓储类
在 DAL 文件夹中,创建一个文件名为 IStudentRepository.cs 的文件,将当前的代码使用如下代码替换。
using System; using System.Collections.Generic; using System.Linq; using System.Web; using ContosoUniversity.Models; namespace ContosoUniversity.DAL { public interface IStudentRepository : IDisposable { IEnumerable<Student> GetStudents(); Student GetStudentByID(int studentId); void InsertStudent(Student student); void DeleteStudent(int studentID); void UpdateStudent(Student student); void Save(); } }
代码定义了一套典型的增、删、改、查方法。包括两个读取方法 – 一个返回所有的学生实体,一个通过 ID 查询单个实体。
在 DAL 文件夹中,创建名为 StudentRepository.cs 的类文件,使用下面的代码替换原有的代码,这个类实现了 IStudentRepository 接口。
using System; using System.Collections.Generic; using System.Linq; using System.Data; using ContosoUniversity.Models; namespace ContosoUniversity.DAL { public class StudentRepository : IStudentRepository, IDisposable { private SchoolContext context; public StudentRepository(SchoolContext context) { this.context = context; } public IEnumerable<Student> GetStudents() { return context.Students.ToList(); } public Student GetStudentByID(int id) { return context.Students.Find(id); } public void InsertStudent(Student student) { context.Students.Add(student); } public void DeleteStudent(int studentID) { Student student = context.Students.Find(studentID); context.Students.Remove(student); } public void UpdateStudent(Student student) { context.Entry(student).State = EntityState.Modified; } public void Save() { context.SaveChanges(); } private bool disposed = false; protected virtual void Dispose(bool disposing) { if (!this.disposed) { if (disposing) { context.Dispose(); } } this.disposed = true; } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } } }
数据库上下文是类中定义的一个成员变量,构造函数期望传递一个数据库上下文对象实例。
private SchoolContext context; public StudentRepository(SchoolContext context) { this.context = context; }
你需要创建一个新的数据库上下文实例,但是如果在控制器中需要使用多个仓储类,每一个会得到一个不同的数据库上下文对象。后面在 Course 控制器中,你将要使用多个仓储,会看到如何使用工作单元类来保证所有的仓储使用相同的数据库上下文对象。
仓储类还实现了 IDisposable 接口,如同在前面控制器中所见,释放数据库上下文,仓储的增删改查方法也如前所见调用数据库上下文的方法。
9-3 修改 Student 控制器使用仓储
在 StudentController.cs 中,使用下面的代码替换现有的代码。
using System; using System.Collections.Generic; using System.Data; using System.Data.Entity; using System.Linq; using System.Web; using System.Web.Mvc; using ContosoUniversity.Models; using ContosoUniversity.DAL; using PagedList; namespace ContosoUniversity.Controllers { public class StudentController : Controller { private IStudentRepository studentRepository; public StudentController() { this.studentRepository = new StudentRepository(new SchoolContext()); } public StudentController(IStudentRepository studentRepository) { this.studentRepository = studentRepository; } // // GET: /Student/ public ViewResult Index(string sortOrder, string currentFilter, string searchString, int? page) { ViewBag.CurrentSort = sortOrder; ViewBag.NameSortParm = String.IsNullOrEmpty(sortOrder) ? "Name desc" : ""; ViewBag.DateSortParm = sortOrder == "Date" ? "Date desc" : "Date"; if (Request.HttpMethod == "GET") { searchString = currentFilter; } else { page = 1; } ViewBag.CurrentFilter = searchString; var students = from s in studentRepository.GetStudents() select s; if (!String.IsNullOrEmpty(searchString)) { students = students.Where(s => s.LastName.ToUpper().Contains(searchString.ToUpper()) || s.FirstMidName.ToUpper().Contains(searchString.ToUpper())); } switch (sortOrder) { case "Name desc": students = students.OrderByDescending(s => s.LastName); break; case "Date": students = students.OrderBy(s => s.EnrollmentDate); break; case "Date desc": students = students.OrderByDescending(s => s.EnrollmentDate); break; default: students = students.OrderBy(s => s.LastName); break; } int pageSize = 3; int pageNumber = (page ?? 1); return View(students.ToPagedList(pageNumber, pageSize)); } // // GET: /Student/Details/5 public ViewResult Details(int id) { Student student = studentRepository.GetStudentByID(id); return View(student); } // // GET: /Student/Create public ActionResult Create() { return View(); } // // POST: /Student/Create [HttpPost] public ActionResult Create(Student student) { try { if (ModelState.IsValid) { studentRepository.InsertStudent(student); studentRepository.Save(); return RedirectToAction("Index"); } } catch (DataException) { //Log the error (add a variable name after DataException) ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists see your system administrator."); } return View(student); } // // GET: /Student/Edit/5 public ActionResult Edit(int id) { Student student = studentRepository.GetStudentByID(id); return View(student); } // // POST: /Student/Edit/5 [HttpPost] public ActionResult Edit(Student student) { try { if (ModelState.IsValid) { studentRepository.UpdateStudent(student); studentRepository.Save(); return RedirectToAction("Index"); } } catch (DataException) { //Log the error (add a variable name after DataException) ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists see your system administrator."); } return View(student); } // // GET: /Student/Delete/5 public ActionResult Delete(int id, bool? saveChangesError) { if (saveChangesError.GetValueOrDefault()) { ViewBag.ErrorMessage = "Unable to save changes. Try again, and if the problem persists see your system administrator."; } Student student = studentRepository.GetStudentByID(id); return View(student); } // // POST: /Student/Delete/5 [HttpPost, ActionName("Delete")] public ActionResult DeleteConfirmed(int id) { try { Student student = studentRepository.GetStudentByID(id); studentRepository.DeleteStudent(id); studentRepository.Save(); } catch (DataException) { //Log the error (add a variable name after DataException) return RedirectToAction("Delete", new System.Web.Routing.RouteValueDictionary { { "id", id }, { "saveChangesError", true } }); } return RedirectToAction("Index"); } protected override void Dispose(bool disposing) { studentRepository.Dispose(); base.Dispose(disposing); } } }
在控制器中定义了一个 IStudentRepository 接口的类变量,而不是直接的数据库上下文。
private IStudentRepository studentRepository;
默认的构造函数创建一个新的上下文接口,可选的构造函数允许调用者传递一个数据库上下文实例。
public StudentController() { this.studentRepository = new StudentRepository(new SchoolContext()); } public StudentController(IStudentRepository studentRepository) { this.studentRepository = studentRepository; }
( 如果使用依赖注入,或者 DI,就不需要默认构造函数,因为 DI 容器会为你创建正确的仓储对象 )
在 CRUD 方法中,调用仓储方法来而不是数据库上下文的方法。
var students = from s in studentRepository.GetStudents() select s;
Student student = studentRepository.GetStudentByID(id);
studentRepository.InsertStudent(student);
studentRepository.Save();
studentRepository.UpdateStudent(student);
studentRepository.Save();
studentRepository.DeleteStudent(id);
studentRepository.Save();
现在的 Dispose 方法释放仓储而不是数据库上下文。
studentRepository.Dispose();
运行程序,点击 Students 窗格。
现在的页面显示与使用仓储之前完全相同。其他的学生页面也一样。实际上,在 Index 控制器方法的过滤和排序中,存在一个重要的不同,原来版本的代码如下:
var students = from s in context.Students select s; if (!String.IsNullOrEmpty(searchString)) { students = students.Where(s => s.LastName.ToUpper().Contains(searchString.ToUpper()) || s.FirstMidName.ToUpper().Contains(searchString.ToUpper())); }
在原来版本的代码中,students 变量的类型是 IQueryable ,查询在使用诸如 ToList 方法转换为集合之前并不会发送到数据库中。这意味着这里的 Where 方法在处理到数据库中的时候变成 SQL 中的 where 子句。同时意味着仅仅选中的实体从数据库中返回。从 context.Students 修改为 studentRepository.GetStudents() 之后,代码中的 students 变量类型成为 IEnumerable 集合,包括数据库中所有的学生。通过 Where 方法得到的结果是一样的,但是处理在 Web 服务器的内存中进行,而不是在数据库中。对于大量的数据来说,这样做是低效的。后继的段落展示如何通过仓储方法实现在数据库中完成。
现在你已经在控制器和 EF 数据库上下文之间创建了抽象层。如果你将在这个程序中执行自动化的单元测试,可以在单元测试项目中创建一个替代的实现接口 IStudentRepository 仓储类,来代替实际的上下文完成读写数据。这个模拟 ( Mock ) 的仓储类可以通过操作内存中的集合来测试控制器功能。
9-4 实现泛型的仓储和工作单元
对每一个实体类型创建一个仓储将会导致大量重复代码。还会带来部分更新的问题。例如,假设在一个事务中更新两个不同的实体。 如果每一个仓储使用不同的数据库上下文实例,一个可能成功了,另外一个失败了。一种减少冗余代码的方式是使用泛型仓储,另一种方式是使用工作单元类来确保所有的仓储都使用同样的数据库上下文 ( 来协调所有的更新 )。
在这一节中,你将要创建 GenericRepository 类和 UnitOfWork 类。在 Course 控制器中使用它们来访问 Department 和 Course 实体集。如前所述,为了保持教程的简单,不为这些类创建接口,但是为了以后使用它们进行 TDD 的便利,你应该像在 Student 仓储中一样通过接口实现。
9-4-1 创建泛型仓储
在 DAL 文件夹中,创建 GenericRepository.cs ,使用下面的代码替换原有代码。
using System; using System.Collections.Generic; using System.Linq; using System.Data; using System.Data.Entity; using ContosoUniversity.Models; using System.Linq.Expressions; namespace ContosoUniversity.DAL { public class GenericRepository<TEntity> where TEntity : class { internal SchoolContext context; internal DbSet<TEntity> dbSet; public GenericRepository(SchoolContext context) { this.context = context; this.dbSet = context.Set<TEntity>(); } public virtual IEnumerable<TEntity> Get( Expression<Func<TEntity, bool>> filter = null, Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>> orderBy = null, string includeProperties = "") { IQueryable<TEntity> query = dbSet; if (filter != null) { query = query.Where(filter); } foreach (var includeProperty in includeProperties.Split (new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) { query = query.Include(includeProperty); } if (orderBy != null) { return orderBy(query).ToList(); } else { return query.ToList(); } } public virtual TEntity GetByID(object id) { return dbSet.Find(id); } public virtual void Insert(TEntity entity) { dbSet.Add(entity); } public virtual void Delete(object id) { TEntity entityToDelete = dbSet.Find(id); Delete(entityToDelete); } public virtual void Delete(TEntity entityToDelete) { if (context.Entry(entityToDelete).State == EntityState.Detached) { dbSet.Attach(entityToDelete); } dbSet.Remove(entityToDelete); } public virtual void Update(TEntity entityToUpdate) { dbSet.Attach(entityToUpdate); context.Entry(entityToUpdate).State = EntityState.Modified; } } }
为数据库上下文创建变量,以及仓储代表的实体集。
internal SchoolContext context; internal DbSet dbSet;
构造函数接受一个数据库上下文实例,然后初始化实体集变量。
public GenericRepository(SchoolContext context) { this.context = context; this.dbSet = context.Set(); }
Get 方法接受 Lambda 表达式,允许调用代码通过 Lambda 表达式来传递过滤条件和排序列,字符串参数允许调用者传递一个逗号分隔的导航属性进行预先加载。
public virtual IEnumerable<TEntity> Get( Expression<Func<TEntity, bool>> filter = null, Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>> orderBy = null, string includeProperties = "")
代码 Expression<Func<TEntity, bool>> filter 表示调用方需要提供一个基于 TEntity 类型的 Lambda 表达式,表达式将会返回 bool 类型的值。例如,如果仓储实例化为 Student 类型,调用的方法可能为 filter 传递的参数为 student => student.LastName == "Smith"
代码 Func<IQueryable<TEntity>, IOrderedQueryable<TEntity>> orderBy 也表示调用方需要提供一个 Lambda 表达式,在这里,表达式是 TEntity 类型的 IQueryable 对象。返回排序版本的 IQueryable 对象。例如,如果仓储实例化为 Student 实体类型,代码为 orderBy 参数传递的参数可能为 q => q.OrderBy(s => s.LastName) 。
Get 方法创建一个 IQueryable 对象,如果存在过滤条件的话,再使用过滤条件。
IQueryable<TEntity> query = dbSet; if (filter != null) { query = query.Where(filter); }
然后,在解析逗号分隔的列表之后,应用预先加载。
foreach (var includeProperty in includeProperties.Split (new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries)) { query = query.Include(includeProperty); }
最后,如果存在排序条件,应用 orderBy 表达式,否则它返回没有排序的查询。
if (orderBy != null) { return orderBy(query).ToList(); } else { return query.ToList(); }
在调用 Get 方法的时候,你可以不提供这些参数,而通过方法返回的 IEnumerable 集合进行过滤和排序,但是排序和过滤将会在 Web 服务器的内存中进行。通过使用这些参数,可以使这些工作在数据库中进行而不是在 Web 服务器上进行。另外一种替代方式是为特定的实体类型创建派生类,增加特定的 Get 方法,诸如 GetStudentsInNameOrder 或者 GetStudentsByName。然而,在复杂的应用中,这会导致大量的派生类和特定方法,在维护的时候会导致大量的工作。
在 GetByID, Insert 和 Update 中的方法如同在非泛型方法中一样简单 ( 在 GetByID 方法扎没有提供预先加载参数,因为不能对 Find 方法进行预先加载 )。
Delete 方法有两个重载。
public virtual void Delete(object id) { TEntity entityToDelete = dbSet.Find(id); dbSet.Remove(entityToDelete); } public virtual void Delete(TEntity entityToDelete) { if (context.Entry(entityToDelete).State == EntityState.Detached) { dbSet.Attach(entityToDelete); } dbSet.Remove(entityToDelete); }
一个允许仅仅传递实体的 ID 进行删除,另外一个使用实体实例。像在处理并发中所见,对于并发处理你需要 Delete 方法获取包含追踪属性原始值的实体实例。
泛型仓储可以处理典型的 CRUD 需求。当特定的实体有特定的需求时,例如更加复杂的过滤或者排序,可以通过创建派生类来增加额外的方法。
9-4-2 创建工作单元类
工作单元类服务于一个目的:当你使用多个仓储的时候,共享单个的数据库上下文实例。因此,当工作单元完成的时候,你可以通过在这个数据库上下文实例上调用 SaveChanges 方法来保证相关的所有操作被协调处理。所有这个类需要的就是一个 Save 方法和每个仓储一个的属性。每个仓储属性返回使用相同的数据库上下文对象创建的仓储对象实例。
在 DAL 文件夹中,创建名为 UnitOfWork.cs 的文件,使用下面的代码替换原有内容。
using System; using ContosoUniversity.Models; namespace ContosoUniversity.DAL { public class UnitOfWork : IDisposable { private SchoolContext context = new SchoolContext(); private GenericRepository<Department> departmentRepository; private GenericRepository<Course> courseRepository; public GenericRepository<Department> DepartmentRepository { get { if (this.departmentRepository == null) { this.departmentRepository = new GenericRepository<Department>(context); } return departmentRepository; } } public GenericRepository<Course> CourseRepository { get { if (this.courseRepository == null) { this.courseRepository = new GenericRepository<Course>(context); } return courseRepository; } } public void Save() { context.SaveChanges(); } private bool disposed = false; protected virtual void Dispose(bool disposing) { if (!this.disposed) { if (disposing) { context.Dispose(); } } this.disposed = true; } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } } }
代码为数据库上下文以及每个仓储创建类级成员变量。对于 context 变量,新的上下文对象被实例化。
private SchoolContext context = new SchoolContext(); private GenericRepository<Department> departmentRepository; private GenericRepository<Course> courseRepository;
每个仓储属性检查仓储是否已经被创建了,如果没有,就传递数据库上下文对象,初始化仓储对象,因此,所有的仓储共享相同的数据库上下文。
public GenericRepository<Department> DepartmentRepository { get { if (this.departmentRepository == null) { this.departmentRepository = new GenericRepository<Department>(context); } return departmentRepository; } }
像在类中实例化数据库上下文的其他类一样, UnitOfWork 类也实现了 IDisposable 接口来释放数据库上下文。
9-4-3 修改 CourseController 使用工作单元类和仓储
使用如下代码替换当前的 CourseController.cs。
using System; using System.Collections.Generic; using System.Data; using System.Data.Entity; using System.Linq; using System.Web; using System.Web.Mvc; using ContosoUniversity.Models; using ContosoUniversity.DAL; namespace ContosoUniversity.Controllers { public class CourseController : Controller { private UnitOfWork unitOfWork = new UnitOfWork(); // // GET: /Course/ public ViewResult Index() { var courses = unitOfWork.CourseRepository.Get(includeProperties: "Department"); return View(courses.ToList()); } // // GET: /Course/Details/5 public ViewResult Details(int id) { Course course = unitOfWork.CourseRepository.GetByID(id); return View(course); } // // GET: /Course/Create public ActionResult Create() { PopulateDepartmentsDropDownList(); return View(); } [HttpPost] public ActionResult Create(Course course) { try { if (ModelState.IsValid) { unitOfWork.CourseRepository.Insert(course); unitOfWork.Save(); return RedirectToAction("Index"); } } catch (DataException) { //Log the error (add a variable name after DataException) ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator."); } PopulateDepartmentsDropDownList(course.DepartmentID); return View(course); } public ActionResult Edit(int id) { Course course = unitOfWork.CourseRepository.GetByID(id); PopulateDepartmentsDropDownList(course.DepartmentID); return View(course); } [HttpPost] public ActionResult Edit(Course course) { try { if (ModelState.IsValid) { unitOfWork.CourseRepository.Update(course); unitOfWork.Save(); return RedirectToAction("Index"); } } catch (DataException) { //Log the error (add a variable name after DataException) ModelState.AddModelError("", "Unable to save changes. Try again, and if the problem persists, see your system administrator."); } PopulateDepartmentsDropDownList(course.DepartmentID); return View(course); } private void PopulateDepartmentsDropDownList(object selectedDepartment = null) { var departmentsQuery = unitOfWork.DepartmentRepository.Get( orderBy: q => q.OrderBy(d => d.Name)); ViewBag.DepartmentID = new SelectList(departmentsQuery, "DepartmentID", "Name", selectedDepartment); } // // GET: /Course/Delete/5 public ActionResult Delete(int id) { Course course = unitOfWork.CourseRepository.GetByID(id); return View(course); } // // POST: /Course/Delete/5 [HttpPost, ActionName("Delete")] public ActionResult DeleteConfirmed(int id) { Course course = unitOfWork.CourseRepository.GetByID(id); unitOfWork.CourseRepository.Delete(id); unitOfWork.Save(); return RedirectToAction("Index"); } protected override void Dispose(bool disposing) { unitOfWork.Dispose(); base.Dispose(disposing); } } }
代码中增加了 UnitOfWork 类级成员变量。( 如果在这里使用接口,就不需要在这里实例化对象,相反,应该实现类似前面 Student 仓储的两个构造函数 )
private UnitOfWork unitOfWork = new UnitOfWork();
在类中的其他部分,所有引用的数据库上下文替换为适当的仓储。使用 UnitOfWork 属性来访问仓储。Dispose 方法用来释放 UnitOfWork 实例。
var courses = unitOfWork.CourseRepository.Get(includeProperties: "Department"); // ... Course course = unitOfWork.CourseRepository.GetByID(id); // ... unitOfWork.CourseRepository.Insert(course); unitOfWork.Save(); // ... Course course = unitOfWork.CourseRepository.GetByID(id); // ... unitOfWork.CourseRepository.Update(course); unitOfWork.Save(); // ... var departmentsQuery = unitOfWork.DepartmentRepository.Get( orderBy: q => q.OrderBy(d => d.Name)); // ... Course course = unitOfWork.CourseRepository.GetByID(id); // ... unitOfWork.CourseRepository.Delete(id); unitOfWork.Save(); // ... unitOfWork.Dispose();
运行程序,点击 Courses 窗格。
页面工作如同既往修改之前一样,Course 页面也同样工作。
你现在已经实现了仓储和工作单元模式。在泛型仓储中使用 Lambda 表达式作为参数。更多对 IQueryable 对象使用表达式的信息,可以参阅 MSDN 库中的 IQueryable(T) Interface (System.Linq) 。下一次,将学习如何处理一些高级场景。