通知

一个对象,它收集有关域层中的错误和其他信息,并将这些信息传达给表示层。

2004 年 8 月 9 日

这是我在 2000 年代中期进行的 进一步的企业应用程序架构开发 写作的一部分。遗憾的是,此后太多其他事情吸引了我的注意力,所以我没有时间进一步研究它们,而且在可预见的未来我也没有看到太多时间。因此,这些材料非常草稿形式,我不会进行任何更正或更新,直到我能找到时间再次处理它们。

一个常见的应用程序场景是表示层从用户那里捕获数据并将该数据传递给域进行验证。域需要进行一系列检查,如果任何检查失败,则需要让表示层知道。但是,分离的表示层 不允许域直接与表示层通信。

一个 通知 是一个对象,域使用它来收集有关验证期间错误的信息。当出现错误时,通知 会被发送回表示层,以便表示层可以显示有关错误的更多信息。

工作原理

在最简单的形式中,通知可以只是一个字符串集合,这些字符串是域在执行其工作时生成的错误消息。对于域层进行的每次验证,每次失败都会导致将一个错误添加到通知中。但是,为通知提供比这更明确的接口是有意义的。通知对象通常具有用于添加错误的方法,这些方法使用错误代码而不是字符串,以及用于判断通知中是否存在任何错误的便利方法。

如果您正在使用 数据传输对象 (DTO),则在 层超类型 中将通知添加到 DTO 中是有意义的。这允许所有交互干净地使用通知。

如果您的域逻辑相对简单,例如使用事务脚本,那么逻辑可以直接引用 通知。这使得在添加错误时很容易引用它。在使用 域模型 的分层系统中,引用 通知 可能更成问题,因为此类域模型通常无法看到诸如传入 DTO 之类的东西。在这种情况下,有必要将 通知 放入某种会话对象中,以便域对象可以轻松访问。

错误代码需要出现在表示层和域之间共享的类中。使用错误代码可以更明确地说明预期错误,并使表示层更容易以比仅打印错误消息更具交互性的方式呈现错误。对于简单的域层,通常将这些代码嵌入数据传输对象(如果您正在使用它)或特定交互的错误代码集中就足够了。对于域模型,错误代码需要围绕域模型本身的词汇表进行设计。

虽然错误通常是 通知 最需要的方面,但对于 通知 来说,将域希望传达给其调用者的任何其他信息传递回去也很有用。这些可能包括警告(不是严重到足以阻止交互的问题)和信息消息,以显示给用户。这些需要在 通知 上分开,以便表示层可以轻松判断是否存在任何错误。

如果您在表示层和域逻辑位于不同进程的系统中使用 通知,则需要确保 通知 仅包含可以安全地跨网络传输的信息,通常这意味着您不能在这样的 通知 中嵌入对域对象的引用。

当表示层收到来自验证的响应时,它需要检查 通知 以确定是否存在错误。如果是,它可以从 通知 中提取信息以将这些错误显示给用户。如果存在错误,一个选项是让域引发异常,以便表示层可以使用异常处理来处理错误。总的来说,我认为验证错误是如此常见,以至于使用异常处理机制处理这些情况并不值得,但这并不是压倒性的偏好。

何时使用它

只要验证是由无法直接依赖于启动验证的模块的代码层完成的,您就应该使用 通知。这在分层架构中非常常见,例如 分离的表示层

使用 通知 的最明显替代方案是让域使用异常处理来指示错误。这种方法让域在验证检查失败时抛出异常。这样做的问题是它只指示第一个验证错误。通常显示所有验证错误更有帮助,特别是如果验证需要往返远程域层。

另一种替代方案是让域层为验证错误引发事件。这允许标记多个错误。但是,对于远程域层来说,这不太好,因为每个事件都会导致网络调用。

示例:检查窗口的错误 (C#)

图 1 中,我有一个简单的表单要提交以评判保险索赔。我有三部分数据要提交:保单号(字符串)、索赔类型(来自选择列表的文本)和事故日期(DateTime)。

如果我的数据很简单,只需等待我的有效性检查

  • 检查这三部分数据是否都不缺失(字符串为空或空白)。
  • 检查保单号是否在数据存储中存在。
  • 检查事故日期是否晚于保单的生效日期。

我们希望尽可能多地将信息反馈给用户,因此,如果我们可以合理地检测到多个错误,我们应该这样做。

我将从域层开始讨论。域逻辑的基本接口位于 服务层 中。

class 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 的层超类型。这包含用于创建和访问与交互一起使用的 通知 的通用代码。

class DataTransferObject...

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

通知类是我们将在域层中用来捕获错误的类。本质上,它是一个错误集合,每个错误都是一个围绕字符串的简单包装器。

class Notification...

  private IList _errors = new ArrayList();

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

class Notification.Error

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

命令的 run 方法非常简单。

class class RegisterClaim : ServerCommand...

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

同样,层超类型提供了一些通用功能来存储 DTO 并访问通知。

class ServerCommand...

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

validate 方法执行我上面提到的验证。本质上,它所做的就是运行一系列条件检查,如果任何检查失败,则将错误添加到通知中。

class 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);
    }
  }

关于这方面最复杂的事情是,某些验证检查只有在其他检查没有失败的情况下才有意义——这导致了 validate 方法中的条件逻辑。对于更现实规模的方法,将它们分解成更小的块很重要。

常见的通用验证位可以(也应该)被提取并放入层超类型中。

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 中为交互定义一个固定的错误列表。

class 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 字段,以便在错误在网络上传输时进行序列化时,比较能够正常工作。

这几乎就是域层中所有有趣的行为。对于表示层,我将使用 自治视图。我们感兴趣的行为发生在按下提交按钮时。

class 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 包含错误,那么我们需要显示它们。

class 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 附带的标准错误提供程序。这将在有问题的字段旁边显示一个错误图标,以及一个工具提示来显示导致问题的错误消息。

class FrmRegisterClaim...

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

如果字段中的任何内容发生变化,我会清除错误信息。

class 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);    
  }