首页
关于这个博客
Search
1
Java 实现Google 账号单点登录(OAuth 2.0)全流程解析
822 阅读
2
Spring AI 无法获取大模型深度思考内容?解决方案来了
359 阅读
3
EasyExcel 实战:导出带图片的 Excel 完整方案
168 阅读
4
微信小程序实现页面返回前确认弹窗:兼容左上角返回与右滑返回
155 阅读
5
服务器遭遇 XMRig 挖矿程序入侵排查与清理全记录
152 阅读
Java 核心
框架与中间件
数据库技术
开发工具与效率
问题排查与踩坑记录
程序员成长与思考
前端
登录
Search
标签搜索
java虚拟机
JVM
保姆级教程
Java
Spring AI
SpringBoot
Spring
WebFlux
Nginx
Spring Retry
EasyExcel
流式输出
WebSocket
JustAuth
sso
google
单点登录
源码解析
Tool
图片导出
Luca Ju
累计撰写
39
篇文章
累计收到
1
条评论
首页
栏目
Java 核心
框架与中间件
数据库技术
开发工具与效率
问题排查与踩坑记录
程序员成长与思考
前端
页面
关于这个博客
搜索到
39
篇与
的结果
2026-02-03
Jakarta Validation 优雅实现参数校验:从基础使用到自定义扩展
在后端开发中,参数校验是保障接口安全性和数据合法性的核心环节,硬编码的if-else校验逻辑不仅繁琐冗余,还会让代码可读性大打折扣。Jakarta Validation(原Java Validation)为我们提供了一套轻量、优雅的注解式参数校验方案,通过标准化的注解即可实现各类参数校验规则,大幅简化开发流程。本文将从基础注解使用、实战场景落地、统一异常处理到自定义校验注解,全方位讲解Jakarta Validation的使用技巧,结合实际业务代码示例,让你快速上手并灵活运用到项目中。一、核心校验注解速查Jakarta Validation提供了一系列开箱即用的校验注解,覆盖空值、长度、格式、数值、日期等绝大多数日常校验场景,核心注解及功能如下表所示,可直接作为开发速查手册:注解适用类型核心功能@NotNull所有类型字段值不能为null@NotBlank字符串不能为null,且去除首尾空格后长度大于0@NotEmpty字符串/集合/数组不能为null,且长度/元素个数大于0@Size(min, max)字符串/集合/数组长度/元素个数在[min, max]范围内@Pattern(regexp)字符串必须匹配指定的正则表达式@Email字符串必须符合合法的邮箱格式(支持自定义正则)@Min(value)数值类型数值必须大于等于value@Max(value)数值类型数值必须小于等于value@Positive数值类型必须为正数(大于0)@Negative数值类型必须为负数(小于0)@PositiveOrZero数值类型必须为正数或0@NegativeOrZero数值类型必须为负数或0@Future日期/时间类型必须是未来的时间@FutureOrPresent日期/时间类型必须是未来或当前时间@Past日期/时间类型必须是过去的时间@PastOrPresent日期/时间类型必须是过去或当前时间注解使用小技巧@NotBlank/@NotEmpty/@NotNull 区分:字符串优先用@NotBlank(过滤空白字符),集合/数组用@NotEmpty,非字符串非集合类型用@NotNull;注解组合使用:实际业务中可组合多个注解,如用户账号需同时满足「非空、正则匹配、长度限制」;默认提示语自定义:所有注解都支持message属性,用于自定义校验失败的提示信息,贴合业务场景。二、实战场景落地:三种核心使用方式Jakarta Validation的注解可根据参数传递方式灵活使用,核心分为「简单参数直接注解」「实体对象属性注解」两种核心场景,后者是项目中最常用的方式。场景1:GET请求简单参数,直接注解参数对于GET请求的URL拼接参数(如/user/get?name=test&id=1),可直接在接口方法的参数前添加校验注解,适用于参数数量少的简单场景。/** * 根据用户名和ID查询用户 * @param name 用户名(不能为空) * @param id 用户ID(必须为正数) * @return 用户信息 */ @GetMapping("/get") public CommonResult<UserVO> getUser( @NotBlank(message = "用户名不能为空") String name, @Positive(message = "用户ID必须为正数") Long id ) { UserVO user = userService.getByNameAndId(name, id); return CommonResult.success(user); }场景2:POST请求实体参数,注解+@Valid 触发校验对于POST/PUT请求,参数通常封装为实体对象(如新增/编辑用户的入参),只需在实体的属性上添加校验注解,并在接口方法的实体参数前添加@Valid(或@Validated)注解,即可触发整体校验逻辑。这是项目中最常用的方式,适合复杂参数的校验场景。步骤1:实体类添加校验注解@Data @Schema(description = "新增用户请求参数") public class UserSaveReqVO { @Schema(description = "用户编号(编辑时传,新增时不传)", example = "1024") private Long id; @Schema(description = "用户账号", requiredMode = Schema.RequiredMode.REQUIRED, example = "yudao") @NotBlank(message = "用户账号不能为空") @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "用户账号仅支持数字、字母组成") @Size(min = 4, max = 30, message = "用户账号长度为4-30个字符") private String username; @Schema(description = "用户密码", requiredMode = Schema.RequiredMode.REQUIRED, example = "123456a") @NotBlank(message = "用户密码不能为空") @Size(min = 6, max = 20, message = "用户密码长度为6-20个字符") private String password; @Schema(description = "用户年龄", example = "25") @Min(value = 18, message = "用户年龄不能小于18岁") @Max(value = 60, message = "用户年龄不能大于60岁") private Integer age; @Schema(description = "邮箱", example = "test@example.com") @NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不合法") private String email; }步骤2:接口方法添加@Valid 触发校验@PostMapping("/create") @Operation(summary = "新增用户") public CommonResult<Long> createUser(@Valid @RequestBody UserSaveReqVO reqVO) { Long userId = userService.createUser(reqVO); return CommonResult.success(userId); }关键区别:@Valid vs @Validated@Valid:属于JSR-380标准注解,支持嵌套实体校验(如实体中包含另一个实体属性);@Validated:属于Spring扩展注解,支持分组校验(如新增和编辑用户时,同一实体的校验规则不同),可替代@Valid使用。嵌套实体校验示例:如果UserSaveReqVO中包含AddressVO实体属性,只需在AddressVO属性上添加@Valid+自身属性注解,即可触发嵌套校验。@Data public class UserSaveReqVO { // 其他属性... @Schema(description = "用户地址") @Valid // 触发嵌套校验 @NotNull(message = "用户地址不能为空") private AddressVO address; } @Data public class AddressVO { @NotBlank(message = "省不能为空") private String province; @NotBlank(message = "市不能为空") private String city; }三、全局异常拦截:统一处理校验失败结果当参数校验失败时,Jakarta Validation会自动抛出异常,不同场景抛出的异常类型不同:简单参数校验失败:抛出ConstraintViolationException;实体对象校验失败:抛出MethodArgumentNotValidException。为了让前端能接收到统一格式的错误返回,我们需要在项目中添加全局异常处理器,拦截这些校验异常,封装成统一的返回结果。全局异常处理器实现结合Spring Boot的@RestControllerAdvice和@ExceptionHandler实现全局异常拦截,统一返回格式(如包含错误码、错误信息的CommonResult)。@RestControllerAdvice @Slf4j public class GlobalExceptionHandler { /** * 处理实体对象参数校验失败异常(@Valid + 实体注解) */ @ExceptionHandler(MethodArgumentNotValidException.class) public CommonResult<?> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { log.warn("参数校验失败:{}", ex.getMessage()); // 获取校验失败的第一条错误信息 String errorMsg = getFirstValidErrorMessage(ex.getBindingResult()); return CommonResult.error(HttpStatus.BAD_REQUEST.value(), "请求参数不正确:" + errorMsg); } /** * 处理简单参数校验失败异常(直接注解参数) */ @ExceptionHandler(ConstraintViolationException.class) public CommonResult<?> handleConstraintViolation(ConstraintViolationException ex) { log.warn("参数校验失败:{}", ex.getMessage()); // 获取校验失败的第一条错误信息 String errorMsg = ex.getConstraintViolations().stream() .map(ConstraintViolation::getMessage) .findFirst() .orElse("参数校验失败"); return CommonResult.error(HttpStatus.BAD_REQUEST.value(), "请求参数不正确:" + errorMsg); } /** * 提取BindingResult中的第一条错误信息 */ private String getFirstValidErrorMessage(BindingResult bindingResult) { // 优先获取字段级别的错误 if (bindingResult.hasFieldErrors()) { return bindingResult.getFieldErrors().get(0).getDefaultMessage(); } // 无字段错误则获取全局错误 if (bindingResult.hasGlobalErrors()) { return bindingResult.getGlobalErrors().get(0).getDefaultMessage(); } return "参数校验失败"; } }异常处理小技巧返回第一条错误信息:避免返回所有错误信息导致前端展示混乱,优先返回第一条校验失败的信息;统一错误码:参数校验失败统一使用400(BAD_REQUEST)错误码,符合HTTP协议规范;日志记录:记录异常日志便于问题排查,但无需打印完整堆栈(非运行时异常,属于业务异常)。四、高级扩展:自定义校验注解Jakarta Validation提供的默认注解无法覆盖所有业务场景(如「参数必须为指定枚举值」「手机号格式校验」「身份证号校验」),此时可通过自定义校验注解实现个性化的校验规则,步骤固定且可复用。实战示例:实现「参数必须为指定枚举值」的自定义注解以最常见的「参数必须是枚举中的某个值」为例,实现自定义注解@InEnum,支持校验参数是否为指定枚举的有效值。步骤1:定义自定义注解通过@Constraint指定校验器实现类,注解的属性可传递自定义参数(如枚举类),同时指定注解的适用目标(字段、参数等)。@Target({ ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) @Documented // 指定校验器实现类:InEnumValidator @Constraint(validatedBy = {InEnumValidator.class}) public @interface InEnum { /** * 校验失败的提示语 */ String message() default "必须为指定枚举值:{value}"; /** * 分组校验(可省略,默认空) */ Class<?>[] groups() default {}; /** * 负载(可省略,默认空) */ Class<? extends Payload>[] payload() default {}; /** * 目标枚举类(必须实现ArrayValuable接口,提供枚举值数组) */ Class<? extends ArrayValuable<?>> value(); } /** * 枚举值获取接口,所有需要被@InEnum校验的枚举需实现此接口 * @param <T> 枚举值类型 */ public interface ArrayValuable<T> { /** * 获取枚举的所有值数组 */ T[] array(); }步骤2:实现注解校验器实现ConstraintValidator<A, T>接口,其中A为自定义注解,T为被校验的参数类型,重写initialize(初始化注解参数)和isValid(核心校验逻辑)方法。@Slf4j public class InEnumValidator implements ConstraintValidator<InEnum, Object> { /** * 枚举的有效值集合 */ private List<?> validValues; /** * 初始化:获取注解中指定的枚举类,提取其有效值 */ @Override public void initialize(InEnum annotation) { Class<? extends ArrayValuable<?>> enumClass = annotation.value(); // 获取枚举的所有实例 ArrayValuable<?>[] enumConstants = enumClass.getEnumConstants(); if (ArrayUtil.isEmpty(enumConstants)) { this.validValues = Collections.emptyList(); return; } // 提取枚举的有效值数组,转为List方便判断 this.validValues = Arrays.asList(enumConstants[0].array()); } /** * 核心校验逻辑 * @param value 被校验的参数值 * @param context 校验上下文 * @return true=校验通过,false=校验失败 */ @Override public boolean isValid(Object value, ConstraintValidatorContext context) { // 1. 参数为null时,默认校验通过(如需非空,可配合@NotNull注解) if (value == null) { return true; } // 2. 参数值在枚举有效值集合中,校验通过 if (validValues.contains(value)) { return true; } // 3. 校验失败,自定义提示语(替换{value}为实际枚举有效值) context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate( context.getDefaultConstraintMessageTemplate() .replace("{value}", validValues.toString()) ).addConstraintViolation(); return false; } }步骤3:枚举实现接口并使用自定义注解让目标枚举实现ArrayValuable接口,提供有效值数组,然后在实体/参数上添加@InEnum注解即可。/** * 性别枚举 */ public enum GenderEnum implements ArrayValuable<Integer> { MALE(1, "男"), FEMALE(2, "女"); private final Integer code; private final String name; GenderEnum(Integer code, String name) { this.code = code; this.name = name; } @Override public Integer[] array() { // 返回枚举的有效值数组 return new Integer[]{MALE.getCode(), FEMALE.getCode()}; } public Integer getCode() { return code; } } // 在实体中使用@InEnum注解 @Data public class UserSaveReqVO { // 其他属性... @Schema(description = "性别(1=男,2=女)", example = "1") @NotNull(message = "性别不能为空") @InEnum(value = GenderEnum.class, message = "性别必须为{value}") private Integer gender; }自定义注解开发通用规范注解属性规范:必须包含message/groups/payload三个基础属性(符合Jakarta Validation标准);空值处理:校验器中默认对null放行,如需非空可配合@NotNull注解,解耦「非空校验」和「业务规则校验」;提示语自定义:通过context.disableDefaultConstraintViolation()禁用默认提示语,实现动态替换(如替换枚举有效值);可复用性:自定义注解应设计为通用型(如手机号、身份证号校验注解),可在项目中全局复用。五、实用进阶技巧1. 分组校验:同一实体不同场景不同校验规则实际开发中,新增和编辑用户时,同一实体的校验规则可能不同(如新增时id无需传,编辑时id必须传),可通过分组校验实现,基于@Validated的分组属性。步骤1:定义分组标识接口/** * 校验分组 - 新增 */ public interface AddGroup { } /** * 校验分组 - 编辑 */ public interface EditGroup { }步骤2:实体注解指定分组@Data public class UserSaveReqVO { @Schema(description = "用户编号", example = "1024") @NotNull(message = "用户ID不能为空", groups = EditGroup.class) // 仅编辑时校验id非空 private Long id; @NotBlank(message = "用户账号不能为空", groups = {AddGroup.class, EditGroup.class}) // 新增+编辑都校验 private String username; }步骤3:接口指定分组触发校验// 新增用户:使用AddGroup分组 @PostMapping("/create") public CommonResult<Long> createUser(@Validated(AddGroup.class) @RequestBody UserSaveReqVO reqVO) { return CommonResult.success(userService.createUser(reqVO)); } // 编辑用户:使用EditGroup分组 @PutMapping("/edit") public CommonResult<Boolean> editUser(@Validated(EditGroup.class) @RequestBody UserSaveReqVO reqVO) { return CommonResult.success(userService.editUser(reqVO)); }六、总结Jakarta Validation通过注解式编程让参数校验从繁琐的硬编码中解放出来,实现了「校验规则和业务逻辑的解耦」,让代码更简洁、优雅、易维护。合理使用Jakarta Validation,不仅能提升开发效率,还能让接口的参数校验更规范、更健壮,为项目的稳定性提供保障。
2026年02月03日
1 阅读
0 评论
0 点赞
2026-01-16
Spring Retry 重试机制:优雅解决接口调用失败问题
在日常开发中,我们经常会遇到第三方接口不稳定、网络抖动导致的调用失败场景。很多人第一反应是在 try-catch 里写 for 循环重试,再搭配 Thread.sleep() 控制间隔——这种写法不仅冗余,还难以维护。今天给大家推荐 Spring Retry 框架,它基于 AOP 实现,能让你零侵入式地为方法添加重试功能,大幅简化代码!一、快速上手:三步集成 Spring Retry1. 添加 Maven 依赖Spring Retry 核心依赖 + AOP 依赖(因为其底层是 AOP 实现),这里推荐使用 2.0.12 稳定版本:<!-- Spring Retry 核心依赖 --> <dependency> <groupId>org.springframework.retry</groupId> <artifactId>spring-retry</artifactId> <version>2.0.12</version> </dependency> <!-- AOP 依赖(Spring Boot 项目推荐此 starter) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>2. 启用 Spring Retry 功能在 Spring Boot 主启动类上添加 @EnableRetry 注解,一键开启重试功能:import org.springframework.retry.annotation.EnableRetry; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @EnableRetry // 启用重试功能 @SpringBootApplication public class SpringRetryDemoApplication { public static void main(String[] args) { SpringApplication.run(SpringRetryDemoApplication.class, args); } }3. 核心注解:@Retryable 标记重试方法在需要重试的方法上添加 @Retryable 注解,即可实现重试逻辑。基础用法import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; @Service public class RetryDemoService { // 标记该方法需要重试 @Retryable public void basicRetry() { int random = (int) (Math.random() * 10); System.out.println("当前随机数:" + random); // 模拟异常:随机数为偶数时抛出异常 if (random % 2 == 0) { throw new RuntimeException("随机数为偶数,触发异常"); } System.out.println("方法执行成功!"); } }基础用法说明未指定异常类型时,方法抛出任何异常都会触发重试。默认重试次数:3次(包含首次执行,实际重试 2 次)。默认重试间隔:1秒。当重试次数耗尽仍失败时,会抛出 ExhaustedRetryException 异常。二、进阶配置:灵活定制重试策略@Retryable 注解提供了丰富的属性,可根据业务需求精准控制重试逻辑。1. @Retryable 核心属性说明属性名作用示例value/retryFor指定触发重试的异常类型retryFor = RuntimeException.classinclude同 value,优先级更高include = {NullPointerException.class}exclude指定不触发重试的异常类型exclude = IllegalArgumentException.classmaxAttempts最大重试次数(包含首次执行)maxAttempts = 5backoff配置重试间隔策略@Backoff(delay = 1000, multiplier = 2)stateful是否有状态重试(异常信息保留)stateful = true2. 实战示例:指数退避重试需求:调用第三方接口时,仅在抛出 RuntimeException 时重试,最大重试 5 次,重试间隔按 1s → 2s → 4s → 8s 指数增长(避免高频重试压垮接口)。import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Retryable; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class RetryDemoController { @GetMapping("/test/retry") // 仅对RuntimeException重试,最大5次,指数退避间隔 @Retryable( retryFor = RuntimeException.class, maxAttempts = 5, backoff = @Backoff(delay = 1000, multiplier = 2.0) ) public String testRetry() { int random = (int) (Math.random() * 10); System.out.println("[" + System.currentTimeMillis() + "] 当前随机数:" + random); if (random % 2 == 0) { throw new RuntimeException("随机数为偶数,触发重试"); } return "调用成功!随机数:" + random; } }三、兜底处理:@Recover 重试失败后的恢复逻辑当重试次数耗尽仍失败时,我们需要一个兜底方法来处理最终的失败(比如记录日志、返回默认结果),这时候就需要 @Recover 注解。1. @Recover 用法规则恢复方法和 @Retryable 方法应该在同一个类中。后续参数需和 @Retryable 方法的参数列表完全一致。返回值需和 @Retryable 方法的返回值完全一致。2. 实战示例:重试失败后返回默认结果import org.springframework.retry.annotation.Recover; import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; @Service public class RetryDemoService { @Retryable( retryFor = RuntimeException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000) ) public String callThirdPartyApi(String param) { System.out.println("调用第三方接口,参数:" + param); // 模拟接口调用失败 throw new RuntimeException("第三方接口超时"); } // 重试失败后的恢复方法 @Recover public String recover(RuntimeException e, String param) { System.out.println("重试次数耗尽,执行兜底逻辑!异常信息:" + e.getMessage()); System.out.println("请求参数:" + param); // 返回默认结果 return "接口调用失败,已触发兜底策略"; } }四、注意事项(避坑指南)@Retryable 不能修饰 private 方法:因为 Spring AOP 无法代理 private 方法,重试逻辑会失效。避免同类方法调用:如果在同一个类中调用 @Retryable 方法(非代理调用),重试逻辑也会失效。重试策略要合理:避免设置过短的间隔和过多的重试次数,增加服务压力。五、总结Spring Retry 凭借注解化的方式,让我们摆脱了手写重试逻辑的繁琐,实现了代码的优雅和解耦。核心要点如下:三步集成:加依赖 → 启注解 → 标记方法。灵活配置:通过 @Retryable 属性定制重试次数、间隔、触发异常。兜底保障:通过 @Recover 处理重试失败的最终逻辑。掌握 Spring Retry,能让你在应对不稳定接口时更加从容,大幅提升系统的健壮性!
2026年01月16日
1 阅读
0 评论
1 点赞
2026-01-14
为什么不建议使用Executors创建线程池?
在Java开发中,线程池是优化并发性能的核心工具,但线程池的创建方式却藏着不少坑。《阿里巴巴Java开发手册》明确规定:线程池不允许使用Executors创建,必须通过ThreadPoolExecutor手动创建。很多新手可能会疑惑:Executors提供的方法简洁又方便,为什么会被禁止?今天就从底层实现出发,彻底讲清楚这个问题,同时补充线程池的核心知识,帮你避开面试和开发中的高频陷阱。一、先认识下「背锅侠」:Executors类Executors是JUC(java.util.concurrent)包下的工具类,专门用于快速创建线程池,提供了4个核心方法:newFixedThreadPool:固定线程数的线程池newSingleThreadExecutor:单线程线程池newCachedThreadPool:可缓存的线程池newScheduledThreadPool:支持定时/周期性任务的线程池这些方法看似「开箱即用」,但底层参数配置存在致命缺陷,我们逐个拆解。1. 隐患1:newFixedThreadPool & newSingleThreadExecutor——内存溢出风险先看newFixedThreadPool的底层实现代码:public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) { return new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(), threadFactory); }核心问题出在等待队列LinkedBlockingQueue上。我们点进LinkedBlockingQueue的无参构造:public LinkedBlockingQueue() { this(Integer.MAX_VALUE); // 队列长度默认是Integer.MAX_VALUE }关键坑点:Integer.MAX_VALUE是2147483647,相当于「无界队列」。当任务提交速度远大于线程处理速度时,任务会不断堆积在队列中,导致JVM内存持续飙升,最终触发OOM(内存溢出)。newSingleThreadExecutor的问题和它完全一致,底层也是用了无界的LinkedBlockingQueue,且核心线程数固定为1,任务堆积的风险更高:public static ExecutorService newSingleThreadExecutor() { return new FinalizableDelegatedExecutorService (new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>())); }2. 隐患2:newCachedThreadPool & newScheduledThreadPool——资源耗尽风险再看newCachedThreadPool的实现:public static ExecutorService newCachedThreadPool() { return new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SynchronousQueue<Runnable>()); }核心问题有两个:核心线程数为0:没有常驻线程,每次有任务都需要创建新线程(除非有空闲线程可复用)最大线程数为Integer.MAX_VALUE:理论上可以创建无限多线程关键坑点:当短时间内提交大量任务时,线程池会疯狂创建新线程,而每个线程都会占用一定的内存和CPU资源,最终导致系统资源耗尽,程序崩溃。newScheduledThreadPool的问题类似,最大线程数同样是Integer.MAX_VALUE,存在相同的资源耗尽风险:public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) { return new ScheduledThreadPoolExecutor(corePoolSize); } // 父类构造(ScheduledThreadPoolExecutor继承自ThreadPoolExecutor) public ScheduledThreadPoolExecutor(int corePoolSize) { super(corePoolSize, Integer.MAX_VALUE, DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS, new DelayedWorkQueue()); }二、正确姿势:ThreadPoolExecutor手动创建(推荐)Executors的问题本质是「参数固化」,无法根据业务场景灵活配置。而ThreadPoolExecutor允许我们手动指定所有核心参数,从根源上避免上述隐患。1. 核心参数详解(面试高频考点)ThreadPoolExecutor的核心构造方法:public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler) { // ... 省略参数校验逻辑 }7个参数的作用必须记牢,直接关系到线程池的性能和稳定性:corePoolSize(核心线程数):线程池的常驻线程数,即使空闲也不会销毁(除非设置allowCoreThreadTimeOut=true)。maximumPoolSize(最大线程数):线程池允许创建的最大线程数,超出核心线程数的是「非核心线程」。keepAliveTime(空闲线程存活时间):非核心线程空闲后的最大存活时间,超时会被销毁,释放资源。unit(时间单位):keepAliveTime的时间单位,如MILLISECONDS(毫秒)、SECONDS(秒)。workQueue(工作队列):用于存放等待执行的任务,必须使用「有界队列」(如ArrayBlockingQueue),避免任务堆积。threadFactory(线程工厂):用于创建线程,可自定义线程名称(方便问题排查)、设置线程优先级等。handler(拒绝策略):当线程数达最大且队列满时,新任务的处理策略(如丢弃任务、抛出异常、由提交线程执行等)。2. 推荐实践:自定义线程池示例结合业务场景(如处理用户订单任务),手动创建线程池:import java.util.concurrent.*; public class ThreadPoolDemo { // 线程工厂:自定义线程名称,方便排查问题 private static final ThreadFactory THREAD_FACTORY = new ThreadFactory() { private int count = 1; @Override public Thread newThread(Runnable r) { Thread thread = new Thread(r); thread.setName("order-thread-pool-" + count++); return thread; } }; // 拒绝策略:队列满时抛出异常,及时发现问题 private static final RejectedExecutionHandler REJECTED_HANDLER = new ThreadPoolExecutor.AbortPolicy(); // 自定义线程池 public static final ThreadPoolExecutor ORDER_THREAD_POOL = new ThreadPoolExecutor( 5, // 核心线程数:根据CPU核心数或业务量配置 10, // 最大线程数:不超过CPU核心数*2(IO密集型可适当增加) 60, // 空闲线程存活时间:60秒 TimeUnit.SECONDS, new ArrayBlockingQueue<>(100), // 有界队列:容量100,避免任务堆积 THREAD_FACTORY, REJECTED_HANDLER ); public static void main(String[] args) { // 提交任务 for (int i = 0; i < 100; i++) { int finalI = i; ORDER_THREAD_POOL.submit(() -> { System.out.println(Thread.currentThread().getName() + " 处理订单:" + finalI); }); } // 关闭线程池(实际项目中可在应用关闭时调用) ORDER_THREAD_POOL.shutdown(); } }三、加餐:线程池工作流程(面试必问)理解线程池的工作流程,能帮你更合理地配置参数。当任务提交后,线程池会按以下步骤处理:提交任务:通过execute()或submit()方法提交任务。检查核心线程:若当前运行的线程数 < corePoolSize,立即创建核心线程执行任务;否则进入下一步。检查工作队列:若队列未满,将任务放入队列等待执行;否则进入下一步。检查最大线程:若当前运行的线程数 < maximumPoolSize,创建非核心线程执行任务;否则进入下一步。触发拒绝策略:线程数达最大且队列满时,执行拒绝策略处理任务。记忆小技巧:核心线程优先接活 → 活太多就放队列 → 队列满了就加临时线程 → 临时线程也满了就拒绝。四、总结Executors被禁止的核心原因是「参数不可控」,导致线程池存在内存溢出或资源耗尽的风险;而ThreadPoolExecutor通过手动配置核心参数,能根据业务场景精准控制线程池的行为,从根源上规避风险。最后再划几个重点:必须使用有界队列(如ArrayBlockingQueue),避免任务堆积。最大线程数需合理配置(CPU密集型:核心数+1;IO密集型:核心数*2)。自定义线程工厂,方便问题排查。选择合适的拒绝策略,避免静默失败。掌握线程池的正确创建方式,不仅能提升程序的稳定性,也是Java面试中的高频考点。希望这篇文章能帮你彻底搞懂这个问题~
2026年01月14日
1 阅读
0 评论
1 点赞
2026-01-13
玩转 MyBatis-Plus 多数据源配置,Spring Boot 项目快速实现多库操作
在实际开发中,我们经常会遇到一个 Spring Boot 项目需要操作多个数据库的场景,比如从 Oracle 拉取数据同步到 MySQL。如果手动管理多个数据源的连接和切换,不仅开发效率低,还容易出现数据源混淆的问题。而 MyBatis-Plus 提供的 dynamic-datasource-spring-boot-starter 依赖,能够帮助我们快速实现多数据源的配置和动态切换,极大简化多库操作的开发流程。本文就来详细讲解 Spring Boot 整合 MyBatis-Plus 多数据源的具体实现步骤。一、核心依赖引入首先,我们需要在项目的 pom.xml 文件中引入 MyBatis-Plus 多数据源的核心依赖。这里以 3.1.0 版本为例(可根据项目实际情况选择兼容版本):<dependency> <groupId>com.baomidou</groupId> <artifactId>dynamic-datasource-spring-boot-starter</artifactId> <version>4.2.0</version> </dependency>注意:如果项目中已经引入了 MyBatis-Plus 的核心依赖,无需重复引入,该 starter 已包含相关依赖。二、多数据源配置(application.yml)接下来,在 Spring Boot 的核心配置文件 application.yml 中配置多个数据源的连接信息。本文以 1 个 MySQL 数据源 + 2 个 Oracle 数据源为例,配置如下:spring: datasource: dynamic: # 设置默认的数据源,默认数据源的 key 需与下方 datasource 中的配置一致 primary: mysql-ocr # 严格模式:默认 true,未匹配到指定数据源时抛异常,false 则使用默认数据源 strict: true # 配置多个数据源,key 自定义(建议与业务相关,便于区分) datasource: # MySQL 数据源:ocr 业务库 mysql-ocr: url: jdbc:mysql://ip:port/ocr?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai username: username password: password driver-class-name: com.mysql.cj.jdbc.Driver # Oracle 数据源1:orcl 业务库 oracle-orcl: url: jdbc:oracle:thin:@ip:port:helowin username: username password: password driver-class-name: oracle.jdbc.OracleDriver # Oracle 数据源2:cbxx 业务库 oracle-cbxx: url: jdbc:oracle:thin:@ip:port:helowin username: username password: password driver-class-name: oracle.jdbc.OracleDriver配置参数说明参数作用primary指定默认数据源,当不指定数据源时,默认使用该配置的数据源strict严格模式开关,开启后若调用不存在的数据源会抛出异常,关闭则默认使用主数据源datasource多数据源的具体配置节点,每个子节点对应一个数据源,key 为自定义的数据源名称三、Mapper 层目录结构与数据源注解配置为了更清晰地管理不同数据源对应的 Mapper 接口,我们可以按照数据源划分包结构,同时通过 @DS 注解指定 Mapper 对应的数据源。1. Mapper 层目录结构推荐按照数据源名称创建独立的包,将不同数据源的 Mapper 接口分类存放,便于后期维护:src/main/java/com/xxx/mapper ├── cbxx // oracle-cbxx 数据源对应的 Mapper 包 │ └── VOcrCbxxMapper.java ├── ocr // mysql-ocr 数据源对应的 Mapper 包 │ └── OcrCmMapper.java └── orcl // oracle-orcl 数据源对应的 Mapper 包 └── VOcrMeterMapper.java2. @DS 注解指定数据源@DS 注解是 MyBatis-Plus 多数据源的核心注解,用于指定当前 Mapper 接口或方法对应的数据源,支持类级别和方法级别,遵循就近原则(方法上的注解优先级高于类上的注解)。在 Mapper 接口上添加 @DS 注解,指定该接口下所有方法都使用对应的数据源,也可以在方法上单独添加 @DS 注解,优先级更高:import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.dynamic.datasource.annotation.DS; import com.xxx.entity.VOcrCbxx; import org.springframework.stereotype.Repository; /** * oracle-cbxx 数据源对应的 Mapper 接口 * @DS 注解指定数据源名称,与 application.yml 中配置的 key 一致 */ @DS("oracle-cbxx") @Mapper public interface VOcrCbxxMapper extends BaseMapper<VOcrCbxx> { /** * 自定义查询方法:根据日期查询数据 * 该方法默认使用类上指定的 oracle-cbxx 数据源 */ default List<VOcrCbxx> selectByBqcbr(String date) { QueryWrapper<VOcrCbxx> wrapper = new QueryWrapper<>(); // 适配 Oracle 日期函数,匹配年月日 wrapper.apply("TRUNC(bqcbr) = TO_DATE({0}, 'YYYY-MM-DD')", date); return selectList(wrapper); } /** * 自定义方法:使用 oracle-orcl 数据源查询 * 方法级别 @DS 注解优先级高于类级别 */ @DS("oracle-orcl") List<VOcrCbxx> selectFromOrclByCondition(String condition); }四、核心使用说明注解优先级:方法上的 @DS 注解 > 类上的 @DS 注解 > 全局默认数据源。事务支持:多数据源下的事务需要使用 @DSTransactional 注解(而非 Spring 原生的 @Transactional),该注解能保证同一数据源内的事务一致性;跨数据源事务需结合分布式事务方案(如 TCC)。避免数据源混用:建议严格按照包结构划分 Mapper,避免不同数据源的 Mapper 混杂,降低维护成本。动态切换数据源:除了通过 @DS 注解静态指定数据源,还可以通过 DynamicDataSourceContextHolder 类手动切换数据源,适用于动态选择数据源的业务场景:// 手动切换到 mysql-ocr 数据源 DynamicDataSourceContextHolder.push("mysql-ocr"); // 执行数据库操作 ocrCmMapper.selectById(1L); // 清空当前数据源上下文 DynamicDataSourceContextHolder.clear();五、总结通过 MyBatis-Plus 的 dynamic-datasource-spring-boot-starter,我们可以在 Spring Boot 项目中零侵入式地实现多数据源配置,核心步骤总结如下:引入多数据源核心依赖;在 application.yml 中配置多数据源连接信息,指定默认数据源;按数据源划分 Mapper 包结构,通过 @DS 注解指定 Mapper/方法对应的数据源;业务层直接注入 Mapper 接口使用,无需关心数据源切换细节。这种配置方式简洁高效,极大降低了多数据源开发的复杂度,非常适合需要操作多个异构数据库的业务场景。
2026年01月13日
3 阅读
0 评论
0 点赞
2025-11-21
EasyExcel 实战:导出带图片的 Excel 完整方案
在实际开发中,可能会遇到导出 Excel 时需要包含图片的场景。EasyExcel 作为阿里开源的高效 Excel 处理工具,虽然原生支持图片导出,但在图片格式适配、单元格样式调整等细节上需要自定义扩展。本文将详细讲解如何基于 EasyExcel 实现带图片的 Excel 导出,包含完整代码示例和关键细节说明。一、最终效果展示导出的 Excel 中,图片将自适应单元格大小,无图片时显示"无图"提示,整体格式整洁规范:二、实现步骤详解1. 引入依赖首先在 pom.xml 中添加 EasyExcel 核心依赖(本文使用 4.0.3 版本,适配大部分场景):<dependency> <groupId>com.alibaba</groupId> <artifactId>easyexcel</artifactId> <version>4.0.3</version> </dependency>2. 定义导出实体类创建 RecordExcelVO 作为 Excel 导出的数据载体,核心关注图片字段的配置:package com.retail.ocr.model.vo; import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.retail.ocr.excel.converter.ImageConverter; import lombok.Data; /** * Excel 导出实体(识别记录) * * @author jucunqi * @since 2025/11/20 */ @Data public class RecordExcelVO { @ColumnWidth(20) // 列宽设置 @ExcelProperty("手机号") // Excel 表头名称 private String phoneNumber; @ColumnWidth(25) @ExcelProperty("识别时间") private String createTime; @ColumnWidth(20) @ExcelProperty("识别读数") private String number1; @ColumnWidth(20) @ExcelProperty("识别条码") private String number2; // 图片字段:使用自定义转换器处理 byte[] 类型 @ExcelProperty(value = "识别照片", converter = ImageConverter.class) private byte[] imageBytes; @ColumnWidth(20) @ExcelProperty("标记内容") private String markDesc; @ColumnWidth(20) @ExcelProperty("备注") private String remark; }关键说明:图片字段类型为 byte[]:便于存储图片二进制数据(可从文件、数据库 BLOB 字段、网络图片转换获取)。@ExcelProperty(converter = ImageConverter.class):指定自定义转换器,将 byte[] 转换为 Excel 可识别的图片格式。@ColumnWidth:统一设置列宽,优化 Excel 显示效果。3. 实现图片转换器(核心)自定义 ImageConverter 实现 Converter<byte[]> 接口,负责将图片二进制数据转换为 EasyExcel 支持的 ImageData 格式:package com.retail.ocr.excel.converter; import com.alibaba.excel.converters.Converter; import com.alibaba.excel.enums.CellDataTypeEnum; import com.alibaba.excel.metadata.GlobalConfiguration; import com.alibaba.excel.metadata.data.ImageData; import com.alibaba.excel.metadata.data.ReadCellData; import com.alibaba.excel.metadata.data.WriteCellData; import com.alibaba.excel.metadata.property.ExcelContentProperty; import com.alibaba.excel.util.ListUtils; import lombok.extern.slf4j.Slf4j; import org.springframework.util.CollectionUtils; import java.util.List; @Slf4j public class ImageConverter implements Converter<byte[]> { @Override public Class<?> supportJavaTypeKey() { return List.class; } @Override public CellDataTypeEnum supportExcelTypeKey() { return CellDataTypeEnum.EMPTY; } @Override public byte[] convertToJavaData(ReadCellData<?> cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception { return null; } @Override public WriteCellData<?> convertToExcelData(byte[] value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) throws Exception { // 这里进行对数据实体类的byte[]进行处理 List<ImageData> data = ListUtils.newArrayList(); ImageData imageData; try { imageData = new ImageData(); imageData.setImage(value); data.add(imageData); } catch (Exception e) { log.error("导出临时记录图片异常:", e); } WriteCellData<?> cellData = new WriteCellData<>(); if (!CollectionUtils.isEmpty(data)) { // 图片返回图片列表 cellData.setImageDataList(data); cellData.setType(CellDataTypeEnum.EMPTY); } else { // 没有图片使用汉字表示 cellData.setStringValue("无图"); cellData.setType(CellDataTypeEnum.STRING); } return cellData; } }核心逻辑:校验图片二进制数据有效性,避免空指针异常。有图片时:封装 ImageData 列表,指定 Excel 单元格类型为 IMAGE。无图片时:设置文本"无图",单元格类型为 STRING,提升用户体验。4. 自定义图片写入处理器由于 EasyExcel 原生图片导出不支持自适应单元格、多图片排列等需求,需自定义 CellWriteHandler 处理图片的位置、大小和样式:package com.retail.ocr.excel.handler; import com.alibaba.excel.enums.CellDataTypeEnum; import com.alibaba.excel.metadata.Head; import com.alibaba.excel.metadata.data.ImageData; import com.alibaba.excel.metadata.data.WriteCellData; import com.alibaba.excel.write.handler.CellWriteHandler; import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; import com.alibaba.excel.write.metadata.holder.WriteTableHolder; import org.apache.commons.collections4.CollectionUtils; import org.apache.poi.hssf.usermodel.HSSFWorkbook; import org.apache.poi.ss.usermodel.*; import org.apache.poi.util.Units; import org.apache.poi.xssf.usermodel.XSSFDrawing; import org.apache.poi.xssf.usermodel.XSSFPicture; import org.apache.poi.xssf.usermodel.XSSFShape; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicReference; public class ImageWriteHandler implements CellWriteHandler { /** * 已经处理的Cell */ private final CopyOnWriteArrayList<String> REPEATS = new CopyOnWriteArrayList<>(); /** * 单元格的图片最大张数(每列的单元格图片张数不确定,单元格宽度需按照张数最多的长度来设置) */ private final AtomicReference<Integer> MAX_IMAGE_SIZE = new AtomicReference<>(0); /** * 标记手动添加的图片,用于排除EasyExcel自动添加的图片 */ private final CopyOnWriteArrayList<Integer> CREATE_PIC_INDEX = new CopyOnWriteArrayList<>(); @Override public void beforeCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Row row, Head head, Integer columnIndex, Integer relativeRowIndex, Boolean isHead) { } @Override public void afterCellCreate(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { } @Override public void afterCellDataConverted(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, WriteCellData<?> cellData, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { // 在 数据转换成功后 不是头就把类型设置成空 if (isHead) { return; } // 将要插入图片的单元格的type设置为空,下面再填充图片 if (CollectionUtils.isNotEmpty(cellData.getImageDataList())) { cellData.setType(CellDataTypeEnum.EMPTY); } } @Override public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, List<WriteCellData<?>> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { // 在 单元格写入完毕后 ,自己填充图片 if (isHead || CollectionUtils.isEmpty(cellDataList)) { return; } boolean listFlag = false; Sheet sheet = cell.getSheet(); // 此处为ExcelUrlConverterUtil的返回值 List<ImageData> imageDataList = cellDataList.get(0).getImageDataList(); if (CollectionUtils.isNotEmpty(imageDataList)) { listFlag = true; } if (!listFlag && imageDataList == null) { return; } String key = cell.getRowIndex() + "_" + cell.getColumnIndex(); if (REPEATS.contains(key)) { return; } REPEATS.add(key); if (imageDataList.size() > MAX_IMAGE_SIZE.get()) { MAX_IMAGE_SIZE.set(imageDataList.size()); } // 默认要导出的图片大小为60*60px,60px的行高大约是900,60px列宽大概是248*8 sheet.getRow(cell.getRowIndex()).setHeight((short) 900); sheet.setColumnWidth(cell.getColumnIndex(), listFlag ? 240 * 8 * MAX_IMAGE_SIZE.get() : 240 * 8); if (listFlag) { for (int i = 0; i < imageDataList.size(); i++) { ImageData imageData = imageDataList.get(i); if (imageData == null) { continue; } byte[] image = imageData.getImage(); this.insertImage(sheet, cell, image, i); } } else { this.insertImage(sheet, cell, imageDataList.get(0).getImage(), 0); } // 清除EasyExcel自动添加的没有格式的图片 XSSFDrawing drawingPatriarch = (XSSFDrawing) sheet.getDrawingPatriarch(); List<XSSFShape> shapes = drawingPatriarch.getShapes(); for (int i = 0; i < shapes.size(); i++) { XSSFShape shape = shapes.get(i); if (shape instanceof XSSFPicture && !CREATE_PIC_INDEX.contains(i)) { CREATE_PIC_INDEX.add(i); XSSFPicture picture = (XSSFPicture) shape; picture.resize(0); } } } /** * 重新插入一个图片 * * @param sheet Excel页面 * @param cell 表格元素 * @param pictureData 图片数据 * @param i 图片顺序 */ private void insertImage(Sheet sheet, Cell cell, byte[] pictureData, int i) { int picWidth = Units.pixelToEMU(60); int index = sheet.getWorkbook().addPicture(pictureData, HSSFWorkbook.PICTURE_TYPE_PNG); CREATE_PIC_INDEX.add(index); Drawing<?> drawing = sheet.getDrawingPatriarch(); if (drawing == null) { drawing = sheet.createDrawingPatriarch(); } CreationHelper helper = sheet.getWorkbook().getCreationHelper(); ClientAnchor anchor = helper.createClientAnchor(); // 设置图片坐标 anchor.setDx1(picWidth * i); anchor.setDx2(picWidth + picWidth * i); anchor.setDy1(0); anchor.setDy2(0); // 设置图片位置 int columnIndex = cell.getColumnIndex(); anchor.setCol1(columnIndex); anchor.setCol2(columnIndex); int rowIndex = cell.getRowIndex(); anchor.setRow1(rowIndex); anchor.setRow2(rowIndex + 1); // ClientAnchor.AnchorType里有多种类型可选,从网上看的之前是不移动,直接悬浮在单元格上了,现在这个是随着单元格移动 anchor.setAnchorType(ClientAnchor.AnchorType.MOVE_AND_RESIZE); drawing.createPicture(anchor, index); } }核心功能:自适应行列宽:根据图片数量动态调整列宽,固定行高适配 60*60px 图片。多图片横向排列:支持单元格内多图片横向显示,间距 10px,避免重叠。图片随单元格移动:设置锚点类型为 MOVE_AND_RESIZE,图片会随单元格位置变化而同步移动。5. 业务层导出实现最后在 Controller 中编写导出接口,整合上述组件完成 Excel 导出:@GetMapping("/recognizeRecordExport") public void recognizeRecordExport(RecognizeRecordPageReqVo reqVo, HttpServletResponse response) throws IOException { // 获取数据逻辑省略 List<RecordExcelVO> excelVOList = getRecordExcelVOS(reqVo); String filename = "识别数据.xlsx"; // 输出 Excel EasyExcel.write(response.getOutputStream(), RecordExcelVO.class) .autoCloseStream(false) // 不要自动关闭,交给 Servlet 自己处理 .registerWriteHandler(new ImageWriteHandler()) .sheet(filename).doWrite(excelVOList); // 设置 header 和 contentType。写在最后的原因是,避免报错时,响应 contentType 已经被修改了 response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(filename)); response.setContentType("application/vnd.ms-excel;charset=UTF-8"); }关键注意事项:中文文件名乱码:使用 URLEncoder.encode 编码文件名,并指定 filename*=UTF-8'' 格式,兼容主流浏览器。响应头设置:contentType 对于 xlsx 格式需设置为 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,xls 格式需改为 application/vnd.ms-excel。流关闭策略:设置 autoCloseStream(false),避免 EasyExcel 提前关闭响应流导致下载失败。以上就是使用EasyExcel导出带图片文件的所有内容了,希望对大家有所帮助~
2025年11月21日
168 阅读
0 评论
2 点赞
1
2
...
8