【Java分享客棧】從線上環境摘取了四個程式碼優化記錄分享給大家

福隆苑居士發表於2022-04-16

前言

因為前段時間新專案已經完成目前趨於穩定,所以最近我被分配到了公司的運維組,負責維護另外一個專案,包含處理客戶反饋的日常問題,以及對系統缺陷進行優化。


經過了接近兩週的維護,除了日常問題以外,程式碼層面我一共處理了一個BUG,優化了三個問題,我把這四個問題歸納成了四段編碼小技巧分享給大家,希望能有所幫助,今後若遇到類似的問題可以到我這裡翻出來看看,想必能節省許多時間。


技巧

1、stream分組

很多人都知道java8的stream很好用,但很多人其實不會用,或者說搜了許多資料還是用不好,歸根究底就是許多百度的資料沒有合適的案例,讓人似懂非懂。我這裡就從線上專案中提取出了一段stream分組的程式碼片段,幫大家一看就懂。

首先,我把表結構展示一下,當然為了做案例簡化了,方便理解。

  • 醫生資訊表
id doctor_name phone photo_url area_code
1 張三 13612345678 https://head.img.com/abc.png EAST
2 李四 15845678901 https://head.img.com/xyz.png WEST
  • 院區表
id area_code area_name
1 EAST 東院區
2 SOUTH 南院區
3 WEST 西院區
4 NORTH 北院區

需求:查詢醫生資訊列表,要展示院區名稱。


在我做優化之前,上一位同事是這麼寫的:

// 查詢醫生列表
List<DoctorVO> doctorVOList = doctorService.findDoctorList();

// 遍歷醫生列表,裝入院區名稱。
doctorVOList.forEach((vo)->{
    // 院區編碼
    String areaCode = vo.getAreaCode(); 
    // 根據院區編碼查詢院區資訊
    HospitalAreaDTO hospitalAreaDTO = areaService.findOneByAreaCode(areaCode);
    // 放入院區名稱
    vo.setAreaName(hospitalAreaDTO.getAreaName());
});

// 返回
return doctorVOList;

可以看到,他是遍歷醫生列表,然後分別去查詢每個醫生所在院區的名稱並返回,等於說若有100個醫生,那麼就要查詢100次院區表,雖然MySQL8.0+以後的查詢效率其實變高了,這種小表查詢其實影響沒那麼大,但作為一個成熟的線上專案,這種程式碼就是新手水平,我敢打包票很多人都這麼寫過。


優化後:

// 查詢醫生列表
List<DoctorVO> doctorVOList = doctorService.findDoctorList();

// 以areaCode為key將院區列表分組放入記憶體中
Map<String,List<HospitalAreaDTO>> areaMap = areaService.findAll().stream()
            .collect(Collectors.groupingBy(e-> e.getAreaCode()));


// 遍歷醫生列表,裝入院區名稱。
List<DoctorVO> doctorVOList = new ArrayList<>();
doctorVOList.forEach((vo)->{
    // 院區編碼
    String areaCode = vo.getAreaCode(); 
    // 根據院區編碼從map中拿到院區名稱
    String areaName = areaMap.get(areaCode).get(0).getAreaName();
    // 放入院區名稱
    vo.setAreaName(areaName);
});

// 返回
return doctorVOList;

可以看到,這裡直接使用stream分組將院區資訊按照院區編碼為key,院區資訊為value放入記憶體中,然後遍歷醫生列表時,根據院區編碼直接從記憶體中取到對應的院區名稱即可,前後只查詢了1次,極大提高了效率,節省了資料庫資源。

只要是類似這種遍歷查詢需要從其他小表查出某屬性值的場景時,都可以使用這種方式。


2、stream排序

這個排序其實很簡單,就是根據客戶要求的多個規則給醫生列表排序,這裡的規則是:按照是否線上、是否排班降序,且按照醫生職稱、醫生編號升序。

專案中用到了mybatis,所以之前的寫法是直接寫sql語句,但sql語句複雜一點的話後期交給其他同事是不好維護的。

其實,查出列表後,直接在記憶體中通過stream進行排序就很舒適,所以我把專案中這部分的sql語句寫法優化成了直接在程式碼中進行查詢並排序。
stream多屬性不同規則排序:

// 查詢列表
List<HomePageDoctorsDTO> respDTOList = findHomePageDoctorList();

// 排序
List<HomePageDoctorsDTO> sortList = respDTOList.stream()
    .sorted(
        Comparator.comparing(HomePageDoctorsDTO::getOnlineFlag, Comparator.reverseOrder())
        .thenComparing(HomePageDoctorsDTO::getScheduleStatus, Comparator.reverseOrder())
        .thenComparing(HomePageDoctorsDTO::getDoctorTitleSort)
        .thenComparing(HomePageDoctorsDTO::getDoctorNo)
    )
    .collect(Collectors.toList());

// 返回
return sortList;

上面一段程式碼就OK了,十分簡單,reverseOrder()表示降序,不寫就表示預設的升序。

這裡需要注意一點,網上很多資料都有用到:

Comparator.comparing(HomePageDoctorsRespDTO::getOnlineFlag).reverse()

這樣的方式來進行降序,這是有誤區的,可以專門查下或試下reverse()的用法,它只是反轉不是降序排列,類似於從左到右變為從右到左這樣的形式,降序一定要用上面程式碼的寫法,這是一個要注意的坑。


3、非同步執行緒

非同步執行緒很多人都知道,直接使用@Async註解即可,但很多人不知道使用這個註解的限制條件,往往以為自己用上了,實際上根本沒有走非同步執行緒。

  1. @Async註解只能標註在void方法上;
  2. @Async註解標註的方法必須是public修飾;
  3. @Async註解標註的方法和呼叫方在同一個類中,不會生效。

以上條件缺一不可,哪怕滿足前兩個也不行,還是不會走非同步執行緒。

我維護的這個專案就是滿足了前兩個,實際上沒有生效,說明寫這段程式碼的同事想法是好的,希望不佔用主執行緒從而提高介面效率,但實際上自己也沒有充分測試,以為是有效的,我相信很多人也這麼幹過。
這裡,我優化了下,給大家一個最科學的寫法,保證有效,這裡我以發簡訊通知為例。


首先,定義一個專門寫非同步方法的類叫AsyncService。

/**
 * 非同步方法的服務, 不影響主程式執行。
 */
@Service
public class AsyncService {
    private final Logger log = LoggerFactory.getLogger(AsyncService.class);
    
    @Autowired
    private PhoneService phoneService;
    
    /**
     * 發簡訊通知患者檢查時間
     * @param dto 患者資訊
     * @param consult 諮詢資訊
     */
    @Async
    public void sendMsgToPatient(PatientDTO patientDTO, ConsultDTO consultDTO) {
        // 訊息內容
        String phone = patientDTO.getTelphone();
        String msg = "您好,"+ patientDTO.getName() +",已成功為你預約"
        + consultDTO.getDeviceType() +"檢查,時間是"+ consultDTO.getCheckDate() 
        +",望您做好檢查時間安排。就診卡號:"+ consultDTO.getPatientId() 
        +",檢查專案:" + consultDTO.getTermName();
        
        // 發簡訊
        phoneService.sendPhoneMsg(phone, msg);
    }
}

這裡注意,使用public修飾符,void方法,前面限制條件已經講過。


其次,我們要在配置類中宣告@EnableAsync註解開啟非同步執行緒。

@Configuration
@EnableAsync
public class AsyncConfiguration implements AsyncConfigurer {

    // 具體實現
    // ....
    
}

最後,我們在業務方法中呼叫即可。

public BusinessResult doBusiness(PatientDTO patientDTO, ConsultDTO consultDTO) { 
    // 處理業務邏輯,此處省略...
    // ....
    
    // 非同步發簡訊通知患者檢查時間
    asyncService.sendMsgToPatient(patientDTO, consultDTO);
}

這樣,這個發簡訊的業務就會走非同步執行緒,哪怕有其他類似業務需要非同步呼叫,也都可以放到AsyncService中去統一處理。


我們還要注意一點,以上方式的非同步執行緒實際上走的是預設執行緒池,而預設執行緒池並不是推薦的,因為在大量使用過程中可能出現執行緒數不夠導致堵塞的情況,所以我們還要進一步優化,使用自定義執行緒池。

這裡,我們使用阿里開發手冊中推薦的ThreadPoolTaskExecutor。

@Configuration
@EnableAsync
public class AsyncConfiguration implements AsyncConfigurer {

    private final Logger log = LoggerFactory.getLogger(AsyncConfiguration.class);

    @Override
    @Bean(name = "taskExecutor")
    public Executor getAsyncExecutor() {
        log.debug("Creating Async Task Executor");
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("async-Executor-");
        return executor;
    }

    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new SimpleAsyncUncaughtExceptionHandler();
    }
}

這裡,我們分別設定了核心執行緒數8、最大執行緒數50、任務佇列1000,執行緒名稱以async-Executor-開頭。

這些配置其實可以提取出來放到yml檔案中,具體配置多少要結合專案使用非同步執行緒的規模以及伺服器自身的水平來判斷,我們這個專案用到非同步執行緒的地方不算太多,主要是發簡訊通知和訂閱訊息通知時,而且伺服器本身是8核16G,所以這個設定是相對符合的。


4、統一異常管理

統一異常管理是我著重要講的,這次我維護的專案中在這塊寫的簡直是難以忍受,線上排查問題很多重要的資訊啥也看不到,檢查程式碼發現明明用到了統一異常管理,但寫法簡直是外行水準,氣的我肚子疼。


首先,我說一下規範:

  1. 統一異常管理後,如非必要絕不能再try...catch,如果必須try...catch請一定要log.error(e)記錄日誌列印堆疊資訊,並且throw異常,否則該程式碼塊出問題線上什麼也看不到;

  2. 統一異常管理後,介面層面校驗錯誤時不要直接使用通用響應物件返回,比如ResultUtil.error(500, "查詢xx失敗"),這樣會導致統一異常管理失去效能,因為這就是正常返回了一個物件,不是出現異常,所以我們應該在校驗錯誤時直接throw new BusinessException("查詢xx失敗")主動丟擲一個異常,這樣才會被捕獲到;

  3. 統一異常管理後,全域性異常管理類中最好使用Spring自帶的ResponseEntity包裝一層,保證異常時HTTP狀態不是200,而是正確的異常狀態,這樣前端工程師才能根據HTTP狀態判斷介面連通性,然後再根據業務狀態判斷介面獲取資料是否成功。

這裡,我把專案中優化後的全域性異常統一處理程式碼貼上來分享給大家:

首先,我們自定義三個常用異常。

校驗引數的異常,繼承執行時異常RuntimeException。

/**
* 引數不正確異常
*/
public class BadArgumentException extends RuntimeException {
    public BadArgumentException(){
        super();
    }

    public BadArgumentException(String errMsg){
        super(errMsg);
    }
}

校驗許可權的異常,繼承執行時異常RuntimeException。

/**
* 無訪問許可權異常
*/
public class NotAuthorityException extends RuntimeException {
    
    public NotAuthorityException(){
        super("沒有訪問許可權。");
    }
 
    public NotAuthorityException(String errMsg){
        super(errMsg);
    }
}

業務邏輯異常,繼承執行時異常RuntimeException。

/**
* 業務邏輯異常
*/
public class BusinessException extends RuntimeException {

    public BusinessException(){
        super();
    }

    public BusinessException(String errMsg){
        super(errMsg);
    }
    public BusinessException(String errMsg,Throwable throwable){
        super(errMsg,throwable);
    }

}

其次,我們宣告一個全域性異常處理類。

/**
* 統一異常處理
*/
@RestControllerAdvice
@Slf4j
public class ExceptoinTranslator {

    /**
    * 許可權異常
    */
    @ExceptionHandler(value = {AccessDeniedException.class,NotAuthorityException.class})
    public ResponseEntity handleNoAuthorities(Exception ex){
        return ResponseEntity.status(HttpCodeEnum.FORBIDDEN.getCode()).body(
            ResultUtil.forbidden(ex.getMessage())
        );
    }
    
    /**
    * 引數錯誤異常
    */
    @ExceptionHandler(value = BadArgumentException.class)
    public ResponseEntity handleBadArgument(Exception ex){
        return ResponseEntity.status(HttpStatus.BAD_REQUEST.value()).body(
            ResultUtil.custom(HttpStatus.BAD_REQUEST.value(), ex.getMessage())
        );
    }
    
    /**
    * 介面引數校驗異常
    */
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public ResponseEntity handleArguNotValid(MethodArgumentNotValidException ex){
        FieldError fieldError=ex.getBindingResult().getFieldErrors().get(0);
        String msg = !StringUtils.isEmpty(fieldError.getDefaultMessage()) ? fieldError.getDefaultMessage():"引數不合法";
        return ResponseEntity.status(HttpStatus.BAD_REQUEST.value()).body(
            ResultUtil.custom(HttpStatus.BAD_REQUEST.value(), msg)
        );
    }
    
    /**
    * 引數不合法異常
    */
    @ExceptionHandler(value = ConstraintViolationException.class)
    public ResponseEntity handleConstraintViolation(ConstraintViolationException ex){
        String err=ex.getMessage();
        Set<ConstraintViolation<?>> set=ex.getConstraintViolations();
        if(!set.isEmpty()){
           err= set.iterator().next().getMessage();
        }
        String msg = StringUtils.isEmpty(err)?"引數不合法":err;
        return ResponseEntity.status(HttpStatus.BAD_REQUEST.value()).body(
            ResultUtil.custom(HttpStatus.BAD_REQUEST.value(), msg)
        );
    }
    
    /**
    * 引數不合法異常
    */
    @ExceptionHandler(value = {IllegalArgumentException.class})
    public ResponseEntity handleIllegalArgu(Exception ex){
        String err=ex.getMessage();
        String msg = StringUtils.isEmpty(err)?"引數不合法":err;
        return ResponseEntity.status(HttpStatus.BAD_REQUEST.value()).body(
            ResultUtil.custom(HttpStatus.BAD_REQUEST.value(), msg)
        );
    }

    /**
    * 業務邏輯處理異常,也是我們最常用的主動丟擲的異常。
    */
    @ExceptionHandler(value = BusinessException.class)
    public ResponseEntity handleBadBusiness(Exception ex){
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR.value()).body(
            ResultUtil.custom(HttpStatus.INTERNAL_SERVER_ERROR.value(), ex.getMessage())
        );
    }

    /**
    * HTTP請求方法不支援異常
    */
    @ExceptionHandler(value = HttpRequestMethodNotSupportedException.class)
    public ResponseEntity methodNotSupportException(Exception ex){
        return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED.value()).body(
            ResultUtil.custom(HttpStatus.METHOD_NOT_ALLOWED.value(), "請求方法不支援!")
        );
    }

    /**
    * 除上面以外所有其他異常的處理會進入這裡
    */
    @ExceptionHandler(value = Exception.class)
    public ResponseEntity handleException(Exception ex){
    	log.error("[ExceptoinTranslator]>>>> 全域性異常: ", ex);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR.value()).body(
            ResultUtil.custom(HttpStatus.INTERNAL_SERVER_ERROR.value(), "發生內部錯誤!")
        );
    }
    
}

上面這個全域性異常處理,包含了專案最有可能出現的:幾種引數異常、許可權異常、HTTP方法不支援異常、自定義業務異常、其他異常,基本上夠用了,如果還想更細緻一點還可以自定義其他的異常放進來。

這裡要關注的兩點是:

1、我們統一使用Spring的ResponseEntity進行了外層包裝,而不是直接使用自定義響應物件ResultUtil來返回,這樣保證了我們介面返回的業務狀態和介面本身的HTTP狀態是一致的,前端就可以判斷介面連通性了,如果不明白區別,使用一下Postman就可以看到右上角的HTTP狀態了,你使用自定義響應物件返回時永遠都是200;

2、最後其他所有異常Exception.class的捕獲,務必進行log.error(ex)日誌記錄,這樣線上排查時才能看到具體的堆疊資訊。


總結

  1. 合理利用stream分組提高查詢效率;

  2. stream排序避免踩坑;

  3. 非同步執行緒最佳用法;

  4. 統一異常管理最佳使用方式。



本人原創文章純手打,大多來源於工作,覺得有一滴滴幫助就一鍵四連吧!

點個關注,不再迷路!

點個收藏,不再彷徨!

點個推薦,夢想實現!

點個贊,天天賺!


相關文章