記錄一個由於倉庫層錯誤導致軟刪除失效的問題

LYX6666 發表於 2022-02-15

零、前言

為了方便描述,我們將專案進行一下抽象和簡化。
這是一個前端用Angular、後端用Spring的專案,專案E-R圖的其中一段如下:
image.png
不難看出,鄉鎮和社群是1對多的關係。

在管理較低階的區域時,需要關聯到較高階區域的外來鍵(例如:社群必須有一個所屬的鄉鎮)

image.png

由於這幾種區域的查詢都很頻繁,為減少SQL頻率,在後臺設定了快取。
為了避免刪除資料導致整個系統的錯誤,全域性啟用了軟刪除。

一、問題復現

在任何一個實體的管理頁面(如鄉鎮管理)中刪除一個物件(鄉鎮),列表中不再顯示刪除的資料,資料庫中也能看到,已刪除物件的deleted=1:

image.png
image.png


但是在其他實體關聯查詢時(社群中設定鄉鎮時),確可以查到已經刪除的物件,而且居然還能儲存...

image.png

(如果儲存已刪除的資料,會導致系統報錯)

簡單總結一下就是:

由於專案程式碼的某些問題,軟刪除在列表分頁查詢時正常,但到了需要外來鍵關聯時,軟刪除卻失效了。

二、排查問題

排除快取原因

首先從issue上看,可能是後端的快取導致的(之前出現過類似的問題)。後端設定了重新登入時清除快取,因此測試很簡單。
嘗試了瀏覽器重新整理、退出重新登入、換瀏覽器等操作,並沒有解決問題,現在基本上排除快取原因了。

進一步排查,Spring中使用debug模式步進查詢功能的內部程式碼,發現返回值中出現了被刪除的資訊

image.png

至此可以斷定不是快取問題而是查詢方法的問題。

檢查呼叫關係

既然是查詢出了問題,為什麼在鄉鎮列表卻可以正常區分已刪除的資料呢?

帶著疑問,我找到了前後端的呼叫關係:

鄉鎮列表發起的分頁查詢,最終呼叫到findAll方法

image.png

而在社群->鄉鎮選擇器中查詢鄉鎮,最終也會呼叫到findAll方法,但引數不同
image.png

// findAll沒有引數
@Override
public List<Town> findAll() {
  return (List<Town>) this.townRepository.findAll();
}

// page有引數
@Override
public Page<Town> page(String name, Pageable pageable) {
  return this.townRepository.findAll(TownSpecs.containingName(name)), pageable);
}

於是初步判斷,是倉庫層TownRepository的getAll()方法漏寫了軟刪除相關的功能導致的,但目前我們看到的程式碼中,並沒有關於軟刪除是如何實現的,所以繼續找。

探索軟刪除的實現

既然已經知道問題出在哪,接下來就去找,在這個專案中,軟刪除是怎麼實現的,以及影響倉庫層查詢的關鍵的程式碼在哪裡,我從歷史的Pull Request中找到軟刪除的PR。

發現本專案中,所有的實體都繼承了基礎實體,啟用軟刪除需要在基礎實體中設定deleted和deleteAt欄位,以及相關的Setter、Getter方法,用來表示已刪除和刪除時間:
image.png

然後在所有的繼承類上新增@SQLDelete註解,把刪除功能替換成”設定deleted=1“
第二行@where(clause = "deleted = false") 作用是在查詢時只查詢沒有被軟刪除的資料。
image.png

此時我想到了一個笨方法:在倉庫層所有的findAll上增加deleted = 0 的條件,但問題是,這麼多的實體,會產生大量重複程式碼,而且也沒有從根本上解決問題,因此放棄。

至此,找了一圈還是沒找到答案:按理說這樣已經可以生效了,但為什麼findAll()會不正常呢?

又比對了一下本地最新版本的程式碼,發現繼承實體中已經刪去了@where(clause = "deleted = false")
image.png

正當我納悶的時候,發現程式碼註釋裡有一個思否連結,開啟一看正是潘老師之前寫的軟刪除的部落格,於是我又讀了一遍。
spring boot實現軟刪除

這才瞭解到:
@Where(clause = "deleted = false")會導致我們在進行all或page查詢時,得到一個500 EntiyNotFound錯誤。

部落格中也給出瞭解決方法:建立一個SoftDeleteCrudRepository介面,繼承並覆蓋JPA內部的CrudRepository的方法,手動的為查詢方法新增deleted = false條件(具體程式碼見部落格),這樣既能實現軟刪除又避免了500錯誤。

三、解開BUG的神祕面紗

所以可以猜測到,問題一定是出現在我們自己寫的SoftDeleteCrudRepository上,大概是因為某些方法沒有override。

來到此專案的軟刪除倉庫中,關於findAll的過載方法有這些:

  @Override
  public Page<T> findAll(Pageable pageable) {
    return this.findAll(this.andDeleteFalseSpecification(null), pageable);
  }

  @Override
  public Page<T> findAll(@Nullable Specification<T> specification, Pageable pageable) {
    return super.findAll(this.andDeleteFalseSpecification(specification), pageable);
  }

  @Override
  public List<T> findAll(@Nullable Specification<T> specification, Sort sort) {
    return super.findAll(this.andDeleteFalseSpecification(specification), sort);
  }

我們再來回顧一下,剛才的兩種情況是怎麼呼叫的:

// findAll沒有引數
@Override
public List<Town> findAll() {
  return (List<Town>) this.townRepository.findAll();
}

// page有引數
@Override
public Page<Town> page(String name, Pageable pageable) {
  return this.townRepository.findAll(TownSpecs.containingName(name)), pageable);
}

好,破案了,我們的軟刪除類中並沒有override空引數情況的findAll方法,因此對於空引數,並沒有自動加上deleted=1 的查詢條件。

所以只需要在這裡加上:

  @Override
  public List<T> findAll(@Nullable Specification<T> specification) {
    return super.findAll(this.andDeleteFalseSpecification(specification));
  }

就可以讓本文的bug在所有倉庫的findAll()方法中消失,日後再出現新的呼叫方式,也只需修改軟刪除類即可。
至此問題終於解決。

參考資料

後記

其實這個專案真正的 寫法比參考資料中更復雜:

  • SoftDeleteCrudRepository不再是介面,而是實現類
  • 其他倉庫並不是直接繼承SoftDeleteCrudRepository,而是使用工廠模式注入
  • 專案中工廠模式的程式碼我沒看懂,其實最後也沒搞明白,其他倉庫在沒有extends也沒有implements的情況下,是怎麼呼叫SoftDeleteCrudRepository的。

版權宣告

本文作者:河北工業大學夢雲智開發團隊 - 劉宇軒
新人經驗不足,有建議歡迎交流,有錯誤歡迎輕噴