DDD中實現業務規則的驗證 - Marcin

banq發表於2019-07-14

資料的正確性和執行特定領域的業務規則的能力是軟體開發的幾個方面之一,幾乎任何專案都是如此。由於很難想象任何不需要某種驗證的非hello-world應用程式,解決這個問題對整個專案的成功至關重要。
當然,這樣的核心概念必然會影響整體架構,所採取的任何方法都應確保只使用有效資料在整個程式碼中執行相關的業務操作。因此,所選設計應與業務邏輯無縫整合,並提供合理的保護級別。此外,從開發,維護和使用者體驗的角度來看,軟體通常需要在發生驗證錯誤時進行報告。
我們與Zbyszek Artemiuk和Przemek Rafalski一起討論了這個主題,在最後的文章中,我們想提出一種方法來實現對域驅動設計精神保持的簡單應用程式的驗證。

所提供的程式碼庫僅指與此討論相關的類。完整的專案可在此處獲得

應用案例
我們的小專案的主要功能是透過指定的電子郵件地址和密碼註冊使用者。代表我們領域的相關基本模型是使用者實體:

public class User {

  private final UserId id;
  private final Email email;
  private final Password password;

  public User(UserId id, Email email, Password password) {
    this.id = id;
    this.email = email;
    this.password = password;
  }


與包含電子郵件和密碼概念的值物件:

public class Email {

  private final String email;

  public Email(String email) {
    this.email = email;
  }

public class Password {

  private final String value;

  public Password(String value) {
    this.value = value;
  }
}

最後一個重要功能是能夠透過電子郵件地址值查詢使用者。為此,讓我們引入另一個類:

public interface Users {
  void add(User user);
}


從我們的應用程式角度來看,我們將分別公開兩個REST端點,用於接受使用者註冊請求和返回在給定id下注冊的使用者的電子郵件地址。

@RestController
public class UsersEndpoint {

  private final Users users;
  private final IdGenerator idGenerator;

  public UsersEndpoint(Users users, IdGenerator idGenerator) {
    this.users = users;
    this.idGenerator = idGenerator;
  }

  @PostMapping("/users")
  public ResponseEntity<UserCreationResponse> handleCreateUserRequest(@RequestBody UserCreationRequest request) {
    User newUser = new User(idGenerator.id(), new Email(request.email), new Password(request.password));
    users.add(newUser);
    return ResponseEntity.status(HttpStatus.CREATED).body(UserCreationResponse.success(newUser.getId().toString()));
  }

  @GetMapping("/users/{id}")
  public ResponseEntity<String> getUserEmailById(@PathVariable String id) {
    return ResponseEntity.ok(String.valueOf(usersFinder.findUserEmailById(UserId.of(id))));
  }

  public static class UserCreationRequest {
    public String email;
    public String password;
  }


業務規則:驗證
對資料正確性的要求非常明顯 - 應用程式只能接受具有指定格式良好的電子郵件地址的註冊請求。此外,由於它應該唯一地標識某個使用者,我們不能允許兩個使用者共享一個例項。為了保護我們心愛的使用者免受對其密碼的暴力攻擊,我們希望他們只使用強大的密碼,根據應用程式定義的策略 - 它的長度不得少於5個字元。
除了保護我們的業務規則之外,我們還希望在處理註冊請求時發生驗證失敗的資訊。
讓我們首先實現實際的驗證邏輯,因為它可能在任何專案中都是優先考慮的。
由於我們希望將驗證邏輯保持在與它相關的域物件的附近,讓我們使用各個構建塊的建構函式開始我們的實現,從我們的值物件 - Password和Email類開始。

密碼驗證
驗證密碼的強度不需要任何其他上下文資料,除了註冊請求中傳遞的值。然後,我們的實現包含在一個簡單的PasswordValidator類,如果驗證不透過丟擲PasswordTooWeakException,如下所示:

public class PasswordValidator {
  public void validate(String value) {
    if (value == null || value.length() < 5) {
      throw new PasswordTooWeakException());
    }
  }
}
public class PasswordTooWeakException extends ValidationException {
  public PasswordTooWeakException() {
    super("password too weak");
  }
}

在Password值物件中使用:
public class Password {

  private final String value;
  private final PasswordValidator validator = new PasswordValidator();

  public Password(String value) {
      validator.validate(value);

      this.value = value;
  }


這樣,我們就部分地滿足了我們對電子郵件的要求。作為其唯一性的優秀專案實際上反映了與包含User實體更相關的限制,而不是與電子郵件地址本身相關的限制。

使用者驗證
由於我們需要檢查給定的電子郵件地址是否尚未註冊到任何現有使用者,因此我們的驗證邏輯顯然需要對儲存庫進行一些訪問。將這種依賴放在User實體類中並在建構函式中執行基於儲存庫的驗證似乎不是一個好主意 。值得慶幸的是,我們可以使用最簡單的建立模式之一來緩解它 - 工廠。在建立複雜物件,參與依賴關係管理等時,使用此模式。此外,在DDD中,它通常還負責執行驗證。這聽起來像是我們場景的完美搭配!

public class UserFactory {

  private final Users users;
  private final IdGenerator idGenerator;
  private final EmailUniquenessValidator validator;

  public UserFactory(Users users, IdGenerator idGenerator) {
    validator = new EmailUniquenessValidator(users);
    this.users = users;
    this.idGenerator = idGenerator;
  }

  public User create(Password password, Email email) {
    validator.validate(email);

    return new User(idGenerator.id(), email, password);
  }



我們就可以在EmailUniquenessValidator類中實現必要的驗證邏輯:

public class EmailUniquenessValidator {

  private final Users users;

  public EmailUniquenessValidator(Users users) {
    this.users = users;
  }

  public void validate(Email email) {
    if (!users.isUniqueEmail(email)){
      throw new NotUniqueEmailAddress(email);
    }


public class NotUniqueEmailAddress extends ValidationException {
  public NotUniqueEmailAddress(Email email) {
    super(String.format("Not unique email address - '%s'", email));
  }
}


為簡單起見,我們只是限制對介面的更改,因為實際的實現與我們的討論無關。

public interface Users {

    void add(User user);

    boolean isUniqueEmail(Email email);
}



處理驗證異常
我們不想明確地丟擲異常,而是將這個決定委託給一些外部處理程式。一種方法是使該validate方法接受附加引數。
至於我們新類的責任,我們只希望它能夠接受驗證異常。然後讓我們建立以下ValidationExceptionHandler介面來表達:

public interface ValidationExceptionHandler {
  void add(ValidationException e);
}


我們當前的需求揭示了兩個必需的實現: 一個用於收集錯誤的目的:

public class AggregatingValidationExceptionHandler implements ValidationExceptionHandler {

    private final List<RuntimeException> errors = new ArrayList<>();

    @Override
    public void add(ValidationException e) {
        this.errors.add(e);
    }

    public boolean hasErrors() {
        return !errors.isEmpty();
    }

    public List<String> getErrors() {
        return errors.stream().map(Throwable::getMessage).collect(Collectors.toList());
    }

}

而另一個,它將立即重新丟擲第一個新增的異常,就像我們最初的方法:

public class ThrowingValidationExceptionHandler implements ValidationExceptionHandler {

    @Override
    public void add(ValidationException e) {
        throw e;
    }
}


現在轉向validators,由於它的簡單性,就使用PasswordValidator。我們新定義的驗證異常處理程式的用法如下面的程式碼片段所示:

public class PasswordValidator {

  public void validate(String value, ValidationExceptionHandler validationExceptionHandler) {
    if (value == null || value.length() < 5) {
      validationExceptionHandler.add(new PasswordTooWeakException());
    }
  }
}

透過這種實現,我們ValidationExceptionHandler負責產生錯誤的情況。讓我們將新方法引數更高一級地傳遞給Password類。

public class Password {

  private final String value;
  private final PasswordValidator validator = new PasswordValidator();

  public Password(String value, ValidationExceptionHandler validationExceptionHandler) {
      validator.validate(value, validationExceptionHandler);

      this.value = value;
  }
}


由於我們的更改,我們無法再確保任何例項化Password物件都是有效的,因為它取決於所使用的實際實現ValidationExceptionHandler,這不是我們希望的。值得慶幸的是,為了提供向後相容的行為,我們實現了一個ThrowingValidationExceptionHandler 適合作為預設處理程式的類。對於非構造相關的場景,驗證操作需要移動到單獨的方法,接受處理程式作為引數。作為風格偏好的問題,讓我們為構造和驗證新增靜態方法:

public class Password {
  //...
  private Password(String value) {
    Password.test(value, new ThrowingValidationExceptionHandler());
    this.value = value;
  }

  public static Password of(String value) {
      return new Password(value);
  }

  public static void test(String password, ValidationExceptionHandler validationExceptionHandler) {
      new PasswordValidator().validate(password, validationExceptionHandler);
  }


控制器
從我們在使用者註冊端點的邏輯流程的角度來看,我們首先要確保在嘗試建立任何域物件之前可以使用指定的資料。我們來介紹一下這種方法:

@RestController
public class UsersEndpoint {

    //...

    @PostMapping("/users")
    public ResponseEntity<UserCreationResponse> handleCreateUserRequest(@RequestBody UserCreationRequest request) {
        List<String> errors = validateRequest(request);
        if (errors.isEmpty()) {
            User newUser = saveNewUser(request.email, request.password);
            return ResponseEntity.status(HttpStatus.CREATED).body(UserCreationResponse.success(newUser.getId().toString()));
        } else {
            return ResponseEntity.status(BAD_REQUEST).body(UserCreationResponse.failure(errors));
        }
    }

    private List<String> validateRequest(UserCreationRequest request) {
        AggregatingValidationExceptionHandler validationExceptionHandler = new AggregatingValidationExceptionHandler();

        Email.test(request.email, validationExceptionHandler);
        Password.test(request.password, validationExceptionHandler);
        if (validationExceptionHandler.hasErrors()) {
            return validationExceptionHandler.getErrors();
        }

        userFactory.test(Email.of(request.email), validationExceptionHandler);
        if (validationExceptionHandler.hasErrors()) {
            return validationExceptionHandler.getErrors();
        }

        return Collections.emptyList();
    }

    private User saveNewUser(String email, String password) {
        User user = userFactory.create(Password.of(password), Email.of(email));
        users.add(user);
        return user;
    }

    //...
}


在這裡,我們可以清楚地看到新新增的test 方法的用法,以及收集異常處理程式。這回答了收集所有發生的驗證錯誤的要求,並允許我們僅在實際沒有發生錯誤時才建立域物件。
在初始測試期間,我們的儲存狀態不包含指定地址,但是在構建時,其狀態已經更改,驗證不再會透過,可能會引發併發問題,具有悲觀鎖定的事務雖然可能解決這個問題,但並不總是符合條件。好訊息是,這種情況可以被認為是非常罕見的,並不是真的那麼危險,因為可能發生的最糟糕的事情是不會建立物件並且ValidationException需要處理型別化的異常。
另一個問題是執行兩次驗證,這可能會對效能造成重大影響。對於我們的例子中PasswordValidator和EmailValidator驗證所需的計算顯然可以忽略不計。相反,EmailUniquenesValidator由於它可以訪問外部資源(可能是具有非最佳連線特性的遠端DB),因此可能會導致我們麻煩。

總結
正如我們所見,在我們的領域物件周圍實現領域驗證規則並不是那麼麻煩,並且使用工廠和訪問者模式,甚至可以說它是容易的。此外,將這兩個方面保持在實體周圍有助於理解域規則。
在使用方面,將驗證邏輯推送到儘可能低的級別可確保只建立有效的領域物件,從而使開發人員免於採取防禦措施。它還有助於專案維護,防止驗證邏輯在應用程式的幾個不同層中傳播。儘管多次執行檢查或存在樂觀鎖定問題的可能性存在缺點,但我們相信這種方法允許我們在業務邏輯和驗證程式碼之間劃清界限,以一種整潔和優雅的方式這樣做。我們希望您會發現它對您的專案有用!

相關文章