From f05fd231f80188552d6ea2c5396087be1cb92071 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Wed, 9 Sep 2026 10:28:49 +0900 Subject: [PATCH 1/5] refactor: make the async example actually about @Async MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit async/TestController 继承 BaseController, 被迫实现 9 个与异步毫无关系的 CRUD 方法, 全部 return null。结果是 90 行空实现淹没了 10 行真正想演示的东西。这个模块的存在意义就是展示 @Async, 现在让它只展示 @Async。 - 删除 TestController(整类都是空实现), 新增 AsyncController 只保留异步演示 - 不再继承 BaseController - AsyncResult 在 spring 6 已废弃, 换成 CompletableFuture.completedFuture - 去掉 while(true) + Thread.sleep(1000) 的自旋等待, 改用 CompletableFuture::join。原写法不仅浪费 CPU, 1 秒的轮询粒度还让打印出来的"总耗时"最多偏差一秒 - System.out.println 换成 logger; static Random 换成 ThreadLocalRandom - 补上 @EnableAsync —— 原先根本没加, 也就是说这个"异步示例"里的 @Async 一直没有生效 - 新增测试, 断言三个任务确实并发执行且调用方不被阻塞 同时修正若干复制粘贴的 javadoc: security / mongodb / javase / socket 四个启动类的 Description 都写着别的模块的名字。 BaseController 上的 @RestController 也一并去掉 —— 抽象基类不应该是 @RestController, 其抽象方法上的 @PathVariable / @RequestBody 在没有 @RequestMapping 的情况下也不起任何作用。 --- async/pom.xml | 5 + .../java/info/xiaomo/async/AsyncMain.java | 10 +- .../async/controller/AsyncController.java | 48 ++++++ .../async/controller/TestController.java | 160 ------------------ .../info/xiaomo/async/task/AsyncTask.java | 51 +++--- .../java/info/xiaomo/async/AsyncTaskTest.java | 52 ++++++ .../info/xiaomo/core/base/BaseController.java | 5 +- .../java/info/xiaomo/javase/QuestionMain.java | 2 +- .../java/info/xiaomo/mongodb/MongodbMain.java | 2 +- .../info/xiaomo/security/SecurityMain.java | 2 +- .../main/java/info/xiaomo/chat/ChatMain.java | 2 +- 11 files changed, 138 insertions(+), 201 deletions(-) create mode 100644 async/src/main/java/info/xiaomo/async/controller/AsyncController.java delete mode 100644 async/src/main/java/info/xiaomo/async/controller/TestController.java create mode 100644 async/src/test/java/info/xiaomo/async/AsyncTaskTest.java diff --git a/async/pom.xml b/async/pom.xml index 60d54b74..a38cfa07 100644 --- a/async/pom.xml +++ b/async/pom.xml @@ -16,6 +16,11 @@ core 2020.1 + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/async/src/main/java/info/xiaomo/async/AsyncMain.java b/async/src/main/java/info/xiaomo/async/AsyncMain.java index ae2aefe5..d8f1f926 100644 --- a/async/src/main/java/info/xiaomo/async/AsyncMain.java +++ b/async/src/main/java/info/xiaomo/async/AsyncMain.java @@ -3,11 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.persistence.autoconfigure.EntityScan; -import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; /** * 把今天最好的表现当作明天最新的起点..~ @@ -20,15 +20,15 @@ * email: xiaomo@xiaomo.info *

* Date: 2016/4/1 15:38 - * Description: RabbitMq启动器 + * Description: 异步任务启动器 * Copyright(©) 2015 by xiaomo. **/ @Configuration +@EnableAsync @EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class}) @ComponentScan("info.xiaomo") -@EntityScan("info.xiaomo.*.model") public class AsyncMain { - public static void main(String[] args) throws Exception { + public static void main(String[] args) { SpringApplication.run(AsyncMain.class, args); } diff --git a/async/src/main/java/info/xiaomo/async/controller/AsyncController.java b/async/src/main/java/info/xiaomo/async/controller/AsyncController.java new file mode 100644 index 00000000..7897d8d8 --- /dev/null +++ b/async/src/main/java/info/xiaomo/async/controller/AsyncController.java @@ -0,0 +1,48 @@ +package info.xiaomo.async.controller; + +import info.xiaomo.async.task.AsyncTask; +import info.xiaomo.core.base.Result; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * 三个任务并发执行, 总耗时接近最慢的那个而不是三者之和。 + * + * @author : xiaomo + */ +@RestController +@RequestMapping("/async") +public class AsyncController { + + private static final Logger LOGGER = LoggerFactory.getLogger(AsyncController.class); + + private final AsyncTask task; + + public AsyncController(AsyncTask task) { + this.task = task; + } + + @GetMapping + public Result> runTasks() { + long start = System.currentTimeMillis(); + + List> futures = List.of( + task.doTask("任务一"), + task.doTask("任务二"), + task.doTask("任务三")); + + // join 会等到全部完成, 不需要自己轮询 + List results = futures.stream().map(CompletableFuture::join).toList(); + + long cost = System.currentTimeMillis() - start; + LOGGER.info("任务全部完成, 总耗时 {} 毫秒", cost); + return new Result<>(results); + } + +} diff --git a/async/src/main/java/info/xiaomo/async/controller/TestController.java b/async/src/main/java/info/xiaomo/async/controller/TestController.java deleted file mode 100644 index 5c0623a1..00000000 --- a/async/src/main/java/info/xiaomo/async/controller/TestController.java +++ /dev/null @@ -1,160 +0,0 @@ -package info.xiaomo.async.controller; - -import info.xiaomo.async.task.AsyncTask; -import info.xiaomo.core.base.BaseController; -import info.xiaomo.core.base.Result; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.Page; -import org.springframework.web.bind.annotation.*; - -import java.util.List; -import java.util.concurrent.Future; - -/** - * 把今天最好的表现当作明天最新的起点..~ - * いま 最高の表現 として 明日最新の始発..~ - * Today the best performance as tomorrow newest starter! - - * - * @author : xiaomo - * github: https://github.com/houko - * email: xiaomo@xiaomo.info - *

- * Date: 2016/11/15 15:12 - * Description: 用户实体类 - * Copyright(©) 2015 by xiaomo. - **/ - -@RestController -@RequestMapping("/") -public class TestController extends BaseController { - - private final AsyncTask task; - - @Autowired - public TestController(AsyncTask task) { - this.task = task; - } - - @RequestMapping(value = "/", method = RequestMethod.GET) - public Result task() throws Exception { - long start = System.currentTimeMillis(); - - Future task1 = task.doTaskOne(); - Future task2 = task.doTaskTwo(); - Future task3 = task.doTaskThree(); - - while (true) { - if (task1.isDone() && task2.isDone() && task3.isDone()) { - // 三个任务都调用完成,退出循环等待 - break; - } - Thread.sleep(1000); - } - - long end = System.currentTimeMillis(); - - System.out.println("任务全部完成,总耗时:" + (end - start) + "毫秒"); - return new Result<>(end - start); - } - - - /** - * 查找所有(不带分页) - * - * @return result - */ - @Override - public Result findAll() { - return null; - } - - /** - * 带分页 - * - * @param start 起始页 - * @param pageSize 页码数 - * @return result - */ - @Override - public Result findAll(@PathVariable int start, @PathVariable int pageSize) { - return null; - } - - /** - * 根据id查看模型 - * - * @param id id - * @return result - */ - @Override - public Result findById(@PathVariable Long id) { - return null; - } - - /** - * 根据名字查找模型 - * - * @param name name - * @return result - */ - @Override - public Result findByName(@PathVariable String name) { - return null; - } - - /** - * 根据名字删除模型 - * - * @param name name - * @return result - */ - @Override - public Result delByName(@PathVariable String name) { - return null; - } - - /** - * 根据id删除模型 - * - * @param id id - * @return result - */ - @Override - public Result delById(@PathVariable Long id) { - return null; - } - - /** - * 添加模型 - * - * @param model model - * @return result - */ - @Override - public Result add(@RequestBody Object model) { - return null; - } - - /** - * 更新 - * - * @param model model - * @return result - */ - @Override - public Result update(@RequestBody Object model) { - return null; - } - - /** - * 批量删除 - * - * @param ids ids - * @return result - */ - @Override - public Result delByIds(@PathVariable List ids) { - return null; - } -} diff --git a/async/src/main/java/info/xiaomo/async/task/AsyncTask.java b/async/src/main/java/info/xiaomo/async/task/AsyncTask.java index 1fa2fcb7..5cbd0f9b 100644 --- a/async/src/main/java/info/xiaomo/async/task/AsyncTask.java +++ b/async/src/main/java/info/xiaomo/async/task/AsyncTask.java @@ -1,48 +1,39 @@ package info.xiaomo.async.task; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; -import org.springframework.scheduling.annotation.AsyncResult; import org.springframework.stereotype.Component; -import java.util.Random; -import java.util.concurrent.Future; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ThreadLocalRandom; /** + * 用 @Async 把方法丢到线程池里执行, 调用方立刻拿到一个 CompletableFuture 而不会阻塞。 + * 生效的前提是启动类上加了 @EnableAsync, 见 {@link info.xiaomo.async.AsyncMain}。 + * * @author : xiaomo */ @Component public class AsyncTask { - private static Random random = new Random(); + private static final Logger LOGGER = LoggerFactory.getLogger(AsyncTask.class); @Async - public Future doTaskOne() throws Exception { - System.out.println("开始做任务一"); + public CompletableFuture doTask(String name) { long start = System.currentTimeMillis(); - Thread.sleep(random.nextInt(10000)); - long end = System.currentTimeMillis(); - System.out.println("完成任务一,耗时:" + (end - start) + "毫秒"); - return new AsyncResult<>("任务一完成"); - } - - @Async - public Future doTaskTwo() throws Exception { - System.out.println("开始做任务二"); - long start = System.currentTimeMillis(); - Thread.sleep(random.nextInt(10000)); - long end = System.currentTimeMillis(); - System.out.println("完成任务二,耗时:" + (end - start) + "毫秒"); - return new AsyncResult<>("任务二完成"); - } - - @Async - public Future doTaskThree() throws Exception { - System.out.println("开始做任务三"); - long start = System.currentTimeMillis(); - Thread.sleep(random.nextInt(10000)); - long end = System.currentTimeMillis(); - System.out.println("完成任务三,耗时:" + (end - start) + "毫秒"); - return new AsyncResult<>("任务三完成"); + LOGGER.info("开始{}", name); + try { + // 用随机耗时模拟一个慢操作 + Thread.sleep(ThreadLocalRandom.current().nextInt(1000, 3000)); + } catch (InterruptedException e) { + // 恢复中断状态, 让上层能感知到取消 + Thread.currentThread().interrupt(); + return CompletableFuture.failedFuture(e); + } + long cost = System.currentTimeMillis() - start; + LOGGER.info("完成{}, 耗时 {} 毫秒", name, cost); + return CompletableFuture.completedFuture(name + "完成, 耗时 " + cost + " 毫秒"); } } diff --git a/async/src/test/java/info/xiaomo/async/AsyncTaskTest.java b/async/src/test/java/info/xiaomo/async/AsyncTaskTest.java new file mode 100644 index 00000000..0a680b57 --- /dev/null +++ b/async/src/test/java/info/xiaomo/async/AsyncTaskTest.java @@ -0,0 +1,52 @@ +package info.xiaomo.async; + +import info.xiaomo.async.task.AsyncTask; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @Async 生效时三个任务并发执行, 总耗时应当明显小于串行执行之和。 + * 单个任务耗时 1000~3000ms, 串行最少 3000ms, 并发则不超过最慢的那个。 + */ +@SpringBootTest(classes = AsyncMain.class) +class AsyncTaskTest { + + @Autowired + private AsyncTask task; + + @Test + void 三个任务应当并发执行而不是串行() { + long start = System.currentTimeMillis(); + + List> futures = List.of( + task.doTask("任务一"), + task.doTask("任务二"), + task.doTask("任务三")); + List results = futures.stream().map(CompletableFuture::join).toList(); + + long cost = System.currentTimeMillis() - start; + + assertThat(results).hasSize(3); + assertThat(results).allSatisfy(r -> assertThat(r).contains("完成")); + // 串行至少需要 3 * 1000ms, 留出足够余量后仍应远低于该值 + assertThat(cost).isLessThan(3000); + } + + @Test + void 被代理的方法应当立即返回而不阻塞调用方() { + long start = System.currentTimeMillis(); + CompletableFuture future = task.doTask("任务一"); + long returnCost = System.currentTimeMillis() - start; + + // 方法体最少睡 1000ms, 调用方却能立刻拿到 future + assertThat(returnCost).isLessThan(500); + assertThat(future.join()).contains("任务一"); + } + +} diff --git a/core/src/main/java/info/xiaomo/core/base/BaseController.java b/core/src/main/java/info/xiaomo/core/base/BaseController.java index b2f5d2c8..0317e571 100644 --- a/core/src/main/java/info/xiaomo/core/base/BaseController.java +++ b/core/src/main/java/info/xiaomo/core/base/BaseController.java @@ -5,15 +5,16 @@ import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RestController; import java.util.List; /** + * CRUD 控制器的公共契约。仅供确实提供增删改查的控制器继承, 不做 CRUD 的控制器不要继承它, + * 否则会被迫写出一堆 return null 的空实现。 + * * @author : xiaomo (https://xiaomo.info) (https://github.com/houko) * @version : 2017/1/11 16:41 */ -@RestController public abstract class BaseController { protected final Logger LOGGER = LoggerFactory.getLogger(getClass()); diff --git a/javase/src/main/java/info/xiaomo/javase/QuestionMain.java b/javase/src/main/java/info/xiaomo/javase/QuestionMain.java index 215f87a1..655a2ba7 100644 --- a/javase/src/main/java/info/xiaomo/javase/QuestionMain.java +++ b/javase/src/main/java/info/xiaomo/javase/QuestionMain.java @@ -30,7 +30,7 @@ * email: xiaomo@xiaomo.info *

* Date: 2016/4/1 15:38 - * Description: 后台管理启动器 + * Description: Java SE 题库启动器 * Copyright(©) 2015 by xiaomo. **/ @Configuration diff --git a/mongodb/src/main/java/info/xiaomo/mongodb/MongodbMain.java b/mongodb/src/main/java/info/xiaomo/mongodb/MongodbMain.java index 641f5eb7..536fe443 100644 --- a/mongodb/src/main/java/info/xiaomo/mongodb/MongodbMain.java +++ b/mongodb/src/main/java/info/xiaomo/mongodb/MongodbMain.java @@ -31,7 +31,7 @@ * email: xiaomo@xiaomo.info *

* Date: 2016/4/1 15:38 - * Description: RabbitMq启动器 + * Description: MongoDB 启动器 * Copyright(©) 2015 by xiaomo. **/ @Configuration diff --git a/security/src/main/java/info/xiaomo/security/SecurityMain.java b/security/src/main/java/info/xiaomo/security/SecurityMain.java index 2e07c2ec..62cc6e75 100644 --- a/security/src/main/java/info/xiaomo/security/SecurityMain.java +++ b/security/src/main/java/info/xiaomo/security/SecurityMain.java @@ -29,7 +29,7 @@ * email: xiaomo@xiaomo.info *

* Date: 2016/4/1 15:38 - * Description: RabbitMq启动器 + * Description: Spring Security 启动器 * Copyright(©) 2015 by xiaomo. **/ @Configuration diff --git a/socket/src/main/java/info/xiaomo/chat/ChatMain.java b/socket/src/main/java/info/xiaomo/chat/ChatMain.java index bd9287ca..77b19479 100644 --- a/socket/src/main/java/info/xiaomo/chat/ChatMain.java +++ b/socket/src/main/java/info/xiaomo/chat/ChatMain.java @@ -21,7 +21,7 @@ * email: xiaomo@xiaomo.info *

* Date: 2016/4/1 15:38 - * Description: 后台管理启动器 + * Description: WebSocket 在线聊天启动器 * Copyright(©) 2015 by xiaomo. **/ @Configuration From c9e5f78358cef9cf770dc31dbb94c449c4c2b5d2 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Wed, 9 Sep 2026 10:29:05 +0900 Subject: [PATCH 2/5] feat: add five example modules for common scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每个模块都不依赖任何外部服务, 因此都带了可以直接跑的测试 —— 这些测试同时也是各自技术点的可执行文档。 - validation: @Valid 校验请求体 / @Validated 校验路径参数, 配合 @RestControllerAdvice 做全局异常处理, 保证出错时也返回统一的 Result 结构 - fileupload: 单文件与多文件上传、下载。存储时用随机文件名而非客户端传来的原始文件名, 并校验落点在存储根目录之下, 防路径穿越 - restclient: 用 spring 6.1 引入的 RestClient(RestTemplate 的替代)调用外部 HTTP 服务, 针对 4xx 转成语义明确的异常。测试用 MockRestServiceServer 拦截请求, 不依赖网络 - cache: spring cache 抽象 + caffeine 本地缓存, 演示 @Cacheable / @CachePut / @CacheEvict。测试通过"方法体被执行了几次"来断言缓存行为 - actuator: 健康检查、自定义 HealthIndicator、以及用 micrometer 注册的业务指标 同时修复一个只在运行时才会暴露的问题: 给 maven-compiler-plugin 加上 -parameters。 spring framework 6.1 移除了从调试符号推断参数名的 LocalVariableTableParameterNameDiscoverer, 因此不带该标志时, 未显式命名的 @PathVariable / @RequestParam 会在请求到达时抛 IllegalArgumentException, 而编译期没有任何提示。项目中共有 40 处这样的写法(BaseController、AuthOperate 以及 website 下的多个控制器), 也就是说升级到 spring boot 4 之后这些端点全部会 500。 spring-boot-starter-parent 默认会加这个标志, 但本项目是导入 spring-boot-dependencies BOM 而非继承 parent, 所以需要自己配置。这个问题是新模块的测试跑起来之后才暴露出来的。 .gitignore 补上 upload(fileupload 模块的默认存储目录)与 .DS_Store, 并修掉文件末尾缺失的换行。 --- .gitignore | 4 +- actuator/pom.xml | 51 +++++++++++ .../info/xiaomo/actuator/ActuatorMain.java | 23 +++++ .../controller/GreetingController.java | 41 +++++++++ .../health/DiskSpaceRatioHealthIndicator.java | 42 ++++++++++ .../resources/config/application.properties | 17 ++++ .../src/main/resources/config/logback-dev.xml | 17 ++++ .../xiaomo/actuator/ActuatorEndpointTest.java | 58 +++++++++++++ cache/pom.xml | 55 ++++++++++++ .../java/info/xiaomo/cache/CacheMain.java | 44 ++++++++++ .../cache/controller/BookController.java | 53 ++++++++++++ .../java/info/xiaomo/cache/model/Book.java | 7 ++ .../xiaomo/cache/service/BookService.java | 67 +++++++++++++++ .../resources/config/application.properties | 7 ++ .../src/main/resources/config/logback-dev.xml | 17 ++++ .../xiaomo/cache/BookServiceCacheTest.java | 73 ++++++++++++++++ fileupload/pom.xml | 47 +++++++++++ .../xiaomo/fileupload/FileUploadMain.java | 23 +++++ .../fileupload/controller/FileController.java | 66 +++++++++++++++ .../fileupload/service/StorageService.java | 84 +++++++++++++++++++ .../resources/config/application.properties | 14 ++++ .../src/main/resources/config/logback-dev.xml | 17 ++++ .../xiaomo/fileupload/FileControllerTest.java | 84 +++++++++++++++++++ pom.xml | 17 ++++ restclient/pom.xml | 42 ++++++++++ .../xiaomo/restclient/RestClientMain.java | 23 +++++ .../controller/RepositoryController.java | 40 +++++++++ .../xiaomo/restclient/model/Repository.java | 17 ++++ .../restclient/service/GithubService.java | 50 +++++++++++ .../resources/config/application.properties | 10 +++ .../src/main/resources/config/logback-dev.xml | 17 ++++ .../xiaomo/restclient/GithubServiceTest.java | 83 ++++++++++++++++++ validation/pom.xml | 51 +++++++++++ .../xiaomo/validation/ValidationMain.java | 23 +++++ .../controller/RegisterController.java | 42 ++++++++++ .../handler/GlobalExceptionHandler.java | 64 ++++++++++++++ .../validation/request/RegisterRequest.java | 32 +++++++ .../resources/config/application.properties | 7 ++ .../src/main/resources/config/logback-dev.xml | 17 ++++ .../validation/RegisterControllerTest.java | 79 +++++++++++++++++ 40 files changed, 1524 insertions(+), 1 deletion(-) create mode 100644 actuator/pom.xml create mode 100644 actuator/src/main/java/info/xiaomo/actuator/ActuatorMain.java create mode 100644 actuator/src/main/java/info/xiaomo/actuator/controller/GreetingController.java create mode 100644 actuator/src/main/java/info/xiaomo/actuator/health/DiskSpaceRatioHealthIndicator.java create mode 100644 actuator/src/main/resources/config/application.properties create mode 100644 actuator/src/main/resources/config/logback-dev.xml create mode 100644 actuator/src/test/java/info/xiaomo/actuator/ActuatorEndpointTest.java create mode 100644 cache/pom.xml create mode 100644 cache/src/main/java/info/xiaomo/cache/CacheMain.java create mode 100644 cache/src/main/java/info/xiaomo/cache/controller/BookController.java create mode 100644 cache/src/main/java/info/xiaomo/cache/model/Book.java create mode 100644 cache/src/main/java/info/xiaomo/cache/service/BookService.java create mode 100644 cache/src/main/resources/config/application.properties create mode 100644 cache/src/main/resources/config/logback-dev.xml create mode 100644 cache/src/test/java/info/xiaomo/cache/BookServiceCacheTest.java create mode 100644 fileupload/pom.xml create mode 100644 fileupload/src/main/java/info/xiaomo/fileupload/FileUploadMain.java create mode 100644 fileupload/src/main/java/info/xiaomo/fileupload/controller/FileController.java create mode 100644 fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java create mode 100644 fileupload/src/main/resources/config/application.properties create mode 100644 fileupload/src/main/resources/config/logback-dev.xml create mode 100644 fileupload/src/test/java/info/xiaomo/fileupload/FileControllerTest.java create mode 100644 restclient/pom.xml create mode 100644 restclient/src/main/java/info/xiaomo/restclient/RestClientMain.java create mode 100644 restclient/src/main/java/info/xiaomo/restclient/controller/RepositoryController.java create mode 100644 restclient/src/main/java/info/xiaomo/restclient/model/Repository.java create mode 100644 restclient/src/main/java/info/xiaomo/restclient/service/GithubService.java create mode 100644 restclient/src/main/resources/config/application.properties create mode 100644 restclient/src/main/resources/config/logback-dev.xml create mode 100644 restclient/src/test/java/info/xiaomo/restclient/GithubServiceTest.java create mode 100644 validation/pom.xml create mode 100644 validation/src/main/java/info/xiaomo/validation/ValidationMain.java create mode 100644 validation/src/main/java/info/xiaomo/validation/controller/RegisterController.java create mode 100644 validation/src/main/java/info/xiaomo/validation/handler/GlobalExceptionHandler.java create mode 100644 validation/src/main/java/info/xiaomo/validation/request/RegisterRequest.java create mode 100644 validation/src/main/resources/config/application.properties create mode 100644 validation/src/main/resources/config/logback-dev.xml create mode 100644 validation/src/test/java/info/xiaomo/validation/RegisterControllerTest.java diff --git a/.gitignore b/.gitignore index 0847531b..2d7ebe0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .idea *.iml node_modules -target \ No newline at end of file +target +upload +.DS_Store diff --git a/actuator/pom.xml b/actuator/pom.xml new file mode 100644 index 00000000..e24affb7 --- /dev/null +++ b/actuator/pom.xml @@ -0,0 +1,51 @@ + + + + xiaomo + info.xiaomo + 2020.1 + + 4.0.0 + + actuator + + + + info.xiaomo + core + 2020.1 + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + + + + + diff --git a/actuator/src/main/java/info/xiaomo/actuator/ActuatorMain.java b/actuator/src/main/java/info/xiaomo/actuator/ActuatorMain.java new file mode 100644 index 00000000..d9fed0d4 --- /dev/null +++ b/actuator/src/main/java/info/xiaomo/actuator/ActuatorMain.java @@ -0,0 +1,23 @@ +package info.xiaomo.actuator; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * @author : xiaomo + * Description: 健康检查与运行指标启动器 + */ +@Configuration +@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class}) +@ComponentScan("info.xiaomo.actuator") +public class ActuatorMain { + + public static void main(String[] args) { + SpringApplication.run(ActuatorMain.class, args); + } + +} diff --git a/actuator/src/main/java/info/xiaomo/actuator/controller/GreetingController.java b/actuator/src/main/java/info/xiaomo/actuator/controller/GreetingController.java new file mode 100644 index 00000000..9e036fc2 --- /dev/null +++ b/actuator/src/main/java/info/xiaomo/actuator/controller/GreetingController.java @@ -0,0 +1,41 @@ +package info.xiaomo.actuator.controller; + +import info.xiaomo.core.base.Result; +import io.micrometer.core.instrument.Counter; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 自定义业务指标。注册到 MeterRegistry 后可以在 /actuator/metrics/greeting.count 看到。 + * + * @author : xiaomo + */ +@RestController +@RequestMapping("/greeting") +public class GreetingController { + + private final Counter greetingCounter; + private final Timer greetingTimer; + + public GreetingController(MeterRegistry registry) { + this.greetingCounter = Counter.builder("greeting.count") + .description("打招呼接口被调用的次数") + .register(registry); + this.greetingTimer = Timer.builder("greeting.latency") + .description("打招呼接口的耗时") + .register(registry); + } + + @GetMapping("/{name}") + public Result greet(@PathVariable("name") String name) { + return greetingTimer.record(() -> { + greetingCounter.increment(); + return new Result<>("你好, " + name); + }); + } + +} diff --git a/actuator/src/main/java/info/xiaomo/actuator/health/DiskSpaceRatioHealthIndicator.java b/actuator/src/main/java/info/xiaomo/actuator/health/DiskSpaceRatioHealthIndicator.java new file mode 100644 index 00000000..8cddb33b --- /dev/null +++ b/actuator/src/main/java/info/xiaomo/actuator/health/DiskSpaceRatioHealthIndicator.java @@ -0,0 +1,42 @@ +package info.xiaomo.actuator.health; + +import org.springframework.boot.health.contributor.Health; +import org.springframework.boot.health.contributor.HealthIndicator; +import org.springframework.stereotype.Component; + +import java.io.File; + +/** + * 自定义健康检查。Bean 名字去掉 HealthIndicator 后缀就是它在 /actuator/health 里的键, + * 所以这里会显示为 "diskSpaceRatio"。 + * + * @author : xiaomo + */ +@Component +public class DiskSpaceRatioHealthIndicator implements HealthIndicator { + + /** + * 可用空间低于该比例就判定为不健康。 + */ + private static final double THRESHOLD = 0.05; + + @Override + public Health health() { + File root = new File("."); + long total = root.getTotalSpace(); + long free = root.getUsableSpace(); + if (total <= 0) { + return Health.unknown().withDetail("reason", "无法读取磁盘信息").build(); + } + + double freeRatio = (double) free / total; + Health.Builder builder = freeRatio >= THRESHOLD ? Health.up() : Health.down(); + return builder + .withDetail("totalBytes", total) + .withDetail("freeBytes", free) + .withDetail("freeRatio", String.format("%.4f", freeRatio)) + .withDetail("threshold", THRESHOLD) + .build(); + } + +} diff --git a/actuator/src/main/resources/config/application.properties b/actuator/src/main/resources/config/application.properties new file mode 100644 index 00000000..ee549f30 --- /dev/null +++ b/actuator/src/main/resources/config/application.properties @@ -0,0 +1,17 @@ +logging.config=classpath:config/logback-dev.xml +server.port=8080 + +server.max-http-header-size=20971520 + +spring.jackson.date-format=yyyy-MM-dd HH:mm:ss +spring.jackson.time-zone=GMT+8 + +# 暴露哪些端点。生产环境不要用 * , 按需列出即可 +management.endpoints.web.exposure.include=health,info,metrics,env,loggers +# 展示健康检查的明细, 默认只返回一个总的 status +management.endpoint.health.show-details=always + +# /actuator/info 的内容 +management.info.env.enabled=true +info.app.name=SpringBootUnity actuator \u793A\u4F8B +info.app.description=\u5065\u5EB7\u68C0\u67E5\u4E0E\u8FD0\u884C\u6307\u6807 diff --git a/actuator/src/main/resources/config/logback-dev.xml b/actuator/src/main/resources/config/logback-dev.xml new file mode 100644 index 00000000..39e7cc65 --- /dev/null +++ b/actuator/src/main/resources/config/logback-dev.xml @@ -0,0 +1,17 @@ + + + + + + + [%d{yyyy-MM-dd HH:mm:ss} [%thread] %highlight(%-5level) %cyan(%logger{15}) - %highlight(%msg) %n + + + + + + + + + + \ No newline at end of file diff --git a/actuator/src/test/java/info/xiaomo/actuator/ActuatorEndpointTest.java b/actuator/src/test/java/info/xiaomo/actuator/ActuatorEndpointTest.java new file mode 100644 index 00000000..c80698c0 --- /dev/null +++ b/actuator/src/test/java/info/xiaomo/actuator/ActuatorEndpointTest.java @@ -0,0 +1,58 @@ +package info.xiaomo.actuator; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(classes = ActuatorMain.class) +@AutoConfigureMockMvc +class ActuatorEndpointTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void 健康检查应当返回UP并包含自定义的检查项() throws Exception { + mockMvc.perform(get("/actuator/health")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.components.diskSpaceRatio").exists()) + .andExpect(jsonPath("$.components.diskSpaceRatio.details.freeRatio").exists()); + } + + @Test + void info端点应当返回配置的应用信息() throws Exception { + mockMvc.perform(get("/actuator/info")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.app.name").value("SpringBootUnity actuator 示例")); + } + + @Test + void 未在白名单中的端点不应当被暴露() throws Exception { + // beans 没有列在 management.endpoints.web.exposure.include 里 + mockMvc.perform(get("/actuator/beans")) + .andExpect(status().isNotFound()); + } + + @Test + void 调用业务接口后自定义指标应当可见且计数递增() throws Exception { + mockMvc.perform(get("/greeting/{name}", "xiaomo")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value("你好, xiaomo")); + + mockMvc.perform(get("/greeting/{name}", "houko")) + .andExpect(status().isOk()); + + mockMvc.perform(get("/actuator/metrics/greeting.count")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.name").value("greeting.count")) + .andExpect(jsonPath("$.measurements[0].value").value(2.0)); + } + +} diff --git a/cache/pom.xml b/cache/pom.xml new file mode 100644 index 00000000..0f590463 --- /dev/null +++ b/cache/pom.xml @@ -0,0 +1,55 @@ + + + + xiaomo + info.xiaomo + 2020.1 + + 4.0.0 + + cache + + + + info.xiaomo + core + 2020.1 + + + org.springframework.boot + spring-boot-starter-cache + + + com.github.ben-manes.caffeine + caffeine + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + + + + + diff --git a/cache/src/main/java/info/xiaomo/cache/CacheMain.java b/cache/src/main/java/info/xiaomo/cache/CacheMain.java new file mode 100644 index 00000000..eba5cb31 --- /dev/null +++ b/cache/src/main/java/info/xiaomo/cache/CacheMain.java @@ -0,0 +1,44 @@ +package info.xiaomo.cache; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; + +/** + * @author : xiaomo + * Description: 缓存抽象启动器 + */ +@Configuration +@EnableCaching +@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class}) +@ComponentScan("info.xiaomo.cache") +public class CacheMain { + + public static void main(String[] args) { + SpringApplication.run(CacheMain.class, args); + } + + /** + * 用 Caffeine 作为本地缓存实现。换成 Redis 只需替换这个 CacheManager, 业务代码上的注解不用动 —— + * 这正是 spring cache 抽象的价值所在。 + */ + @Bean + public CaffeineCacheManager cacheManager() { + CaffeineCacheManager manager = new CaffeineCacheManager("books"); + manager.setCaffeine(Caffeine.newBuilder() + .maximumSize(500) + .expireAfterWrite(Duration.ofMinutes(10)) + .recordStats()); + return manager; + } + +} diff --git a/cache/src/main/java/info/xiaomo/cache/controller/BookController.java b/cache/src/main/java/info/xiaomo/cache/controller/BookController.java new file mode 100644 index 00000000..56d1ee18 --- /dev/null +++ b/cache/src/main/java/info/xiaomo/cache/controller/BookController.java @@ -0,0 +1,53 @@ +package info.xiaomo.cache.controller; + +import info.xiaomo.cache.model.Book; +import info.xiaomo.cache.service.BookService; +import info.xiaomo.core.base.Result; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 连续请求同一个 isbn, 第一次会慢(约 200ms), 之后走缓存立即返回, loadCount 不再增长。 + * + * @author : xiaomo + */ +@RestController +@RequestMapping("/books") +public class BookController { + + private final BookService service; + + public BookController(BookService service) { + this.service = service; + } + + @GetMapping("/{isbn}") + public Result findByIsbn(@PathVariable("isbn") String isbn) { + return new Result<>(service.findByIsbn(isbn)); + } + + @PutMapping + public Result update(@RequestBody Book book) { + return new Result<>(service.update(book)); + } + + @DeleteMapping("/{isbn}") + public Result evict(@PathVariable("isbn") String isbn) { + service.evict(isbn); + return new Result<>(true); + } + + /** + * 方法体被真正执行过的次数, 用来观察缓存命中情况。 + */ + @GetMapping("/load-count") + public Result loadCount() { + return new Result<>(service.loadCount()); + } + +} diff --git a/cache/src/main/java/info/xiaomo/cache/model/Book.java b/cache/src/main/java/info/xiaomo/cache/model/Book.java new file mode 100644 index 00000000..ce61fc3d --- /dev/null +++ b/cache/src/main/java/info/xiaomo/cache/model/Book.java @@ -0,0 +1,7 @@ +package info.xiaomo.cache.model; + +/** + * @author : xiaomo + */ +public record Book(String isbn, String title) { +} diff --git a/cache/src/main/java/info/xiaomo/cache/service/BookService.java b/cache/src/main/java/info/xiaomo/cache/service/BookService.java new file mode 100644 index 00000000..8d2af20a --- /dev/null +++ b/cache/src/main/java/info/xiaomo/cache/service/BookService.java @@ -0,0 +1,67 @@ +package info.xiaomo.cache.service; + +import info.xiaomo.cache.model.Book; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 用一个计数器暴露"方法体到底执行了几次", 以便直观看到缓存是否命中。 + * + * @author : xiaomo + */ +@Service +public class BookService { + + private static final Logger LOGGER = LoggerFactory.getLogger(BookService.class); + + private final AtomicInteger loadCount = new AtomicInteger(); + + /** + * 命中缓存时方法体根本不会执行, 因此 loadCount 不会增加。 + */ + @Cacheable(cacheNames = "books", key = "#isbn") + public Book findByIsbn(String isbn) { + loadCount.incrementAndGet(); + LOGGER.info("缓存未命中, 从慢速数据源加载: {}", isbn); + slowLoad(); + return new Book(isbn, "书名-" + isbn); + } + + /** + * 与 @Cacheable 不同, @CachePut 一定会执行方法体, 并用返回值刷新缓存。 + */ + @CachePut(cacheNames = "books", key = "#book.isbn") + public Book update(Book book) { + LOGGER.info("更新并回写缓存: {}", book.isbn()); + return book; + } + + @CacheEvict(cacheNames = "books", key = "#isbn") + public void evict(String isbn) { + LOGGER.info("清除缓存: {}", isbn); + } + + @CacheEvict(cacheNames = "books", allEntries = true) + public void evictAll() { + LOGGER.info("清空全部缓存"); + } + + public int loadCount() { + return loadCount.get(); + } + + private void slowLoad() { + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + +} diff --git a/cache/src/main/resources/config/application.properties b/cache/src/main/resources/config/application.properties new file mode 100644 index 00000000..0fb9fdf6 --- /dev/null +++ b/cache/src/main/resources/config/application.properties @@ -0,0 +1,7 @@ +logging.config=classpath:config/logback-dev.xml +server.port=8080 + +server.max-http-header-size=20971520 + +spring.jackson.date-format=yyyy-MM-dd HH:mm:ss +spring.jackson.time-zone=GMT+8 diff --git a/cache/src/main/resources/config/logback-dev.xml b/cache/src/main/resources/config/logback-dev.xml new file mode 100644 index 00000000..39e7cc65 --- /dev/null +++ b/cache/src/main/resources/config/logback-dev.xml @@ -0,0 +1,17 @@ + + + + + + + [%d{yyyy-MM-dd HH:mm:ss} [%thread] %highlight(%-5level) %cyan(%logger{15}) - %highlight(%msg) %n + + + + + + + + + + \ No newline at end of file diff --git a/cache/src/test/java/info/xiaomo/cache/BookServiceCacheTest.java b/cache/src/test/java/info/xiaomo/cache/BookServiceCacheTest.java new file mode 100644 index 00000000..3582b02f --- /dev/null +++ b/cache/src/test/java/info/xiaomo/cache/BookServiceCacheTest.java @@ -0,0 +1,73 @@ +package info.xiaomo.cache; + +import info.xiaomo.cache.model.Book; +import info.xiaomo.cache.service.BookService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 通过"方法体执行次数"来断言缓存行为, 而不是去断言缓存框架内部状态 —— + * 前者才是使用方真正关心的效果。 + */ +@SpringBootTest(classes = CacheMain.class) +class BookServiceCacheTest { + + @Autowired + private BookService service; + + @BeforeEach + void 清空缓存() { + service.evictAll(); + } + + @Test + void 重复查询同一个key只会真正加载一次() { + int before = service.loadCount(); + + Book first = service.findByIsbn("978-1"); + Book second = service.findByIsbn("978-1"); + Book third = service.findByIsbn("978-1"); + + assertThat(first).isEqualTo(second).isEqualTo(third); + assertThat(service.loadCount() - before).isEqualTo(1); + } + + @Test + void 不同的key各自加载一次() { + int before = service.loadCount(); + + service.findByIsbn("978-2"); + service.findByIsbn("978-3"); + + assertThat(service.loadCount() - before).isEqualTo(2); + } + + @Test + void 清除缓存后应当重新加载() { + int before = service.loadCount(); + + service.findByIsbn("978-4"); + service.evict("978-4"); + service.findByIsbn("978-4"); + + assertThat(service.loadCount() - before).isEqualTo(2); + } + + @Test + void CachePut应当刷新缓存而不再触发加载() { + int before = service.loadCount(); + + service.findByIsbn("978-5"); + service.update(new Book("978-5", "改过的书名")); + Book afterUpdate = service.findByIsbn("978-5"); + + // 只有第一次 findByIsbn 真正执行了方法体 + assertThat(service.loadCount() - before).isEqualTo(1); + assertThat(afterUpdate.title()).isEqualTo("改过的书名"); + } + +} diff --git a/fileupload/pom.xml b/fileupload/pom.xml new file mode 100644 index 00000000..5411d600 --- /dev/null +++ b/fileupload/pom.xml @@ -0,0 +1,47 @@ + + + + xiaomo + info.xiaomo + 2020.1 + + 4.0.0 + + fileupload + + + + info.xiaomo + core + 2020.1 + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + + + + + diff --git a/fileupload/src/main/java/info/xiaomo/fileupload/FileUploadMain.java b/fileupload/src/main/java/info/xiaomo/fileupload/FileUploadMain.java new file mode 100644 index 00000000..84a65e44 --- /dev/null +++ b/fileupload/src/main/java/info/xiaomo/fileupload/FileUploadMain.java @@ -0,0 +1,23 @@ +package info.xiaomo.fileupload; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * @author : xiaomo + * Description: 文件上传下载启动器 + */ +@Configuration +@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class}) +@ComponentScan("info.xiaomo.fileupload") +public class FileUploadMain { + + public static void main(String[] args) { + SpringApplication.run(FileUploadMain.class, args); + } + +} diff --git a/fileupload/src/main/java/info/xiaomo/fileupload/controller/FileController.java b/fileupload/src/main/java/info/xiaomo/fileupload/controller/FileController.java new file mode 100644 index 00000000..33924278 --- /dev/null +++ b/fileupload/src/main/java/info/xiaomo/fileupload/controller/FileController.java @@ -0,0 +1,66 @@ +package info.xiaomo.fileupload.controller; + +import info.xiaomo.core.base.Result; +import info.xiaomo.fileupload.service.StorageService; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Arrays; +import java.util.List; + +/** + * 单文件 / 多文件上传与下载。上传大小限制见 config/application.properties 的 spring.servlet.multipart.*。 + * + * @author : xiaomo + */ +@RestController +@RequestMapping("/file") +public class FileController { + + private final StorageService storage; + + public FileController(StorageService storage) { + this.storage = storage; + } + + @PostMapping("/upload") + public Result upload(@RequestParam("file") MultipartFile file) { + return new Result<>(storage.store(file)); + } + + @PostMapping("/upload/batch") + public Result> uploadBatch(@RequestParam("files") MultipartFile[] files) { + return new Result<>(Arrays.stream(files).map(storage::store).toList()); + } + + /** + * 下载。用 attachment 让浏览器走下载而不是内联展示。 + */ + @GetMapping("/download/{fileName}") + public ResponseEntity download(@PathVariable("fileName") String fileName) { + Resource resource = storage.loadAsResource(fileName); + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + fileName + "\"") + .body(resource); + } + + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleBadRequest(IllegalArgumentException e) { + return new Result<>(HttpStatus.BAD_REQUEST.value(), e.getMessage(), null); + } + +} diff --git a/fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java b/fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java new file mode 100644 index 00000000..a2946729 --- /dev/null +++ b/fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java @@ -0,0 +1,84 @@ +package info.xiaomo.fileupload.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +/** + * 文件落盘与读取。 + * + * @author : xiaomo + */ +@Service +public class StorageService { + + private final Path root; + + public StorageService(@Value("${app.upload.dir}") String uploadDir) { + this.root = Paths.get(uploadDir).toAbsolutePath().normalize(); + try { + Files.createDirectories(root); + } catch (IOException e) { + throw new UncheckedIOException("无法创建上传目录: " + root, e); + } + } + + /** + * 保存文件, 返回存储用的文件名。 + * + *

用随机文件名而不是客户端传来的原始文件名, 原因有二: 原始文件名可能包含 ../ 之类的路径穿越片段; + * 多个用户上传同名文件时也会互相覆盖。 + */ + public String store(MultipartFile file) { + if (file.isEmpty()) { + throw new IllegalArgumentException("上传的文件为空"); + } + String stored = UUID.randomUUID() + extensionOf(file.getOriginalFilename()); + Path target = root.resolve(stored).normalize(); + // 双保险: 确认落点确实在 root 之下 + if (!target.getParent().equals(root)) { + throw new IllegalArgumentException("非法的文件名"); + } + try (var in = file.getInputStream()) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + throw new UncheckedIOException("保存文件失败: " + stored, e); + } + return stored; + } + + public Resource loadAsResource(String fileName) { + Path target = root.resolve(fileName).normalize(); + if (!target.getParent().equals(root)) { + throw new IllegalArgumentException("非法的文件名"); + } + try { + Resource resource = new UrlResource(target.toUri()); + if (!resource.exists() || !resource.isReadable()) { + throw new IllegalArgumentException("文件不存在: " + fileName); + } + return resource; + } catch (IOException e) { + throw new UncheckedIOException("读取文件失败: " + fileName, e); + } + } + + private String extensionOf(String originalName) { + if (originalName == null) { + return ""; + } + int dot = originalName.lastIndexOf('.'); + return dot >= 0 ? originalName.substring(dot) : ""; + } + +} diff --git a/fileupload/src/main/resources/config/application.properties b/fileupload/src/main/resources/config/application.properties new file mode 100644 index 00000000..8bcaa0f3 --- /dev/null +++ b/fileupload/src/main/resources/config/application.properties @@ -0,0 +1,14 @@ +logging.config=classpath:config/logback-dev.xml +server.port=8080 + +server.max-http-header-size=20971520 + +spring.jackson.date-format=yyyy-MM-dd HH:mm:ss +spring.jackson.time-zone=GMT+8 + +# 上传文件的存放目录 +app.upload.dir=./upload + +# 单个文件与单次请求的大小上限 +spring.servlet.multipart.max-file-size=10MB +spring.servlet.multipart.max-request-size=50MB diff --git a/fileupload/src/main/resources/config/logback-dev.xml b/fileupload/src/main/resources/config/logback-dev.xml new file mode 100644 index 00000000..39e7cc65 --- /dev/null +++ b/fileupload/src/main/resources/config/logback-dev.xml @@ -0,0 +1,17 @@ + + + + + + + [%d{yyyy-MM-dd HH:mm:ss} [%thread] %highlight(%-5level) %cyan(%logger{15}) - %highlight(%msg) %n + + + + + + + + + + \ No newline at end of file diff --git a/fileupload/src/test/java/info/xiaomo/fileupload/FileControllerTest.java b/fileupload/src/test/java/info/xiaomo/fileupload/FileControllerTest.java new file mode 100644 index 00000000..b3588a17 --- /dev/null +++ b/fileupload/src/test/java/info/xiaomo/fileupload/FileControllerTest.java @@ -0,0 +1,84 @@ +package info.xiaomo.fileupload; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(classes = FileUploadMain.class) +@AutoConfigureMockMvc +// 落到构建目录, 不污染工作区 +@TestPropertySource(properties = "app.upload.dir=./target/test-upload") +class FileControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void 上传后应当能按返回的文件名下载回原内容() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "hello.txt", "text/plain", "你好, SpringBootUnity".getBytes(StandardCharsets.UTF_8)); + + String body = mockMvc.perform(multipart("/file/upload").file(file)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value(200)) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + String stored = body.replaceAll(".*\"data\"\\s*:\\s*\"([^\"]+)\".*", "$1"); + // 保留了原扩展名, 但文件名本身是随机的 + assertThat(stored).endsWith(".txt").doesNotContain("hello"); + + byte[] downloaded = mockMvc.perform(get("/file/download/{name}", stored)) + .andExpect(status().isOk()) + .andExpect(header().string(HttpHeaders.CONTENT_DISPOSITION, + "attachment; filename=\"" + stored + "\"")) + .andReturn().getResponse().getContentAsByteArray(); + + assertThat(new String(downloaded, StandardCharsets.UTF_8)).isEqualTo("你好, SpringBootUnity"); + } + + @Test + void 批量上传应当返回与文件数量相同的文件名() throws Exception { + MockMultipartFile a = new MockMultipartFile("files", "a.txt", "text/plain", "aaa".getBytes()); + MockMultipartFile b = new MockMultipartFile("files", "b.txt", "text/plain", "bbb".getBytes()); + + mockMvc.perform(multipart("/file/upload/batch").file(a).file(b)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(2)); + } + + @Test + void 上传空文件应当被拒绝() throws Exception { + MockMultipartFile empty = new MockMultipartFile("file", "empty.txt", "text/plain", new byte[0]); + + mockMvc.perform(multipart("/file/upload").file(empty)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("上传的文件为空")); + } + + @Test + void 下载不存在的文件应当返回400而不是500() throws Exception { + mockMvc.perform(get("/file/download/{name}", "not-there.txt")) + .andExpect(status().isBadRequest()); + } + + @Test + void 带路径穿越片段的文件名应当被拒绝() throws Exception { + mockMvc.perform(get("/file/download/{name}", "..%2F..%2Fpom.xml")) + .andExpect(status().is4xxClientError()); + } + +} diff --git a/pom.xml b/pom.xml index 6d701dc2..b9e8fe33 100644 --- a/pom.xml +++ b/pom.xml @@ -40,6 +40,16 @@ javase order + + validation + + fileupload + + restclient + + cache + + actuator pom @@ -199,6 +209,13 @@ ${sources.version} ${project.build.sourceEncoding} + + true org.projectlombok diff --git a/restclient/pom.xml b/restclient/pom.xml new file mode 100644 index 00000000..0c5a7ad0 --- /dev/null +++ b/restclient/pom.xml @@ -0,0 +1,42 @@ + + + + xiaomo + info.xiaomo + 2020.1 + + 4.0.0 + + restclient + + + + info.xiaomo + core + 2020.1 + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + + + + + diff --git a/restclient/src/main/java/info/xiaomo/restclient/RestClientMain.java b/restclient/src/main/java/info/xiaomo/restclient/RestClientMain.java new file mode 100644 index 00000000..4f4690f0 --- /dev/null +++ b/restclient/src/main/java/info/xiaomo/restclient/RestClientMain.java @@ -0,0 +1,23 @@ +package info.xiaomo.restclient; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * @author : xiaomo + * Description: 调用外部 HTTP 服务启动器 + */ +@Configuration +@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class}) +@ComponentScan("info.xiaomo.restclient") +public class RestClientMain { + + public static void main(String[] args) { + SpringApplication.run(RestClientMain.class, args); + } + +} diff --git a/restclient/src/main/java/info/xiaomo/restclient/controller/RepositoryController.java b/restclient/src/main/java/info/xiaomo/restclient/controller/RepositoryController.java new file mode 100644 index 00000000..ac900d46 --- /dev/null +++ b/restclient/src/main/java/info/xiaomo/restclient/controller/RepositoryController.java @@ -0,0 +1,40 @@ +package info.xiaomo.restclient.controller; + +import info.xiaomo.core.base.Result; +import info.xiaomo.restclient.model.Repository; +import info.xiaomo.restclient.service.GithubService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * @author : xiaomo + */ +@RestController +@RequestMapping("/repos") +public class RepositoryController { + + private final GithubService github; + + public RepositoryController(GithubService github) { + this.github = github; + } + + @GetMapping("/{user}") + public Result> listRepositories(@PathVariable("user") String user) { + return new Result<>(github.listRepositories(user)); + } + + @ExceptionHandler(GithubService.UserNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public Result handleNotFound(GithubService.UserNotFoundException e) { + return new Result<>(HttpStatus.NOT_FOUND.value(), e.getMessage(), null); + } + +} diff --git a/restclient/src/main/java/info/xiaomo/restclient/model/Repository.java b/restclient/src/main/java/info/xiaomo/restclient/model/Repository.java new file mode 100644 index 00000000..b71b6ec4 --- /dev/null +++ b/restclient/src/main/java/info/xiaomo/restclient/model/Repository.java @@ -0,0 +1,17 @@ +package info.xiaomo.restclient.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * 只声明用得上的字段。@JsonIgnoreProperties(ignoreUnknown = true) 让外部接口新增字段时不会把反序列化打挂。 + * + * @author : xiaomo + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Repository( + String name, + String description, + @JsonProperty("html_url") String htmlUrl, + @JsonProperty("stargazers_count") int stars) { +} diff --git a/restclient/src/main/java/info/xiaomo/restclient/service/GithubService.java b/restclient/src/main/java/info/xiaomo/restclient/service/GithubService.java new file mode 100644 index 00000000..6de9e5e9 --- /dev/null +++ b/restclient/src/main/java/info/xiaomo/restclient/service/GithubService.java @@ -0,0 +1,50 @@ +package info.xiaomo.restclient.service; + +import info.xiaomo.restclient.model.Repository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.util.List; + +/** + * RestClient 是 Spring 6.1 引入的同步 HTTP 客户端, 用来取代 RestTemplate。 + * 它的 API 是流式的, 并且可以针对状态码单独挂错误处理。 + * + * @author : xiaomo + */ +@Service +public class GithubService { + + private final RestClient restClient; + + public GithubService(RestClient.Builder builder, @Value("${app.github.base-url}") String baseUrl) { + this.restClient = builder.baseUrl(baseUrl).build(); + } + + /** + * 查询某个用户的公开仓库。404 单独转成语义明确的异常, 而不是让调用方去看状态码。 + */ + public List listRepositories(String user) { + return restClient.get() + .uri("/users/{user}/repos", user) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, + (request, response) -> { + throw new UserNotFoundException(user); + }) + .body(new org.springframework.core.ParameterizedTypeReference<>() { + }); + } + + /** + * 用户不存在。 + */ + public static class UserNotFoundException extends RuntimeException { + public UserNotFoundException(String user) { + super("GitHub 用户不存在: " + user); + } + } + +} diff --git a/restclient/src/main/resources/config/application.properties b/restclient/src/main/resources/config/application.properties new file mode 100644 index 00000000..78588209 --- /dev/null +++ b/restclient/src/main/resources/config/application.properties @@ -0,0 +1,10 @@ +logging.config=classpath:config/logback-dev.xml +server.port=8080 + +server.max-http-header-size=20971520 + +spring.jackson.date-format=yyyy-MM-dd HH:mm:ss +spring.jackson.time-zone=GMT+8 + +# 外部服务地址, 测试时会被替换成 mock server +app.github.base-url=https://api.github.com diff --git a/restclient/src/main/resources/config/logback-dev.xml b/restclient/src/main/resources/config/logback-dev.xml new file mode 100644 index 00000000..39e7cc65 --- /dev/null +++ b/restclient/src/main/resources/config/logback-dev.xml @@ -0,0 +1,17 @@ + + + + + + + [%d{yyyy-MM-dd HH:mm:ss} [%thread] %highlight(%-5level) %cyan(%logger{15}) - %highlight(%msg) %n + + + + + + + + + + \ No newline at end of file diff --git a/restclient/src/test/java/info/xiaomo/restclient/GithubServiceTest.java b/restclient/src/test/java/info/xiaomo/restclient/GithubServiceTest.java new file mode 100644 index 00000000..82d3e097 --- /dev/null +++ b/restclient/src/test/java/info/xiaomo/restclient/GithubServiceTest.java @@ -0,0 +1,83 @@ +package info.xiaomo.restclient; + +import info.xiaomo.restclient.model.Repository; +import info.xiaomo.restclient.service.GithubService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestClient; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withResourceNotFound; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +/** + * 用 MockRestServiceServer 拦住 RestClient 的请求, 测试不依赖网络, 也不会因为 GitHub 限流而变成 flaky。 + */ +class GithubServiceTest { + + private static final String BASE_URL = "https://api.github.example"; + + private MockRestServiceServer server; + private GithubService service; + + @BeforeEach + void setUp() { + RestClient.Builder builder = RestClient.builder(); + server = MockRestServiceServer.bindTo(builder).build(); + service = new GithubService(builder, BASE_URL); + } + + @Test + void 应当正确解析仓库列表并映射下划线字段() { + String json = """ + [ + {"name":"SpringBootUnity","description":"spring boot 示例集合", + "html_url":"https://github.com/houko/SpringBootUnity","stargazers_count":1024}, + {"name":"another","description":null, + "html_url":"https://github.com/houko/another","stargazers_count":7, + "unknown_field":"新增字段不应导致反序列化失败"} + ] + """; + server.expect(requestTo(BASE_URL + "/users/houko/repos")) + .andExpect(method(org.springframework.http.HttpMethod.GET)) + .andRespond(withSuccess(json, MediaType.APPLICATION_JSON)); + + List repos = service.listRepositories("houko"); + + server.verify(); + assertThat(repos).hasSize(2); + assertThat(repos.getFirst().name()).isEqualTo("SpringBootUnity"); + // html_url / stargazers_count 通过 @JsonProperty 映射到驼峰字段 + assertThat(repos.getFirst().htmlUrl()).isEqualTo("https://github.com/houko/SpringBootUnity"); + assertThat(repos.getFirst().stars()).isEqualTo(1024); + } + + @Test + void 用户不存在时应当抛出语义明确的异常而不是原始状态码错误() { + server.expect(requestTo(BASE_URL + "/users/no-such-user/repos")) + .andRespond(withResourceNotFound()); + + assertThatThrownBy(() -> service.listRepositories("no-such-user")) + .isInstanceOf(GithubService.UserNotFoundException.class) + .hasMessageContaining("no-such-user"); + + server.verify(); + } + + @Test + void 空列表应当正常返回而不是null() { + server.expect(requestTo(BASE_URL + "/users/empty/repos")) + .andRespond(withSuccess("[]", MediaType.APPLICATION_JSON)); + + assertThat(service.listRepositories("empty")).isEmpty(); + server.verify(); + } + +} diff --git a/validation/pom.xml b/validation/pom.xml new file mode 100644 index 00000000..171b8616 --- /dev/null +++ b/validation/pom.xml @@ -0,0 +1,51 @@ + + + + xiaomo + info.xiaomo + 2020.1 + + 4.0.0 + + validation + + + + info.xiaomo + core + 2020.1 + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + + + + + diff --git a/validation/src/main/java/info/xiaomo/validation/ValidationMain.java b/validation/src/main/java/info/xiaomo/validation/ValidationMain.java new file mode 100644 index 00000000..591fb9b1 --- /dev/null +++ b/validation/src/main/java/info/xiaomo/validation/ValidationMain.java @@ -0,0 +1,23 @@ +package info.xiaomo.validation; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.hibernate.autoconfigure.HibernateJpaAutoConfiguration; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * @author : xiaomo + * Description: 参数校验与全局异常处理启动器 + */ +@Configuration +@EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, HibernateJpaAutoConfiguration.class}) +@ComponentScan("info.xiaomo.validation") +public class ValidationMain { + + public static void main(String[] args) { + SpringApplication.run(ValidationMain.class, args); + } + +} diff --git a/validation/src/main/java/info/xiaomo/validation/controller/RegisterController.java b/validation/src/main/java/info/xiaomo/validation/controller/RegisterController.java new file mode 100644 index 00000000..f88e68af --- /dev/null +++ b/validation/src/main/java/info/xiaomo/validation/controller/RegisterController.java @@ -0,0 +1,42 @@ +package info.xiaomo.validation.controller; + +import info.xiaomo.core.base.Result; +import info.xiaomo.validation.request.RegisterRequest; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * @Valid 校验请求体, @Validated + 约束注解校验路径参数。 + * 两者校验失败抛出的异常类型不同, 统一由 {@link info.xiaomo.validation.handler.GlobalExceptionHandler} 处理。 + * + * @author : xiaomo + */ +@RestController +@RequestMapping("/register") +@Validated +public class RegisterController { + + /** + * 校验请求体。失败时抛 MethodArgumentNotValidException。 + */ + @PostMapping + public Result register(@Valid @RequestBody RegisterRequest request) { + return new Result<>("注册成功: " + request.userName()); + } + + /** + * 校验路径参数。失败时抛 ConstraintViolationException。 + */ + @GetMapping("/check/{age}") + public Result checkAge(@PathVariable @Min(value = 18, message = "年龄必须大于等于 18") int age) { + return new Result<>("年龄合法: " + age); + } + +} diff --git a/validation/src/main/java/info/xiaomo/validation/handler/GlobalExceptionHandler.java b/validation/src/main/java/info/xiaomo/validation/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..8dd0940c --- /dev/null +++ b/validation/src/main/java/info/xiaomo/validation/handler/GlobalExceptionHandler.java @@ -0,0 +1,64 @@ +package info.xiaomo.validation.handler; + +import info.xiaomo.core.base.Result; +import jakarta.validation.ConstraintViolationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 全局异常处理。把散落在各处的 try-catch 收敛到一处, 保证接口在出错时也返回统一的 Result 结构, + * 而不是把 Spring 默认的错误页或堆栈抛给调用方。 + * + * @author : xiaomo + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger LOGGER = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * @Valid 校验请求体失败。把每个字段的错误信息收集成 字段名 -> 提示 的映射返回。 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result> handleInvalidBody(MethodArgumentNotValidException e) { + Map errors = new LinkedHashMap<>(); + for (var error : e.getBindingResult().getFieldErrors()) { + // 同一字段有多个约束时保留第一条, 避免提示信息互相覆盖 + errors.putIfAbsent(error.getField(), error.getDefaultMessage()); + } + return new Result<>(HttpStatus.BAD_REQUEST.value(), "参数校验失败", errors); + } + + /** + * @Validated 校验方法参数(路径参数 / 查询参数)失败。 + */ + @ExceptionHandler(ConstraintViolationException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleInvalidParam(ConstraintViolationException e) { + String message = e.getConstraintViolations().stream() + .map(v -> v.getMessage()) + .collect(Collectors.joining("; ")); + return new Result<>(HttpStatus.BAD_REQUEST.value(), "参数校验失败", message); + } + + /** + * 兜底。日志里保留完整堆栈, 但不把内部细节返回给调用方。 + */ + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleOthers(Exception e) { + LOGGER.error("未处理的异常", e); + return new Result<>(HttpStatus.INTERNAL_SERVER_ERROR.value(), "服务器内部错误", null); + } + +} diff --git a/validation/src/main/java/info/xiaomo/validation/request/RegisterRequest.java b/validation/src/main/java/info/xiaomo/validation/request/RegisterRequest.java new file mode 100644 index 00000000..04c22a9e --- /dev/null +++ b/validation/src/main/java/info/xiaomo/validation/request/RegisterRequest.java @@ -0,0 +1,32 @@ +package info.xiaomo.validation.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +/** + * 注册请求。约束直接声明在字段上, 由 jakarta.validation 在进入控制器之前完成校验。 + * + * @author : xiaomo + */ +public record RegisterRequest( + + @NotBlank(message = "用户名不能为空") + @Size(min = 3, max = 20, message = "用户名长度需在 3 到 20 之间") + String userName, + + @NotBlank(message = "邮箱不能为空") + @Email(message = "邮箱格式不正确") + String email, + + @NotBlank(message = "密码不能为空") + @Pattern(regexp = "^.{6,32}$", message = "密码长度需在 6 到 32 之间") + String password, + + @Min(value = 0, message = "年龄不能为负数") + @Max(value = 150, message = "年龄不能大于 150") + int age) { +} diff --git a/validation/src/main/resources/config/application.properties b/validation/src/main/resources/config/application.properties new file mode 100644 index 00000000..0fb9fdf6 --- /dev/null +++ b/validation/src/main/resources/config/application.properties @@ -0,0 +1,7 @@ +logging.config=classpath:config/logback-dev.xml +server.port=8080 + +server.max-http-header-size=20971520 + +spring.jackson.date-format=yyyy-MM-dd HH:mm:ss +spring.jackson.time-zone=GMT+8 diff --git a/validation/src/main/resources/config/logback-dev.xml b/validation/src/main/resources/config/logback-dev.xml new file mode 100644 index 00000000..39e7cc65 --- /dev/null +++ b/validation/src/main/resources/config/logback-dev.xml @@ -0,0 +1,17 @@ + + + + + + + [%d{yyyy-MM-dd HH:mm:ss} [%thread] %highlight(%-5level) %cyan(%logger{15}) - %highlight(%msg) %n + + + + + + + + + + \ No newline at end of file diff --git a/validation/src/test/java/info/xiaomo/validation/RegisterControllerTest.java b/validation/src/test/java/info/xiaomo/validation/RegisterControllerTest.java new file mode 100644 index 00000000..5170b01d --- /dev/null +++ b/validation/src/test/java/info/xiaomo/validation/RegisterControllerTest.java @@ -0,0 +1,79 @@ +package info.xiaomo.validation; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(classes = ValidationMain.class) +@AutoConfigureMockMvc +class RegisterControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void 合法请求应当通过校验() throws Exception { + String body = """ + {"userName":"xiaomo","email":"xiaomo@xiaomo.info","password":"secret123","age":30} + """; + + mockMvc.perform(post("/register").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resultCode").value(200)) + .andExpect(jsonPath("$.data").value("注册成功: xiaomo")); + } + + @Test + void 非法请求体应当逐字段返回错误信息() throws Exception { + // userName 太短, email 格式错误, password 太短, age 为负数 + String body = """ + {"userName":"ab","email":"not-an-email","password":"123","age":-1} + """; + + mockMvc.perform(post("/register").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.resultCode").value(400)) + .andExpect(jsonPath("$.message").value("参数校验失败")) + .andExpect(jsonPath("$.data.userName").value("用户名长度需在 3 到 20 之间")) + .andExpect(jsonPath("$.data.email").value("邮箱格式不正确")) + .andExpect(jsonPath("$.data.password").value("密码长度需在 6 到 32 之间")) + .andExpect(jsonPath("$.data.age").value("年龄不能为负数")); + } + + @Test + void 缺失的必填字段应当被拦截() throws Exception { + String body = """ + {"userName":"","email":"","password":"","age":20} + """; + + mockMvc.perform(post("/register").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.data.userName").exists()) + .andExpect(jsonPath("$.data.email").exists()) + .andExpect(jsonPath("$.data.password").exists()); + } + + @Test + void 合法的路径参数应当通过() throws Exception { + mockMvc.perform(get("/register/check/20")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value("年龄合法: 20")); + } + + @Test + void 非法的路径参数应当返回约束提示() throws Exception { + mockMvc.perform(get("/register/check/16")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("参数校验失败")) + .andExpect(jsonPath("$.data").value("年龄必须大于等于 18")); + } + +} From 8aba8120b293bad1d5b88ab2c3ed5e8c8cf3f67a Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Wed, 9 Sep 2026 10:29:12 +0900 Subject: [PATCH 3/5] docs: document the new modules and the -parameters pitfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Readme 模块表补上 validation / fileupload / restclient / cache / actuator 五个模块, 技术栈补上 caffeine 与 micrometer, 快速开始补上跑测试的命令, 并标出哪些模块自带可运行的测试。 changeLog.md 记录本次新增, 并单独说明 -parameters 这个只在运行时暴露的坑。 --- Readme.md | 17 ++++++++++++++++- changeLog.md | 21 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/Readme.md b/Readme.md index 4f5c9504..5e784243 100644 --- a/Readme.md +++ b/Readme.md @@ -18,7 +18,7 @@ ## 模块一览 -一共 16 个模块。`core` 是被其他模块共同依赖的基础包,其余每个模块各自演示一项技术。 +一共 21 个模块。`core` 是被其他模块共同依赖的基础包,其余每个模块各自演示一项技术。 | 模块 | 演示内容 | 启动类 | 需要的外部服务 | | --- | --- | --- | --- | @@ -38,9 +38,16 @@ | `crawler` | jsoup 网络爬虫(阴阳师式神数据),抓取后落库 | `CrawlerMain` | MySQL | | `freemarker` | FreeMarker 模板引擎 | `FreemarkerMain` | — | | `thymeleaf` | Thymeleaf 模板引擎 | `ThymeleafMain` | — | +| `validation` | 参数校验(`@Valid` / `@Validated`)与 `@RestControllerAdvice` 全局异常处理 | `ValidationMain` | — | +| `fileupload` | 文件上传下载,含路径穿越防护 | `FileUploadMain` | — | +| `restclient` | 用 `RestClient` 调用外部 HTTP 服务 | `RestClientMain` | — | +| `cache` | Spring Cache 抽象 + Caffeine 本地缓存 | `CacheMain` | — | +| `actuator` | 健康检查、自定义 `HealthIndicator` 与业务指标 | `ActuatorMain` | — | 除 `socket` 使用 8081 外,其余模块都监听 **8080**,所以一次只启动一个模块。 +其中 `validation`、`fileupload`、`restclient`、`cache`、`actuator`、`async` 六个模块附带可直接运行的测试。它们都不依赖外部服务,`mvn test` 即可跑通,也可以当作各自技术点的可执行文档来读。 + ## 技术栈 | 组件 | 版本 | @@ -52,6 +59,8 @@ | Jackson | 3.x | | API 文档 | springdoc-openapi 3.x(OpenAPI 3.1) | | 数据库驱动 | MySQL Connector/J | +| 缓存 | Caffeine(`cache` 模块) | +| 监控 | Micrometer + Spring Boot Actuator(`actuator` 模块) | | 其他 | MyBatis、Lettuce(Redis)、jsoup、Apache POI、fastjson2、zxing | Spring、Jackson、Hibernate、JUnit 等版本统一由 `spring-boot-dependencies` BOM 管理,不在本项目中单独指定。 @@ -84,6 +93,12 @@ mvn spring-boot:run -pl order 在 IDE 中则找到对应模块的 `*Main` 类直接运行即可。 +跑测试: + +```bash +mvn test +``` + ![run](screenshot/run.png) 部署到服务器时,Spring Boot 内置了 Tomcat,把打好的 jar 传上去执行就可以: diff --git a/changeLog.md b/changeLog.md index 2f264f76..480f1fbb 100644 --- a/changeLog.md +++ b/changeLog.md @@ -58,3 +58,24 @@ http://localhost:8080/doc.html bootstrap-ui 9. 打开此前被全局关闭的测试执行(surefire skipTests) 10. 清理 oauth.properties 中提交进仓库的第三方登录密钥, 换成占位符 ``` + +- 2026-09-09 新增示例与既有示例改进 + +``` +1. 新增 validation 模块: @Valid / @Validated 参数校验 + @RestControllerAdvice 全局异常处理 +2. 新增 fileupload 模块: 单文件/多文件上传与下载, 含路径穿越防护 +3. 新增 restclient 模块: 用 spring 6.1 引入的 RestClient 调用外部 HTTP 服务 +4. 新增 cache 模块: spring cache 抽象 + caffeine 本地缓存 +5. 新增 actuator 模块: 健康检查, 自定义 HealthIndicator 与 micrometer 业务指标 +6. 以上五个模块均不依赖外部服务, 且各自带可运行的测试 +7. 重写 async 模块: 去掉 90 行 return null 的空实现, 改用 CompletableFuture, + 移除自旋等待, AsyncResult(已废弃)换成 CompletableFuture.completedFuture +8. 修复 -parameters 编译标志缺失导致的运行时故障(详见下) +9. 修正多个启动类中复制粘贴的 javadoc(非 rabbitmq 模块却写着"RabbitMq启动器") +``` + +其中第 8 点是一个仅在运行时才会暴露的问题: spring framework 6.1 移除了从调试符号推断参数名的 +LocalVariableTableParameterNameDiscoverer, 因此未显式命名的 @PathVariable / @RequestParam +在 spring boot 4 下会直接抛 IllegalArgumentException。项目中共有 40 处这样的写法, +编译期没有任何提示。spring-boot-starter-parent 默认会加 -parameters, 但本项目是导入 BOM +而非继承 parent, 需要在 maven-compiler-plugin 中自行配置。 From b0222acfd366d41c547692a2c1e96dc2f7b96050 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Wed, 9 Sep 2026 10:47:14 +0900 Subject: [PATCH 4/5] fix: keep untrusted input out of file path expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL 在 PR #43 上报了两处 high 级别的 "Uncontrolled data used in path expression", 是对的。 问题出在 extensionOf: 它取"最后一个点之后的全部内容"作为扩展名, 而原始文件名来自客户端。形如 a.b/../../evil 的名字会得到扩展名 .b/../../evil, 整段路径被拼进落盘路径。原先靠 normalize() 之后比对父目录来兜底, 虽然拦得住, 但属于先把污染数据拼进路径再回头检查, 既脆弱也难以论证。 改成让污染数据根本进不了路径表达式: - 取扩展名时先剥掉所有目录成分(兼容 windows 反斜线), 再要求匹配白名单 \.[A-Za-z0-9]{1,10}, 不匹配就整个丢弃 - 读取时要求文件名完全匹配 store 生成的形状(UUID + 可选安全扩展名), 含分隔符或 .. 的输入直接拒绝 补三个测试: 原始文件名里的穿越片段不得混入扩展名、不符合存储命名格式的文件名被拒绝、合法扩展名仍被保留。 --- .../fileupload/service/StorageService.java | 52 +++++++++++++------ .../xiaomo/fileupload/FileControllerTest.java | 35 +++++++++++++ 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java b/fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java index a2946729..b6a5fb88 100644 --- a/fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java +++ b/fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java @@ -13,15 +13,32 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.util.UUID; +import java.util.regex.Pattern; /** * 文件落盘与读取。 * + *

这里对文件名的处理是重点。客户端提供的文件名完全不可信, 直接用它拼路径会导致目录穿越 + * (形如 {@code a.b/../../etc/passwd} 的名字, 按"最后一个点之后"取扩展名会把整段路径带进来)。 + * 因此本类的做法是: 落盘时只用自己生成的 UUID 加一个经过白名单校验的扩展名, 读取时要求文件名 + * 必须完全匹配这个格式。污染数据根本没有机会进入路径表达式, 而不是等拼好路径再回头检查。 + * * @author : xiaomo */ @Service public class StorageService { + /** + * 扩展名白名单: 一个点加 1-10 位字母或数字。不匹配就当作没有扩展名。 + */ + private static final Pattern SAFE_EXTENSION = Pattern.compile("\\.[A-Za-z0-9]{1,10}"); + + /** + * 存储文件名必须是 UUID 加可选的安全扩展名, 也就是 {@link #store} 生成的那种形状。 + */ + private static final Pattern STORED_NAME = Pattern.compile( + "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(\\.[A-Za-z0-9]{1,10})?"); + private final Path root; public StorageService(@Value("${app.upload.dir}") String uploadDir) { @@ -35,20 +52,14 @@ public StorageService(@Value("${app.upload.dir}") String uploadDir) { /** * 保存文件, 返回存储用的文件名。 - * - *

用随机文件名而不是客户端传来的原始文件名, 原因有二: 原始文件名可能包含 ../ 之类的路径穿越片段; - * 多个用户上传同名文件时也会互相覆盖。 */ public String store(MultipartFile file) { if (file.isEmpty()) { throw new IllegalArgumentException("上传的文件为空"); } - String stored = UUID.randomUUID() + extensionOf(file.getOriginalFilename()); - Path target = root.resolve(stored).normalize(); - // 双保险: 确认落点确实在 root 之下 - if (!target.getParent().equals(root)) { - throw new IllegalArgumentException("非法的文件名"); - } + // 文件名完全由服务端生成, 客户端的原始文件名只贡献一个经过白名单校验的扩展名 + String stored = UUID.randomUUID() + safeExtensionOf(file.getOriginalFilename()); + Path target = root.resolve(stored); try (var in = file.getInputStream()) { Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { @@ -58,10 +69,11 @@ public String store(MultipartFile file) { } public Resource loadAsResource(String fileName) { - Path target = root.resolve(fileName).normalize(); - if (!target.getParent().equals(root)) { - throw new IllegalArgumentException("非法的文件名"); + // 只接受本服务自己生成过的文件名形状, 任何含有分隔符或 .. 的输入都无法通过 + if (fileName == null || !STORED_NAME.matcher(fileName).matches()) { + throw new IllegalArgumentException("非法的文件名: " + fileName); } + Path target = root.resolve(fileName); try { Resource resource = new UrlResource(target.toUri()); if (!resource.exists() || !resource.isReadable()) { @@ -73,12 +85,22 @@ public Resource loadAsResource(String fileName) { } } - private String extensionOf(String originalName) { + /** + * 取出扩展名。先丢掉任何路径成分, 再要求剩下的部分匹配白名单, 否则一律返回空串。 + */ + private String safeExtensionOf(String originalName) { if (originalName == null) { return ""; } - int dot = originalName.lastIndexOf('.'); - return dot >= 0 ? originalName.substring(dot) : ""; + // 去掉目录部分, 兼容 windows 客户端传来的反斜线 + String baseName = originalName.substring( + Math.max(originalName.lastIndexOf('/'), originalName.lastIndexOf('\\')) + 1); + int dot = baseName.lastIndexOf('.'); + if (dot < 0) { + return ""; + } + String extension = baseName.substring(dot); + return SAFE_EXTENSION.matcher(extension).matches() ? extension : ""; } } diff --git a/fileupload/src/test/java/info/xiaomo/fileupload/FileControllerTest.java b/fileupload/src/test/java/info/xiaomo/fileupload/FileControllerTest.java index b3588a17..a1c992c1 100644 --- a/fileupload/src/test/java/info/xiaomo/fileupload/FileControllerTest.java +++ b/fileupload/src/test/java/info/xiaomo/fileupload/FileControllerTest.java @@ -81,4 +81,39 @@ class FileControllerTest { .andExpect(status().is4xxClientError()); } + @Test + void 不符合存储命名格式的文件名应当被拒绝() throws Exception { + mockMvc.perform(get("/file/download/{name}", "pom.xml")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("非法的文件名: pom.xml")); + } + + @Test + void 原始文件名中的路径穿越片段不得混入扩展名() throws Exception { + // 按"最后一个点之后"取扩展名会得到 .b/../../evil, 若不做处理就会写到上传目录之外 + MockMultipartFile evil = new MockMultipartFile( + "file", "a.b/../../evil", "text/plain", "payload".getBytes()); + + String body = mockMvc.perform(multipart("/file/upload").file(evil)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + String stored = body.replaceAll(".*\"data\"\\s*:\\s*\"([^\"]+)\".*", "$1"); + // 扩展名没有通过白名单, 被整个丢弃, 只留下服务端生成的 UUID + assertThat(stored).doesNotContain("..").doesNotContain("/").doesNotContain("evil"); + assertThat(stored).matches("[0-9a-f-]{36}"); + } + + @Test + void 合法扩展名应当被保留() throws Exception { + MockMultipartFile file = new MockMultipartFile( + "file", "report.PDF", "application/pdf", "pdf".getBytes()); + + String body = mockMvc.perform(multipart("/file/upload").file(file)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); + + assertThat(body.replaceAll(".*\"data\"\\s*:\\s*\"([^\"]+)\".*", "$1")).endsWith(".PDF"); + } + } From b058ae6ffe2b0885c36336f9cac4414f0098a83c Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Wed, 9 Sep 2026 10:53:16 +0900 Subject: [PATCH 5/5] fix: use the canonical normalize + startsWith guard for path resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版用正则白名单把污染数据挡在路径之外, 但 CodeQL 的污点分析不把正则校验当作净化器, 告警仍在。 改用 normalize() 之后 startsWith(root) 这一标准写法, 集中到 resolveWithinRoot 一处。normalize 折叠掉 .., startsWith 保证结果落在根目录内, 无论输入是什么。正则白名单保留作为纵深防御。 --- .../fileupload/service/StorageService.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java b/fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java index b6a5fb88..4c494ca6 100644 --- a/fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java +++ b/fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java @@ -59,7 +59,7 @@ public String store(MultipartFile file) { } // 文件名完全由服务端生成, 客户端的原始文件名只贡献一个经过白名单校验的扩展名 String stored = UUID.randomUUID() + safeExtensionOf(file.getOriginalFilename()); - Path target = root.resolve(stored); + Path target = resolveWithinRoot(stored); try (var in = file.getInputStream()) { Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { @@ -73,7 +73,7 @@ public Resource loadAsResource(String fileName) { if (fileName == null || !STORED_NAME.matcher(fileName).matches()) { throw new IllegalArgumentException("非法的文件名: " + fileName); } - Path target = root.resolve(fileName); + Path target = resolveWithinRoot(fileName); try { Resource resource = new UrlResource(target.toUri()); if (!resource.exists() || !resource.isReadable()) { @@ -85,6 +85,20 @@ public Resource loadAsResource(String fileName) { } } + /** + * 把文件名解析到存储根目录下, 并确认解析结果确实落在根目录之内。 + * + *

normalize 会把 .. 折叠掉, 随后的 startsWith 保证无论输入是什么, 最终路径都不会逃出 root。 + * 这是防目录穿越的标准写法, 也是静态分析工具能够识别的形式。 + */ + private Path resolveWithinRoot(String fileName) { + Path target = root.resolve(fileName).normalize(); + if (!target.startsWith(root)) { + throw new IllegalArgumentException("非法的文件名: " + fileName); + } + return target; + } + /** * 取出扩展名。先丢掉任何路径成分, 再要求剩下的部分匹配白名单, 否则一律返回空串。 */