13
小项目:任务管理 API
Mini Project: Task Manager API
把 Web 层、JPA、校验、全局异常、分页全部串起来,做一个完整的任务管理后端。技术栈:Spring Boot 3.5 + Spring Data JPA + H2 + Validation。
目录结构
src/main/java/com/example/task/
├── TaskManagerApplication.java // 主启动类
├── entity/Task.java // JPA 实体
├── repo/TaskRepository.java // Spring Data 接口
├── dto/TaskRequest.java // 请求 DTO,带校验
├── service/TaskService.java // 业务逻辑
└── web/TaskController.java // REST 接口
src/main/resources/
└── application.yml // H2 配置
Task 实体
package com.example.task.entity;
import jakarta.persistence.*;
@Entity
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String title;
private boolean done;
// JPA 要求无参构造器
protected Task() {}
public Task(String title) { this.title = title; }
// getter / setter 省略
}
TaskRepository:继承 JpaRepository 即拥有 CRUD
package com.example.task.repo;
import com.example.task.entity.Task;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskRepository extends JpaRepository<Task, Long> {
// JpaRepository 已经自带 save/findById/findAll/deleteById
// 分页 findAll(Pageable) 也有,不用自己写
}
TaskRequest:带校验的请求 DTO
package com.example.task.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class TaskRequest {
@NotBlank(message = "标题不能为空")
@Size(max = 200, message = "标题最长 200 字")
private String title;
// getter / setter 省略
}
TaskService:业务逻辑
package com.example.task.service;
import com.example.task.dto.TaskRequest;
import com.example.task.entity.Task;
import com.example.task.repo.TaskRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
@Service
public class TaskService {
private final TaskRepository repo;
public TaskService(TaskRepository repo) { this.repo = repo; }
// 分页查询
public Page<Task> list(int page, int size) {
return repo.findAll(PageRequest.of(page, size));
}
// 创建任务
public Task create(TaskRequest req) {
return repo.save(new Task(req.getTitle()));
}
// 标记完成,找不到就抛异常 → 全局处理器转 404
public Task markDone(long id) {
Task t = repo.findById(id)
.orElseThrow(() -> new IllegalArgumentException("任务不存在:" + id));
t.setDone(true);
return repo.save(t);
}
}
TaskController:REST 接口
package com.example.task.web;
import com.example.task.dto.TaskRequest;
import com.example.task.entity.Task;
import com.example.task.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService service;
public TaskController(TaskService service) { this.service = service; }
@GetMapping
public Page<Task> list(@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
return service.list(page, size);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Task create(@Valid @RequestBody TaskRequest req) {
return service.create(req);
}
@PatchMapping("/{id}/done")
public Task done(@PathVariable long id) {
return service.markDone(id);
}
}
GlobalExceptionHandler:统一错误响应
package com.example.task.web;
import org.springframework.http.HttpStatus;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
@RestControllerAdvice
public class GlobalExceptionHandler {
// 校验失败:把字段错误整理成 Map 返回
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.reduce((a, b) -> a + "; " + b)
.orElse("参数错误");
return new ErrorResponse(400, msg);
}
// 业务异常(任务不存在)→ 404
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(IllegalArgumentException e) {
return new ErrorResponse(404, e.getMessage());
}
// 兜底
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleOther(Exception e) {
return new ErrorResponse(500, "服务器开小差了");
}
// 统一错误响应体
public record ErrorResponse(int code, String message) {}
}
application.yml:H2 内存库
spring:
datasource:
url: jdbc:h2:mem:taskdb
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
jpa:
hibernate:
ddl-auto: update
show-sql: true
server:
port: 8080
启动后用 curl 测:
# 创建任务
curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title":"学完 Spring Boot"}'
# 分页查
curl http://localhost:8080/api/tasks?page=0&size=5
# 标记完成
curl -X PATCH http://localhost:8080/api/tasks/1/done
从 0 到 1 补齐:pom.xml、单元测试、Docker
上面是核心代码,这里把"跑起来"需要的最后几块拼图补齐:Maven 依赖、一个 Service 单元测试、Docker 部署。照这份就能完整交付。
pom.xml:只要四个 starter
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version> <!-- 版本由 parent 仲裁,子依赖不用写版本 -->
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency><!-- Web:MVC + 内嵌 Tomcat -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency><!-- JPA + Hibernate -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency><!-- 参数校验 -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency><!-- H2 内存数据库 -->
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency><!-- 测试全家桶 -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
TaskService 单元测试:Mock 掉 Repository
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class) // 纯 Mockito 测试,不起 Spring 容器
class TaskServiceTest {
@Mock TaskRepository repo; // 假的 Repository
@InjectMocks TaskService service; // 被测对象,自动注入上面的假 repo
@Test
void markDoneWhenNotFoundShouldThrow() {
// 打桩:findById(99) 返回空 → 应抛 IllegalArgumentException
when(repo.findById(99L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> service.markDone(99L))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("任务不存在");
}
}
mvn test 运行结果:
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
# 纯 Mockito 不起容器,毫秒级跑完——单元测试就该这么快。
Dockerfile(沿用第 11 章多阶段构建)
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml . &&& mvn dependency:go-offline
COPY src ./src &&& mvn package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
一条龙:
docker build -t task-manager:1.0 .
docker run -p 8080:8080 task-manager:1.0
# 打开 http://localhost:8080/api/tasks 就能用了。
本章面试题 · 第 12 章(任务管理项目)
1.(架构题)为什么 Controller 不直接调 Repository,非要中间隔一层 Service?
查看答案
答案:分层是为了解耦和复用。业务逻辑(校验、事务、多步操作)放 Service,将来加缓存、加权限、加日志只改 Service;Controller 只负责收参数、调 Service、返回视图。解析:一个 Service 可能被多个 Controller 调用,直接 Controller 调 Repository 就没法复用了。
2.(实践题)创建任务时标题不能为空,你在哪一层做这个校验?
查看答案
答案:DTO 上贴 @NotBlank,Controller 参数加 @Valid,非法请求直接 400,不进业务层。复杂的业务规则(如"标题不能和现有任务重复")才放 Service。解析:参数格式校验在入口(@Valid),业务规则在 Service。