前言
因為前段時間新項目已經完成目前趨於穩定,所以最近我被分配到了公司的運維組,負責維護另外一個項目,包含處理客戶反饋的日常問題,以及對系統缺陷進行優化。
經過了接近兩周的維護,除了日常問題以外,代碼層面我一共處理了一個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注解即可,但很多人不知道使用這個注解的限制條件,往往以為自己用上了,實際上根本沒有走異步線程。
- @Async注解只能標注在void方法上;
- @Async注解標注的方法必須是public修飾;
- @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、統一異常管理
統一異常管理是我着重要講的,這次我維護的項目中在這塊寫的簡直是難以忍受,線上排查問題很多重要的信息啥也看不到,檢查代碼發現明明用到了統一異常管理,但寫法簡直是外行水准,氣的我肚子疼。
首先,我說一下規范:
統一異常管理后,如非必要絕不能再try...catch,如果必須try...catch請一定要log.error(e)記錄日志打印堆棧信息,並且throw異常,否則該代碼塊出問題線上什么也看不到;
統一異常管理后,接口層面校驗錯誤時不要直接使用通用響應對象返回,比如ResultUtil.error(500, "查詢xx失敗"),這樣會導致統一異常管理失去效能,因為這就是正常返回了一個對象,不是出現異常,所以我們應該在校驗錯誤時直接throw new BusinessException("查詢xx失敗")主動拋出一個異常,這樣才會被捕獲到;
統一異常管理后,全局異常管理類中最好使用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)日志記錄,這樣線上排查時才能看到具體的堆棧信息。
總結
合理利用stream分組提高查詢效率;
stream排序避免踩坑;
異步線程最佳用法;
統一異常管理最佳使用方式。
本人原創文章純手打,大多來源於工作,覺得有一滴滴幫助就一鍵四連吧!
點個關注,不再迷路!
點個收藏,不再彷徨!
點個推薦,夢想實現!
點個贊,天天賺!
