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/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/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 中自行配置。
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/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..4c494ca6
--- /dev/null
+++ b/fileupload/src/main/java/info/xiaomo/fileupload/service/StorageService.java
@@ -0,0 +1,120 @@
+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;
+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) {
+ 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() + safeExtensionOf(file.getOriginalFilename());
+ Path target = resolveWithinRoot(stored);
+ 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) {
+ // 只接受本服务自己生成过的文件名形状, 任何含有分隔符或 .. 的输入都无法通过
+ if (fileName == null || !STORED_NAME.matcher(fileName).matches()) {
+ throw new IllegalArgumentException("非法的文件名: " + fileName);
+ }
+ Path target = resolveWithinRoot(fileName);
+ 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);
+ }
+ }
+
+ /**
+ * 把文件名解析到存储根目录下, 并确认解析结果确实落在根目录之内。
+ *
+ *
normalize 会把 .. 折叠掉, 随后的 startsWith 保证无论输入是什么, 最终路径都不会逃出 root。
+ * 这是防目录穿越的标准写法, 也是静态分析工具能够识别的形式。
+ */
+ private Path resolveWithinRoot(String fileName) {
+ Path target = root.resolve(fileName).normalize();
+ if (!target.startsWith(root)) {
+ throw new IllegalArgumentException("非法的文件名: " + fileName);
+ }
+ return target;
+ }
+
+ /**
+ * 取出扩展名。先丢掉任何路径成分, 再要求剩下的部分匹配白名单, 否则一律返回空串。
+ */
+ private String safeExtensionOf(String originalName) {
+ if (originalName == null) {
+ return "";
+ }
+ // 去掉目录部分, 兼容 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/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..a1c992c1
--- /dev/null
+++ b/fileupload/src/test/java/info/xiaomo/fileupload/FileControllerTest.java
@@ -0,0 +1,119 @@
+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());
+ }
+
+ @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");
+ }
+
+}
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/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/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
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