天天看點

分享十條Java後端開發實戰經驗,幹貨滿滿!

作者:Java架構嘻嘻嘻

前沿

突然回首,部落客算上大學到工作已經從事後端已經有好幾年時間了,借助本篇文章,旨在對自己在公司、個人項目的實戰經驗總結,包括JAVA常遇到的業務場景技術棧、第三方庫以及可複用的代碼編寫,希望能給大家帶來幫助。

目前,我總結了10條常見的業務開發經驗,毫無保留的分享給大家..., 主要涵蓋内容如下:

  1. 悟耕開源Easypoi Excel導入導出最佳實踐
  2. Alibaba Excel導出時自定義格式轉換優雅實作
  3. 不建議直接使用@Async實作異步,需自定義線程池
  4. 解決Java行業常見業務開發數值計算丢失精度問題
  5. Hutool TreeUtil快速構造傳回樹形結構
  6. 事務@Transactional的失效場景
  7. Spring Event實作異步
  8. 業務開發中通用的政策模式模闆
  9. 使用ip2region擷取使用者位址位置資訊
  10. 利用好Java現有優秀的開發庫

一、悟耕開源easypoi - Excel導入導出最佳實踐

Excel導入導出幾乎在很多中背景項目都會用到,特别是一些CRM、OA、商城、企業應用等系統都十分常見,在開發的過程中我也遇到過很多Excel大資料導入導出的功能,一直以來,使用easypoi做了不少導入導出的需求,導入導出資料量從10萬級到現在百萬級(Excel峰值103萬資料量),整理了一下easypoi的導入導出的基礎和進階用法。

  • 大資料導出資料轉換以及資料加密
  • Excel導入資料校驗,并提供錯誤日志下載下傳

1.1 注解說明

常見的5個注解類分别是:

  • @Excel :作用到filed上面,是對Excel列的一個描述;
  • @ExcelCollection:表示一個集合,主要針對一對多的導出,比如一個老師對應多個科目,科目就可以用集合表示;
  • @ExcelEntity:表示一個繼續深入導出的實體,但他沒有太多的實際意義,隻是告訴系統這個對象裡面同樣有導出的字段;
  • @ExcelIgnore:和名字一樣表示這個字段被忽略跳過這個導導出;
  • @ExcelTarget:這個是作用于最外層的對象,描述這個對象的id,以便支援一個對象可以針對不同導出做出不同處理。

1.2 定義easypoi實體類

import cn.afterturn.easypoi.excel.annotation.Excel;
import cn.afterturn.easypoi.handler.inter.IExcelDataModel;
import cn.afterturn.easypoi.handler.inter.IExcelModel;
import lombok.Data;

import javax.validation.constraints.NotBlank;
import java.io.Serializable;

@Data
public class SdSchoolSysUserVerify implements IExcelModel, IExcelDataModel, Serializable {

    private static final long serialVersionUID = 1L;

    @Excel(name = "行号")
    private Integer rowNum;

    @Excel(name = "錯誤資訊")
    private String errorMsg;

    /**
     * 真實姓名
     */
    @Excel(name = "姓名(必填)", width = 25)
    @NotBlank(message = "姓名不能為空")
    private String realname;

    /**
     * 部門編碼,需要和使用者導入模闆名稱對應
     */
    @Excel(name = "部門編碼(必填)", width = 30)
    @NotBlank(message = "部門編碼不能為空")
    private String deptOrgCode;

    /**
     * 角色編碼
     */
    @Excel(name = "角色編碼(必填)", width = 15)
    @NotBlank(message = "角色編碼不能為空")
    private String roleCode;

    /**
     * 手機号碼
     */
    @Excel(name = "手機号碼(選填)", width = 15)
    private String phone;

    /**
     * 電子郵件
     */
    @Excel(name = "電子郵件(選填)", width = 15)
    private String email;

    /**
     * 性别(1:男 2:女)
     */
    @Excel(name = "性别(選填)", width = 15)
    private String sexName;

    /**
     * 工号(選填)
     */
    @Excel(name = "工号(選填)", width = 15)
    private String workNo;
    
    /**
     * 商戶ID
     **/
    private Integer tenantId;
}
複制代碼           

1.3 基礎的導入導出邏輯(資料校驗)

easyPoi導入校驗使用起來也很簡單,以導入系統優化為例:

第一步,定義一個檢驗類SdSchoolSysUserVerify,通過實作IExcelModel、IExcelDataModel,當我們需要輸出導入校驗錯誤資訊的時候,它們兩個就顯的很重要了,IExcelModel負責設定錯誤資訊,IExcelDataModel負責設定行号。

package cn.afterturn.easypoi.handler.inter;

/**
 * Excel 本身資料檔案
 */
public interface IExcelDataModel {

    /**
     * 擷取行号
     */
    public Integer getRowNum();

    /**
     *  設定行号
     */
    public void setRowNum(Integer rowNum);

}
複制代碼           

第二步,定義完實體之後,那麼如何實作我們的校驗邏輯呢,接着自定義一個系統使用者導入校驗處理器SdSchoolSysUserVerifyHandler,通過實作IExcelVerifyHandler<SdSchoolSysUserVerify>,處理器裡編寫我們的校驗邏輯:

/**
 * 系統使用者批量導入校驗處理器
 *
 * @author: jacklin
 * @since: 2021/3/31 11:47
 **/
@Component
public class SdSchoolSysUserVerifyHandler implements IExcelVerifyHandler<SdSchoolSysUserVerify> {

    private static final String PREFIX = "【";
    private static final String SUFFIX = "】";

    @Autowired
    private ISysBaseAPI sysBaseAPI;

    @Override
    public ExcelVerifyHandlerResult verifyHandler(SdSchoolSysUserVerify userVerify) {
        LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
        userVerify.setTenantId(Integer.valueOf(loginUser.getRelTenantIds()));

        StringJoiner joiner = new StringJoiner(", ", PREFIX, SUFFIX);
        if (StringUtils.isBlank(userVerify.getRealname())) {
            joiner.add("使用者姓名不能為空");
        }
        //根據使用者姓名和商戶ID查詢使用者記錄,大于0則提示該姓名使用者已存在
        int realNameCount = sysBaseAPI.countByRealName(userVerify.getRealname(), userVerify.getTenantId());
        if (realNameCount > 0) {
            joiner.add("該姓名使用者已存在,如需添加該使用者請在頁面添加");
        }
        if (StringUtils.isBlank(userVerify.getDeptOrgCode())) {
            joiner.add("部門編碼不能為空");
        } else {
            //查詢系統是否存在該部門編碼
            int deptOrgCodeCount = sysBaseAPI.queryDepartCountByDepartSysCodeTenantId(userVerify.getDeptOrgCode(), userVerify.getTenantId());
            if (deptOrgCodeCount == 0) {
                joiner.add("部門編碼不存在");
            }
        }
        if (oConvertUtils.isEmpty(userVerify.getRoleCode())) {
            joiner.add("使用者角色編碼不能為空");
        } else {
            //查詢系統是否存在該角色
            int count = sysBaseAPI.queryRoleCountByRoleCodeTenantId(userVerify.getRoleCode(), userVerify.getTenantId());
            if (count == 0) {
                joiner.add("該使用者角色編碼不存在");
            } else {
                //查詢配置是否使用者支援導入該角色
                int supportUserImportCount = sysBaseAPI.queryIsSupportUserImportByRoleCode(userVerify.getRoleCode(), userVerify.getTenantId());
                if (supportUserImportCount == 0) {
                    joiner.add("該使用者角色編碼不支援導入");
                }
            }
        }
        if (oConvertUtils.isNotEmpty(userVerify.getPhone())) {
            boolean isPhone = Validator.isMobile(userVerify.getPhone());
            if (!isPhone) {
                joiner.add("手機号填寫格式不正确");
            }
        }
        if (oConvertUtils.isNotEmpty(userVerify.getEmail())) {
            boolean isEmail = Validator.isEmail(userVerify.getEmail());
            if (!isEmail) {
                joiner.add("郵箱填寫格式不正确");
            }
        }
        if (!"【】".equals(joiner.toString())) {
            return new ExcelVerifyHandlerResult(false, joiner.toString());
        }
        return new ExcelVerifyHandlerResult(true);
    }
}

複制代碼           

第三步,在完成第一、二步之後,我們隻需要在導入的時候通過 params.setVerifyHandler(userVerifyHandler)、params.setNeedVerfiy(true)即可以實作導入校驗了。

1.4 不同類型資料的導入和導出(Map/Object)

在某些複雜的場景,我們導入的時候不想直接構造一個bean然後标記注解,但是中間需要處理一些字段邏輯沒辦法直接導入到資料庫,這是用可以用map的形式導入,下面我以一個客戶導入的需求示範一下如何通過map的方式導入資料:

核心方法:

//Map資料格式導入
ExcelImportResult<Map<String, Object>> importResult = ExcelImportUtil.importExcelMore(inputStream, Map.class, params);
複制代碼
複制代碼           

// 擷取導入檢驗通過的資料

List<Map<String, Object>> rightMapList = importResult.getList();

// 擷取導入檢驗失敗的資料

List<Map<String, Object>> failMapList = importResult.getFailList();

最後可以将校驗失敗的資料,通過excel錯誤日志輸出,非常的友善。

1.5 基于多線程ForkJoin實作導入優化

在4.0後的版本,easypoi導入支援了fork/join的多線程支援,使用方法很簡單 ImportParams 新加了兩個參數,設定為true就可以了,多線程導入處理可以提高了導入的處理效率,比如:

params.setConcurrentTask(true);            //4.1版本都支援基于fork/join的線程
複制代碼           

1.6 自定義導入資料處理

這裡列舉說明一下easypoi的幾個比較重要的接口和類:

  • IExcelDataHandler:當存在一下比較特殊的需求場景,easypoi基礎服務無法滿足客戶的需求時,可以通過實作IExcelDataHandler去自定義資料處理,比如數值轉換器處理。
  • IExcelVerifyHandler:一般都是通過實作IExcelVerifyHandler接口實作自己的校驗邏輯。
  • IExcelModel:自定義實體校驗類,主要用于輸出錯誤日志,IExcelModel負責錯誤資訊。
  • IExcelDataModel:自定義實體校驗類,主要用于輸出錯誤日志,IExcelDataModel負責設定行号。

IExcelDataHandler

/**
 * Excel 導入導出 資料處理接口
 * 
 */
public interface IExcelDataHandler<T> {

    /**
     * 導出處理方法
     * 
     * @param obj   目前對象
     * @param name  前字段名稱    
     * @param value 目前值  
     * @return
     */
    public Object exportHandler(T obj, String name, Object value);
 
 }
複制代碼           

1.7 導入組内資料重複校驗實作

可以通過ThreadLocal來實作組内校驗,可以定位輸出每一個錯誤資料的具體是哪一行,友善我們做導入排錯:

/**
 * IM 批量推送使用者導入校驗處理器
 *
 * @author: jacklin
 * @since: 2022/1/18 10:45
 **/
@Slf4j
@Component
public class SdSchoolBatchPushCustomerVerifyHandler implements IExcelVerifyHandler<SdSchoolBatchPushCustomerVerify> {
    @Autowired
    private ISdSchoolCustomerService sdSchoolCustomerService;
    
    private final ThreadLocal<List<SdSchoolBatchPushCustomerVerify>> threadLocal = new ThreadLocal<>();

    private static final String PREFIX = "【";
    private static final String SUFFIX = "】";


    /**
     * 最新采用ThreadLocal線程本地記憶體變量方式實作組内校驗,效果可以
     *
     * @author: jacklin
     * @since: 2022/2/11 16:26
     **/
    @Override
    public ExcelVerifyHandlerResult verifyHandler(SdSchoolBatchPushCustomerVerify customerVerify) {

        StringJoiner joiner = new StringJoiner(", ", PREFIX, SUFFIX);
        String registerUserPhone = customerVerify.getRegisterUserPhone();
        if (StringUtils.isBlank(registerUserPhone)) {
            joiner.add("注冊手機号不能為空");
        } else {
            //手機号格式校驗
            boolean mobile = Validator.isMobile(registerUserPhone);
            if (!mobile) {
                joiner.add("手機号格式不正确");
            }
        }

        List<SdSchoolBatchPushCustomerVerify> threadLocalValue = threadLocal.get();
        if (threadLocalValue == null) {
            threadLocalValue = new ArrayList<>();
        }

        threadLocalValue.forEach(e -> {
            if (e.getRegisterUserPhone().equals(customerVerify.getRegisterUserPhone())) {
                int lineNumber = e.getRowNum() + 1;
                joiner.add("資料與第" + lineNumber + "行重複");
            }
        });
        //添加本行資料對象到ThreadLocal中
        threadLocalValue.add(customerVerify);
        threadLocal.set(threadLocalValue);

        if (!"【】".equals(joiner.toString())) {
            return new ExcelVerifyHandlerResult(false, joiner.toString());
        }
        return new ExcelVerifyHandlerResult(true);
    }

    public ThreadLocal<List<SdSchoolBatchPushCustomerVerify>> getThreadLocal() {
        return threadLocal;
    }
}

複制代碼           

核心代碼:

threadLocalValue.forEach(e -> {
    if (e.getRegisterUserPhone().equals(customerVerify.getRegisterUserPhone())) {
        int lineNumber = e.getRowNum() + 1;
        joiner.add("資料與第" + lineNumber + "行重複");
    }
});
//添加本行資料對象到ThreadLocal中
threadLocalValue.add(customerVerify);
threadLocal.set(threadLocalValue);
複制代碼           

二、Alibaba excel導出時自定義格式轉換優雅實作

EasyExcel 是一個基于Java的簡單、省記憶體的讀寫Excel的開源項目。在盡可能節約記憶體的情況下支援讀寫百M的Excel。

Java解析、生成Excel比較有名的架構有Apache poi、jxl。但他們都存在一個嚴重的問題就是非常的耗記憶體,poi有一套SAX模式的API可以一定程度的解決一些記憶體溢出的問題,但POI還是有一些缺陷,比如07版Excel解壓縮以及解壓後存儲都是在記憶體中完成的,記憶體消耗依然很大。EasyExcel 是 alibaba 出的一個基于 java poi得Excel通用處理類庫,它的優勢在于記憶體消耗。對比easypoi方案,EasyExcel在記憶體消耗、知名度上更出衆些。

部落客在使用過程中發現導出Excel,官網對自定義格式字段提供了 converter 接口,當我們的Excel導入需要将是/否文字轉成資料庫1/0的時候,這時候就需要自定義轉換器WhetherConverter實作了:

import com.alibaba.excel.converters.Converter;
import com.alibaba.excel.enums.CellDataTypeEnum;
import com.alibaba.excel.metadata.GlobalConfiguration;
import com.alibaba.excel.metadata.data.ReadCellData;
import com.alibaba.excel.metadata.data.WriteCellData;
import com.alibaba.excel.metadata.property.ExcelContentProperty;
import com.dragonpass.global.modules.agent.enumreate.Whether;

import java.util.Objects;

/**
 * 自定義Excel導入導出轉換器
 *
 * @author Linbz
 * @since 2022/11/24 9:55
 */
public class WhetherConverter implements Converter<Integer> {

    @Override
    public Class<?> supportJavaTypeKey() {
        return Integer.class;
    }

    @Override
    public CellDataTypeEnum supportExcelTypeKey() {
        return CellDataTypeEnum.STRING;
    }

    /**
     * 導入,文字轉數字,是/否 -> 1/0
     */
    @Override
    public Integer convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        Integer result = Whether.NO.getCode();
        result = Whether.YES.getDesc().equals(cellData.getStringValue()) ? Whether.YES.getCode() : Whether.NO.getCode();
        return result;
    }

    /**
     * 導出,數字轉文字,1/0 -> 是/否
     */
    @Override
    public WriteCellData<?> convertToExcelData(Integer value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception {
        return new WriteCellData(Objects.equals(value, Whether.YES.getCode()) ? Whether.YES.getDesc() : Whether.NO.getDesc());
    }
}
複制代碼           

導入導出實體類

在導出 ExcelProperty 中添加 WhetherConverter ,就優雅得實作了自定義格式得需求:

public static class ExportTemplate {

    @Data
    public static class ExcelInput {

        private String agentId;
    }

    @Data
    public static class ExcelOutput {

        @ExcelProperty(value = "類型名稱")
        @ColumnWidth(12)
        private String typeName;

        @ExcelProperty(value = "權益扣取後額外扣費(是/否)", converter = WhetherConverter.class)
        @ColumnWidth(24)
        private Integer needPay;

        @ExcelProperty(value = "扣費金額")
        @ColumnWidth(12)
        private BigDecimal price;

        @ExcelProperty(value = "是否為預設項(是/否)", converter = WhetherConverter.class)
        @ColumnWidth(24)
        private Integer isDefault;

        @ExcelProperty(value = "Ncode")
        @ColumnWidth(12)
        private String loungeCode;
    }

    @Data
    @NoArgsConstructor
    public static class DataCheckResult {
        @ExcelProperty(value = "結果")
        private Boolean checkResult = Boolean.TRUE;

        @ExcelProperty(value = "備注")
        private String remark;

        public DataCheckResult(Boolean checkResult, String remark) {
            this.checkResult = checkResult;
            this.remark = remark;
        }
    }
}
複制代碼           

ExcelUtil.importByEasyExcel導入

@Override
@Transactional(rollbackFor = Exception.class)
public AgtAgentDiffPriceRuleDTO.ImportExcelDataDTO.Output importExcelData(AgtAgentDiffPriceRuleDTO.ImportExcelDataDTO.Input input) {
    // ExcelUtil.importByEasyExcel
    List<AgtAgentDiffPriceRuleDTO.ExportTemplate.ExcelOutput> dataList = ExcelUtil.importByEasyExcel(input.getFile().getInputStream(), AgtAgentDiffPriceRuleDTO.ExportTemplate.ExcelOutput.class, Integer.MAX_VALUE, true);
    // 導入資料校驗
    AgtAgentDiffPriceRuleDTO.ExportTemplate.DataCheckResult dataCheckResult = dataCheckForResult(dataList, input);

    if (dataCheckResult.getCheckResult()) {
        //TODO 校驗成功,插入資料...
    }
}

複制代碼           

三、不建議直接使用@Async實作異步,需自定義線程池

@Async 應用預設線程池

Spring應用預設的線程池,指在@Async注解在使用時,不指定線程池的名稱。檢視源碼,@Async的預設線程池為SimpleAsyncTaskExecutor。

無傳回值的異步調用

@Override
@Async("taskExecutor")
public void pageExportOrderBigExcel(HttpServletResponse response, JSONObject queryConditionDataJson, SdSchoolFilterConfig sdSchoolFilterConfig, LoginUser loginUser, SdSchoolDataExportTaskRecord exportTask, HttpServletRequest request, String tenantId) {
    try {

        Thread.sleep(1000);
        exportTask.setExportTaskStartTime(new Date());
        sdSchoolOrderService.exportOrderBigExcelPage(response, queryConditionDataJson, exportTask, sdSchoolFilterConfig.getFilterName(), loginUser, request, tenantId);
        exportTask.setExportTaskEndTime(new Date());
        exportTaskRecordService.updateById(exportTask);

    } catch (Exception e) {
        log.error("訂單資料分頁導出失敗", e);
   }
}
複制代碼           

預設線程池的弊端

線上程池應用中,參考阿裡巴巴Java開發規範:線程池不允許使用Executors去建立,不允許使用系統預設的線程池,推薦通過ThreadPoolExecutor的方式,這樣的處理方式讓開發的工程師更加明确線程池的運作規則,規避資源耗盡的風險。Executors各個方法的弊端:

  • newFixedThreadPool和newSingleThreadExecutor:主要問題是堆積的請求處理隊列可能會耗費非常大的記憶體,甚至OOM。
  • newCachedThreadPool和newScheduledThreadPool:要問題是線程數最大數是Integer.MAX_VALUE,可能會建立數量非常多的線程,甚至OOM。

@Async預設異步配置使用的是SimpleAsyncTaskExecutor,該線程池預設來一個任務建立一個線程,若系統中不斷的建立線程,最終會導緻系統占用記憶體過高,引發OutOfMemoryError錯誤。針對線程建立問題,SimpleAsyncTaskExecutor提供了限流機制,通過concurrencyLimit屬性來控制開關,當concurrencyLimit>=0時開啟限流機制,預設關閉限流機制即concurrencyLimit=-1,當關閉情況下,會不斷建立新的線程來處理任務。基于預設配置,SimpleAsyncTaskExecutor并不是嚴格意義的線程池,達不到線程複用的功能。

@Async應用自定義線程池

自定義線程池,可對系統中線程池更加細粒度的控制,友善調整線程池大小配置,線程執行異常控制和處理。在設定系統自定義線程池代替預設線程池時,雖可通過多種模式設定,但替換預設線程池最終産生的線程池有且隻能設定一個(不能設定多個類繼承 AsyncConfigurer)。自定義線程池有如下方式:

  • 重新實作接口AsyncConfigurer;
  • 繼承AsyncConfigurerSupport;
  • 配置由自定義的TaskExecutor替代内置的任務執行器。

通過檢視Spring源碼關于@Async的預設調用規則,會優先查詢源碼中實作AsyncConfigurer這個接口的類,實作這個接口的類為AsyncConfigurerSupport。但預設配置的線程池和異步處理方法均為空,是以,無論是繼承或者重新實作接口,都需指定一個線程池。且重新實作 public Executor getAsyncExecutor () 方法。

實作接口AsyncConfigurer

@Configuration
 public class AsyncConfiguration implements AsyncConfigurer {

     @Bean("taskExecutor")
     public ThreadPoolTaskExecutor executor() {
         ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
         int corePoolSize = 10;
         executor.setCorePoolSize(corePoolSize);
         int maxPoolSize = 50;
         executor.setMaxPoolSize(maxPoolSize);
         int queueCapacity = 10;
         executor.setQueueCapacity(queueCapacity);
         executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
         executor.setThreadNamePrefix( "asyncServiceExecutor-");
         executor.setWaitForTasksToCompleteOnShutdown(true);
         executor.setAwaitTerminationSeconds(awaitTerminationSeconds);
         executor.initialize();
         return executor;
     }
 
     @Override
     public Executor getAsyncExecutor() {
         return executor();
     }
 }
複制代碼           

繼承AsyncConfigurerSupport

Configuration  
@EnableAsync  
class SpringAsyncConfigurer extends AsyncConfigurerSupport {  
  
    @Bean  
    public ThreadPoolTaskExecutor asyncExecutor() {  
        ThreadPoolTaskExecutor threadPool = new ThreadPoolTaskExecutor();  
        threadPool.setCorePoolSize(3);  
        threadPool.setMaxPoolSize(3);  
        threadPool.setWaitForTasksToCompleteOnShutdown(true);  
        threadPool.setAwaitTerminationSeconds(60 * 15);  
        return threadPool;  
    }  
  
    @Override  
    public Executor getAsyncExecutor() {  
        return asyncExecutor;  
  }  
}
複制代碼           

配置自定義的TaskExecutor (建議采用方式)

/**
 * 線程池參數配置,多個線程池實作線程池隔離,@Async注解,預設使用系統自定義線程池,可在項目中設定多個線程池,在異步調用的時候,指明需要調用的線程池名稱,比如:@Async("taskName")
 *
 * @author: jacklin
 * @since: 2021/5/18 11:44
 **/
@EnableAsync
@Configuration
public class TaskPoolConfig {

    /**
     * 異步導出
     *
     * @author: jacklin
     * @since: 2022/11/16 17:41
     **/
    @Bean("taskExecutor")
    public Executor taskExecutor() {
        //傳回可用處理器的Java虛拟機的數量 12
        int i = Runtime.getRuntime().availableProcessors();
        System.out.println("系統最大線程數  : " + i);
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        //核心線程池大小
        executor.setCorePoolSize(16);
        //最大線程數
        executor.setMaxPoolSize(20);
        //配置隊列容量,預設值為Integer.MAX_VALUE
        executor.setQueueCapacity(99999);
        //活躍時間
        executor.setKeepAliveSeconds(60);
        //線程名字字首
        executor.setThreadNamePrefix("asyncServiceExecutor -");
        //設定此執行程式應該在關閉時阻止的最大秒數,以便在容器的其餘部分繼續關閉之前等待剩餘的任務完成他們的執行
        executor.setAwaitTerminationSeconds(60);
        //等待所有的任務結束後再關閉線程池
        executor.setWaitForTasksToCompleteOnShutdown(true);
        return executor;
    }
}
複制代碼           

多個線程池(線程池隔離)

@Async注解,使用系統預設或者自定義的線程池(代替預設線程池)。可在項目中設定多個線程池,在異步調用時,指明需要調用的線程池名稱,如@Async("new_taskName")。

四、解決Java行業常見業務開發數值計算丢失精度問題

一直以來我都會負責公司有關訂單子產品的項目開發,時常會面對各種金額的計算,在開發的過程中需要注意防止計算精度丢失的問題,今天我說說數值計算的精度、舍入和溢出問題,出于總結,也希望可以為一些讀者“閉坑”。

“危險”的 Double

我們先從簡單的反直覺的四則運算看起。對幾個簡單的浮點數進行加減乘除運算:

System.out.println(0.1+0.2);
System.out.println(1.0-0.8);
System.out.println(4.015*100);
System.out.println(123.3/100);
double amount1 = 2.15;
double amount2 = 1.10;
if (amount1 - amount2 == 1.05)
System.out.println("OK");
複制代碼           

結果輸出如下:

0.30000000000000004
0.19999999999999996
401.49999999999994
1.2329999999999999
複制代碼           

可以看到,輸出結果和我們預期的很不一樣。比如,0.1+0.2 輸出的不是 0.3 而是0.30000000000000004;再比如,對 2.15-1.10 和 1.05 判等,結果判等不成立,出現這種問題的主要原因是,計算機是以二進制存儲數值的,浮點數也不例外,對于計算機而言,0.1 無法精确表達,這是浮點數計算造成精度損失的根源。

很多人可能會說,以 0.1 為例,其十進制和二進制間轉換後相差非常小,不會對計算産生什麼影響。但,所謂積土成山,如果大量使用double來作大量的金錢計算,最終損失的精度就是大量的資金出入。比如,每天有一百萬次交易,每次交易都差一分錢,一個月下來就差30 萬。這就不是小事兒了。那,如何解決這個問題呢?

BigDecimal 類型

我們大都聽說過BigDecimal類型,浮點數精确表達和運算的場景,一定要使用這個類型。不過,在使用 BigDecimal時有幾個坑需要避開。我們用BigDecimal把之前的四則運算改一下:

System.out.println(new BigDecimal(0.1).add(new BigDecimal(0.2)));
System.out.println(new BigDecimal(1.0).subtract(new BigDecimal(0.8)));
System.out.println(new BigDecimal(4.015).multiply(new BigDecimal(100)));
System.out.println(new BigDecimal(123.3).divide(new BigDecimal(100)));
複制代碼           

輸出如下:

0.3000000000000000166533453693773481063544750213623046875
0.1999999999999999555910790149937383830547332763671875
401.49999999999996802557689079549163579940795898437500
1.232999999999999971578290569595992565155029296875
複制代碼           

可以看到,運算結果還是不精确,隻不過是精度高了而已。這裡給出浮點數運算避坑第一原則:使用 BigDecimal 表示和計算浮點數,且務必使用字元串的構造方法來初始化BigDecimal:

System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
System.out.println(new BigDecimal("1.0").subtract(new BigDecimal("0.8")));
System.out.println(new BigDecimal("4.015").multiply(new BigDecimal("100")));
System.out.println(new BigDecimal("123.3").divide(new BigDecimal("100")));
複制代碼           

改進後,就得到我們想要的輸出結果了:

0.3
0.2
401.500
1.233
複制代碼           

數值判斷

現在我們知道了,應該使用BigDecimal來進行浮點數的表示、計算、格式化。Java中的原則:包裝類的比較要通過equals進行,而不能使用 ==。那麼,使用equals方法對兩個BigDecimal判等,一定能得到我們想要的結果嗎?比如:

System.out.println(new BigDecimal("1.0").equals(new BigDecimal("1")));
複制代碼           

答案是:false,為什麼呢?BigDecimal的equals方法的注釋中說明了原因,equals比較的是 BigDecimal的value和scale,1.0的scale是 1,1的scale是0,是以結果一定是false。

如果我們希望隻比較 BigDecimal 的 value,可以使用 compareTo 方法,修改代碼如下:

System.out.println(new BigDecimal("1.0").compareTo(new BigDecimal("1"))==0);
複制代碼           

輸出結果是:true

解決方案,自定義ArithmeticUtils工具類,用于高精度處理常用的數學運算

package io.halo.payment.utils;

import java.math.BigDecimal;
import java.math.RoundingMode;

/**
 * 用于高精度處理常用的數學運算
 *
 * @author: austin
 * @since: 2022/12/20 22:54
 */
public class ArithmeticUtils {

    /**
     * 預設除法運算精度
     */
    private static final int DIV_SCALE = 10;

    /**
     * 加法運算
     *
     * @param var1 被加數
     * @param var2 加數
     */

    public static double add(double var1, double var2) {
        BigDecimal b1 = new BigDecimal(Double.toString(var1));
        BigDecimal b2 = new BigDecimal(Double.toString(var2));
        return b1.add(b2).doubleValue();
    }

    /**
     * 加法運算
     *
     * @param var1 被加數
     * @param var2 加數
     */
    public static BigDecimal add(String var1, String var2) {
        BigDecimal b1 = new BigDecimal(var1);
        BigDecimal b2 = new BigDecimal(var2);
        return b1.add(b2);
    }

    /**
     * 加法運算
     *
     * @param var1  被加數
     * @param var2  加數
     * @param scale 保留scale位小數
     */
    public static String add(String var1, String var2, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(var1);
        BigDecimal b2 = new BigDecimal(var2);
        return b1.add(b2).setScale(scale, RoundingMode.HALF_UP).toString();
    }

    /**
     * 減法運算
     *
     * @param var1 被減數
     * @param var2 減數
     */
    public static double sub(double var1, double var2) {
        BigDecimal b1 = new BigDecimal(Double.toString(var1));
        BigDecimal b2 = new BigDecimal(Double.toString(var2));
        return b1.subtract(b2).doubleValue();
    }

    /**
     * 減法運算
     *
     * @param var1 被減數
     * @param var2 減數
     */
    public static BigDecimal sub(String var1, String var2) {
        BigDecimal b1 = new BigDecimal(var1);
        BigDecimal b2 = new BigDecimal(var2);
        return b1.subtract(b2);
    }

    /**
     * 減法運算
     *
     * @param var1  被減數
     * @param var2  減數
     * @param scale 保留scale 位小數
     */
    public static String sub(String var1, String var2, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(var1);
        BigDecimal b2 = new BigDecimal(var2);
        return b1.subtract(b2).setScale(scale, RoundingMode.HALF_UP).toString();
    }

    /**
     * 乘法運算
     *
     * @param var1 被乘數
     * @param var2 乘數
     */
    public static double mul(double var1, double var2) {
        BigDecimal b1 = new BigDecimal(Double.toString(var1));
        BigDecimal b2 = new BigDecimal(Double.toString(var2));
        return b1.multiply(b2).doubleValue();
    }

    /**
     * 乘法運算
     *
     * @param var1 被乘數
     * @param var2 乘數
     */
    public static BigDecimal mul(String var1, String var2) {
        BigDecimal b1 = new BigDecimal(var1);
        BigDecimal b2 = new BigDecimal(var2);
        return b1.multiply(b2);
    }

    /**
     * 乘法運算
     *
     * @param var1  被乘數
     * @param var2  乘數
     * @param scale 保留scale 位小數
     */
    public static double mul(double var1, double var2, int scale) {
        BigDecimal b1 = new BigDecimal(Double.toString(var1));
        BigDecimal b2 = new BigDecimal(Double.toString(var2));
        return round(b1.multiply(b2).doubleValue(), scale);
    }

    /**
     * 乘法運算
     *
     * @param var1  被乘數
     * @param var2  乘數
     * @param scale 保留scale 位小數
     */
    public static String mul(String var1, String var2, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(var1);
        BigDecimal b2 = new BigDecimal(var2);
        return b1.multiply(b2).setScale(scale, RoundingMode.HALF_UP).toString();
    }

    /**
     * 提供(相對)精确的除法運算,當發生除不盡的情況時,精确到小數點以後10位,以後的數字四舍五入
     *
     * @param var1 被除數
     * @param var2 除數
     */

    public static double div(double var1, double var2) {
        return div(var1, var2, DIV_SCALE);
    }

    /**
     * 提供(相對)精确的除法運算。當發生除不盡的情況時,由scale參數指定精度,以後的數字四舍五入
     *
     * @param var1  被除數
     * @param var2  除數
     * @param scale 表示表示需要精确到小數點以後幾位。
     */
    public static double div(double var1, double var2, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(Double.toString(var1));
        BigDecimal b2 = new BigDecimal(Double.toString(var2));
        return b1.divide(b2, scale, RoundingMode.HALF_UP).doubleValue();
    }

    /**
     * 提供(相對)精确的除法運算。當發生除不盡的情況時,由scale參數指
     * 定精度,以後的數字四舍五入
     *
     * @param var1  被除數
     * @param var2  除數
     * @param scale 表示需要精确到小數點以後幾位
     */
    public static String div(String var1, String var2, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b1 = new BigDecimal(var1);
        BigDecimal b2 = new BigDecimal(var2);
        return b1.divide(b2, scale, RoundingMode.HALF_UP).toString();
    }

    /**
     * 提供精确的小數位四舍五入處理
     *
     * @param var   需要四舍五入的數字
     * @param scale 小數點後保留幾位
     */
    public static double round(double var, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b = new BigDecimal(Double.toString(var));
        return b.setScale(scale, RoundingMode.HALF_UP).doubleValue();
    }

    /**
     * 提供精确的小數位四舍五入處理
     *
     * @param var   需要四舍五入的數字
     * @param scale 小數點後保留幾位
     */
    public static String round(String var, int scale) {
        if (scale < 0) {
            throw new IllegalArgumentException("The scale must be a positive integer or zero");
        }
        BigDecimal b = new BigDecimal(var);
        return b.setScale(scale, RoundingMode.HALF_UP).toString();
    }
    
    /**
     * 比較大小
     *
     * @param var1 被比較數
     * @param var2 比較數
     * @return 如果v1大于v2 則傳回true 否則false
     */
    public static boolean compare(String var1, String var2) {
        BigDecimal b1 = new BigDecimal(var1);
        BigDecimal b2 = new BigDecimal(var2);
        int result = b1.compareTo(b2);
        return result > 0 ? true : false;
    }
}
複制代碼           

五、Hutool TreeUtil快速構造傳回樹形結構

項目中經常會遇到各種需要以樹形結構展示的功能,如菜單樹、分類樹、部門樹,Hutool的TreeUtil主要是用來快速構造樹形結構,以及擷取所有葉子節點等操作。

步驟:

1️⃣ 引入hutool最新pom包。

2️⃣ 擷取構造樹的分類資料。

3️⃣ TreeNodeConfig資訊配置,配置節點名稱、孩子節點key資訊、排序等等。

4️⃣ 調用TreeUtil.build()構造樹。

pom依賴

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.7.22</version>
</dependency>
複制代碼           

資料分類Service接口層

/**
 * 構造班型資料分類樹方法
 *
 * @author: jacklin
 * @date: 2022/4/20 16:44
 **/
List<Tree<String>> constructTree();
複制代碼           

實作層

@Override
public List<Tree<String>> constructTree() {
    //1.擷取所有資料分類
    List<SdSchoolClassTypeDataCategory> dataList = this.lambdaQuery().getBaseMapper().selectList(Wrappers.lambdaQuery(SdSchoolClassTypeDataCategory.class)
            .eq(SdSchoolClassTypeDataCategory::getStatus, SchoolConstant.ENABLE_STATUS)
            .eq(SdSchoolClassTypeDataCategory::getDeleted, SchoolConstant.DELETE_STATUS_NORMAL));

    //2.配置
    TreeNodeConfig config = new TreeNodeConfig();
    config.setIdKey("id");                              //預設id,可以不設定
    config.setParentIdKey("pid");                       //父id
    config.setNameKey("dataCategoryName");              //分類名稱
    config.setDeep(3);                                  //最大遞歸深度
    config.setChildrenKey("childrenList");              //孩子節點
    config.setWeightKey("sort");                        //排序字段

    //3.轉樹
    List<Tree<String>> treeList = TreeUtil.build(dataList, "0", config, ((object, treeNode) -> {
        treeNode.putExtra("id", object.getId());
        treeNode.putExtra("pid", object.getPid());
        treeNode.putExtra("dataCategoryName", object.getDataCategoryName());
        treeNode.putExtra("level", object.getLevel());
        treeNode.putExtra("sort", object.getSort());
        //擴充屬性...
    }));

    return treeList;
}
複制代碼           

通過TreeNodeConfig我們可以自定義節點的名稱、關系節點id名稱,這樣就可以和不同的資料庫做對應。

Controller層

/**
 * 擷取構造樹
 *
 * @author: jacklin
 * @date: 2022/4/20 17:18
 **/
@ApiOperation(value = "擷取構造樹", notes = "擷取構造樹")
@GetMapping(value = "/getConstructTree")
public Result<?> getConstructTree() {
    List<Tree<String>> treeList = sdSchoolClassTypeDataCategoryService.constructTree();
    return Result.OK(treeList);
}
複制代碼           

響應内容

{
    "success":true,
    "message":"操作成功!",
    "code":200,
    "result":[
         {
            "id":"1447031605584797698",
            "pid":"0",
            "dataCategoryName":"開發測試資料一級分類",
            "level":1,
            "sort":1,
            "childrenList":[
                {
                    "id":"1447031722601684993",
                    "pid":"1447031605584797698",
                    "dataCategoryName":"開發測試資料二級分類",
                    "level":2,
                    "sort":1,
                    "childrenList":[
                        {
                            "id":"1516684508672299010",
                            "pid":"1447031722601684993",
                            "dataCategoryName":"開發測試資料三級分類",
                            "level":3,
                            "sort":1
                        }
                    ]
                }
            ]
        },
        {
            "id":"1447849327826636801",
            "pid":"0",
            "dataCategoryName":"測試資料分類",
            "level":1,
            "sort":1,
            "childrenList":[
                {
                    "id":"1447849471787732993",
                    "pid":"1447849327826636801",
                    "dataCategoryName":"測試資料分類2-1",
                    "level":2,
                    "sort":1
                },
                {
                    "id":"1447849472085528577",
                    "pid":"1447849327826636801",
                    "dataCategoryName":"測試資料分類2-2",
                    "level":2,
                    "sort":1
                },
                {
                    "id":"1447849472219746305",
                    "pid":"1447849327826636801",
                    "dataCategoryName":"測試資料分類2-3",
                    "level":2,
                    "sort":1
                }
            ]
        }
    ]
}
複制代碼           

Hutool樹結構工具-TreeUtil

六、事務@Transactional的失效場景

分享十條Java後端開發實戰經驗,幹貨滿滿!

6.1 失效場景集一:代理不生效

Spring中注解解析的尿性都是基于代理來實作的,如果目标方法無法被Spring代理到,那麼它将無法被Spring進行事務管理。

Spring生成代理的方式有兩種:

  • 基于接口的JDK動态代理,要求目标代理類需要實作一個接口才能被代理
  • 基于實作目标類子類的CGLIB代理

以下情況會因為代理不生效導緻事務管控失敗:

(1)将注解标注在接口方法上

@Transactional是支援标注在方法與類上的。一旦标注在接口上,對應接口實作類的代理方式如果是CGLIB,将通過生成子類的方式生成目标類的代理,将無法解析到@Transactional,進而事務失效。

這種錯誤我們還是犯得比較少的,基本上我們都會将注解标注在接口的實作類方法上,官方也不推薦這種。

(2)被final、static關鍵字修飾的類或方法

CGLIB是通過生成目标類子類的方式生成代理類的,被final、static修飾後,無法繼承父類與父類的方法。

(3)類方法内部調用

事務的管理是通過代理執行的方式生效的,如果是方法内部調用,将不會走代理邏輯,也就調用不到了。

(4)目前類沒有被Spring管理

這個沒什麼好說的,都沒有被Spring管理成為IOC容器中的一個bean,更别說被事務切面代理到了。

6.2 失效場景集二:架構或底層不支援的功能

這類失效場景主要聚焦在架構本身在解析@Transactional時的内部支援。如果使用的場景本身就是架構不支援的,那事務也是無法生效的。

(1)非public修飾的方法

不支援非public修飾的方法進行事務管理。

(2)多線程調用

事務資訊是跟線程綁定的。是以在多線程環境下,事務的資訊都是獨立的,将會導緻Spring在接管事務上出現差異。

(3)資料庫本身不支援事務

比如MySQL的Myisam存儲引擎是不支援事務的,隻有innodb存儲引擎才支援。

這個問題出現的機率極其小,因為MySQL5之後預設情況下是使用innodb存儲引擎了。

但如果配置錯誤或者是曆史項目,發現事務怎麼配都不生效的時候,記得看看存儲引擎本身是否支援事務。

(4)未開啟事務

這個也是一個比較麻煩的問題,在Spring Boot項目中已經不存在了,已經有DataSourceTransactionManagerAutoConfiguration預設開啟了事務管理。

但是在MVC項目中還需要在applicationContext.xml檔案中,手動配置事務相關參數。如果忘了配置,事務肯定是不會生效的。

6.3 失效場景集撒三:錯誤的使用@Trasactional

日常開發我們最常犯的錯誤的可能因為配置不正确,導緻方法上的事務沒生效,復原失敗!

(1)錯誤的傳播機制

Spring支援了7種傳播機制,分别為:

事務行為 說明
REQUIRED(Spring預設的事務傳播類型) 如果目前沒有事務,則自己建立一個事務,如果目前存在事務則加入這個事務
SUPPORTS 目前存在事務,則加入目前事務,如果目前沒有事務,就以非事務方法執行
MANDATORY 目前存在事務,則加入目前事務,如果目前事務不存在,則抛出異常
REQUIRES_NEW 建立一個新事務,如果存在目前事務,則挂起該事務
NOT_SUPPORTED 以非事務方式執行,如果目前存在事務,則挂起目前事務
NEVER 如果目前沒有事務存在,就以非事務方式執行;如果有,就抛出異常。就是B從不以事務方式運作A中不能有事務,如果沒有,B就以非事務方式執行,如果A存在事務,那麼直接抛異常
NESTED(嵌套的) 如果目前事務存在,則在嵌套事務中執行,否則REQUIRED的操作一樣(開啟一個事務) 如果A中沒有事務,那麼B建立一個事務執行,如果A中也有事務,那麼B會會把事務嵌套在裡面

上面不支援事務的傳播機制為:SUPPORTS,NOT_SUPPORTED,NEVER。

如果配置了這三種傳播方式的話,在發生異常的時候,事務是不會復原的。

(2)rollbackFor屬性設定錯誤

預設情況下事務僅復原運作時異常和Error,不復原受檢異常(例如IOException)。

是以如果方法中抛出了IO異常,預設情況下事務也會復原失敗。

我們可以通過指定@Transactional(rollbackFor = Exception.class)的方式進行全異常捕獲。

(3)異常被程式内部catch

如果需要對特定的異常進行捕獲處理,記得再次将異常抛出,讓最外層的事務感覺到。

(4)嵌套事務

七、Spring Event實作異步,業務解耦神器

實際業務開發過程中,業務邏輯可能非常複雜,核心業務 + N 個子業務。如果都放到一塊兒去做,代碼可能會很長,耦合度不斷攀升,維護起來也麻煩,甚至頭疼。還有一些業務場景不需要在一次請求中同步完成,比如郵件發送、短信發送等。

MQ确實可以解決這個問題,但MQ相對來說比較重,非必要不提升架構複雜度。針對這些問題,我們了解一下Spring Event。

7.1 自定義事件

定義事件,繼承ApplicationEvent的類成為一個事件類:

public class AsyncSendEmailEvent extends ApplicationEvent {

    /**
     * 郵箱
     **/
    private String email;

   /**
     * 主題
     **/
    private String subject;

    /**
     * 内容
     **/
    private String content;
  
    /**
     * 接收者
     **/
    private String targetUserId;

}
複制代碼           

7.2 定義事件監聽器

@Slf4j
@Component
public class AsyncSendEmailEventListener implements ApplicationListener<AsyncSendEmailEvent> {

    @Autowired
    private IMessageHandler mesageHandler;
    
    @Async("taskExecutor")
    @Override
    public void onApplicationEvent(AsyncSendEmailEvent event) {
        if (event == null) {
            return;
        }

        String email = event.getEmail();
        String subject = event.getSubject();
        String content = event.getContent();
        String targetUserId = event.getTargetUserId();
        mesageHandler.sendsendEmailSms(email, subject, content, targerUserId);
      }
}
複制代碼           

7.3 開啟異步

  • 啟動類增加@EnableAsync注解
  • Listener類需要開啟異步的方法增加@Async注解

另外,可能有些時候采用ApplicationEvent實作異步的使用,當程式出現異常錯誤的時候,需要考慮補償機制,那麼這時候可以結合Spring Retry重試來幫助我們避免這種異常造成資料不一緻問題。

八、業務開發中通用的政策模式模闆

在政策模式(Strategy Pattern)中,一個類的行為或其算法可以在運作時更改。這種類型的設計模式屬于行為型模式。

業務背景

商場搞活動,根據客戶購買商品的金額,收費時給與不同的打折,比如,購買 金額>=2000 的打八折(0.8),金額 500 ~ 1000 的,打九折(0.9),購買金額 0 ~ 500 的九五折(0.95),根據不同的金額走不同計算政策邏輯。

首先定義一個Strategy接口來表示一個政策:

public interface Strategy {

    /**
     * 采用政策
     */
    String strategy();

    /**
     * 計算方法邏輯
     */
    void algorithm();
}
複制代碼           

其中strategy方法傳回目前政策的唯一辨別,algorithm則是該政策的具體執行的計算邏輯。

下面是Strategy接口的兩個實作類:

public class ConcreteStrategyA implements Strategy {
    
    @Override
    public String strategy() {
        return StrategySelector.strategyA.getStrategy();
    }

    @Override
    public void algorithm() {
        System.out.println("process with strategyA...");
    }
}

public class ConcreteStrategyB implements Strategy {

    @Override
    public String strategy() {
        return StrategySelector.strategyB.getStrategy();
    }

    @Override
    public void algorithm() {
        System.out.println("process with strategyB...");
    }
}

public class ConcreteStrategyC implements Strategy {

    @Override
    public String strategy() {
        return StrategySelector.strategyC.getStrategy();
    }

    @Override
    public void algorithm() {
        System.out.println("process with strategyC...");
    }
}
複制代碼           

自定義政策選擇枚舉StrategySelector:

@Getter
public enum StrategySelector {

    strategyA(1,"strategyA"),
    strategyB(2,"strategyB"),
    strategyC(3,"strategyC");
    
    private Integer code;
    private String strategy;

    StrategySelector(Integer code, String strategy) {
        this.code = code;
        this.strategy = strategy;
    }
}
複制代碼           

然後定義一個StrategyRunner接口用來表示政策的排程器:

public interface StrategyRunner {
    void execute(String strategy);
}
複制代碼           

execute方法内部通過判斷strategy的值來決定具體執行哪一個政策。

public class StrategyRunnerImpl implements StrategyRunner {

    private static final List<Strategy> STRATEGIES = Arrays.asList(new ConcreteStrategyA(), new ConcreteStrategyB(), new ConcreteStrategyC());
    private static Map<String, Strategy> STRATEGY_MAP = Maps.newHashMap();

    static {
        STRATEGY_MAP = STRATEGIES.stream().collect(Collectors.toMap(Strategy::strategy, s -> s));
    }

    @Override
    public void execute(String strategy) {
        STRATEGY_MAP.get(strategy).algorithm();
    }
}
複制代碼           

在StrategyRunnerImpl内部,定義了一個STRATEGIES清單來儲存所有Strategy實作類的執行個體,以及一個叫做STRATEGY_MAP的Map來儲存strategy和Strategy執行個體之間的對應關系,static塊中的代碼用于從STRATEGIES清單構造STRATEGY_MAP。這樣,在execute方法中就可以很友善地擷取到指定strategy的Strategy執行個體。

SpringBoot項目中實作并運用政策模式

@Component
public class ConcreteStrategyA implements Strategy {
    
    @Override
    public String strategy() {
        return StrategySelector.strategyA.getStrategy();
    }

    @Override
    public void algorithm() {
        System.out.println("process with strategyA...");
    }
}

@Component
public class ConcreteStrategyB implements Strategy {

    @Override
    public String strategy() {
        return StrategySelector.strategyB.getStrategy();
    }

    @Override
    public void algorithm() {
        System.out.println("process with strategyB...");
    }
}

@Component
public class ConcreteStrategyC implements Strategy {

    @Override
    public String strategy() {
        return StrategySelector.strategyC.getStrategy();
    }

    @Override
    public void algorithm() {
        System.out.println("process with strategyC...");
    }
}
複制代碼           

然後,定義一個StrategyConfig配置類,用于向容器注入一個StrategyRunner:

@Configuration
public class StrategyConfig {

    @Bean
    public StrategyRunner runner(List<Strategy> strategies) {
        Map<String, Strategy> strategyMap = strategies.stream().collect(Collectors.toMap(Strategy::strategy, s -> s));
        return flag -> strategyMap.get(flag).algorithm();
    }
}
複制代碼           

不難發現,strategyRunner方法的實作,其中的邏輯與之前的StrategyRunnerImpl幾乎完全相同,也是根據一個List<Strategy>來構造一個Map<String, Strategy>。隻不過,這裡的strategies清單不是我們自己構造的,而是通過方法參數傳進來的。由于strategyRunner标注了Bean注解,是以參數上的List<Strategy>實際上是在Spring Boot初始化過程中從容器擷取的,是以我們之前向容器中注冊的那兩個實作類會在這裡被注入。

這樣,我們再也無需操心系統中一共有多少個Strategy實作類,因為Spring Boot的自動配置會幫我們自動發現所有實作類。我們隻需編寫自己的Strategy實作類,然後将它注冊進容器,并在任何需要的地方注入StrategyRunner:

@Autowired private StrategyRunner strategyRunner;
複制代碼           

然後直接使用strategyRunner就行了:

@RestController
@RequestMapping(value = "/designPatterns")
public class DesignPatternController {

    @Autowired
    private StrategyRunner strategyRunner;

    @GetMapping(value = "/algorithm")
    public void algorithm(@RequestParam("strategy") String strategy) {
        strategyRunner.execute(strategy);
    }
}
複制代碼           

通路:http://localhost:10069/designPatterns/algorithm 控制台輸出如下:

process with strategyA...
複制代碼           

類似的業務場景,完全可以結合業務通過方面的代碼來進行改造實作,非常實用~

九、使用ip2region擷取使用者位址資訊

ip2region v2.0 - 是一個離線IP位址定位庫和IP定位資料管理架構。

現在很多軟體比如:微網誌、抖音、小紅書、頭條、快手、騰訊等各大平台陸續都上線了 網絡使用者IP位址顯示功能,境外使用者顯示的是國家,國内的使用者顯示的省份。

以往,Java中擷取IP屬性的,主要分為以下幾步:

  • 通過HttpServletRequest對象,擷取使用者的IP位址
  • 通過IP位址,擷取對應的省份、城市

首先需要寫一個IP擷取的工具類,因為每一次使用者的Request請求,都會攜帶上請求的IP位址放到請求頭中,下面這段代碼你肯定不陌生:

/**
     * 擷取IP位址
     * 
     * 使用Nginx等反向代理軟體, 則不能通過request.getRemoteAddr()擷取IP位址
     * 如果使用了多級反向代理的話,X-Forwarded-For的值并不止一個,而是一串IP位址,X-Forwarded-For中第一個非unknown的有效IP字元串,則為真實IP位址
     */
    public static String getIpAddr(HttpServletRequest request) {
        String ip = null;
        try {
            ip = request.getHeader("x-forwarded-for");
            if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
                ip = request.getHeader("Proxy-Client-IP");
            }
            if (StringUtils.isEmpty(ip) || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
                ip = request.getHeader("WL-Proxy-Client-IP");
            }
            if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
                ip = request.getHeader("HTTP_CLIENT_IP");
            }
            if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
                ip = request.getHeader("HTTP_X_FORWARDED_FOR");
            }
            if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
                ip = request.getHeader("X-REAL-IP");
            }
            if (StringUtils.isEmpty(ip) || "unknown".equalsIgnoreCase(ip)) {
                ip = request.getRemoteAddr();
            }
        } catch (Exception e) {
            logger.error("Failed to get the IP address information", e);
        }

        return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
    }
複制代碼           

通過此方法,從請求Header中擷取到使用者的IP位址。

還有之前的的項目擷取IP位址歸屬省份、城市的需求,比較常用的是淘寶ip庫,位址:

ip.taobao.com/

分享十條Java後端開發實戰經驗,幹貨滿滿!

輸入本地IP位址可以查詢到對應的省市資訊:

分享十條Java後端開發實戰經驗,幹貨滿滿!

模拟根據ip從淘寶IP庫擷取目前位置資訊,源碼如下:

public static JSONObject getAddressByIp(String ip, RestTemplate restTemplate) {
    logger.info("淘寶IP庫擷取使用者IP位址資訊...");
    ResponseEntity<String> forEntity = restTemplate.getForEntity("https://ip.taobao.com/outGetIpInfo?ip=" + ip, String.class);
    JSONObject result = JSONObject.parseObject(forEntity.getBody());
    logger.info("擷取到淘寶IP庫響應資訊: {}", result);
    if (result.getIntValue("code") == 0) {
        logger.info("request successful!");
    } else {
        logger.info("request failed, 原因:{}", result.getString("msg"));
    }
    return getAddressByIp(ip, restTemplate);
}

public static void main(String[] args) {
    getAddressByIp("119.129.116.64", new RestTemplate());
}
複制代碼           

響應:

11:14:53.266 [main] INFO org.universal.common.util.IPUtils - 淘寶IP庫擷取使用者IP位址資訊...
11:14:55.063 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET https://ip.taobao.com/outGetIpInfo?ip=119.129.116.64
11:14:55.107 [main] DEBUG org.springframework.web.client.RestTemplate - Accept=[text/plain, application/json, application/*+json, */*]
11:14:57.416 [main] DEBUG org.springframework.web.client.RestTemplate - Response 200 OK
11:14:57.418 [main] DEBUG org.springframework.web.client.RestTemplate - Reading to [java.lang.String] as "application/json;charset=UTF-8"
11:14:58.273 [main] INFO org.universal.common.util.IPUtils - 擷取到淘寶IP庫響應資訊: {"msg":"the request over max qps for user ,the accessKey=public","code":4}
11:14:58.522 [main] INFO org.universal.common.util.IPUtils - request failed, 原因:the request over max qps for user ,the accessKey=public
11:14:58.522 [main] INFO org.universal.common.util.IPUtils - 淘寶IP庫擷取使用者IP位址資訊...
11:14:58.522 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET https://ip.taobao.com/outGetIpInfo?ip=119.129.116.64
11:14:58.523 [main] DEBUG org.springframework.web.client.RestTemplate - Accept=[text/plain, application/json, application/*+json, */*]
11:14:58.657 [main] DEBUG org.springframework.web.client.RestTemplate - Response 200 OK
11:14:58.657 [main] DEBUG org.springframework.web.client.RestTemplate - Reading to [java.lang.String] as "application/json;charset=UTF-8"

// ---------------- 成功擷取到ip位址資訊(中國/廣東/廣州) START ----------------
11:14:58.658 [main] INFO org.universal.common.util.IPUtils - 擷取到淘寶IP庫響應資訊: {"msg":"query success","code":0,"data":{"area":"","country":"中國","isp_id":"100017","queryIp":"119.129.116.64","city":"廣州","ip":"119.129.116.64","isp":"電信","county":"","region_id":"440000","area_id":"","region":"廣東","country_id":"CN","city_id":"440100"}}
11:14:58.658 [main] INFO org.universal.common.util.IPUtils - request successful!
11:14:58.658 [main] INFO org.universal.common.util.IPUtils - 淘寶IP庫擷取使用者IP位址資訊...
11:14:58.681 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET https://ip.taobao.com/outGetIpInfo?ip=119.129.116.64
11:14:58.682 [main] DEBUG org.springframework.web.client.RestTemplate - Accept=[text/plain, application/json, application/*+json, */*]
11:14:58.802 [main] DEBUG org.springframework.web.client.RestTemplate - Response 200 OK
11:14:58.803 [main] DEBUG org.springframework.web.client.RestTemplate - Reading to [java.lang.String] as "application/json;charset=UTF-8"
// ------------------------- 成功擷取到ip位址資訊 END -------------------------

11:14:58.805 [main] INFO org.universal.common.util.IPUtils - 擷取到淘寶IP庫響應資訊: {"msg":"the request over max qps for user ,the accessKey=public","code":4}
11:14:58.805 [main] INFO org.universal.common.util.IPUtils - request failed, 原因:the request over max qps for user ,the accessKey=public
11:14:58.805 [main] INFO org.universal.common.util.IPUtils - 淘寶IP庫擷取使用者IP位址資訊...
11:14:58.806 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET https://ip.taobao.com/outGetIpInfo?ip=119.129.116.64
11:14:58.806 [main] DEBUG org.springframework.web.client.RestTemplate - Accept=[text/plain, application/json, application/*+json, */*]
11:14:58.947 [main] DEBUG org.springframework.web.client.RestTemplate - Response 200 OK
11:14:58.976 [main] DEBUG org.springframework.web.client.RestTemplate - Reading to [java.lang.String] as "application/json;charset=UTF-8"
11:14:58.981 [main] INFO org.universal.common.util.IPUtils - 擷取到淘寶IP庫響應資訊: {"msg":"the request over max qps for user ,the accessKey=public","code":4}
11:14:58.981 [main] INFO org.universal.common.util.IPUtils - request failed, 原因:the request over max qps for user ,the accessKey=public
11:14:59.092 [main] INFO org.universal.common.util.IPUtils - 淘寶IP庫擷取使用者IP位址資訊...
11:14:59.092 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET https://ip.taobao.com/outGetIpInfo?ip=119.129.116.64
11:14:59.092 [main] DEBUG org.springframework.web.client.RestTemplate - Accept=[text/plain, application/json, application/*+json, */*]
11:14:59.223 [main] DEBUG org.springframework.web.client.RestTemplate - Response 200 OK
11:14:59.223 [main] DEBUG org.springframework.web.client.RestTemplate - Reading to [java.lang.String] as "application/json;charset=UTF-8"
11:14:59.223 [main] INFO org.universal.common.util.IPUtils - 擷取到淘寶IP庫響應資訊: {"msg":"the request over max qps for user ,the accessKey=public","code":4}
11:14:59.223 [main] INFO org.universal.common.util.IPUtils - request failed, 原因:the request over max qps for user ,the accessKey=public
11:14:59.320 [main] INFO org.universal.common.util.IPUtils - 淘寶IP庫擷取使用者IP位址資訊...
11:14:59.321 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET https://ip.taobao.com/outGetIpInfo?ip=119.129.116.64
11:14:59.321 [main] DEBUG org.springframework.web.client.RestTemplate - Accept=[text/plain, application/json, application/*+json, */*]
11:14:59.470 [main] DEBUG org.springframework.web.client.RestTemplate - Response 200 OK
11:14:59.471 [main] DEBUG org.springframework.web.client.RestTemplate - Reading to [java.lang.String] as "application/json;charset=UTF-8"
11:14:59.471 [main] INFO org.universal.common.util.IPUtils - 擷取到淘寶IP庫響應資訊: {"msg":"the request over max qps for user ,the accessKey=public","code":4}
11:14:59.471 [main] INFO org.universal.common.util.IPUtils - request failed, 原因:the request over max qps for user ,the accessKey=public
11:14:59.471 [main] INFO org.universal.common.util.IPUtils - 淘寶IP庫擷取使用者IP位址資訊...
11:14:59.472 [main] DEBUG org.springframework.web.client.RestTemplate - HTTP GET https://ip.taobao.com/outGetIpInfo?ip=119.129.116.64
11:14:59.472 [main] DEBUG org.springframework.web.client.RestTemplate - Accept=[text/plain, application/json, application/*+json, */*]
11:14:59.598 [main] DEBUG org.springframework.web.client.RestTemplate - Response 200 OK
11:14:59.598 [main] DEBUG org.springframework.web.client.RestTemplate - Reading to [java.lang.String] as "application/json;charset=UTF-8"
11:14:59.598 [main] INFO org.universal.common.util.IPUtils - 擷取到淘寶IP庫響應資訊: {"msg":"the request over max qps for user ,the accessKey=public","code":4}
11:14:59.599 [main] INFO org.universal.common.util.IPUtils - request failed, 原因:the request over max qps for user ,the accessKey=public
複制代碼           

可以看到控制台輸出的日志檔案中,大量的請求傳回失敗,原因:the request over max qps for user ,the accessKey=public,主要是由于接口淘寶對接口進行QPS限流。

而随着ip2region項目的開源和更新疊代,可以幫助我們解決IP位址定位解析的業務場景開發需求問題,Gitee位址: ip2region

99.9%準确率:

資料聚合了一些知名ip到地名查詢提供商的資料,這些是他們官方的的準确率,經測試着實比經典的純真IP定位準确一些。 ip2region的資料聚合自以下服務商的開放API或者資料(更新程式每秒請求次數2到4次)。

p2region V2.0 特性

1、标準化的資料格式

每個 ip 資料段的 region 資訊都固定了格式:國家|區域|省份|城市|ISP,隻有中國的資料絕大部分精确到了城市,其他國家部分資料隻能定位到國家,後前的選項全部是0。

2、資料去重和壓縮

xdb 格式生成程式會自動去重和壓縮部分資料,預設的全部 IP 資料,生成的 ip2region.xdb 資料庫是 11MiB,随着資料的詳細度增加資料庫的大小也慢慢增大。

3、極速查詢響應

即使是完全基于 xdb 檔案的查詢,單次查詢響應時間在十微秒級别,可通過如下兩種方式開啟記憶體加速查詢:

  1. vIndex 索引緩存 :使用固定的 512KiB 的記憶體空間緩存 vector index 資料,減少一次 IO 磁盤操作,保持平均查詢效率穩定在10-20微秒之間。
  2. xdb 整個檔案緩存:将整個 xdb 檔案全部加載到記憶體,記憶體占用等同于 xdb 檔案大小,無磁盤 IO 操作,保持微秒級别的查詢效率。

4、極速查詢響應

v2.0 格式的 xdb 支援億級别的 IP 資料段行數,region資訊也可以完全自定義,例如:你可以在region中追加特定業務需求的資料,例如:GPS資訊/國際統一地域資訊編碼/郵編等。也就是你完全可以使用ip2region來管理你自己的 IP 定位資料。

ip2region xdb java IP位址資訊解析用戶端實作

pom依賴

<dependency>
    <groupId>org.lionsoul</groupId>
    <artifactId>ip2region</artifactId>
    <version>2.6.4</version>
</dependency>
複制代碼           

完全基于檔案的查詢

import org.lionsoul.ip2region.xdb.Searcher;
import java.io.*;
import java.util.concurrent.TimeUnit;

public class SearcherTest {
    public static void main(String[] args) {
        // 1、建立 searcher 對象
        String dbPath = "ip2region.xdb file path";
        Searcher searcher = null;
        try {
            searcher = Searcher.newWithFileOnly(dbPath);
        } catch (IOException e) {
            System.out.printf("failed to create searcher with `%s`: %s\n", dbPath, e);
            return;
        }

        // 2、查詢
        try {
            String ip = "1.2.3.4";
            long sTime = System.nanoTime();
            String region = searcher.search(ip);
            long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
            System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
        } catch (Exception e) {
            System.out.printf("failed to search(%s): %s\n", ip, e);
        }

        // 3、關閉資源
        searcher.close();
        
        // 備注:并發使用,每個線程需要建立一個獨立的 searcher 對象單獨使用。
    }
}
複制代碼           

IDEA代碼實作,測試擷取目前IP位址資訊:

public class SearchTest {
    public static void main(String[] args) throws IOException {
        // 1、建立 searcher 對象
        String dbPath = "D:\Sourcetree_workplace\git-優秀開源項目\ip2region\data\ip2region.xdb";
        Searcher searcher = null;
        try {
            searcher = Searcher.newWithFileOnly(dbPath);
        } catch (IOException e) {
            System.out.printf("failed to create searcher with `%s`: %s\n", dbPath, e);
            return;
        }

        // 本地IP位址
        String ip = "119.129.116.64";

        // 2、查詢
        try {
            long sTime = System.nanoTime();
            String region = searcher.search(ip);
            long cost = TimeUnit.NANOSECONDS.toMicros((long) (System.nanoTime() - sTime));
            System.out.printf("{region: %s, ioCount: %d, took: %d μs}\n", region, searcher.getIOCount(), cost);
        } catch (Exception e) {
            System.out.printf("failed to search(%s): %s\n", ip, e);
        }

        // 3、關閉資源
        searcher.close();

        // 備注:并發使用,每個線程需要建立一個獨立的 searcher 對象單獨使用。
    }
}
複制代碼           

完全基于檔案的查詢

IP屬地國内的話,會展示省份,國外的話,隻會展示國家。可以通過如下圖這個方法進行進一步封裝,得到擷取IP屬地的資訊,查詢結果如下:

分享十條Java後端開發實戰經驗,幹貨滿滿!

十、利用好Java現有優秀的開發庫

俗話說:工欲善其事,必先利其器,好的工具可以達到事半功倍的效果。

一名優秀的技術開發者,往往都能利用現有的資源,利用好市面上優秀的工具包來協助開發,基本上,每個項目裡都有一個包,叫做utils。這個包專門承載我們自己項目的工具類,比如常見的DateUtils、HttpUtils、Collections

所謂Utils就是:這個東西我們用得很多,但是原API不夠好用,于是我們給它封裝為一個比較通用的方法。

10.1 JAVA常用工具包推薦

工具包 介紹
Apache Commons 位址
Guava 位址
Hutool 位址

最新maven倉庫

<!-- apache.commons-lang3 -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.12.0</version>
</dependency>

<!-- google.guava -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.1-jre</version>
</dependency>

<!-- hutool-all -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.10</version>
</dependency>
複制代碼           

10.2 Http請求遠端調用庫推薦

HTTP調用是非常常見的,很多公司對外的接口幾乎都會提供HTTP調用。比如我們調用百度UNIT智能對話API實作與機器人對話服務,調用各個管道商發送短信等等等。

  • JDK自帶的HttpURLConnection标準庫
  • Apache HTTPComponents HttpClient
  • OkHttp
  • Retrofit
  • Forest

10.2.1 HttpURLConnection

使用HttpURLConnection發起HTTP請求最大的優點是不需要引入額外的依賴,但是使用起來非常繁瑣,也缺乏連接配接池管理、域名機械控制等特性支援。

使用标準庫的最大好處就是不需要引入額外的依賴,但使用起來比較繁瑣,就像直接使用JDBC連接配接資料庫那樣,需要很多模闆代碼。來發起一個簡單的HTTP POST請求:

public class HttpUrlConnectionDemo {
    public static void main(String[] args) throws IOException {
        String urlString = "https://httpbin.org/post";
        String bodyString = "password=123";

        URL url = new URL(urlString);
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("POST");
        conn.setDoOutput(true);

        OutputStream os = conn.getOutputStream();
        os.write(bodyString.getBytes("utf-8"));
        os.flush();
        os.close();

        if (conn.getResponseCode() == HttpURLConnection.HTTP_OK) {
            InputStream is = conn.getInputStream();
            BufferedReader reader = new BufferedReader(new InputStreamReader(is));
            StringBuilder sb = new StringBuilder();
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
            System.out.println("響應内容:" + sb.toString());
        } else {
            System.out.println("響應碼:" + conn.getResponseCode());
        }
    }
}

複制代碼           

HttpURLConnection發起的HTTP請求比較原始,基本上算是對網絡傳輸層的一次淺層次的封裝;有了 HttpURLConnection對象後,就可以擷取到輸出流,然後把要發送的内容發送出去;再通過輸入流讀取到伺服器端響應的内容;最後列印。

不過HttpURLConnection不支援HTTP/2.0,為了解決這個問題,Java 9的時候官方的标準庫增加了一個更進階别的HttpClient,再發起POST請求就顯得高大上多了,不僅支援異步,還支援順滑的鍊式調用。

public class HttpClientDemo {
    public static void main(String[] args) throws URISyntaxException {
        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
                .uri(new URI("https://postman-echo.com/post"))
                .headers("Content-Type", "text/plain;charset=UTF-8")
                .POST(HttpRequest.BodyPublishers.ofString("二哥牛逼"))
                .build();
        client.sendAsync(request, HttpResponse.BodyHandlers.ofString())
                .thenApply(HttpResponse::body)
                .thenAccept(System.out::println)
                .join();
    }
}

複制代碼           

10.2.2 Apache HttpComponents HttpClient

Apache HttpComponents HttpClient支援的特性也非常豐富:

  • 基于标準、純淨的Java語言,實作了HTTP1.0和HTTP1.1;
  • 以可擴充的面向對象的結構實作了HTTP全部的方法;
  • 支援加密的HTTPS協定(HTTP通過SSL協定);
  • Request的輸出流可以避免流中内容體直接從socket緩沖到伺服器;
  • Response的輸入流可以有效的從socket伺服器直接讀取相應内容。

10.2.3 OkHttp

OkHttp是一個執行效率比較高的HTTP用戶端:

  • 支援HTTP/2.0,當多個請求對應同一個Host位址時,可共用同一個Socket;
  • 連接配接池可減少請求延遲;
  • 支援GZIP壓縮,減少網絡傳輸的資料大小;
  • 支援Response資料緩存,避免重複網絡請求;
public class OkHttpPostDemo {
    public static final MediaType JSON = MediaType.get("application/json; charset=utf-8");

    OkHttpClient client = new OkHttpClient();

    String post(String url, String json) throws IOException {
        RequestBody body = RequestBody.create(json, JSON);
        Request request = new Request.Builder()
                .url(url)
                .post(body)
                .build();
        try (Response response = client.newCall(request).execute()) {
            return response.body().string();
        }
    }

    public static void main(String[] args) throws IOException {
        OkHttpPostDemo example = new OkHttpPostDemo();
        String json = "{'name':'二哥'}";
        String response = example.post("https://httpbin.org/post", json);
        System.out.println(response);
    }
}

複制代碼           

10.2.4 Forest

Forest是一個高層的、極簡的聲明式HTTP調用API架構。相比于直接使用Httpclient你不再用寫一大堆重複的代碼了,而是像調用本地方法一樣去發送HTTP請求。

Forest就字面意思而言,就是森林的意思。但仔細看可以拆成For和Rest兩個單詞,也就是為了Rest(Rest為一種基于HTTP的架構風格)。 而合起來就是森林,森林由很多樹木花草組成(可以了解為各種不同的服務),它們表面上看獨立,實則在地下根莖交錯縱橫、互相連接配接依存,這樣看就有點現代分布式服務化的味道了。 最後,這兩個單詞反過來讀就像是Resultful。

分享十條Java後端開發實戰經驗,幹貨滿滿!

Maven依賴

<dependency>
    <groupId>com.dtflys.forest</groupId>
    <artifactId>forest-spring-boot-starter</artifactId>
    <version>1.5.19</version>
</dependency>
複制代碼           

簡單請求

public interface MyClient {

    @Request("http://localhost:8080/hello")
    String simpleRequest();

}
複制代碼           

通過@Request注解,将上面的MyClient接口中的simpleRequest()方法綁定了一個 HTTP 請求, 其 URL 為http://localhost:8080/hello ,并預設使用GET方式,且将請求響應的資料以String的方式傳回給調用者。

稍微複雜點的請求,需要在請求頭設定資訊

public interface MyClient {

    @Request(
            url = "http://localhost:8080/hello/user",
            headers = "Accept: text/plain"
    )
    String sendRequest(@Query("uname") String username);
}
複制代碼           

上面的sendRequest方法綁定的HTTP請求,定義了URL資訊,以及把Accept:text/plain加到了請求頭中, 方法的參數String username綁定了注解@Query("uname"),它的作用是将調用者傳入入參username時,自動将username的值加入到 HTTP 的請求參數uname中。

這段實際産生的HTTP請求如下:

GET http://localhost:8080/hello/user?uname=foo
HEADER:
    Accept: text/plain
複制代碼           

請求方法,假設發起post請求,有3種寫法:

public interface MyClient {

    /**
     * 使用 @Post 注解,可以去掉 type = "POST" 這行屬性
     */
    @Post("http://localhost:8080/hello")
    String simplePost1();


    /**
     * 通過 @Request 注解的 type 參數指定 HTTP 請求的方式。
     */
    @Request(
            url = "http://localhost:8080/hello",
            type = "POST"
    )
    String simplePost2();

    /**
     * 使用 @PostRequest 注解,和上面效果等價
     */
    @PostRequest("http://localhost:8080/hello")
    String simplePost3();

}
複制代碼           

可以用@GetRequest, @PostRequest等注解代替@Request注解,這樣就可以省去寫type屬性的麻煩了。

請求體

在POST和PUT等請求方法中,通常使用 HTTP 請求體進行傳輸資料。在 Forest 中有多種方式設定請求體資料。

表單格式

上面使用@Body注解的例子用的是普通的表單格式,也就是contentType屬性為application/x-www-form-urlencoded的格式,即contentType不做配置時的預設值。

表單格式的請求體以字元串 key1=value1&key2=value2&...&key{n}=value{n} 的形式進行傳輸資料,其中value都是已經過URL Encode編碼過的字元串。

/**
 * contentType屬性設定為 application/x-www-form-urlencoded 即為表單格式,
 * 當然不設定的時候預設值也為 application/x-www-form-urlencoded, 也同樣是表單格式。
 * 在 @Body 注解的 value 屬性中設定的名稱為表單項的 key 名,
 * 而注解所修飾的參數值即為表單項的值,它可以為任何類型,不過最終都會轉換為字元串進行傳輸。
 */
@Post(
    url = "http://localhost:8080/user",
    contentType = "application/x-www-form-urlencoded",
    headers = {"Accept:text/plain"}
)
String sendPost(@Body("key1") String value1,  @Body("key2") Integer value2, @Body("key3") Long value3);
複制代碼           

調用後産生的結果可能如下:

POST http://localhost:8080/hello/user
HEADER:
    Content-Type: application/x-www-form-urlencoded
BODY:
    key1=xxx&key2=1000&key3=9999
複制代碼           

當@Body注解修飾的參數為一個對象,并注解的value屬性不設定任何名稱的時候,會将注解所修飾參數值對象視為一整個表單,其對象中的所有屬性将按 屬性名1=屬性值1&屬性名2=屬性值2&...&屬性名{n}=屬性值{n} 的形式通過請求體進行傳輸資料。

/**
 * contentType 屬性不設定預設為 application/x-www-form-urlencoded
 * 要以對象作為表達傳輸項時,其 @Body 注解的 value 名稱不能設定
 */
@Post(
    url = "http://localhost:8080/hello/user",
    headers = {"Accept:text/plain"}
)
String send(@Body User user);
複制代碼           

調用産生的結果如下:

POST http://localhost:8080/hello/user
HEADER:
    Content-Type: application/x-www-form-urlencoded
BODY:
    username=foo&password=bar
複制代碼           

JSON格式

@JSONBody注解修飾對象

@JSONBody = @Body+contentType的格式,除了@JSONBody注解,使用@Body注解也可以,隻要将contentType屬性或Content-Type請求頭指定為application/json便可。

發送JSON非常簡單,隻要用@JSONBody注解修飾相關參數就可以了,該注解自1.5.0-RC1版本起可以使用。 使用@JSONBody注解的同時就可以省略contentType = "application/json"屬性設定

/**
 * 被@JSONBody注解修飾的參數會根據其類型被自定解析為JSON字元串
 * 使用@JSONBody注解時可以省略 contentType = "application/json"屬性設定
 */
@Post("http://localhost:8080/hello/user")
String helloUser(@JSONBody User user);
複制代碼           

調用後産生的結果如下:

POST http://localhost:8080/hello/user
HEADER:
    Content-Type: application/json
BODY:
    {"username": "foo", "password": "bar"}
複制代碼           

切記使用@JSONBody綁定對象入參的時候,JSONBody的value一定要空着,比如,@JSONBody User user寫法:

/**
 * 被@JSONBody注解修飾的Map類型參數會被自定解析為JSON字元串
 */
@Post(url = "http://localhost:8080/hello/user")
String helloUser1(@JSONBody User user);
複制代碼           

當@JSONBody修飾Map的時候:

/**
 * 被@JSONBody注解修飾的Map類型參數會被自定解析為JSON字元串
 */
@Post(url = "http://localhost:8080/hello/user")
String helloUser2(@JSONBody Map<String, Object> userMap);

//若調用代碼是這樣的:
Map<String, Object> map = new HashMap<>();
map.put("username", "foo");
map.put("password", "bar");
client.helloUser(map);

//會産生的結果:
POST http://localhost:8080/hello/user
HEADER:
    Content-Type: application/json
BODY:
    {"username": "foo", "password": "bar"}
複制代碼           

詳細forest請求體說明可以參考官方文檔:forest.dtflyx.com/docs/basic/…

注解BaseRequest

@BaseRequest注解定義在接口類上,在@BaseRequest上定義的屬性會被配置設定到該接口中每一個方法上,但方法上定義的請求屬性會覆寫@BaseRequest上重複定義的内容。 是以可以認為@BaseRequest上定義的屬性内容是所在接口中所有請求的預設屬性。

/**
 * @BaseRequest 為配置接口層級請求資訊的注解
 * 其屬性會成為該接口下所有請求的預設屬性
 * 但可以被方法上定義的屬性所覆寫
 */
@BaseRequest(
    baseURL = "http://localhost:8080",     // 預設域名
    headers = {
        "Accept:text/plain"                // 預設請求頭
    },
    sslProtocol = "TLS"                    // 預設單向SSL協定
)
public interface MyClient {
  
    // 方法的URL不必再寫域名部分
    @Get("/hello/user")
    String send1(@Query("username") String username);

    // 若方法的URL是完整包含http://開頭的,那麼會以方法的URL中域名為準,不會被接口層級中的baseURL屬性覆寫,這個确實是非常友善了~
    @Get("http://www.xxx.com/hello/user")
    String send2(@Query("username") String username);
  
    @Get(
        url = "/hello/user",
        headers = {
            "Accept:application/json"      // 覆寫接口層級配置的請求頭資訊
        }
    )     
    String send3(@Query("username") String username);

}
複制代碼           

forest異步請求

在Forest使用異步請求,可以通過設定@Request注解的async屬性為true實作,不設定或設定為false即為同步請求

/**
 * async 屬性為 true 即為異步請求,為 false 則為同步請求
 * 不設定該屬性時,預設為 false
 */
@Post(
        url = "http://localhost:8080/user/updateUserTagById",
        async = true
)
boolean asyncUpdate(String userId);
複制代碼           

好了,本篇文章主要的總結并分享部落客在開發中積累的實戰經驗,毫無保留的分享給大家,如果覺得對你有幫助的,歡迎點贊+關注❤+收藏✔,也歡迎在評論區下留言讨論✏

作者:austin流川楓

連結:https://juejin.cn/post/7195770699523997757

繼續閱讀