通知

收集領域層中錯誤和其他資訊的物件,並將其傳達給簡報。

2004 年 8 月 9 日

這是 進階企業應用程式架構開發 文章的一部分,我是在 2000 年代中期撰寫的。遺憾的是,從那以後,太多其他事情吸引了我的注意力,所以我沒有時間進一步研究它們,而且在可預見的未來,我也沒有看到太多時間。因此,這份文件基本上是草稿形式,直到我有時間再次研究它之前,我不會進行任何更正或更新。

常見的應用程式情境是簡報擷取使用者的資料,並將該資料提交給領域進行驗證。領域需要進行多項檢查,如果其中任何一項失敗,則讓簡報知道。然而,分離簡報 不允許領域直接與簡報溝通。

通知 是領域用來收集驗證期間錯誤資訊的物件。當錯誤出現時,通知 會傳送回簡報,以便簡報可以顯示有關錯誤的更多資訊。

運作方式

在最簡單的形式中,通知可以只是一個字串集合,這些字串是領域在執行工作時產生的錯誤訊息。隨著領域層進行的每次驗證,每次失敗都會導致錯誤新增到通知中。然而,讓通知有比這更明確的介面是有意義的。通知物件通常會有方法來新增使用錯誤碼而非字串的錯誤,以及判斷通知中是否有任何錯誤的便利方法。

如果您正在使用 資料傳輸物件 (DTO),則將通知新增至 層超級類型 中的 DTO 是有意義的。這允許所有互動使用通知來進行清除。

如果您的網域邏輯相對簡單,例如使用交易指令碼,則邏輯可以對 通知 進行直接參照。這使得在新增錯誤時可以輕鬆地參照它。在使用 網域模型 的更多層級系統中,參照 通知 可能會更加有問題,因為此類網域模型通常無法看到諸如傳入 DTO 之類的事物。在這種情況下,有必要將 通知 放置在網域物件可以輕鬆存取的某種類型的階段物件中。

錯誤碼需要出現在表示層和網域之間共用的類別上。使用錯誤碼提供預期錯誤的更明確陳述,並使表示層能夠以比僅列印錯誤訊息更互動的方式呈現錯誤。對於簡單的網域層,通常足以將這些碼嵌入資料傳輸物件 (如果您正在使用一個) 或特定互動的錯誤碼組中。使用網域模型時,錯誤碼需要圍繞網域模型本身的詞彙進行設計。

雖然錯誤通常是 通知 最需要的方面,但 通知 也可傳回網域希望傳達給呼叫者的任何其他資訊,這也很有用。這些可能包括警告 (不嚴重到足以停止互動的問題) 和要顯示給使用者的資訊訊息。這些需要在 通知 上分開,以便表示層可以輕鬆判斷是否存在任何錯誤。

如果您在表示層和網域邏輯位於不同程序的系統中使用 通知,則需要確保 通知 僅包含可以安全地透過網路傳輸的資訊,這通常表示您無法在這些 通知 中嵌入對網域物件的參照。

當簡報收到驗證回應時,它需要檢查 通知 以確定是否有錯誤。如果有,它可以從 通知 中提取資訊,以向使用者顯示這些錯誤。如果出現錯誤,其中一個選項是讓網域引發例外狀況,以便簡報可以使用例外狀況處理來處理錯誤。整體而言,我認為驗證錯誤很常見,不值得為這些情況使用例外狀況處理機制,但這不是壓倒性的偏好。

何時使用

當驗證是由無法直接依賴於啟動驗證的模組的程式碼層執行時,您應該使用 通知。這在分層架構中非常常見,即 分離簡報

使用 通知 最明顯的替代方法是讓網域使用例外狀況處理來指出錯誤。這種方法讓網域在驗證檢查失敗時擲回例外狀況。這樣做問題在於它只指出第一個驗證錯誤。通常顯示每個驗證錯誤會更有幫助,特別是如果驗證需要往返於遠端網域層。

另一種替代方法是讓網域層為驗證錯誤引發事件。這允許多個錯誤被標記。然而,這對於遠端網域層來說不太好,因為每個事件都會導致網路呼叫。

範例:檢查視窗錯誤 (C#)

圖 1 中,我有一個簡化的表單,用於提交評斷保險理賠。我必須提交三項資料:保單號碼 (字串)、理賠類型 (從選取清單中的文字) 和事故日期 (DateTime)。

如果我的資料很簡化,只需等待我的有效性檢查

  • 檢查這三項資料是否都沒有遺漏 (字串為 null 或空白。
  • 檢查資料儲存庫中是否有保單號碼。
  • 檢查事故日期是否晚於保單生效日期。

我們希望盡可能向使用者提供更多資訊,因此如果我們可以合理地偵測到多個錯誤,我們就應該這麼做。

我將從領域層開始討論。領域邏輯的基本介面在服務層中。

類別 ClaimService...

  public void RegisterClaim (RegisterClaimDTO claim) {
    RegisterClaim cmd = new RegisterClaim(claim);
    cmd.Run();
  }

此方法只建立並執行命令物件來執行實際工作。將命令物件包裝在方法呼叫服務層之後,有助於簡化伺服器 API,並讓建立遠端外觀變得更容易。

我使用資料傳輸物件來傳輸資料。RegisterClaimDTO 包含主要資料。

RegisterClaimDTO : DataTransferObject

  private string _policyID;
  private string _Type;
  private DateTime _incidentDate = BLANK_DATE;

  public string PolicyId {
    get { return _policyID; }
    set { _policyID = value; }
  }
  public string Type {
    get { return _Type; }
    set { _Type = value; }
  }
  public DateTime IncidentDate {
    get { return _incidentDate; }
    set { _incidentDate = value; }
  }

DataTransferObject 是所有 DTO 的層級父類別。此類別包含用於建立和存取通知的通用程式碼,以配合互動。

類別 DataTransferObject...

  private Notification _notification = new Notification();
  public Notification Notification {
    get { return _notification; }
    set { _notification = value; }
  }

通知類別是我們用來擷取領域層中錯誤的類別。它基本上是錯誤的集合,每個錯誤都是字串周圍的簡單包裝。

類別 Notification...

  private IList _errors = new ArrayList();

  public IList Errors {
    get { return _errors; }
    set { _errors = value; }
  }
  public bool HasErrors {
    get {return 0 != Errors.Count;}      
  }

類別 Notification.Error

  private string message;
  public Error(string message) {
    this.message = message;
  }

命令的執行方法非常簡單。

類別 class RegisterClaim : ServerCommand...

  public RegisterClaim(RegisterClaimDTO claim) : base(claim) {}
  public void Run() {
    Validate();
    if (!notification.HasErrors)
      RegisterClaimInBackendSystems();    
  }

層級父類別再次提供一些通用功能來儲存 DTO,並存取通知。

類別 ServerCommand...

  public ServerCommand(DataTransferObject data){
    this._data = data;
  }
  protected DataTransferObject _data;
  protected Notification notification {
    get {return _data.Notification;}
  }

驗證方法執行我上面提到的驗證。它基本上只執行一系列條件檢查,如果任何檢查失敗,就會將錯誤新增到通知中。

類別 RegisterClaim...

  private void Validate() {
    failIfNullOrBlank(Data.PolicyId, RegisterClaimDTO.MISSING_POLICY_NUMBER);
    failIfNullOrBlank(Data.Type, RegisterClaimDTO.MISSING_INCIDENT_TYPE);
    fail (Data.IncidentDate == RegisterClaimDTO.BLANK_DATE, RegisterClaimDTO.MISSING_INCIDENT_DATE);
    if (isNullOrBlank(Data.PolicyId)) return;
    Policy policy = FindPolicy(Data.PolicyId);
    if (policy == null) {
      notification.Errors.Add(RegisterClaimDTO.UNKNOWN_POLICY_NUMBER);
    }
    else {
      fail ((Data.IncidentDate.CompareTo(policy.InceptionDate) < 0), 
            RegisterClaimDTO.DATE_BEFORE_POLICY_START);
    }
  }

這大部分最複雜的事情是,某些驗證檢查只有在其他檢查沒有失敗時才有意義,這會導致驗證方法中的條件邏輯。如果方法的規模更逼真,就必須將它們分解成更小的區塊。

驗證的常見通用位元可以(而且應該)萃取出來,並放入層級父類別中。

protected bool isNullOrBlank(String s) {
  return (s == null || s == "");
}
protected void failIfNullOrBlank (string s, Notification.Error error) {
  fail (isNullOrBlank(s), error);
}
protected void fail(bool condition, Notification.Error error) {
  if (condition) notification.Errors.Add(error);
}

最簡單的通知錯誤形式就是只使用字串作為錯誤訊息。我比較喜歡至少進行最少的包裝,定義一個簡單的錯誤類別,並在 DTO 中定義互動的固定錯誤清單。

類別 RegisterClaimDTO...

  public static Notification.Error MISSING_POLICY_NUMBER = new Notification.Error("Policy number is missing");
  public static Notification.Error UNKNOWN_POLICY_NUMBER = new Notification.Error("This policy number is unknown");
  public static Notification.Error MISSING_INCIDENT_TYPE = new Notification.Error("Incident type is missing");
  public static Notification.Error MISSING_INCIDENT_DATE = new Notification.Error("Incident Date is missing");
  public static Notification.Error DATE_BEFORE_POLICY_START 
    = new Notification.Error("Incident Date is before we started doing this business");

如果您要跨層級進行通訊,可能需要將 ID 欄位新增到錯誤中,以讓錯誤透過網路序列化時,比較可以正常運作。

這幾乎就是領域層中所有有趣的行為。對於簡報,我將使用自主檢視。我們有興趣的行為會在按下送出按鈕時發生。

類別 FrmRegisterClaim...

  RegisterClaimDTO claim;
  public void Submit() {
    saveToClaim();
    service.RegisterClaim(claim);
    if (claim.Notification.HasErrors) {
      txtResponse.Text = "Not registered, see errors";
      indicateErrors();
    }
    else txtResponse.Text = "Registration Succeeded";
  }
  private void saveToClaim() {
    claim = new RegisterClaimDTO();
    claim.PolicyId = txtPolicyNumber.Text;
    claim.IncidentDate = pkIncidentDate.Value;
    claim.Type = (string) cmbType.SelectedItem;
  }

此方法會從控制項中擷取資訊來填入 DTO,並將資料傳送至領域層。如果回傳的 DTO 包含錯誤,我們就需要顯示這些錯誤。

類別 FrmRegisterClaim...

  private void indicateErrors() {
    checkError(RegisterClaimDTO.MISSING_POLICY_NUMBER, txtPolicyNumber);
    checkError(RegisterClaimDTO.MISSING_INCIDENT_TYPE, cmbType);
    checkError(RegisterClaimDTO.DATE_BEFORE_POLICY_START, pkIncidentDate);
    checkError(RegisterClaimDTO.MISSING_INCIDENT_DATE, pkIncidentDate);
    checkError(RegisterClaimDTO.DATE_BEFORE_POLICY_START, pkIncidentDate);
  }
  private void checkError (Notification.Error error, Control control) {
    if (claim.Notification.IncludesError(error)) 
      showError(control, error.ToString());
  }

我這裡使用在 DTO 中定義的錯誤,並將它們對應到表單中的欄位,這樣正確的欄位就會顯示正確的錯誤。

為了實際顯示錯誤,我使用 .NET 附帶的標準錯誤提供者。這會在有問題的欄位旁邊顯示錯誤圖示,並顯示提示工具提示,以揭露造成問題的錯誤訊息。

類別 FrmRegisterClaim...

  private ErrorProvider errorProvider = new ErrorProvider();
  void showError (Control arg, string message) {
    errorProvider.SetError(arg, message);
  }

如果欄位中的任何內容變更,我會清除錯誤資訊。

類別 FrmRegisterClaim...

  void clearError (Control arg) {
    errorProvider.SetError(arg, null);
  }
  private void txtPolicyNumber_TextChanged(object sender, EventArgs e) {
    clearError((Control)sender);
  }
  private void cmbType_TextChanged(object sender, EventArgs e) {
    clearError((Control)sender);
  }
  private void pkIncidentDate_ValueChanged(object sender, EventArgs e) {
    clearError((Control)sender);    
  }