分享十条Java后端开发实战经验,干货满满!

Excel导入导出几乎在很多中后台项目都会用到,特别是一些CRM、OA、商城、企业应用等系统都十分常见,在开发的过程中我也遇到过很多Excel大

前沿

借助本篇文章,旨在对自己在公司、个人项目的实战经验总结,包括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);

好了,本篇文章主要的总结并分享在开发中积累的实战经验,毫无保留的分享给大家,如果觉得对你有帮助的,欢迎点赞+关注❤+收藏✔

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://itzsg.com/8528.html

(0)
上一篇 2023年 4月 21日 下午9:36
下一篇 2023年 4月 21日 下午9:36

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

联系我们YX

mu99908888

在线咨询: 微信交谈

邮件:itzsgw@126.com

工作时间:时刻准备着!

关注微信