結合DDD和Spring Boot實現基於REST API的併發控制 -DZone Java

banq發表於2020-06-04

在多使用者環境中,處理併發訪問是我們的主要工作。併發控制可以而且應該反映在我們的API中,特別是因為HTTP提供了一組標頭和響應程式碼來支援它。

首選的方法是將version屬性新增到我們的讀取模型中,並在不安全的方法中進一步傳遞它。如果在伺服器端檢測到衝突,我們可以409 CONFLICT通過包含所有必要資訊的訊息來返回狀態,以使客戶端知道問題的根源。

條件請求是更高階的解決方案。GET方法應該返回ETag或Last-Modified標頭,並且它們的值應相應地放在不安全方法的If-Match或If-Unmodified-Since標頭中。發生衝突時,伺服器返回412 PRECONDITION FAILED。

如果我們想強制客戶使用條件請求,則在缺少前提條件的情況下,伺服器將返回428 PRECONDITION REQUIRED。

本文中使用的程式碼示例可在此處找到。

用例

我們將要使用的用例基於DDD參考專案– library。想象一下,我們有一個系統可以自動完成顧客擱置書籍的過程。為了簡單起見,我們假設每本書可以處於兩種可能的狀態之一:可用和擱置。僅當圖書存在於圖書館中且當前可用時,才可以將其擱置。這是在EventStorming會話中如何建模的方式:

結合DDD和Spring Boot實現基於REST API的併發控制 -DZone Java

書籍可用性建模

每個顧客可以將書置於保留狀態(傳送命令)。為了做出這樣的決定,他/她需要首先檢視可用書籍的列表(檢視閱讀模型)。根據不變式,我們將允許或不允許該過程成功。 

我們還假設,我們已經決定要製作Book主要的彙總。視覺化的上述過程Web Sequence Diagrams可能如下所示:

結合DDD和Spring Boot實現基於REST API的併發控制 -DZone Java

就像我們在這張圖中看到的那樣,布魯斯成功地將書123擱置,而史蒂夫需要處理4xx異常。我們xx應該在這裡放什麼?我們將在一秒鐘之內回到它。

併發遵循業務規則

好吧。我們剛剛提供了擱置書籍的功能。但是,域驅動設計中的聚合應該是不變式的堡壘-它們的主要作用是使所有業務規則始終得到滿足並提供操作的原子性。我們在上一節中發現和描述的業務規則之一是,當且僅當有可用書時,才可以擱置該書。這個規則是否總是得到遵守?

有兩種解決方案:

1.完整狀態比較

如果我們要保護自己免受更新丟失的影響,那麼在保持聚合狀態的同時,我們需要做的是檢查同時要更新的聚合是否未被其他人更改。

可以通過將更新之前的聚合屬性與資料庫中當前的屬性進行比較來完成這種檢查。如果比較結果為肯定,我們可以保留聚合的新版本。這些操作(比較和更新)必須是原子的。

該解決方案的優點是不會影響聚合的結構-技術永續性詳細資訊不會洩漏到域層或以上任何其他層中。但是,由於我們需要具有聚合的先前狀態才能進行完全比較,因此需要通過儲存庫埠將此狀態傳遞給持久層。反過來,這會影響儲存庫的簽名save方法,並且還需要在應用程式層進行調整。

該解決方案承擔了對資料庫進行潛在的計算繁重搜尋的負擔。如果我們的總量很大,那麼在資料庫上維護完整索引可能會很痛苦。功能索引可能會有所幫助。

2.鎖

第二種選擇是使用鎖定機制。從高階的角度來看,我們可以區分兩種型別的鎖定:悲觀鎖定和樂觀鎖定。

前一種型別是我們的應用程式獲取特定資源的排他鎖或共享鎖。如果要修改某些資料,則只有排他鎖是唯一的選擇。然後,我們的客戶可以操縱資源,甚至不讓任何其他人讀取資料。但是,共享鎖不允許我們操縱資源,並且對其他仍可以讀取資料的客戶端的限制較少。

相反,開放式鎖定允許每個客戶端隨意讀寫資料,但有一個限制,即在提交事務之前,我們需要檢查特定記錄是否在此期間未被其他人修改。通常通過新增當前版本或上次修改時間戳屬性來完成。

當寫操作的數量與讀操作相比不是那麼大時,樂觀鎖定通常是預設選擇。

資料訪問層中的樂觀鎖定:在Java世界中,通常使用JPA來處理包括鎖定功能在內的資料訪問。可以通過在實體中宣告版本屬性並用@Version註釋對其進行標記來啟用JPA中的樂觀鎖定。

@Entity @Table(name = "book")
class BookEntity {
  //... 
  @Version
  private long version;
  //...

將此版本進一步傳遞到域模型中。由於域模型基於特定於域的抽象定義儲存庫(介面),因此為了使基礎結構(JPA)檢查實體版本成為可能,也要在域中使用該版本。為此,我們引入了Version值物件,並將其新增到Book彙總中。

public class Version {
  private final long value;
  private Version(long value) {
    this.value = value;
  }
  public static Version from(long value) {
    return new Version(value);
  }
  public long asLong() {
    return value;
  }
} 

public interface Book { 
  //...
  Version version()
}

引入StaleStateIdentified針對併發訪問衝突的特定於域或通用的異常。根據Dependency Inversion Principle,具有較高抽象級別的模組不應依賴於具有較低抽象級別的模組。因此,我們應該將其放置在域模組或支援模組中,而不是基礎結構中。由於轉換了低階異常,該異常應由基礎結構介面卡例項化並引發OptimisticLockingFailureException。

public class StaleStateIdentified extends RuntimeException {
  private StaleStateIdentified(UUID id) {     
    super(String.format("Aggregate of id %s is stale", id));
  }
  public static StaleStateIdentified forAggregateWith(UUID id) {     
    return new StaleStateIdentified(id);
  }
}

例項化並引發基礎架構介面卡中的異常,這是由於底層異常的轉換而導致的OptimisticLockingFailureException:

@Component
class JpaBasedBookRepository implements BookRepository {
    private final JpaBookRepository jpaBookRepository;
    //constructor + other methods
    @Override
    public void save(Book book) {
        try {
            BookEntity entity = BookEntity.from(book);
            jpaBookRepository.save(entity);
        } catch (OptimisticLockingFailureException ex) {
            throw StaleStateIdentified.forAggregateWith(book.id().getValue());
        }
    }
}
interface JpaBookRepository extends Repository<BookEntity, UUID> {
    void save(BookEntity bookEntity);
}

現在的問題是,如果StaleStateIdentified在API中被觸發,API中會發生什麼?預設情況下,500 INTERNAL SERVER ERROR將返回狀態,這絕對不是我們希望看到的狀態。現在該是時候處理StaleStateIdentified異常了。

在REST API中處理樂觀鎖定

如果併發訪問衝突應該怎麼辦?我們的API應該返回什麼?我們的終端使用者應該看到什麼?

在提出解決方案之前,讓我們強調一下,在大多數情況下,開發人員不應該回答這些問題,因為這種衝突通常是業務問題,而不是技術問題(即使我們堅信是這樣)。讓我們看下面的例子:

開發人員:“如果兩位顧客試圖擱置同一本書,而其中一位卻因為第二次嘗試而被拒絕,我們該怎麼辦?”

生意:“告訴他太糟糕了。”

開發人員:“如果是我們的優質贊助人呢?”

生意:“哦,好吧,我們應該打給他。是。在這種情況下,請給我傳送電子郵件,我將與他聯絡併為此道歉,並嘗試為他找到其他副本。”

我們可以找到無數示例,證明技術解決方案應始終由真實的業務規則驅動。

為了簡單起見,讓我們假設,我們只是想告訴客戶我們很抱歉。HTTP協議提供的非常基本的機制可以在RFC 7231超文字傳輸​​協議(HTTP / 1.1)中找到:語義和內容,它與返回409 CONFLICT響應有關。這是文件中說明的內容:

409 (Conflict)狀態程式碼表示請求無法
完成,因為與目標的當前狀態發生衝突
的資源。此程式碼用於使用者可能
能夠解決衝突並重新提交請求的情況。伺服器
應該產生一個有效載荷,該載荷包括足夠的資訊供
使用者識別衝突的根源。

這不是我們想要的東西嗎?那好吧。讓我們嘗試編寫一個反映上面所寫內容的測試。

@Test
public void shouldSignalConflict() throws Exception {
  //given
  AvailableBook availableBook = availableBookInTheSystem();
  //and
  BookView book = api.viewBookWith(availableBook.id());
  //and
  AvailableBook updatedBook = bookWasModifiedInTheMeantime(bookIdFrom(book.getId()));
  //when Bruce places book on hold
  PatronId bruce = somePatronId();
  ResultActions bruceResult =  api.sendPlaceOnHoldCommandFor(book.getId(), bruce,
        book.getVersion());
  //then
  bruceResult
      .andExpect(status().isConflict())
      .andExpect(jsonPath("$.id").value(updatedBook.id().asString()))
      .andExpect(jsonPath("$.title").value(updatedBook.title().asString()))
      .andExpect(jsonPath("$.isbn").value(updatedBook.isbn().asString()))
      .andExpect(jsonPath("$.author").value(updatedBook.author().asString()))
      .andExpect(jsonPath("$.status").value("AVAILABLE"))
      .andExpect(jsonPath("$.version").value(not(updatedBook.version().asLong())));
}

我們對系統中可用的書所做的第一件事就是獲得其檢視。為了啟用併發訪問控制,檢視響應需要包含與我們在域模型中已經擁有的版本屬性相對應的版本屬性。除其他外,它包含在我們傳送的將書置於保留狀態的命令中。但是,與此同時,我們修改了這本書(強制更新版本屬性)。結果,我們期望得到一個409 CONFLICT響應,該響應指示由於與目標資源的當前狀態衝突而無法完成請求。此外,我們希望響應表示形式可能包含有助於基於修訂歷史記錄合併差異的資訊,這就是為什麼我們檢查響應正文是否包含該書的當前狀態。 

請注意,在測試方法的最後一行中,我們不檢查的確切值version。其背後的原因是,在REST控制器的上下文中,我們不(也不應該)關心此屬性的計算和更新方式-它發生變化的事實足以提供資訊。因此,我們解決了測試中關注點分離的問題。 

@RestController
@RequestMapping("/books")
class BookHoldingController {
  private final PlacingOnHold placingOnHold;
  BookHoldingController(PlacingOnHold placingOnHold) {
    this.placingOnHold = placingOnHold;
  }
  @PatchMapping("/{bookId}")
  ResponseEntity updateBookStatus(@PathVariable("bookId") UUID bookId,
                                  @RequestBody UpdateBookStatus command) {
    if (PLACED_ON_HOLD.equals(command.getStatus())) {
        PlaceOnHoldCommand placeOnHoldCommand =
            new PlaceOnHoldCommand(BookId.of(bookId), command.patronId(), command.version());
        Result result = placingOnHold.handle(placeOnHoldCommand);
        return buildResponseFrom(result);
    } else {
        return ResponseEntity.ok().build(); //we do not care about it now
    }
  }
  private ResponseEntity buildResponseFrom(Result result) {
    if (result instanceof BookPlacedOnHold) {
        return ResponseEntity.noContent().build();
    } else if (result instanceof BookNotFound) {
        return ResponseEntity.notFound().build();
    } else if (result instanceof BookConflictIdentified) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(((BookConflictIdentified) result)
                        .currentState()
                        .map(BookView::from)
                        .orElse(null));
    } else {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }
  }
}

(banq注:上述控制器程式碼中if else狀態判斷應該放入領域模型,屬於重要業務規則,不能洩露到API控制器中)

updateBookStatus方法中的第一個驗證是檢查是否請求保留書本。如果是這樣,將構建一個命令物件,並將其進一步傳遞給應用程式層服務– placingOnHold.handle()。根據服務呼叫的結果,我們可以構建適當的API響應。如果處理成功(BookPlacedOnHold),我們將返回204 NO_CONTENT。如果請求嘗試修改不存在的資源(BookNotFound),則返回404 NOT_FOUND。在我們的上下文中,第三個也是最重要的選項是BookConflictIdentified。如果得到這樣的響應,我們的API將返回409 CONFLICT訊息,其中的正文包含最新的書本檢視。此時,命令處理的任何其他結果都不是預期的,而是視為500 INTERNAL_SERVER_ERROR。

如果消費者得到409,它需要解釋狀態碼並分析內容,以確定可能是衝突的根源。根據RFC 5789,這些是應用程式和patch格式,用於確定使用者是否可以按原樣重新發出請求,重新計算補丁或失敗。在我們的情況下,我們無法重試保留其格式的訊息。其背後的原因是該version屬性已更改。即使我們應用了新版本,在重新傳送訊息之前,我們也需要檢查衝突的源頭-僅當衝突不是由於將書的狀態更改為PLACED_ON_HOLD(我們只能保留可用的圖書)。不影響狀態的任何其他更改(標題,作者等)都不會影響業務不變式,從而允許消費者重新發出請求。

值得指出的是,將樂觀鎖定與version傳遞給API的屬性一起使用和狀態比較之間存在差異。不好的是,需要將version屬性新增到我們的域,應用程式和API級別,從而導致持久層洩漏技術細節。不過,好處是,現在為了執行更新,該WHERE子句可以限制為aggregate IDand version欄位。簡化基於以下事實:狀態現在由一個引數而不是整個參數列示。關於發生衝突時的API響應,情況幾乎相同。兩種方法都迫使我們的客戶分析響應並決定是否可以重傳。

務實地看待這個問題,我們可以提出一些贊成使用樂觀鎖定的論點。

  • Domain很髒,但是API簡潔明瞭,並且使用前提條件的方式更容易(在後續章節中將對此主題進行詳細介紹)
  • Version 有時可能出於業務目的(例如審計),所以我們有可能獲得更多
  • 如果version仍然難以接受,我們可以使用Last-Modifiedattribute並將其傳送到標頭中。在許多企業中,最後修改資源的時間可能具有更大的意義。

ETag標頭

您是否發現在上述兩種方法中我們實際上都在資料庫上執行條件更新?這不是說我們的請求是有條件的嗎?是的,確實如此,因為我們僅允許客戶在此期間未對其進行修改的情況下才對其進行更新。在第一種情況下,我們需要比較集合的所有屬性,而在第二種情況下,我們僅檢查version和aggregate ID是否相同。所有屬性一致性和基於版本的一致性都定義了要滿足請求的前提條件。

HTTP協議中有一種處理條件請求的顯式標準方法。RFC 7232定義了此概念,包括一組指示資源狀態和前提條件的後設資料標頭:

條件請求是HTTP請求[RFC7231],其中包括一個或多個標頭欄位,這些欄位指示在將方法語義應用於目標資源之前要測試的前提條件。

RFC 7232區分條件讀取和寫入請求。前者通常用於有效的快取機制,這不在本文的討論範圍之內。後面的請求是我們將要重點關注的。讓我們繼續一些理論。

條件請求處理的最基本組成部分是ETag(Entity Tag)標頭,只要我們通過GET請求讀取資源或使用某種不安全的方法對其進行更新,都應返回()標頭。ETag是由擁有資源的伺服器生成的不透明文字驗證器(令牌),該伺服器在當前時間點與其特定表示相關聯。它必須啟用對資源狀態的唯一標識。理想情況下,實體狀態(響應主體)及其後設資料(例如,內容型別)的每次更改都將反映在更新後的ETag值中。

你可能會問:為什麼我們需要ETag一個Last-Modified標頭?實際上有兩個原因,但是從不安全方法執行的角度來看,值得注意的是,根據RFC 7231 Last-Modified標頭模式,時間解析度僅限於秒。在不足的情況下,我們根本不能依靠它。

...

更詳細的Etag標頭其他方式點選標題見原文

 

相關文章