在非同步方法中獲取登陸使用者時出現的問題

李明 發表於 2022-06-27

在重寫分配任務演算法時需要獲取當前登陸使用者區域及其子區域,重寫之後進行測試的時候發現任務直接停留在了分配中,檢視後臺日誌時發現了報錯
圖片.png
根據報錯檢視丟擲錯誤的地方
圖片.png


可以發現是在呼叫getAuthUserDetailWithoutTransaction方法的時候出現的問題,進一步檢視這個方法:

public Optional<AuthUserDetails> getAuthUserDetailWithoutTransaction() {
    logger.debug("根據認證獲取當前登入使用者名稱,並獲取該使用者");

    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    if (authentication != null) {
      AuthUserDetails userDetail;
      if (authentication instanceof UsernamePasswordAuthenticationToken) {
        userDetail = (AuthUserDetails) authentication.getPrincipal();
      } else if (authentication instanceof AuthUserDetails) {
        userDetail = (AuthUserDetails) authentication;
      } else if (authentication instanceof AnonymousAuthenticationToken) {
        return Optional.empty();
      } else {
        throw new RuntimeException("獲取型別不正確");
      }
      return Optional.of(userDetail);
    }

    logger.debug("認證使用者在資料庫中不存在");
    return Optional.empty();
  }

根據後臺日誌輸出的認證使用者在資料庫中不存在可以得出authentication為null,然後為了進一步確認我又嘗試用debug模式執行後臺發現在後臺執行過程中經常會用到上面這個函式,並且獲取到的authentication都不為null
圖片.png
只有在分配任務時返回的authentication為null
圖片.png

可以從下面這個簡化的圖得知函式呼叫關係:
在非同步方法中獲取登陸使用者時出現的問題

又因為報錯資訊中包含

Unexpected exception occurred invoking async method(呼叫非同步方法時發生意外異常)

所以我嘗試去掉addTaskDetailsAndAddFormItemValuesAndUpdateStatistics方法的非同步註解,重新執行後發現一切正常,沒有發生報錯。
所以就去查詢了一下SecurityContextHolder.getContext()@Async同時使用會發生的問題,發現@Async方法中使用SecurityContextHolder.getContext()就會返回
null。

測試:
圖片.png

圖片.png

執行結果:
圖片.png
即在非同步函式內呼叫就會返還null,並且我們還可以發現我們當前的預設執行緒為nio-8081-exec-8,但是非同步函式會新建一個task-1執行緒,並且在新執行緒中獲取不到安全上下文,可以推測預設情況下安全上下文只會存在於主執行緒中,它不會隨著執行緒的新建而被傳遞過來。

SecurityContextHolder用於儲存安全上下文(security context)的資訊。當前操作的使用者是誰,該使用者是否已經被認證,他擁有哪些角色許可權…這些都被儲存在SecurityContextHolder中。SecurityContextHolder預設使用ThreadLocal 策略來儲存認證資訊。這也就意味著,這是一種與執行緒繫結的策略。Spring Security在使用者登入時自動繫結認證資訊到當前執行緒,在使用者退出時,自動清除當前執行緒的認證資訊。

如果我們在getAuthUserDetailWithoutTransaction中這樣設定安全上下文策略便可解決此問題,但是這樣的話又可能對呼叫此方法的其他方法造成影響,所以我們不得不尋找新的解決方法。

 public Optional<AuthUserDetails> getAuthUserDetailWithoutTransaction() {
    logger.debug("根據認證獲取當前登入使用者名稱,並獲取該使用者");
    //設定可繼承本地執行緒策略,使得其可以在非同步方法中被呼叫
    SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);

    . . .
}

起初我想的是隻需要在這個非同步方法中設定策略即可,但是測試後發現此策略並不會隨著方法的呼叫而被傳遞過來。
於是我嘗試直接在M層獲取到當前登陸使用者再將登陸使用者傳給非同步方法
圖片.png
但是再次嘗試時又發生了新的報錯

failed to lazily initialize a collection of role: club.yunzhi.smartcommunity.entity.District.children, could not initialize proxy - no Session

搜尋後發現Hibernate再進行關聯處理時通常用懶載入,可以避免大量資料多次傳遞,也就是說我們在Aservice裡獲取到了user,user對應的區域的子區域可以在Aservice中呼叫,但是如果把user又傳給Bservice,那麼再想呼叫user對應的區域的子區域就會發生報錯。
源service:
圖片.png
接受傳遞的service:
圖片.png
也就是說我們在進行傳遞引數時為了避免這種情況的發生可以直接傳遞相應的id,再根據ID通過倉庫層獲取我們的資料。
也就是說我們可以改成如下這種方式達到目的

 public void taskAssign(){
   .   .   .   
 Optional<Long> optionalWebUserId = this.webUserService.getCurrentLoginWebUserId();
 Long webUserId = optionalWebUserId.orElse(null);
 // 非同步生成任務詳情與表單,並更新各區域統計情況。
 this.taskDetailAsyncService.addTaskDetailsAndAddFormItemValuesAndUpdateStatistics(task, residents, specification, webUserId);
}
List<District> getManageDistrictsWithCurrentLoginUser(Long ...webUserId);
  public final List<District> getManageDistrictsWithCurrentLoginUser(Long ...webUserId) {
    List<District> result;
    Optional<WebUser> optionalUser;

    if(webUserId.length == 0) {
      optionalUser =  this.webUserService.getCurrentLoginWebUser();
    } else {
      optionalUser = this.webUserRepository.findById(webUserId[0]);
   }
    . . .

    }

此時再次嘗試便可達到我們想要的效果。

另外再說一下如何展示多選或單選元件,我們想要達到的效果:
圖片.png
並且使用者點選選項2也不會改變顯示,即類似於disable的效果,但是如果我們直接宣告input為disable就會像下面這樣顯示得不是很清晰。
圖片.png
如果我們常使用redonly的話會發現點選後還是會給予相應的顯示只是不會改變value值。
解決方法:input中宣告onclick為return false

<input
       .  .  .
       onclick = "return false"
       type="radio" >

問題三:
專案中如果一個人有多處房產,那麼分配任務後再進行預覽就會發現多個記錄
圖片.png
遇到這種情況我首先想到的就是分配任務時生成資料生成重複了,但是看完分配程式碼並沒有發現任何問題,在資料庫中檢視也沒有生成重複的資料項。也確認了前呼叫的埠與後臺相匹配,於是就嘗試在前臺輸出一下page,結果發現返回來的資料一模一樣,同樣也在後臺檢視了返還給前臺的資料發現時返還了三個相同的資料,對應的id也是相同的。
圖片.png
到這裡就本就可以確認是在查詢上出了問題。
但是我們在前臺沒有傳遞任何查詢引數,所以基本可以確認是後臺在根據使用者區域進行查詢時出了問題。
後臺查詢條件是這樣構造的

Join<TaskDetail, Building> buildingJoin = root.join("resident")
          .join("houses", JoinType.LEFT)
          .join("building", JoinType.LEFT);
switch (district.getType()) {
case TYPE_BUILDING:
          return criteriaBuilder.equal(buildingJoin.get("id").as(Long.class), districtId);
 . . .
}

正確的
也就是說每個查詢都是獨立的,好呢局house1查詢完返還一個資料,再根據其他house查詢再進行返還,並不是我想象中的下圖的方式:
錯誤的
如果我們想要去除這些重複項只需在查詢條件裡新增這樣一條即可:

 public static Specification<TaskDetail> distinct() {
    return (root, criteriaQuery, criteriaBuilder) -> {
      criteriaQuery.distinct(true);
      return criteriaQuery.getRestriction();
    };
  }