2016 年 5 月

第 31 卷,第 5 期

本文章是由機器翻譯。

ASP.NET - 在 ASP.NET Core 使用 Dependency Injection 撰寫簡潔的程式碼

Steve Smith

ASP.NET Core 1.0 是完全重寫的 ASP.NET,而這個新架構的主要目標的其中一個更模組化的設計。也就是應用程式應該要能夠運用這些架構提供相依性,會在要求所需的 framework 組件。此外,使用 ASP.NET 核心建置應用程式的開發人員應該能夠運用鬆散結合且模組化,讓他們的應用程式相同的功能。具有 ASP.NET MVC ASP.NET 團隊大幅提升撰寫鬆散結合的程式碼架構的支援,但仍是很容易誤的緊密結合在一起,尤其是在控制器類別。

緊密結合在一起

示範軟體緊密結合在一起,沒關係。如果您看一下典型的範例應用程式示範如何建立 ASP.NET MVC (版本 3 到 5) 的網站,您最有可能會發現 (從 NerdDinner MVC 4 範例 DinnersController 類別) 的程式碼如下 ︰

private NerdDinnerContext db = new NerdDinnerContext();
private const int PageSize = 25;
public ActionResult Index(int? page)
{
  int pageIndex = page ?? 1;
  var dinners = db.Dinners
    .Where(d => d.EventDate >= DateTime.Now).OrderBy(d => d.EventDate);
  return View(dinners.ToPagedList(pageIndex, PageSize));
}

這種程式碼是建構的很難單元測試,因為 NerdDinnerContext 會建立類別的一部分,而且需要用來連接至資料庫。不用多說,這類的示範應用程式通常不包含任何單元測試。不過,您的應用程式,可能受益了一些單元測試,即使您不評估測試開發程式時,因此最好撰寫的程式碼,因此無法進行測試。不僅如此,此程式碼違反了不重複自行 (乾) 原則,因為每一個執行的任何資料存取的控制器類別有相同的程式碼,以便建立使用 Entity Framework (EF) 資料庫內容。這會未來變更更昂貴且容易發生錯誤,特別是隨著時間成長,應用程式。

檢視程式碼,以評估其結合時,請記得片語 「 新是黏附 」。 也就是任何地方您看到 「 新 」 關鍵字將類別執行個體化,了解您正在黏附實作該特定的實作程式碼。相依性反轉原則 (原則-DI bit.ly/) 狀態 ︰ 「 抽象概念不應該相依於詳細資料。詳細資料應該相依於抽象概念。 」 在此範例中,如何控制站協同要傳遞至檢視資料的詳細資料取決於如何取得該資料的詳細資訊,也就是 EF。

除了新的關鍵字 「 靜態黏貼 」 是另一個應用程式更難測試和維護的緊密結合的來源。在上述範例中,有相依性時執行的機器的系統時鐘,Ticks 呼叫的形式。這種結合會讓您建立一組測試 Dinners 困難,一些單元測試中使用,因為其 EventDate 屬性必須設定相對於目前的時鐘設定。這種結合可移除從這個方法有好幾種,最簡單的就是讓任何新的抽象層會傳回 Dinners 擔心它,所以它不再是此方法的一部分。或者,我無法進行值的參數,這樣方法可能會傳回所有 Dinners 之後提供的日期時間參數,而不是一定要使用 Ticks。最後,我可以建立的抽象概念,目前的時間,然後再參考該抽象透過目前的時間。如果應用程式經常會參考 Ticks,這可以是較理想的方法。(它也是值得一提,因為這些 dinners 可能會發生在不同的時區,DateTimeOffset 型別可能更好的選擇,在實際的應用程式)。

老實說

類似的程式碼的可維護性的另一個問題是,它不使用其共同作業者誠實。您應該避免撰寫處於無效狀態,才能執行個體化的類別,因為這些是常見的錯誤來源。因此,您的類別以執行其工作所需要的任何項目應該提供透過其建構函式。以明確的相依性原則 (bit.ly/ED-原則) 所述,「 方法和類別應該明確需要共同作業正常運作所需的任何物件。 」 DinnersController 類別有只有預設建構函式,這表示,它應該不需要任何共同作業者,才能正確運作。但是如果您將會測試,會發生什麼事? 將此程式碼做什麼,如果您執行從新的主控台應用程式參考 MVC 專案的專案?

var controller = new DinnersController();
var result = controller.Index(1);

無法在此情況下,首先會嘗試具現化 EF 內容。程式碼擲回 InvalidOperationException: 「 沒有名為 'NerdDinnerContext' 的連接字串找應用程式組態檔中。 」 我已經 deceived ! 此類別需要多個要比其建構函式宣告的函式 ! 如果類別需要存取晚餐執行個體集合的方法,它應該透過其建構函式 (或者,做為參數,其方法上) 的要求。

相依性的插入 (Dependency Injection)

相依性插入 (DI) 是指將傳遞的類別或方法的相依性中做為參數,而不是硬式編碼這些關聯性透過新的或靜態的呼叫。它是一變得愈來愈普遍技術.NET 開發,因為它可以提供採用它的應用程式分開處理。早期版本的 ASP.NET 並未利用 DI,和雖然 ASP.NET MVC 和 Web API 進行到支援它的進度,則兩者都不到目前為止發生並建置完整的支援,包括管理相依性,以及其物件生命週期,在產品的容器。ASP.NET 核心 1.0,DI 不只是支援根據預設,產品本身會廣泛地使用它。

ASP.NET Core 不僅支援 DI,它也包含 DI 容器 — 也稱為逆轉控制 (IoC) 容器或服務的容器。每個 ASP.NET 核心應用程式會設定 Startup 類別 ConfigureServices 方法中使用此容器及其相依性。此容器提供需要的基本支援,但它可以被取代的自訂實作如有需要。不僅如此,EF 核心也有內建支援 DI,因此設定 ASP.NET 核心應用程式中很簡單,只要呼叫擴充方法。我建立了 spinoff 的 NerdDinner,稱為 GeekDinner,這個發行項。EF 核心設定如下所示 ︰

public void ConfigureServices(IServiceCollection services)
{
  services.AddEntityFramework()
    .AddSqlServer()
    .AddDbContext<GeekDinnerDbContext>(options =>
      options.UseSqlServer(ConnectionString));
  services.AddMvc();
}

這個之後,就像 DinnersController 控制器類別從要求的 GeekDinnerDbContext 執行個體使用 DI 相當簡單 ︰

public class DinnersController : Controller
{
  private readonly GeekDinnerDbContext _dbContext;
  public DinnersController(GeekDinnerDbContext dbContext)
  {
    _dbContext = dbContext;
  }
  public IActionResult Index()
  {
    return View(_dbContext.Dinners.ToList());
  }
}

請注意不是新的關鍵字; 的單一執行個體控制站需要所有傳入透過其建構函式和 ASP.NET DI 容器的相依性會負責這對我。由於我著重於撰寫應用程式,我不需要擔心需在完成相依性我類別要求透過其建構函式。當然,如果我想,我可以自訂此行為,甚至完全以另一個實作取代預設容器。由於我的控制器類別現在採用明確的相依性原則,因此我知道,我需要為它提供 GeekDinnerDbContext 的執行個體的函式。我可以安裝程式之 DbContext 的位元,具現化隔離中,控制器會示範這個主控台應用程式 ︰

var optionsBuilder = new DbContextOptionsBuilder();
optionsBuilder.UseSqlServer(Startup.ConnectionString);
var dbContext = new GeekDinnerDbContext(optionsBuilder.Options);
var controller = new DinnersController(dbContext);
var result = (ViewResult) controller.Index();

沒有更多工作涉及建構比 EF6 EF 核心 DbContext 一個,只花了連接字串。這是因為,如同 ASP.NET 核心 EF 核心已設計成可以更模組化。一般來說,您不需要直接處理 DbContextOptionsBuilder 因為它會在幕後使用當您設定 EF 透過 AddSqlServer AddEntityFramework 等的擴充方法。

但您可以測試它嗎?

手動測試您的應用程式的重要性 — 您想要能夠執行它,並查看它實際執行,會產生預期的輸出。但不必這麼做,每次您進行變更是浪費時間。其中一個最大的鬆散結合應用程式的優點是,它們通常比較適合單元測試多於緊密結合的應用程式。儘管如此,進一步 ASP.NET 核心和 EF 核心會更容易測試比它們的前身。若要開始,我會撰寫簡單的測試,直接對控制器傳遞已設定為使用記憶體中存放區的 DbContext。我將使用 DbContextOptions 參數透過其建構函式會公開做為我的測試設定程式碼的一部分 GeekDinnerDbContext:

var optionsBuilder = new DbContextOptionsBuilder<GeekDinnerDbContext>();
optionsBuilder.UseInMemoryDatabase();
_dbContext = new GeekDinnerDbContext(optionsBuilder.Options);
// Add sample data
_dbContext.Dinners.Add(new Dinner() { Title = "Title 1" });
_dbContext.Dinners.Add(new Dinner() { Title = "Title 2" });
_dbContext.Dinners.Add(new Dinner() { Title = "Title 3" });
_dbContext.SaveChanges();

使用此設定在我的測試類別中,很容易撰寫測試,顯示 ViewResult 模型中,會傳回正確的資料 ︰

[Fact]
public void ReturnsDinnersInViewModel()
{
  var controller = new OriginalDinnersController(_dbContext);
  var result = controller.Index();
  var viewResult = Assert.IsType<ViewResult>(result);
  var viewModel = Assert.IsType<IEnumerable<Dinner>>(
    viewResult.ViewData.Model).ToList();
  Assert.Equal(1, viewModel.Count(d => d.Title == "Title 1"));
  Assert.Equal(3, viewModel.Count);
}

當然,沒有很多邏輯,以在此測試,因此這項測試不真正測試有太大。讀者會說,這不是那麼重要的測試,而且我同意與它們。不過,它是起點時,沒有更多邏輯的位置,因為很快就會有。但首先,雖然 EF 核心可以支援單元測試其記憶體中的選項,將仍然避免直接聯繫至 EF 在我的控制器。不需要為了與資料存取基礎結構考量幾 UI 問題 — 事實上,違反了另一個原則,區隔的考量。

沒有什麼不使用

介面隔離原則 (bit.ly/LS-原則) 表示類別,應該只在實際使用的功能而定。如果新的 DI 啟用 DinnersController,它仍然取決於整個 DbContext。而不是黏附至 EF 控制器執行方式,您可以使用抽象層,提供必要的功能 (並沒有太多以上 nothing)。

這個動作方法真的需要什麼才能運作? 不能算是整個 DbContext。它甚至不需要完整 Dinners 屬性內容的存取權。它需要的只是顯示適當的頁面晚餐執行個體的能力。表示這個簡單的.NET 抽象概念是 IEnumerable < Dinner >。因此,我要定義的介面,只會傳回 IEnumerable < 晚餐 >,且會符合 (大部分的) 索引方法的需求 ︰

public interface IDinnerRepository
{
  IEnumerable<Dinner> List();
}

我呼叫這個儲存機制因為它會依照該模式 ︰ 它會擷取類似集合的介面背後的資料存取。如果您不喜歡的儲存機制模式或名稱某種原因,您可以稱它為 IGetDinners 或 IDinnerService 或名稱您偏好 (我技術檢閱者建議 ICanHasDinner)。不論您如何命名型別,將會提供相同的目的。

有了這個地方,我現在調整 DinnersController 接受 IDinnerRepository 做為建構函式參數,而不是 GeekDinnerDbContext,並呼叫清單的方法,而不是直接存取 Dinners DbSet:

private readonly IDinnerRepository _dinnerRepository;
public DinnersController(IDinnerRepository dinnerRepository)
{
  _dinnerRepository = dinnerRepository;
}
public IActionResult Index()
{
  return View(_dinnerRepository.List());
}

此時,您可以建置及執行您的 Web 應用程式,但如果您瀏覽至 /Dinners 遇到例外狀況 ︰ InvalidOperationException: 無法解析型別 'GeekDinner.Core.Interfaces.IdinnerRepository' 的服務在嘗試啟動 GeekDinner.Controllers.DinnersController。我還尚未實作介面,並完成之後,我還需要設定 DI 滿足要求的 IDinnerRepository 時要使用我的實作。實作介面便微不足道 ︰

public class DinnerRepository : IDinnerRepository
{
  private readonly GeekDinnerDbContext _dbContext;
  public DinnerRepository(GeekDinnerDbContext dbContext)
  {
    _dbContext = dbContext;
  }
  public IEnumerable<Dinner> List()
  {
    return _dbContext.Dinners;
  }
}

請注意,最適合的直接結合至 EF 的儲存機制實作。如果需要更換 EF,我將只建立新的實作這個介面。這個實作類別是我的應用程式基礎結構,也就是我的類別會視特定實作的一個位置中應用程式的一部分。

若要設定 ASP.NET 核心類別要求 IDinnerRepository 時插入正確的實作,我需要稍早所示的 ConfigureServices 方法的結尾加入下列程式碼行 ︰

services.AddScoped<IDinnerRepository, DinnerRepository>();

此陳述式會指示 ASP.NET 核心 DI 容器,容器解析的型別取決於 IDinnerRepository 執行個體,只要使用 DinnerRepository 執行個體。範圍表示這個執行個體將用於每個 Web 要求 ASP.NET 控制代碼。服務也可以新增使用暫時性或單一物件的存留期。在此情況下,Scoped 是適當,因為我 DinnerRepository DbContext,也會使用 Scoped 存留期而定。以下是可用的物件存留期的摘要 ︰

  • 暫時性 ︰ 類型的新執行個體在每次要求的型別。
  • 範圍 ︰ 類型的新執行個體建立第一次它有內指定的 HTTP 要求,要求並重複使用的 HTTP 要求期間已解決的所有後續類型。
  • 單一子句 ︰ 類型的單一執行個體是建立一次,並使用該型別的所有後續的要求。

內建的容器支援數個方式來建構它將提供的型別。最常見案例是只需提供容器類型,並會嘗試具現化該類型,提供任何類型都需要接下來的相依性。您也可以提供 lambda 運算式來建構型別,或者,對於單一存留期,您可以在 ConfigureServices 完全建構的執行個體時提供您在註冊。

妥當的依存性插入式攻擊,應用程式會執行像過去一樣。現在,因為 [圖 1 示範,我可以測試應用程式使用這個新的抽象層的位置,而不依賴 EF 直接在我的測試程式碼中使用 IDinnerRepository 介面的假或模擬 (mock) 實作。

[圖 1 測試 DinnersController 使用模擬 (mock) 物件

public class DinnersControllerIndex
{
  private List<Dinner> GetTestDinnerCollection()
  {
    return new List<Dinner>()
    {
      new Dinner() {Title = "Test Dinner 1" },
      new Dinner() {Title = "Test Dinner 2" },
    };
  }
  [Fact]
  public void ReturnsDinnersInViewModel()
  {
    var mockRepository = new Mock<IDinnerRepository>();
    mockRepository.Setup(r =>
      r.List()).Returns(GetTestDinnerCollection());
    var controller = new DinnersController(mockRepository.Object, null);
    var result = controller.Index();
    var viewResult = Assert.IsType<ViewResult>(result);
    var viewModel = Assert.IsType<IEnumerable<Dinner>>(
      viewResult.ViewData.Model).ToList();
    Assert.Equal("Test Dinner 1", viewModel.First().Title);
    Assert.Equal(2, viewModel.Count);
  }
}

Dinner 執行個體的清單是來自不論這項測試。您可以重新撰寫資料存取程式碼,可以使用另一個資料庫、 Azure 資料表儲存體或 XML 檔案,並控制站會使用相同。當然,在此情況下它不會執行一大堆,因此您可能會好奇...

實際的邏輯呢?

到目前為止我真的尚未實作任何實際的商業邏輯,就已經傳回資料的簡單集合的簡單方法。測試的真正價值來自有邏輯和特殊情況下,您需要有信心會如預期方式運作。為了示範這個情況,我將一些需求加入至我的 GeekDinner 網站。站台會公開 API,可讓所有人都可以吃晚餐 RSVP。不過,dinners 會有選擇性的最大容量,而且與會者不應該超過這個容量。要求超過最大容量的與會者使用者應該將 waitlist。最後,dinners 可以指定的與會者必須接收,相對於其開始時間之後, 就停止接受與會者的期限。

我可以撰寫程式碼所有此邏輯付諸行動,但我認為這太多放入一種方法,尤其是 UI 的方法,應該著重 UI 考量,不是商務邏輯的責任。控制器應該確認收到的輸入有效,而且它應該確保它會傳回的回應都適用於用戶端。此外,決策,尤其是商務邏輯不屬於控制站。

將商務邏輯的最佳位置是在應用程式的網域模型中,不應該相依於基礎結構問題 (例如資料庫或 Ui)。晚餐類別最為合理管理 RSVP 考量所述的需求,因為它會儲存最大容量吃晚餐,它就會知道多少與會者已有為止。不過,在邏輯也取決於 RSVP 發生時,它是否超過期限 — 因此方法也需要存取目前的時間。

我可以直接使用 Ticks,但這會使難以測試的邏輯,且會結合在我的網域模型的系統時鐘。另一個選項是使用 IDateTime 抽象概念,並將這晚餐實體插入。不過,最好是保持我的經驗晚餐像實體免費的相依性,特別是如果您計劃使用 O/RM 工具類似 EF 將提取它們從持續性層級。我不想要在該程序中,填入實體的相依性且 EF 不一定能夠不在我的組件上的其他程式碼。提取出晚餐實體的邏輯,並將其放在某種可能會輕易地插入相依 (例如 DinnerService 或 RsvpService) 現在是服務的常見的方法。這可能會導致 anemic 網域模型 antipattern (bit.ly/anemic 模型),不過,在哪些實體幾乎沒有任何行為,而且是只要包的狀態。否,在此情況下解決方案很簡單 — 方法可以只在目前的時間,做為參數,並讓傳遞呼叫的程式碼。

使用這個方法,加入 RSVP 的邏輯很簡單 (請參閱 [圖 2)。這個方法有許多測試,以示範它的行為如預期般。測試可與本文相關的範例專案中。

[圖 2 在網域模型的商務邏輯

public RsvpResult AddRsvp(string name, string email, DateTime currentDateTime)
{
  if (currentDateTime > RsvpDeadlineDateTime())
  {
    return new RsvpResult("Failed - Past deadline.");
  }
  var rsvp = new Rsvp()
  {
    DateCreated = currentDateTime,
    EmailAddress = email,
    Name = name
  };
  if (MaxAttendees.HasValue)
  {
    if (Rsvps.Count(r => !r.IsWaitlist) >= MaxAttendees.Value)
    {
      rsvp.IsWaitlist = true;
      Rsvps.Add(rsvp);
      return new RsvpResult("Waitlist");
    }
  }
  Rsvps.Add(rsvp);
  return new RsvpResult("Success");
}

由這個邏輯會移至網域模型,我已經確定我的控制器 API 方法保持小型和專注於本身的顧慮。如此一來,很容易測試,控制器可以勝任,因為有相對較少的路徑的方法。

控制者責任

控制站一部分是責任的檢查 ModelState 並確定有效。我做這動作方法,為了清楚起見,但較大的應用程式中會排除此重複的程式碼在每個動作中使用動作篩選條件 ︰

[HttpPost]
public IActionResult AddRsvp([FromBody]RsvpRequest rsvpRequest)
{
  if (!ModelState.IsValid)
  {
    return HttpBadRequest(ModelState);
  }

假設 ModelState 為有效,動作接下來必須擷取適當晚餐執行個體使用的要求中所提供的識別項。如果動作找不到符合該識別碼的晚餐執行個體,則應該傳回找不到的結果 ︰

var dinner = _dinnerRepository.GetById(rsvpRequest.DinnerId);
if (dinner == null)
{
  return HttpNotFound("Dinner not found.");
}

完成這些檢查之後, 的動作是可用來委派網域模型,您在前面,晚餐類別上呼叫 AddRsvp 方法,並傳回回應之前儲存網域模型 (在此情況下,晚餐執行個體和它的與會者集合) 的更新的狀態表示所要求的商務作業 ︰

var result = dinner.AddRsvp(rsvpRequest.Name,
    rsvpRequest.Email,
    _systemClock.Now);
  _dinnerRepository.Update(dinner);
  return Ok(result);
}

請記住,我決定晚餐類別不應該有相依性的系統時鐘,改為選擇使用為目前時間傳遞至方法。在控制器中,我傳遞中 _systemClock.Now currentDateTime 參數。這是透過 DI,防止被緊密結合的系統時鐘,太控制器會填入本機欄位。它適合使用 DI 站上,而不是網域的實體,因為控制站永遠可由 ASP.NET 服務容器。這會完成控制站會在其建構函式中宣告任何相依性。_systemClock 是 IDateTime,這是定義並實作只需要幾行程式碼類型的欄位 ︰

public interface IDateTime
{
  DateTime Now { get; }
}
public class MachineClockDateTime : IDateTime
{
  public DateTime Now { get { return System.DateTime.Now; } }
}

當然,我還需要確定 ASP.NET 容器設定為使用 MachineClockDateTime,每當類別需要 IDateTime 的執行個體。這是在 ConfigureServices 啟動類別,並在此情況下,雖然任何物件存留期,我選擇使用單一值,因為 MachineClockDateTime 的一個執行個體可用於整個應用程式 ︰

services.AddSingleton<IDateTime, MachineClockDateTime>();

這個簡單的抽象層就地,我可以測試是否已經過 RSVP 期限,並確保正確的結果會傳回為基礎的控制站的行為。因為我已經測試 Dinner.AddRsvp 方法來驗證它如預期般運作,我不需要很多的測試控制器讓我透過該相同行為的信心,當搭配使用,控制站及網域模型能夠正確運作。

後續步驟

下載相關聯的範例專案,以查看晚餐和 DinnersController 的單元測試。請記住,鬆散結合的程式碼通常更容易單元測試比充滿取決於基礎結構的 「 新 」 或靜態方法呼叫的程式碼緊密結合。「 新是黏附 」,新的關鍵字應該用於刻意,不會無意,在您的應用程式。深入了解 ASP.NET 核心,且在相依性插入支援 docs.asp.net


Steve Smith是獨立的訓練、 指導和顧問,以及 ASP.NET 的 MVP。他有貢獻的數十個到正式的 ASP.NET 核心文件的文件 (docs.asp.net),並與小組學習這項技術的運作方式。他的連絡 ardalis.com 或關注他的 Twitter: @ardalis

感謝以下的微軟技術專家對本文的審閱: Doug Bunting
Doug Bunting 是使用 Microsoft 的 MVC 小組的開發人員。他已經這麼一段時間,而且愛 MVC 核心重寫在新的 DI 典範。