反覆項目 #4 – 讓應用程式鬆散耦合 (VB)
由 Microsoft 提供
在這個第四個反覆項目中,我們利用好幾種軟體設計模式,更輕鬆地維護和修改 Contact Manager 應用程式。 例如,我們將應用程式重構為使用存放庫模式和相依性插入模式。
建置連絡人管理 ASP.NET MVC 應用程式 (VB)
在此系列教學課程中,我們會從頭到尾建置整個連絡人管理應用程式。 Contact Manager 應用程式可讓您儲存連絡人資訊 (姓名、電話號碼和電子郵件地址) 以取得人員名單。
我們會透過多個反覆項目建置應用程式。 每次反覆運算時,我們會逐步改善應用程式。 這種多次反覆運算方法的目標是,讓您能夠瞭解每次變更的原因。
反覆項目 #1 – 建立應用程式 在第一個反覆項目中,我們以最簡單的方式建立 Contact Manager。 我們新增對基本資料庫作業的支援:建立、讀取、更新和刪除 (CRUD)。
反覆項目 #2 – 美化應用程式外觀。 在這個反覆項目中,我們修改預設 ASP.NET MVC 檢視主版頁面和串聯樣式表,以改善應用程式的外觀。
反覆項目 #3 – 新增表單驗證。 在第三個反覆項目中,我們新增基本表單驗證。 我們要防止人員提交未完成表單必填欄位的表單。 我們也驗證電子郵件地址和電話號碼。
反覆項目 #4 – 讓應用程式鬆散耦合。 在這個第四個反覆項目中,我們利用好幾種軟體設計模式,更輕鬆地維護和修改 Contact Manager 應用程式。 例如,我們將應用程式重構為使用存放庫模式和相依性插入模式。
反覆項目 #5 – 建立單元測試。 在第五個反覆項目中,我們會藉由新增單元測試,更容易維護和修改應用程式。 我們會模擬資料模型類別,並為控制器和驗證邏輯建置單元測試。
反覆項目 #6 – 使用測試導向的開發。 在這第六個反覆項目中,我們會先撰寫單元測試,再針對單元測試撰寫程式碼,藉此將新功能新增至應用程式。 在此反覆項目中,我們會新增連絡人群組。
反覆項目 #7 – 新增 Ajax 功能性。 在第七個反覆項目中,我們會藉由新增對 Ajax 的支援來改善應用程式的回應性和效能。
此反覆項目
在這個 Contact Manager 應用程式的第四個反覆項目中,我們會重構應用程式,讓應用程式更鬆散耦合。 當應用程式鬆散耦合時,您可以在應用程式的某個部分中修改程式碼,而不需要修改應用程式其他部分的程式碼。 鬆散耦合的應用程式對變更更有彈性。
目前,Contact Manager 應用程式所使用的所有資料存取和驗證邏輯都包含在控制器類別中。 這是個壞主意。 每當您需要修改應用程式的某個部分時,您都有可能將錯誤引入應用程式的另一個部分。 例如,如果您修改驗證邏輯,則有可能在資料存取或控制器邏輯中引入新的錯誤。
注意
(SRP),類別不應該有一個以上的原因要變更。 混合控制器、驗證和資料庫邏輯是對單一責任原則的大規模違規。
您可能需要修改應用程式有幾個原因。 您可能需要將新功能新增至應用程式、您可能需要修正應用程式中的錯誤,或者您可能需要修改應用程式的功能實作方式。 應用程式很少是靜態的。 它們通常會隨著時間成長和變動。
例如,假設您決定變更實作資料存取層的方式。 現在,Contact Manager 應用程式會使用 Microsoft Entity Framework 來存取資料庫。 不過,您可能會決定移轉至新的或替代的資料存取技術,例如 ADO.NET Data Services 或 NHibernate。 不過,由於資料存取碼不會與驗證和控制器程式碼隔離,因此無法修改應用程式中的資料存取程式碼,而不需要修改與資料存取無關的其他程式碼。
另一方面,當應用程式鬆散耦合時,您可以變更應用程式的其中一個部分,而不需要觸及應用程式的其他部分。 例如,您可以切換資料存取技術,而不需修改驗證或控制器邏輯。
在此反覆項目中,我們會利用數種軟體設計模式,讓我們將 Contact Manager 應用程式重構為更鬆散耦合的應用程式。 完成時,Contact Manager 不會執行之前未執行的任何動作。 不過,未來我們就能更輕鬆地變更應用程式。
注意
重構是重寫應用程式的流程,因此不會遺失任何現有的功能。
使用存放庫軟體設計模式
我們的第一個變更是利用稱為存放庫模式的軟體設計模式。 我們將使用存放庫模式來隔離資料存取程式碼與應用程式的其餘部分。
實作存放庫模式需要我們完成下列兩個步驟:
- 建立介面
- 建立一個實作介面的具體類別
首先,我們需要建立介面,描述我們需要執行的所有資料存取方法。 IContactManagerRepository 介面包含在清單 1 中。 此介面描述五種方法:CreateContact()、DeleteContact()、EditContact()、GetContact 和 ListContacts()。
清單 1 - Models\IContactManagerRepository.vb
Public Interface IContactManagerRepository
Function CreateContact(ByVal contactToCreate As Contact) As Contact
Sub DeleteContact(ByVal contactToDelete As Contact)
Function EditContact(ByVal contactToUpdate As Contact) As Contact
Function GetContact(ByVal id As Integer) As Contact
Function ListContacts() As IEnumerable(Of Contact)
End Interface
接下來,我們需要建立實作 IContactManagerRepository 介面的具體類別。 因為我們使用 Microsoft Entity Framework 來存取資料庫,因此我們將建立名為 EntityContactManagerRepository 的新類別。 此類別包含在清單 2 中。
清單 2 - Models\EntityContactManagerRepository.vb
Public Class EntityContactManagerRepository
Implements IContactManagerRepository
Private _entities As New ContactManagerDBEntities()
Public Function GetContact(ByVal id As Integer) As Contact Implements IContactManagerRepository.GetContact
Return (From c In _entities.ContactSet _
Where c.Id = id _
Select c).FirstOrDefault()
End Function
Public Function ListContacts() As IEnumerable(Of Contact) Implements IContactManagerRepository.ListContacts
Return _entities.ContactSet.ToList()
End Function
Public Function CreateContact(ByVal contactToCreate As Contact) As Contact Implements IContactManagerRepository.CreateContact
_entities.AddToContactSet(contactToCreate)
_entities.SaveChanges()
Return contactToCreate
End Function
Public Function EditContact(ByVal contactToEdit As Contact) As Contact Implements IContactManagerRepository.EditContact
Dim originalContact = GetContact(contactToEdit.Id)
_entities.ApplyPropertyChanges(originalContact.EntityKey.EntitySetName, contactToEdit)
_entities.SaveChanges()
Return contactToEdit
End Function
Public Sub DeleteContact(ByVal contactToDelete As Contact) Implements IContactManagerRepository.DeleteContact
Dim originalContact = GetContact(contactToDelete.Id)
_entities.DeleteObject(originalContact)
_entities.SaveChanges()
End Sub
End Class
請注意,EntityContactManagerRepository 類別會實作 IContactManagerRepository 介面。 類別會實作該介面所描述的所有五個方法。
您可能想知道為什麼我們需要為介面煩惱。 為什麼我們需要建立介面和實作它的類別?
除了一個例外狀況,我們的應用程式其餘部分會與介面互動,而不是具體類別。 我們將呼叫 IContactManagerRepository 介面所公開的方法,而不是呼叫 EntityContactManagerRepository 類別所公開的方法。
如此一來,我們即可使用新類別實作介面,而不需要修改應用程式的其餘部分。 例如,在未來的某個日期,我們可能想要實作一個實作 IContactManagerRepository 介面的 DataServicesContactManagerRepository 類別。 DataServicesContactManagerRepository 類別可能會使用 ADO.NET Data Services 來存取資料庫,而不是Microsoft Entity Framework。
如果我們的應用程式程式碼是針對 IContactManagerRepository 介面進行程式設計,而不是具體的 EntityContactManagerRepository 類別,我們可以切換具體類別,而不需要修改程式碼的其餘部分。 例如,我們可以從 EntityContactManagerRepository 類別切換至 DataServicesContactManagerRepository 類別,而不需要修改我們的資料存取或驗證邏輯。
針對介面 (抽象概念) 進行程式設計,而不是具體類別,可讓我們的應用程式更有彈性地變更。
注意
您可以選取 [重構]、[擷取介面] 功能表選項,從 Visual Studio 內的具體類別快速建立介面。 例如,您可以先建立 EntityContactManagerRepository 類別,然後使用擷取介面自動產生 IContactManagerRepository 介面。
使用相依性插入軟體設計模式
既然我們已將資料存取程式碼移轉至不同的存放庫類別,我們需要修改 Contact 控制器以使用此類別。 我們將利用稱為相依性插入的軟體設計模式,在控制器中使用存放庫類別。
修改過的 Contact 控制器包含在清單 3 中。
清單 3 - Controllers\ContactController.vb
Public Class ContactController
Inherits System.Web.Mvc.Controller
Private _repository As IContactManagerRepository
Sub New()
Me.New(new EntityContactManagerRepository())
End Sub
Sub New(repository As IContactManagerRepository)
_repository = repository
End Sub
Protected Sub ValidateContact(contactToValidate As Contact)
If contactToValidate.FirstName.Trim().Length = 0 Then
ModelState.AddModelError("FirstName", "First name is required.")
End If
If contactToValidate.LastName.Trim().Length = 0 Then
ModelState.AddModelError("LastName", "Last name is required.")
End If
If (contactToValidate.Phone.Length > 0 AndAlso Not Regex.IsMatch(contactToValidate.Phone, "((\(\d{3}\) ?)|(\d{3}-))?\d{3}-\d{4}"))
ModelState.AddModelError("Phone", "Invalid phone number.")
End If
If (contactToValidate.Email.Length > 0 AndAlso Not Regex.IsMatch(contactToValidate.Email, "^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$"))
ModelState.AddModelError("Email", "Invalid email address.")
End If
End Sub
Function Index() As ActionResult
Return View(_repository.ListContacts())
End Function
Function Create() As ActionResult
Return View()
End Function
<AcceptVerbs(HttpVerbs.Post)> _
Function Create(<Bind(Exclude:="Id")> ByVal contactToCreate As Contact) As ActionResult
' Validation logic
ValidateContact(contactToCreate)
If Not ModelState.IsValid Then
Return View()
End If
' Database logic
Try
_repository.CreateContact(contactToCreate)
Return RedirectToAction("Index")
Catch
Return View()
End Try
End Function
Function Edit(ByVal id As Integer) As ActionResult
Return View(_repository.GetContact(id))
End Function
<AcceptVerbs(HttpVerbs.Post)> _
Function Edit(ByVal contactToEdit As Contact) As ActionResult
' Validation logic
ValidateContact(contactToEdit)
If Not ModelState.IsValid Then
Return View()
End If
' Database logic
Try
_repository.EditContact(contactToEdit)
Return RedirectToAction("Index")
Catch
Return View()
End Try
End Function
Function Delete(ByVal id As Integer) As ActionResult
Return View(_repository.GetContact(id))
End Function
<AcceptVerbs(HttpVerbs.Post)> _
Function Delete(ByVal contactToDelete As Contact) As ActionResult
Try
_repository.DeleteContact(contactToDelete)
Return RedirectToAction("Index")
Catch
Return View()
End Try
End Function
End Class
請注意,清單 3 中的 Contact 控制器有兩個建構函式。 第一個建構函式會將 IContactManagerRepository 介面的具體執行個體傳遞至第二個建構函式。 Contact Controller 類別使用建構函式相依性插入。
第一個建構函式中只會使用 EntityContactManagerRepository 類別。 類別的其餘部分會使用 IContactManagerRepository 介面,而不是具體的 EntityContactManagerRepository 類別。
這可讓您輕鬆地在未來切換 IContactManagerRepository 類別的實作。 如果您想要使用 DataServicesContactRepository 類別,而不是 EntityContactManagerRepository 類別,只要修改第一個建構函式即可。
建構函式相依性插入也會讓 Contact 控制器類別變得非常可測試。 在單元測試中,您可以傳遞 IContactManagerRepository 類別的模擬實作來具現化 Contact 控制器。 當我們建置 Contact Manager 應用程式的單元測試時,相依性插入的這項功能對於下一個反覆項目來說非常重要。
注意
如果您想要完全將 Contact 控制器類別與 IContactManagerRepository 介面的特定實作分離,則可以利用支援 Dependency Injection 的架構,例如 StructureMap 或 Microsoft Entity Framework (MEF)。 藉由利用相依性插入架構,您永遠不需要參考程式碼中的具體類別。
建立服務層
您可能已經注意到,我們的驗證邏輯仍然與清單 3 中修改控制器類別中的控制器邏輯混合。 基於與隔離資料存取邏輯的好主意相同,最好隔離我們的驗證邏輯。
若要修正此問題,我們可以建立個別的服務層。 服務層是可在控制器和存放庫類別之間插入的個別層。 服務層包含商業規則,包括我們的所有驗證邏輯。
ContactManagerService 包含在清單 4 中。 它包含來自 Contact 控制器類別的驗證邏輯。
清單 4 - Models\ContactManagerService.vb
Public Class ContactManagerService
Implements IContactManagerService
Private _validationDictionary As IValidationDictionary
Private _repository As IContactManagerRepository
Public Sub New(ByVal validationDictionary As IValidationDictionary)
Me.New(validationDictionary, New EntityContactManagerRepository())
End Sub
Public Sub New(ByVal validationDictionary As IValidationDictionary, ByVal repository As IContactManagerRepository)
_validationDictionary = validationDictionary
_repository = repository
End Sub
Public Function ValidateContact(ByVal contactToValidate As Contact) As Boolean
If contactToValidate.FirstName.Trim().Length = 0 Then
_validationDictionary.AddError("FirstName", "First name is required.")
End If
If contactToValidate.LastName.Trim().Length = 0 Then
_validationDictionary.AddError("LastName", "Last name is required.")
End If
If contactToValidate.Phone.Length > 0 AndAlso (Not Regex.IsMatch(contactToValidate.Phone, "((\(\d{3}\) ?)|(\d{3}-))?\d{3}-\d{4}")) Then
_validationDictionary.AddError("Phone", "Invalid phone number.")
End If
If contactToValidate.Email.Length > 0 AndAlso (Not Regex.IsMatch(contactToValidate.Email, "^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$")) Then
_validationDictionary.AddError("Email", "Invalid email address.")
End If
Return _validationDictionary.IsValid
End Function
#Region "IContactManagerService Members"
Public Function CreateContact(ByVal contactToCreate As Contact) As Boolean Implements IContactManagerService.CreateContact
' Validation logic
If Not ValidateContact(contactToCreate) Then
Return False
End If
' Database logic
Try
_repository.CreateContact(contactToCreate)
Catch
Return False
End Try
Return True
End Function
Public Function EditContact(ByVal contactToEdit As Contact) As Boolean Implements IContactManagerService.EditContact
' Validation logic
If Not ValidateContact(contactToEdit) Then
Return False
End If
' Database logic
Try
_repository.EditContact(contactToEdit)
Catch
Return False
End Try
Return True
End Function
Public Function DeleteContact(ByVal contactToDelete As Contact) As Boolean Implements IContactManagerService.DeleteContact
Try
_repository.DeleteContact(contactToDelete)
Catch
Return False
End Try
Return True
End Function
Public Function GetContact(ByVal id As Integer) As Contact Implements IContactManagerService.GetContact
Return _repository.GetContact(id)
End Function
Public Function ListContacts() As IEnumerable(Of Contact) Implements IContactManagerService.ListContacts
Return _repository.ListContacts()
End Function
#End Region
End Class
請注意,ContactManagerService 的建構函式需要 ValidationDictionary。 服務層會透過這個 ValidationDictionary 與控制器層通訊。 當我們討論裝飾項目模式時,我們會在下一節詳細討論 ValidationDictionary。
此外,請注意,ContactManagerService 會實作 IContactManagerService 介面。 您應該一律努力針對介面進行程式設計,而不是具體類別。 Contact Manager 應用程式中的其他類別不會直接與 ContactManagerService 類別互動。 相反地,除了一個例外狀況,Contact Manager 應用程式的其餘部分是針對 IContactManagerService 介面進行程式設計。
IContactManagerService 介面包含在清單 5 中。
清單 5 - Models\IContactManagerService.vb
Public Interface IContactManagerService
Function CreateContact(ByVal contactToCreate As Contact) As Boolean
Function DeleteContact(ByVal contactToDelete As Contact) As Boolean
Function EditContact(ByVal contactToEdit As Contact) As Boolean
Function GetContact(ByVal id As Integer) As Contact
Function ListContacts() As IEnumerable(Of Contact)
End Interface
修改過的 Contact 控制器類別包含在清單 6 中。 請注意,Contact 控制器不再與 ContactManager 存放庫互動。 相反地,Contact controller 會與 ContactManager 服務互動。 每一層會盡可能與其他層隔離。
清單 6 - Controllers\ContactController.vb
Public Class ContactController
Inherits System.Web.Mvc.Controller
Private _service As IContactManagerService
Sub New()
_service = new ContactManagerService(New ModelStateWrapper(ModelState))
End Sub
Sub New(service As IContactManagerService)
_service = service
End Sub
Function Index() As ActionResult
Return View(_service.ListContacts())
End Function
Function Create() As ActionResult
Return View()
End Function
<AcceptVerbs(HttpVerbs.Post)> _
Function Create(<Bind(Exclude:="Id")> ByVal contactToCreate As Contact) As ActionResult
If _service.CreateContact(contactToCreate) Then
Return RedirectToAction("Index")
End If
Return View()
End Function
Function Edit(ByVal id As Integer) As ActionResult
Return View(_service.GetContact(id))
End Function
<AcceptVerbs(HttpVerbs.Post)> _
Function Edit(ByVal contactToEdit As Contact) As ActionResult
If _service.EditContact(contactToEdit) Then
Return RedirectToAction("Index")
End If
Return View()
End Function
Function Delete(ByVal id As Integer) As ActionResult
Return View(_service.GetContact(id))
End Function
<AcceptVerbs(HttpVerbs.Post)> _
Function Delete(ByVal contactToDelete As Contact) As ActionResult
If _service.DeleteContact(contactToDelete) Then
return RedirectToAction("Index")
End If
Return View()
End Function
End Class
我們的應用程式不再違反單一責任原則 (SRP)。 除了控制應用程式執行流程之外,清單 6 中的 Contact 控制器已去除每個責任。 所有驗證邏輯都已從 Contact 控制器中移除,並推送至服務層。 所有資料庫邏輯都已推送至存放庫層。
使用裝飾項目模式
我們想要能夠完全將服務層與控制器層分離。 原則上,我們應該能夠在與控制器層不同的元件中編譯服務層,而不需要將參考新增至我們的 MVC 應用程式。
不過,我們的服務層必須能夠將驗證錯誤訊息傳回控制器層。 如何讓服務層在不結合控制器和服務層的情況下傳達驗證錯誤訊息? 我們可以利用名為裝飾項目模式的軟體設計模式。
控制器會使用名為 ModelState 的 ModelStateDictionary 來代表驗證錯誤。 因此,您可能會想要將 ModelState 從控制器層傳遞至服務層。 不過,在服務層中使用 ModelState 會使您的服務層相依於 ASP.NET MVC 架構的功能。 這很糟糕,因為有一天,您可能想要搭配 WPF 應用程式使用服務層,而不是使用 ASP.NET MVC 應用程式。 在此情況下,您不想參考 ASP.NET MVC 架構來使用 ModelStateDictionary 類別。
裝飾項目模式可讓您將現有的類別包裝在新類別中,以實作介面。 我們的 Contact Manager 專案包含清單 7 中包含的 ModelStateWrapper 類別。 ModelStateWrapper 類別會實作清單 8 中的介面。
清單 7 - Models\Validation\ModelStateWrapper.vb
Public Class ModelStateWrapper
Implements IValidationDictionary
Private _modelState As ModelStateDictionary
Public Sub New(ByVal modelState As ModelStateDictionary)
_modelState = modelState
End Sub
Public Sub AddError(ByVal key As String, ByVal errorMessage As String) Implements IValidationDictionary.AddError
_modelState.AddModelError(key, errorMessage)
End Sub
Public ReadOnly Property IsValid() As Boolean Implements IValidationDictionary.IsValid
Get
Return _modelState.IsValid
End Get
End Property
End Class
清單 8 - Models\Validation\IValidationDictionary.vb
Public Interface IValidationDictionary
Sub AddError(ByVal key As String, ByVal errorMessage As String)
ReadOnly Property IsValid() As Boolean
End Interface
如果您仔細查看清單 5,您會看到 ContactManager 服務層專門使用 IValidationDictionary 介面。 ContactManager 服務不相依於 ModelStateDictionary 類別。 當 Contact 控制器建立 ContactManager 服務時,控制器會包裝其 ModelState,如下所示:
_service = new ContactManagerService(New ModelStateWrapper(ModelState))
摘要
在此反覆項目中,我們沒有將任何新功能新增至 Contact Manager 應用程式。 此反覆項目的目標是重構 Contact Manager 應用程式,以便更容易維護和修改。
首先,我們實作存放庫軟體設計模式。 我們已將所有資料存取程式碼移轉至個別的 ContactManager 存放庫類別。
我們也將驗證邏輯與控制器邏輯隔離。 我們建立了包含所有驗證程式碼的個別服務層。 控制器層會與服務層互動,而服務層會與存放庫層互動。
當我們建立服務層時,我們會利用裝飾項目模式來隔離 ModelState 與服務層。 在服務層中,我們會針對 IValidationDictionary 介面進行程式設計,而不是 ModelState。
最後,我們利用名為相依性插入模式的軟體設計模式。 此模式可讓我們針對介面 (抽象概念) 進行程式設計,而不是具體類別。 實作相依性插入設計模式也會讓我們的程式碼更容易測試。 在下一個反覆項目中,我們會將單元測試新增至專案。