在現代軟體系統中,有數百或數千名用戶獨立同時與我們的資源交互是很常見的。我們通常希望避免這樣的情況,即一個客戶端所做的更改被另一個客戶端覆蓋而不知道。為了防止數據完整性受到侵犯,我們經常使用資料庫引擎提供的鎖定機制,甚至使用JPA等工具提供的抽象。
您有沒有想過並發控制應該如何反映在我們的API中?當兩個用戶同時更新相同的記錄時會發生什麼?我們會發送錯誤信息嗎?我們將使用什麼HTTP響應代碼?我們將附加哪些HTTP頭?
本文的目的是全面說明如何對RESTAPI建模,使其支持資源的並發控制,並利用HTTP協議的特性。我們還將在Spring框架的幫助下實現此解決方案。
請注意,儘管我們對並發數據訪問做了簡短介紹,但本文並沒有介紹鎖、隔離級別或事務的工作方式。我們將嚴格關注API。
用例
我們將要處理的用例是基於DDD參考項目-圖書館。想像一下,我們有一個系統,使讀者擱置圖書的過程自動化。為了簡單起見,讓我們假設每本書都可以處於兩種可能的狀態之一:可用和擱置。只有當一本書存在於圖書館並且目前可用時,它才能被擱置。這是如何在事件風暴會議:
圖書可用性建模
每一位贊助人都可以擱置這本書(發出命令)。為了做出這樣的決定,他/她需要首先看到可用書籍的列表(查看一個閱讀模型)。根據不變量,我們將允許或不允許進程成功。
我們還假設,我們做了一個決定Book我們的主要集合體。上面的過程可視化了Web Sequence Diagrams可能是這樣的:
Web序列圖
就像我們在這個圖表中看到的,布魯斯成功地把這本書123等等,史蒂夫需要處理4xx例外。什麼xx我們應該放在這裡嗎?我們一會兒再談。
讓我們從提供最小可行的產品開始,暫時不注意並發訪問。下面是我們的簡單測試的樣子。
@SpringBootTest(webEnvironment = RANDOM_PORT)@RunWith(SpringRunner.class)@AutoConfigureMockMvcpublic class BookAPITest {@Autowired private MockMvc mockMvc; @Autowired private BookRepository bookRepository; @Test public void shouldReturnNoContentWhenPlacingAvailableBookOnHold() throws Exception { //given AvailableBook availableBook = availableBookInTheSystem(); //when ResultActions resultActions = sendPlaceOnHoldCommandFor(availableBook.id()); //then resultActions.andExpect(status().isNoContent()); } private ResultActions sendPlaceOnHoldCommandFor(BookId id) throws Exception { return mockMvc .perform(patch("/books/{id}", id.asString()) .content("{"status" : "PLACED_ON_HOLD"}") .header(CONTENT_TYPE, APPLICATION_JSON_VALUE)); } private AvailableBook availableBookInTheSystem() { AvailableBook availableBook = BookFixture.someAvailableBook(); bookRepository.save(availableBook); return availableBook; }}
以下是它的實現方式:
@RestController@RequestMapping("/books")class BookController {private final PlacingOnHold placingOnHold; BookController(PlacingOnHold placingOnHold) { this.placingOnHold = placingOnHold; } @PatchMapping("/{bookId}") ResponseEntity updateBookStatus(@PathVariable("bookId") UUID bookId, @RequestBody UpdateBookStatus command) { if (PLACED_ON_HOLD.equals(command.getStatus())) { placingOnHold.placeOnHold(BookId.of(bookId)); return ResponseEntity.noContent().build(); } else { return ResponseEntity.ok().build(); //we do not care about it now } }}
我們還可以再進行一次檢查來補充測試類:
@Testpublic void shouldReturnBookOnHoldAfterItIsPlacedOnHold() throws Exception { //given AvailableBook availableBook = availableBookInTheSystem();
//and sendPlaceOnHoldCommandFor(availableBook.id()); //when ResultActions resultActions = getBookWith(availableBook.id()); //then resultActions.andExpect(status().isOk()) .andExpect(jsonPath("$.id").value(availableBook.id().asString())) .andExpect(jsonPath("$.status").value("PLACED_ON_HOLD"));}
狀態比較與鎖定
好的。我們剛剛提供了擱置一本書的功能。然而,領域驅動設計中的聚合被認為是不變量的堡壘--它們的主要作用是使所有業務規則始終得到滿足,並提供操作的原子性。我們在上一節中發現和描述的業務規則之一是,只有在書可用的情況下,書才能被擱置。這條規則總是符合嗎?
我們試著分析一下。我們在代碼中提供的第一件事是類型系統--從函數式編程中借用的概念。而不是有一個多用途Book使用Status欄位和成噸的if語句初始化,我們傳遞了AvailableBook和PlacedOnHoldBook而是上課。在這個設置中,它只是AvailableBook它有placeOnHold方法。對於我們的應用程式來說,保護不變量就足夠了嗎?
如果兩位不同的讀者試圖按部就班地放置同一本書,答案是肯定的,因為在這裡支持我們的將是編譯器。否則,我們無論如何都需要處理並發訪問--這就是我們現在要做的事情。這裡有兩個可能的選項:完全狀態比較和鎖定。在本文中,我們將簡要介紹前一個選項,更多地關注後者。
全狀態比較
這個詞背後隱藏著什麼?好吧,如果我們想保護自己免受所謂的丟失更新,我們需要做的是在保持聚合的同時,檢查我們想要更新的聚合是否還沒有被其他人更改。這種檢查可以通過比較更新之前的聚合屬性和資料庫中當前的屬性來完成。如果比較的結果是肯定的,我們可以堅持新版本的我們的總和。這些操作(比較和更新)需要是原子的。
該解決方案的優點是它不影響聚合的結構-技術持久性細節不會洩漏到域層或上面的任何其他層。但是,由於我們需要擁有聚合的前一個狀態才能進行完全的比較,所以我們需要通過存儲庫埠將這個狀態傳遞給我們的持久性層。這反過來又會影響存儲庫的籤名。save方法,並要求在應用層進行調整。然而,它比第二個解決方案要乾淨得多,您將在下面的段落中看到這一點。在我們繼續之前,還值得注意的是,該解決方案承擔了資料庫上潛在的計算量很大的搜索負擔。如果我們的聚合很大,那麼在我們的資料庫上維護一個完整的索引可能會很痛苦。功能指數可能會起到挽救作用。
鎖緊
第二個選擇是使用鎖定機制。從高層次的角度來看,我們可以區分兩種類型的鎖定:悲觀和樂觀。
前一種類型是我們的應用程式獲取特定資源的獨佔或共享鎖。如果我們想要修改一些數據,擁有獨佔鎖是唯一的選擇。然後,我們的客戶端可以操縱資源,甚至不讓任何其他客戶讀取數據。但是,共享鎖不允許我們操作資源,而且對於其他仍然可以讀取數據的客戶端來說,限制更小一些。
相反,樂觀鎖定允許每個客戶端隨意讀取和寫入數據,但限制是在提交事務之前,我們需要檢查某個特定記錄是否在此期間沒有被其他人修改。這通常是通過添加當前版本或最後修改時間戳屬性來完成的。
當寫操作的數量不像讀操作那麼大時,樂觀鎖定通常是默認的選擇。
數據訪問層的樂觀鎖定
在Java世界中,我們通常使用JPA來處理數據訪問,包括鎖定功能。可以通過在實體中聲明版本屬性並將其標記為@Version注釋讓我們看看它的樣子,從測試開始。
@SpringBootTest(webEnvironment = NONE)
@RunWith(SpringRunner.class)
public class OptimisticLockingTest {
@Autowired
private BookRepositoryFixture bookRepositoryFixture;
@Autowired
private BookRepository bookRepository;
private PatronId somePatronId = somePatronId();
@Test(expected = StaleStateIdentified.class)
public void savingEntityInCaseOfConflictShouldResultInError() {
//given
AvailableBook availableBook = bookRepositoryFixture.availableBookInTheSystem();
//and
AvailableBook loadedBook = (AvailableBook) bookRepository.findBy(availableBook.id()).get();
PlacedOnHoldBook loadedBookPlacedOnHold = loadedBook.placeOnHoldBy(somePatronId);
//and
bookWasModifiedInTheMeantime(availableBook);
//when
bookRepository.save(loadedBookPlacedOnHold);
}
private void bookWasModifiedInTheMeantime(AvailableBook availableBook) {
PatronId patronId = somePatronId();
PlacedOnHoldBook placedOnHoldBook = availableBook.placeOnHoldBy(patronId);
bookRepository.save(placedOnHoldBook);
}
}
為了通過這個測試,我們需要提供以下幾點:
在JPA中引入上述版本屬性BookEntity在基礎設施層@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.@Componentclass 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長大了?默認情況下,500 INTERNAL SERVER ERROR身份將被退回,這絕對不是我們希望看到的。現在是我們著手處理StaleStateIdentified那麼例外。
在RESTAPI中處理樂觀鎖定
在並發訪問衝突的情況下應該發生什麼?我們的API應該返回什麼?我們的最終用戶應該看到什麼?
在我們提出解決方案之前,讓我們強調,在大多數情況下,這些問題的答案不應該由開發人員給出,因為這種衝突通常是業務問題,而不是技術問題(即使我們堅信是這樣)。讓我們看看下面的示例:
發展:「如果兩位顧客試圖擱置同一本書,其中一位被拒絕了,就像他一秒鐘後試過的那樣,我們該怎麼辦?」
商業「告訴他太糟糕了」
發展:「如果是我們的優質贊助人呢?」
商業「哦,好吧,我們應該給他打個電話。」是。在這種情況下,給我發一封電子郵件,我會聯繫他,並為此道歉,試圖為他找一些其他的副本。「
我們可以找到無數的例子,證明技術解決方案應該始終由真正的業務規則驅動。
為了保持簡單,讓我們假設,我們只是想告訴我們的客戶,我們很抱歉。HTTP協議提供的非常基本的機制RFC 7231超文本傳輸協議(HTTP/1.1):語義和內容它是關於返回409 CONFLICT回應。以下是文件中所述的內容:
這個409 (Conflict)狀態代碼表示請求不能由於與目標當前狀態發生衝突而完成資源。此代碼用於用戶可能能夠解決衝突並重新提交請求。伺服器應該生成包含足夠信息的有效負載。識別衝突來源的用戶。
衝突最有可能發生在響應PUT請求。為示例,如果使用的是版本控制,而表示是所包含的對資源的更改,這些更改與較早的(第三方)請求,源伺服器可能使用409。響應,指示它無法完成請求。在這裡情況下,響應表示可能包含信息。有助於根據修訂歷史合併差異。
這不是我們要找的嗎?那好吧。讓我們嘗試編寫一個反映上面所寫內容的測試。
@Testpublic 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())));}
這裡發生的事情是,我們對系統中可用的一本書所做的第一件事就是獲取它的視圖。為了啟用並發訪問控制,視圖響應需要包含與我們在域模型中已經擁有的版本屬性相對應的版本屬性。除其他外,它還包含在命令中,我們發送命令將書擱置。不過,在此期間,我們修改了圖書(強制更新Version屬性)。因此,我們希望得到一個409 CONFLICT答覆表明由於與目標資源的當前狀態衝突,無法完成請求。。此外,我們預計表示可能包含有助於根據修訂歷史合併差異的信息。,這就是為什麼我們檢查響應體是否包含書的當前狀態。
請注意,在測試方法的最後一行中,我們不檢查version。其原因在於,在REST控制器的上下文中,我們不(也不應該)關心如何計算和更新該屬性--它更改的事實是足夠的信息。因此,我們處理測試中的關注點分離問題。
在測試就緒之後,我們現在可以更新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(); } }}
第一次驗證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和狀態比較。不好的是,版本屬性需要添加到我們的域、應用程式和API級別,從而導致持久性層的技術細節洩漏。但是,好的是,現在為了執行更新,WHERE子句可限於aggregate ID和version田野。簡化是基於這樣一個事實:狀態現在由一個參數來表示,而不是一個完整的集合。對於衝突情況下的API響應,情況幾乎是一樣的。這兩種方法都迫使我們的客戶分析響應,並決定是否可以重傳。
從實際的角度來看這個問題,我們可以給出一些贊成使用樂觀鎖定的論點。
域是髒的,但是api是清晰的、簡潔的,而且使用先決條件要容易得多(在後面的章節中將更多地介紹這個主題)。Version業務有時會希望達到審計之類的目的,因此我們可能會獲得更多的好處。如果version仍然很難被接受,我們可以用Last-Modified屬性並將其發送到標題中。在許多業務中,對資源進行最後一次修改的時間可能更有意義。
ETag報頭
您注意到了嗎?在前面提到的兩種方法中,我們實際上都在資料庫上執行條件更新嗎?這不意味著我們的要求是有條件的嗎?是的,它確實如此,因為我們允許我們的客戶更新這本書,只有在此期間它還沒有被修改。在第一種情況下,我們需要比較聚合的所有屬性,而在第二種情況下,我們只需要檢查version和aggregate ID都是一樣的。所有屬性一致性和基於版本的一致性都定義了滿足請求的先決條件。
HTTP協議中有一種明確和標準的處理條件請求的方法。RFC 7232定義此概念,包括一組指示資源狀態和預條件的元數據標題:
條件請求是HTTP請求[RFC 7231],它包含一個或多個標頭欄位,指示在將方法語義應用於目標資源之前要測試的先決條件。
RFC 7232區分條件讀和寫請求。前者通常用於有效的緩存機制,這超出了本文的範圍。後一種要求是我們將要關注的問題。讓我們繼續講一些理論吧。
條件請求處理的基本組件是ETag (Entity Tag)應該在任何時候使用GET使用一些不安全的方法請求或更新它。ETag是由擁有資源的伺服器生成的不透明文本驗證器(令牌),並在當前時間點與其特定表示相關聯。它必須啟用對資源狀態的唯一標識。理想情況下,實體狀態(響應體)及其元數據(例如內容類型)中的每一項更改都反映在更新中。ETag價值。
有人可能會問:為什麼我們需要一個ETag當我們有一個Last-Modified頭球?實際上有幾個原因,但從不安全的方法執行的角度來看,值得注意的是RFC 7231 Last-Modified標頭架構將時間解析度限制為秒。在不充分的情況下,我們根本不能依靠它。
ETag驗證
我們將開始描述ETag從它的驗證,而不是產生,而不是偶然。簡而言之,我們創建它的方式取決於所選擇的驗證先決條件的方法。有兩種類型的驗證-強(默認)和弱。
阿ETag當它的值被更新時,當特定資源表示的內容發生變化時,它被認為是強的,並且可以在200 OK對GET請求。重要的是,該值在同一資源的不同表示之間是唯一的,除非這些表示具有相同形式的序列化內容。更具體地說:如果某個特定資源的主體以類型表示application/vnd+company.category+json和application/json兩者是相同的,它們可以共享相同的ETag值,強制使用不同的值。
阿ETag如果資源表示形式的每一次更改都可能更新其值,則被認為是弱的。使用弱標記的原因可能取決於計算它們的算法的局限性。例如,我們可以使用時間戳解析或無法確保同一資源的不同表示之間的唯一性。
哪一個ETag我們要用嗎?那得看情況。強壯ETags很難,甚至不可能有效地產生。瘦弱ETags然而,在資源狀態比較方面,被認為更容易生成,但不太可靠。選擇應該取決於數據的具體情況、支持的表示媒體類型以及最重要的是什麼--我們確保單個資源的不同表示之間的唯一性的能力。
ETag生成
ETag應該按照以下模式構建:
ETag = [W/]"{opaque-tag}"
這個模式看起來很簡單,但需要一些澄清:
W/是區分大小寫的弱驗證可選指示器。如果有-它告訴我們ETag將被確認為弱者。我們將在本文的下一節中找到更多關於這一點的內容。opaque-tag是強制性字符串值,由雙引號包圍。由於伺服器和客戶端之間存在轉義/未轉義問題,建議在opaque-tags.下面我們將找到幾個有效的eTags示例:
"""123"W/"my-weak-tag"正如我們所看到的,ETag可能包含很多類型的東西,但現在的問題是:我們應該如何生成它?我們應該用什麼來代替不透明的標籤?它可能是一個與內容類型分類器相結合的特定於實現的版本號,這是根據內容的表示計算出來的哈希值。它甚至可以是一個亞秒解析度的時間戳。
比較
因為我們已經知道如何產生弱者和強者ETags,我們現在錯過的唯一一件事是如何實際檢查給定的值是否通過了各自的驗證。有一條規則:
ETags在強比較中是相等的若且唯若它們都不是弱的並且它們的值是相同的。ETags在弱比較中是相等的,如果它們的opaque-tags是平等的。請在下表中找到例子:
ETag#1ETag#2強比較
弱比較
「123」「123」匹配匹配「123」W/「123」不匹配匹配W/「123」W/「123」不匹配匹配W/「123」W/「456」不匹配不匹配
進入實現階段,讓我們從測試開始,檢查一本書的表示是否包含ETag頭球。在我們的示例中,我們將直接從book的Version屬性生成它。為了保持簡單,我們還假設只有一個支持的表示,在這個過程中我們省略了它。
@Testpublic void shouldIncludeETagBasedOnVersionInBookViewResponse() throws Exception { //given Version version = someVersion(); AvailableBook availableBook = bookRepositoryFixture.availableBookInTheSystemWith(version); //when ResultActions resultActions = api.getBookWith(availableBook.id()); //then resultActions .andExpect(status().isOk()) .andExpect(header().string(ETAG, String.format("\"%d\"", version.asLong()))); }
為了通過這個測試,我們需要在構建響應時包含標題。
@RestController
@RequestMapping("/books")
class BookFindingController {
private final FindingBook findingBook;
public BookFindingController(FindingBook findingBook) {
this.findingBook = findingBook;
}
@GetMapping("/{bookId}")
ResponseEntity<?> findBookWith(@PathVariable("bookId") UUID bookIdValue) {
Optional<BookView> book = findingBook.findBy(BookId.of(bookIdValue));
return book
.map(it -> ResponseEntity.ok().eTag(ETag.of(Version.from(it.getVersion())).getValue()).body(it))
.orElse(ResponseEntity.notFound().build());
}
}
正如我們所看到的,有一個eTag()方法,我們可以使用該方法來設置我們選擇的標題。Spring框架自動支持管理ETag標頭但它僅限於緩存控制機制。不安全的方法處理由我們來決定。
如果我們建造ETag基於version屬性時,我們可能不再需要它在響應體中(假設它沒有業務價值)。因此,我們可以通過以下斷言加強我們的測試:
.andExpect(jsonPath("$.version").doesNotExist())
並將屬性從序列化中排除。@JsonIgnore注釋:
public class BookView {//... @JsonIgnore private final long version; //...}
最後,我們可以從命令中去掉這個欄位,但是讓我們暫時保留它,因為這會帶來更多的後果。
先決條件
我們知道ETags是如何計算和比較它們。現在是有條件請求的時候了。為了創建條件請求,我們需要利用ETag由伺服器返回,並將其值放入一個條件標頭中:If-Match, If-Not-Matched, If-Modified-Since, If-Unmodified-Since,或If-Range。在這篇文章中,我們將只關注If-Match,和If-Unmodified-Since頭,因為這些是唯一適用於不安全方法的頭。
評價
不管我們使用的標頭是什麼,我們都需要知道什麼時候應該評估這些頭中嵌入的條件。這裡是我們能找到的RFC 7232:
如果伺服器對相同請求的響應沒有這些條件,則必須忽略所有接收到的先決條件,而不是2xx(成功)或412(先決條件失敗)以外的狀態代碼。換句話說,在有條件請求中,重定向和失敗優先於計算先決條件。
這意味著,如果我們在伺服器端有任何驗證,最終會在404, 422,或4xx一般情況下,我們應該首先執行返回的消息。但是,我們需要記住,在對目標資源應用實際的方法語義之前,也必須進行前提檢查。
如果匹配
想法If-MatchHeader是向伺服器提供關於客戶端希望它擁有的特定資源的表示的信息。If-Match標頭可以等於:
*-對答覆的任何表述都是好的,在我們的情況下,這是有用程度最低的(如果有的話)一個特別的ETag之前從響應中檢索到的GET請求逗號分隔列表ETag價值在我們的例子中,最合適的選擇是使用If-Match標頭ETag價值。我們來寫個測試吧。
@Test
public void shouldSignalPreconditionFailed() throws Exception {
//given
AvailableBook availableBook = availableBookInTheSystem();
//and
ResultActions bookViewResponse = api.getBookWith(availableBook.id());
BookView book = api.parseBookViewFrom(bookViewResponse);
String eTag = bookViewResponse.andReturn().getResponse().getHeader(ETAG);
//and
bookWasModifiedInTheMeantime(bookIdFrom(book.getId()));
//when Bruce places book on hold
PatronId bruce = somePatronId();
TestPlaceOnHoldCommand command = placeOnHoldCommandFor(book.getId(), bruce, book.getVersion())
.withIfMatchHeader(eTag);
ResultActions bruceResult = api.send(command);
//then
bruceResult.andExpect(status().isPreconditionFailed());
}
為了通過這個測試,我們需要在BookHoldingController:
@RestController@RequestMapping("/books")class BookHoldingController {private final PlacingOnHold placingOnHold; BookHoldingController(PlacingOnHold placingOnHold) { this.placingOnHold = placingOnHold; } @PatchMapping(path = "/{bookId}", headers = "If-Match") ResponseEntity<?> updateBookStatus(@PathVariable("bookId") UUID bookId, @RequestBody UpdateBookStatus command, @RequestHeader(name = HttpHeaders.IF_MATCH) ETag ifMatch) { if (PLACED_ON_HOLD.equals(command.getStatus())) { Version version = Version.from(Long.parseLong(ifMatch.getTrimmedValue())); PlaceOnHoldCommand placeOnHoldCommand = PlaceOnHoldCommand.commandFor(BookId.of(bookId), command.patronId()) .with(version); Result result = placingOnHold.handle(placeOnHoldCommand); return buildConditionalResponseFrom(result); } else { return ResponseEntity.ok().build(); //we do not care about it now } } @PatchMapping(path = "/{bookId}", headers = "!If-Match") ResponseEntity<?> updateBookStatus(@PathVariable("bookId") UUID bookId, @RequestBody UpdateBookStatus command) { //... } private ResponseEntity<?> buildConditionalResponseFrom(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.PRECONDITION_FAILED).build(); } else { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } private ResponseEntity<?> buildResponseFrom(Result result
而不是修改現有方法(用於返回的方法)409在版本衝突的情況下)我們添加了新的版本,這需要If-Match標題將出現。這有兩個原因。首先,我們可以在不破壞API客戶端的情況下部署我們的新方法。其次,我們可以讓客戶選擇使用有條件請求的商品,還是堅持「經典」解決方案。第二個解決方案承擔將版本屬性保留在PATCH請求身體。
缺少先決條件
到了我們需要決定是否要讓這兩種解決方案並行運行的時刻。有沒有辦法強迫API客戶端使用條件請求?
在……裡面RFC 6585我們可以讀到:
428狀態代碼指示源伺服器要求請求是有條件的。它的典型用途是避免「丟失更新」問題,即客戶端獲取資源的狀態、修改資源並將其返回伺服器,同時第三方修改了伺服器上的狀態,導致衝突。通過要求請求是有條件的,伺服器可以確保客戶端正在使用正確的副本。
當我們決定強制使用預條件時,我們從以下測試開始:
@Testpublic void shouldSignalPreconditionRequiredWhenIfMatchIsHeaderMissing() throws Exception {//given AvailableBook availableBook = bookRepositoryFixture.availableBookInTheSystem(); //when TestPlaceOnHoldCommand command = placeOnHoldCommandFor(availableBook, patronId).withoutIfMatchHeader(); ResultActions resultActions = api.send(command); //then resultActions .andExpect(status().isPreconditionRequired()) .andExpect(jsonPath("$.message").value(equalTo("If-Match header is required")));}
為了通過這個測試,我們需要處理所有與處理有關的事情。409 CONFLICT。清理之後,我們的控制器看起來如下:
@RestController@RequestMapping("/books")class BookHoldingController {private final PlacingOnHold placingOnHold; BookHoldingController(PlacingOnHold placingOnHold) { this.placingOnHold = placingOnHold; } @PatchMapping(path = "/{bookId}") ResponseEntity<?> updateBookStatus(@PathVariable("bookId") UUID bookId, @RequestBody UpdateBookStatus command, @RequestHeader(name = HttpHeaders.IF_MATCH, required = false) ETag ifMatch) { if (PLACED_ON_HOLD.equals(command.getStatus())) { return Optional.ofNullable(ifMatch) .map(eTag -> handle(bookId, command, eTag)) .orElse(preconditionFailed()); } else { return ResponseEntity.ok().build(); //we do not care about it now } } private ResponseEntity<?> handle(UUID bookId, UpdateBookStatus command, ETag ifMatch) { Version version = Version.from(Long.parseLong(ifMatch.getTrimmedValue())); PlaceOnHoldCommand placeOnHoldCommand = PlaceOnHoldCommand.commandFor(BookId.of(bookId), command.patronId()) .with(version); Result result = placingOnHold.handle(placeOnHoldCommand); return buildResponseFrom(result); } 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.PRECONDITION_FAILED).build(); } else { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } private ResponseEntity preconditionFailed() { return ResponseEntity
先決條件優先
就像我們已經說過的,If-Match當涉及到有條件的不安全請求時,頭不是唯一要使用的選項。如果我們決定回來Last-Modified標頭GET請求時,相應的條件請求頭為If-Unmodified-Since。根據RFC 7232, If-Unmodified-Since只有在請求中不包含if-Match標頭時,才能在伺服器端驗證。
結論
在多用戶環境中,處理並發訪問是我們的主要任務。並發控制可以而且應該反映在我們的API中,特別是因為HTTP提供了一組頭部和響應代碼來支持它。
要選擇的第一個選項是將Version屬性添加到讀取模型中,並將其進一步傳遞到不安全的方法中。如果在伺服器端檢測到碰撞,我們可以返回。409 CONFLICT狀態包含所有必要信息,以使客戶端知道問題的根源。
更高級的解決方案是條件請求。GET方法應該返回ETag或Last-Modified頭,它們的值應該相應地放在If-Match或If-Unmodified-Since不安全方法的標題。如果發生衝突,伺服器將返回412 PRECONDITION FAILED.
如果要強制客戶端使用條件請求,則在缺少先決條件的情況下,伺服器將返回428 PRECONDITION REQUIRED.
Spring框架不支持我們在API中直接對並發訪問進行建模。儘管如此,通過測試驅動我們的API顯示,SpringWeb中可用的非常基本的機制使它在我們的指尖上。