Spring Boot 3 完整学习文档
目标:学完本教程能独立开发完整的后端项目
适用版本:Java 17 · Spring Boot 3.4+ · MyBatis-Plus 3.5+ · MySQL 8.0
目录
-
1.环境与工具链 -
2.Spring Boot 基础 -
3.Bean Validation 数据验证 -
4.请求与响应 -
5.依赖注入 -
6.异常处理 -
7.拦截器与过滤器 -
8.MyBatis-Plus 数据库 -
9.Flyway 数据库迁移 -
10.认证与授权(JWT + Spring Security) -
11.文件上传 -
12.异步任务 -
13.CORS 与跨域 -
14.日志配置 -
15.WebSocket -
16.测试 -
17.部署 -
18.最佳实践与项目结构 -
19.常见错误排查 -
20.参考链接
1. 环境与工具链
1.1 Java 版本
Spring Boot 3.x 要求 Java 17+,推荐使用 Java 17 LTS 或 Java 21 LTS。
java --version# openjdk 17.0.2 2022-01-18 LTS
1.2 Maven 包管理
mvn --version# Apache Maven 3.9+
Maven 核心概念:
|
|
|
|---|---|
pom.xml |
|
groupId |
com.example |
artifactId |
blog-api |
<parent> |
|
<dependency> |
|
1.3 创建 Spring Boot 项目
方式一:Spring Initializr(推荐)
# 访问 https://start.spring.io 下载 zip# 或使用命令行curl -s https://start.spring.io/starter.zip \ -d type=maven-project \ -d language=java \ -d bootVersion=3.4.0 \ -d baseDir=blog-api \ -d groupId=com.example \ -d artifactId=blog-api \ -d name=blog-api \ -d packageName=com.example.blog \ -d javaVersion=17 \ -d dependencies=web,mysql,mybatis-plus,lombok,validation,security \ -o blog-api.zip
方式二:Maven 手动创建
<?xml version="1.0" encoding="UTF-8"?><project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.4.0</version> </parent> <groupId>com.example</groupId> <artifactId>blog-api</artifactId> <version>0.0.1-SNAPSHOT</version> <properties> <java.version>17</java.version> <mybatis-plus.version>3.5.9</mybatis-plus.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>${mybatis-plus.version}</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.flywaydb</groupId> <artifactId>flyway-core</artifactId> </dependency> <dependency> <groupId>org.flywaydb</groupId> <artifactId>flyway-mysql</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.12.6</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.12.6</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.12.6</version> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> <configuration> <excludes> <exclude> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> </exclude> </excludes> </configuration> </plugin> </plugins> </build></project>
1.4 依赖速查表
|
|
|
|---|---|
spring-boot-starter-web |
|
spring-boot-starter-validation |
|
spring-boot-starter-security |
|
mybatis-plus-spring-boot3-starter |
|
mysql-connector-j |
|
flyway-core
flyway-mysql |
|
lombok |
|
jjwt-api/impl/jackson |
|
spring-boot-starter-websocket |
|
spring-boot-starter-test |
|
h2 |
|
1.5 项目启动
package com.example.blog;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplicationpublic class BlogApplication { public static void main(String[] args) { SpringApplication.run(BlogApplication.class, args); }}
mvn spring-boot:run# 或mvn clean package -DskipTests && java -jar target/blog-api-0.0.1-SNAPSHOT.jar
1.6 Docker 环境(MySQL)
docker run --name mysql8 \ -e MYSQL_ROOT_PASSWORD=root123 \ -e MYSQL_DATABASE=blog \ -p 3306:3306 \ -d mysql:8.0 \ --character-set-server=utf8mb4 \ --collation-server=utf8mb4_unicode_cidocker exec -it mysql8 mysql -uroot -proot123 -e "SELECT VERSION();"
1.7 application.yml 配置
server: port: 8080spring: datasource: url: jdbc:mysql://localhost:3306/blog?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai username: root password: root123 driver-class-name: com.mysql.cj.jdbc.Driver hikari: maximum-pool-size: 10 minimum-idle: 5 idle-timeout: 300000 connection-timeout: 20000mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0logging: level: com.example.blog: DEBUG
Key Points:
-
• hikari是 Spring Boot 默认连接池 -
• map-underscore-to-camel-case自动将下划线字段映射为驼峰 -
• logic-delete-field配置逻辑删除字段名
💡 Tip:生产环境使用环境变量
${DB_PASSWORD}替代明文密码
2. Spring Boot 基础
2.1 为什么用 Spring Boot?
Spring Boot 解决 Spring 框架”配置地狱”问题:自动配置、内嵌服务器、起步依赖,让开发者聚焦业务逻辑。
2.2 核心注解速查
|
|
|
|---|---|
@SpringBootApplication |
@Configuration
@EnableAutoConfiguration+@ComponentScan |
@RestController |
@Controller
@ResponseBody,返回 JSON |
@RequestMapping |
|
@GetMapping
@PostMapping/@PutMapping/@DeleteMapping |
|
@PathVariable |
|
@RequestParam |
|
@RequestBody |
|
2.3 第一个 REST 接口
package com.example.blog.controller;import org.springframework.web.bind.annotation.*;import java.util.Map;@RestController@RequestMapping("/api/hello")public class HelloController { @GetMapping public Map<String, String> hello() { return Map.of("message", "Hello, Spring Boot!"); } @GetMapping("/{name}") public Map<String, String> helloName(@PathVariable String name) { return Map.of("message", "Hello, " + name + "!"); } @PostMapping public Map<String, Object> echo(@RequestBody Map<String, Object> body) { return body; }}
curl http://localhost:8080/api/hello# {"message":"Hello, Spring Boot!"}curl http://localhost:8080/api/hello/World# {"message":"Hello, World!"}curl -X POST http://localhost:8080/api/hello \ -H "Content-Type: application/json" \ -d '{"foo":"bar"}'# {"foo":"bar"}
Key Points:
-
• @RestController使返回值自动序列化为 JSON -
• @RequestMapping统一前缀,避免重复
2.4 启动流程
main() → SpringApplication.run() → 创建 ApplicationContext → 自动配置(根据 classpath 中的依赖) → 组件扫描(@ComponentScan) → 启动内嵌 Tomcat → 注册 DispatcherServlet → 应用就绪
💡 Tip:接口 404 时,检查 Controller 是否在主类同级或子级包下
3. Bean Validation 数据验证
3.1 为什么需要验证?
客户端传来的数据不可信,服务端必须二次验证,防止脏数据进入数据库。
3.2 基础校验
package com.example.blog.dto;import jakarta.validation.constraints.*;import lombok.Data;@Datapublic class CreateUserRequest { @NotBlank(message = "用户名不能为空") @Size(min = 3, max = 20, message = "用户名长度 3-20 位") private String username; @NotBlank(message = "密码不能为空") @Size(min = 6, max = 30, message = "密码长度 6-30 位") private String password; @NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") private String email; @Min(value = 0, message = "年龄不能为负数") @Max(value = 150, message = "年龄不能超过 150") private Integer age; @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") private String phone;}
package com.example.blog.controller;import com.example.blog.dto.CreateUserRequest;import jakarta.validation.Valid;import org.springframework.web.bind.annotation.*;@RestController@RequestMapping("/api/users")public class UserController { @PostMapping public String create(@Valid @RequestBody CreateUserRequest request) { return "用户 " + request.getUsername() + " 创建成功"; }}
curl -X POST http://localhost:8080/api/users \ -H "Content-Type: application/json" \ -d '{"username":"ab","password":"123","email":"invalid"}'# 400 + 校验错误详情
Key Points:
-
• @Valid触发校验,缺少则不生效 -
• 校验失败自动返回 400
3.3 常用校验注解
|
|
|
|---|---|
@NotNull |
|
@NotBlank |
|
@NotEmpty |
|
@Size(min, max) |
|
@Min
@Max |
|
@Email |
|
@Pattern(regexp) |
|
@Positive
@Negative |
|
@Past
@Future |
|
3.4 分组校验
package com.example.blog.dto;import jakarta.validation.constraints.*;import lombok.Data;@Datapublic class UserDTO { public interface Create {} public interface Update {} @NotNull(groups = Update.class, message = "更新时 ID 不能为空") private Long id; @NotBlank(groups = {Create.class, Update.class}, message = "用户名不能为空") private String username; @NotBlank(groups = Create.class, message = "创建时密码不能为空") private String password;}
@PostMappingpublic String create(@Validated(UserDTO.Create.class) @RequestBody UserDTO dto) { ... }@PutMapping("/{id}")public String update(@Validated(UserDTO.Update.class) @RequestBody UserDTO dto) { ... }
3.5 自定义校验注解
package com.example.blog.validation;import jakarta.validation.Constraint;import jakarta.validation.Payload;import java.lang.annotation.*;@Documented@Constraint(validatedBy = EnumValueValidator.class)@Target({ElementType.FIELD})@Retention(RetentionPolicy.RUNTIME)public @interface EnumValue { String message() default "值不在允许范围内"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; Class<? extends Enum<?>> enumClass();}
package com.example.blog.validation;import jakarta.validation.ConstraintValidator;import jakarta.validation.ConstraintValidatorContext;import java.util.Arrays;public class EnumValueValidator implements ConstraintValidator<EnumValue, String> { private Class<? extends Enum<?>> enumClass; @Override public void initialize(EnumValue annotation) { this.enumClass = annotation.enumClass(); } @Override public boolean isValid(String value, ConstraintValidatorContext context) { if (value == null) return true; return Arrays.stream(enumClass.getEnumConstants()) .anyMatch(e -> e.name().equals(value)); }}
public enum Gender { MALE, FEMALE }@Datapublic class CreateUserRequest { @EnumValue(enumClass = Gender.class, message = "性别只能是 MALE 或 FEMALE") private String gender;}
💡 Tip:
@NotNullvs@NotBlankvs@NotEmpty容易混淆——@NotNull仅检查 null,@NotEmpty检查 null + 空字符串/空集合,@NotBlank检查 null + 空字符串 + 纯空白字符
4. 请求与响应
4.1 为什么需要 DTO?
直接暴露实体类给前端会导致:敏感字段泄漏(如密码哈希)、字段过多、JSON 结构与数据库表耦合。DTO(Data Transfer Object)是接口层专用模型。
4.2 统一响应格式
package com.example.blog.common;import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;@Data@NoArgsConstructor@AllArgsConstructorpublic class R<T> { private int code; private String message; private T data; public static <T> R<T> ok(T data) { return new R<>(200, "success", data); } public static <T> R<T> ok() { return new R<>(200, "success", null); } public static <T> R<T> fail(int code, String message) { return new R<>(code, message, null); } public static <T> R<T> fail(String message) { return new R<>(500, message, null); }}
4.3 路径参数
@RestController@RequestMapping("/api/posts")public class PostController { @GetMapping("/{id}") public R<PostVO> getById(@PathVariable Long id) { PostVO post = postService.getById(id); return R.ok(post); } @DeleteMapping("/{id}") public R<Void> delete(@PathVariable Long id) { postService.deleteById(id); return R.ok(); }}
curl http://localhost:8080/api/posts/1# {"code":200,"message":"success","data":{...}}curl -X DELETE http://localhost:8080/api/posts/1# {"code":200,"message":"success","data":null}
4.4 查询参数
@GetMappingpublic R<PageResult<PostVO>> list( @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size, @RequestParam(required = false) String keyword) { PageResult<PostVO> result = postService.page(page, size, keyword); return R.ok(result);}
curl "http://localhost:8080/api/posts?page=1&size=10&keyword=Spring"# {"code":200,"message":"success","data":{"records":[...],"total":42}}
4.5 请求体
@PostMappingpublic R<PostVO> create(@Valid @RequestBody CreatePostRequest request) { PostVO post = postService.create(request); return R.ok(post);}@PutMapping("/{id}")public R<PostVO> update( @PathVariable Long id, @Valid @RequestBody UpdatePostRequest request) { PostVO post = postService.update(id, request); return R.ok(post);}
curl -X POST http://localhost:8080/api/posts \ -H "Content-Type: application/json" \ -d '{"title":"Hello World","content":"My first post"}'curl -X PUT http://localhost:8080/api/posts/1 \ -H "Content-Type: application/json" \ -d '{"title":"Updated Title","content":"Updated content"}'
4.6 分页结果封装
package com.example.blog.common;import lombok.AllArgsConstructor;import lombok.Data;import lombok.NoArgsConstructor;import java.util.List;@Data@NoArgsConstructor@AllArgsConstructorpublic class PageResult<T> { private List<T> records; private long total; private int page; private int size; public static <T> PageResult<T> of(List<T> records, long total, int page, int size) { return new PageResult<>(records, total, page, size); }}
4.7 VO 与 Request DTO 分离
package com.example.blog.dto;import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.Size;import lombok.Data;@Datapublic class CreatePostRequest { @NotBlank(message = "标题不能为空") @Size(min = 2, max = 100, message = "标题长度 2-100 位") private String title; @NotBlank(message = "内容不能为空") private String content; private String summary; private Long categoryId; private List<String> tags;}
package com.example.blog.vo;import lombok.Data;import java.time.LocalDateTime;import java.util.List;@Datapublic class PostVO { private Long id; private String title; private String summary; private String content; private String authorName; private Long authorId; private String categoryName; private List<String> tags; private LocalDateTime createdAt; private LocalDateTime updatedAt;}
Key Points:
-
• Request DTO 负责接收输入、校验 -
• VO(View Object)负责输出,只包含前端需要的字段 -
• 两者分开避免”一个类既管输入又管输出”的混乱
💡 Tip:永远不要在 VO 中包含密码、密钥等敏感字段
5. 依赖注入
5.1 为什么用依赖注入?
依赖注入(DI)让 Spring 管理对象的创建和生命周期,实现松耦合。不用new创建 Service,而是声明依赖让 Spring 注入。
5.2 构造器注入(推荐)
package com.example.blog.service;import com.example.blog.mapper.PostMapper;import org.springframework.stereotype.Service;@Servicepublic class PostService { private final PostMapper postMapper; // Lombok @RequiredArgsConstructor 可替代此构造器 public PostService(PostMapper postMapper) { this.postMapper = postMapper; } public Post getById(Long id) { return postMapper.selectById(id); }}
// 使用 Lombok 简化@Service@RequiredArgsConstructorpublic class PostService { private final PostMapper postMapper; private final CategoryMapper categoryMapper;}
5.3 三种注入方式对比
|
|
|
|
|---|---|---|
|
|
public X(Dep d) { this.d = d; } |
|
|
|
@Autowired public void setD(Dep d) |
|
|
|
@Autowired private Dep d; |
|
// ❌ 字段注入(不推荐)@Servicepublic class BadService { @Autowired private PostMapper postMapper; // 无法用 final,单元测试困难}// ✅ 构造器注入(推荐)@Servicepublic class GoodService { private final PostMapper postMapper; public GoodService(PostMapper postMapper) { this.postMapper = postMapper; }}
5.4 Bean 生命周期注解
|
|
|
|---|---|
@Component |
|
@Service |
|
@Repository |
|
@Controller
@RestController |
|
@Configuration
@Bean |
|
@Configurationpublic class AppConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public RestTemplate restTemplate() { return new RestTemplate(); }}
5.5 @Autowired 高级用法
// 注入集合:注入所有实现了某个接口的 Bean@Servicepublic class NotificationService { private final Map<String, Notifier> notifiers; public NotificationService(Map<String, Notifier> notifiers) { this.notifiers = notifiers; } public void notify(String channel, String message) { Notifier notifier = notifiers.get(channel); if (notifier != null) { notifier.send(message); } }}// 多个实现@Component("email")public class EmailNotifier implements Notifier { ... }@Component("sms")public class SmsNotifier implements Notifier { ... }
💡 Tip:循环依赖(A 依赖 B,B 依赖 A)会导致启动失败,应重构代码避免
6. 异常处理
6.1 为什么需要全局异常处理?
每个 Controller 方法写 try-catch 是灾难。@ControllerAdvice统一拦截异常,返回一致的错误格式。
6.2 自定义业务异常
package com.example.blog.exception;import lombok.Getter;@Getterpublic class BusinessException extends RuntimeException { private final int code; public BusinessException(int code, String message) { super(message); this.code = code; } public BusinessException(String message) { this(500, message); } public static BusinessException notFound(String resource) { return new BusinessException(404, resource + " 不存在"); } public static BusinessException badRequest(String message) { return new BusinessException(400, message); } public static BusinessException forbidden() { return new BusinessException(403, "没有权限"); } public static BusinessException unauthorized() { return new BusinessException(401, "未登录或 token 已过期"); }}
6.3 全局异常处理器
package com.example.blog.exception;import com.example.blog.common.R;import lombok.extern.slf4j.Slf4j;import org.springframework.http.HttpStatus;import org.springframework.validation.FieldError;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.stream.Collectors;@Slf4j@RestControllerAdvicepublic class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) @ResponseStatus(HttpStatus.OK) public R<Void> handleBusinessException(BusinessException e) { log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage()); return R.fail(e.getCode(), e.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public R<Void> handleValidation(MethodArgumentNotValidException e) { String message = e.getBindingResult().getFieldErrors().stream() .map(FieldError::getDefaultMessage) .collect(Collectors.joining(", ")); return R.fail(400, message); } @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public R<Void> handleException(Exception e) { log.error("系统异常", e); return R.fail(500, "服务器内部错误"); }}
Key Points:
-
• BusinessException的 HTTP 状态码设为 200,业务错误码在R.code中体现 -
• 校验异常 MethodArgumentNotValidException返回 400 -
• 兜底的 Exception处理器记录日志并返回 500
6.4 Service 层抛异常
@Service@RequiredArgsConstructorpublic class PostService { private final PostMapper postMapper; public PostVO getById(Long id) { Post post = postMapper.selectById(id); if (post == null) { throw BusinessException.notFound("文章"); } return toVO(post); } public void deleteById(Long id, Long currentUserId) { Post post = postMapper.selectById(id); if (post == null) { throw BusinessException.notFound("文章"); } if (!post.getAuthorId().equals(currentUserId)) { throw BusinessException.forbidden(); } postMapper.deleteById(id); }}
💡 Tip:
@ExceptionHandler的优先级按异常类型匹配度排序,子类异常优先匹配
7. 拦截器与过滤器
7.1 过滤器 vs 拦截器
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
7.2 自定义拦截器
package com.example.blog.interceptor;import com.example.blog.util.JwtUtil;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import lombok.RequiredArgsConstructor;import org.springframework.stereotype.Component;import org.springframework.web.servlet.HandlerInterceptor;@Component@RequiredArgsConstructorpublic class AuthInterceptor implements HandlerInterceptor { private final JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}"); return false; } try { String userId = jwtUtil.parseToken(token.substring(7)); request.setAttribute("userId", userId); return true; } catch (Exception e) { response.setStatus(401); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"token 无效\"}"); return false; } }}
7.3 注册拦截器
package com.example.blog.config;import com.example.blog.interceptor.AuthInterceptor;import lombok.RequiredArgsConstructor;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.InterceptorRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configuration@RequiredArgsConstructorpublic class WebConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( "/api/auth/login", "/api/auth/register", "/api/hello/**" ); }}
Key Points:
-
• preHandle返回true放行,false拦截 -
• OPTIONS预检请求必须放行(CORS 需要) -
• 使用 excludePathPatterns排除登录等公开接口
7.4 过滤器示例(CORS 预检)
package com.example.blog.filter;import jakarta.servlet.*;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import org.springframework.core.Ordered;import org.springframework.core.annotation.Order;import org.springframework.stereotype.Component;import java.io.IOException;@Component@Order(Ordered.HIGHEST_PRECEDENCE)public class CorsFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletResponse response = (HttpServletResponse) res; HttpServletRequest request = (HttpServletRequest) req; response.setHeader("Access-Control-Allow-Origin", "*"); response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS"); response.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization"); if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); } else { chain.doFilter(req, res); } }}
💡 Tip:Filter 执行顺序由
@Order控制,值越小越先执行;Interceptor 执行顺序由注册顺序决定
8. MyBatis-Plus 数据库
8.1 为什么用 MyBatis-Plus?
MyBatis-Plus 在 MyBatis 基础上提供:无侵入的 CRUD、分页插件、逻辑删除、乐观锁、代码生成器,大幅减少 SQL 编写量。
8.2 实体类
package com.example.blog.entity;import com.baomidou.mybatisplus.annotation.*;import lombok.Data;import java.time.LocalDateTime;@Data@TableName("t_post")public class Post { @TableId(type = IdType.AUTO) private Long id; private String title; private String content; private String summary; private Long authorId; private Long categoryId; @TableLogic private Integer deleted; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updatedAt;}
8.3 Mapper 接口
package com.example.blog.mapper;import com.baomidou.mybatisplus.core.mapper.BaseMapper;import com.example.blog.entity.Post;import org.apache.ibatis.annotations.Mapper;import org.apache.ibatis.annotations.Param;import org.apache.ibatis.annotations.Select;@Mapperpublic interface PostMapper extends BaseMapper<Post> { @Select("SELECT p.*, u.username AS author_name, c.name AS category_name " + "FROM t_post p " + "LEFT JOIN t_user u ON p.author_id = u.id " + "LEFT JOIN t_category c ON p.category_id = c.id " + "WHERE p.id = #{id} AND p.deleted = 0") PostVO selectPostDetail(@Param("id") Long id);}
8.4 内置 CRUD
@Service@RequiredArgsConstructorpublic class PostService { private final PostMapper postMapper; public PostVO create(CreatePostRequest request) { Post post = new Post(); post.setTitle(request.getTitle()); post.setContent(request.getContent()); post.setSummary(request.getSummary()); post.setAuthorId(CurrentUser.getUserId()); postMapper.insert(post); return toVO(post); } public PostVO getById(Long id) { Post post = postMapper.selectById(id); if (post == null) throw BusinessException.notFound("文章"); return toVO(post); } public void updateById(Long id, UpdatePostRequest request) { Post post = postMapper.selectById(id); if (post == null) throw BusinessException.notFound("文章"); if (!post.getAuthorId().equals(CurrentUser.getUserId())) { throw BusinessException.forbidden(); } post.setTitle(request.getTitle()); post.setContent(request.getContent()); postMapper.updateById(post); } public void deleteById(Long id) { postMapper.deleteById(id); // 逻辑删除,设置 deleted=1 }}
MyBatis-Plus 内置方法速查:
|
|
|
|---|---|
insert(entity) |
|
deleteById(id) |
|
deleteBatchIds(ids) |
|
updateById(entity) |
|
selectById(id) |
|
selectBatchIds(ids) |
|
selectList(wrapper) |
|
selectPage(page, wrapper) |
|
selectCount(wrapper) |
|
8.5 条件构造器(Wrapper)
// 等值查询List<Post> posts = postMapper.selectList( new LambdaQueryWrapper<Post>() .eq(Post::getAuthorId, userId) .eq(Post::getCategoryId, categoryId));// 模糊查询List<Post> posts = postMapper.selectList( new LambdaQueryWrapper<Post>() .like(Post::getTitle, keyword) .or() .like(Post::getContent, keyword));// 范围查询 + 排序List<Post> posts = postMapper.selectList( new LambdaQueryWrapper<Post>() .between(Post::getCreatedAt, startDate, endDate) .orderByDesc(Post::getCreatedAt));// 条件动态拼接LambdaQueryWrapper<Post> wrapper = new LambdaQueryWrapper<>();if (keyword != null && !keyword.isEmpty()) { wrapper.like(Post::getTitle, keyword);}if (categoryId != null) { wrapper.eq(Post::getCategoryId, categoryId);}List<Post> posts = postMapper.selectList(wrapper);
8.6 分页配置与使用
package com.example.blog.config;import com.baomidou.mybatisplus.annotation.DbType;import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;@Configurationpublic class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }}
public PageResult<PostVO> page(int pageNum, int pageSize, String keyword) { Page<Post> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Post> wrapper = new LambdaQueryWrapper<>(); if (keyword != null && !keyword.isEmpty()) { wrapper.like(Post::getTitle, keyword); } wrapper.orderByDesc(Post::getCreatedAt); postMapper.selectPage(page, wrapper); List<PostVO> vos = page.getRecords().stream() .map(this::toVO) .collect(Collectors.toList()); return PageResult.of(vos, page.getTotal(), pageNum, pageSize);}
8.7 逻辑删除
# application.ymlmybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 # 已删除 logic-not-delete-value: 0 # 未删除
@TableLogicprivate Integer deleted;
配置后deleteById()自动转换为UPDATE ... SET deleted=1,查询自动追加WHERE deleted=0。
8.8 自动填充(创建/更新时间)
package com.example.blog.handler;import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;import org.apache.ibatis.reflection.MetaObject;import org.springframework.stereotype.Component;import java.time.LocalDateTime;@Componentpublic class AutoFillHandler implements MetaObjectHandler { @Override public void insertFill(MetaObject metaObject) { this.strictInsertFill(metaObject, "createdAt", LocalDateTime.class, LocalDateTime.now()); this.strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now()); } @Override public void updateFill(MetaObject metaObject) { this.strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now()); }}
8.9 N+1 问题与批量查询
// ❌ N+1 问题:循环查数据库List<Post> posts = postMapper.selectList(null);for (Post post : posts) { User author = userMapper.selectById(post.getAuthorId()); // N 次查询 post.setAuthorName(author.getUsername());}// ✅ 批量查询:一次查所有List<Post> posts = postMapper.selectList(null);List<Long> authorIds = posts.stream() .map(Post::getAuthorId) .distinct() .collect(Collectors.toList());List<User> authors = userMapper.selectBatchIds(authorIds);Map<Long, String> nameMap = authors.stream() .collect(Collectors.toMap(User::getId, User::getUsername));for (Post post : posts) { post.setAuthorName(nameMap.get(post.getAuthorId()));}
// ✅ 或直接用 JOIN 一条 SQL 搞定@Select("SELECT p.*, u.username AS author_name " + "FROM t_post p LEFT JOIN t_user u ON p.author_id = u.id " + "WHERE p.deleted = 0")List<PostVO> selectPostsWithAuthor();
💡 Tip:用
LambdaQueryWrapper代替字符串字段名,重构时 IDE 可自动重命名
9. Flyway 数据库迁移
9.1 为什么需要数据库迁移?
多人协作时,每个人本地改表结构会不一致。Flyway 用版本化的 SQL 脚本管理表结构变更,保证所有环境的数据库一致。
9.2 迁移文件命名规则
src/main/resources/db/migration/├── V1__init_schema.sql├── V2__add_post_summary.sql├── V3__add_category_table.sql└── V4__add_user_avatar.sql
命名格式:V{版本号}__{描述}.sql(注意是双下划线)
9.3 初始迁移脚本
-- V1__init_schema.sqlCREATE TABLE t_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, email VARCHAR(100), avatar VARCHAR(255), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;CREATE TABLE t_category ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;CREATE TABLE t_post ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL, content TEXT NOT NULL, summary VARCHAR(500), author_id BIGINT NOT NULL, category_id BIGINT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, INDEX idx_author (author_id), INDEX idx_category (category_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;CREATE TABLE t_comment ( id BIGINT AUTO_INCREMENT PRIMARY KEY, content TEXT NOT NULL, post_id BIGINT NOT NULL, user_id BIGINT NOT NULL, parent_id BIGINT DEFAULT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, INDEX idx_post (post_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;CREATE TABLE t_tag ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL UNIQUE) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;CREATE TABLE t_post_tag ( post_id BIGINT NOT NULL, tag_id BIGINT NOT NULL, PRIMARY KEY (post_id, tag_id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
9.4 后续迁移示例
-- V2__add_post_summary.sqlALTER TABLE t_post ADD COLUMN summary VARCHAR(500) AFTER content;
-- V3__add_category_table.sqlINSERT INTO t_category (name) VALUES ('Java'), ('Spring'), ('数据库'), ('前端');
9.5 application.yml 配置
spring: flyway: enabled: true locations: classpath:db/migration baseline-on-migrate: true # 非空数据库也执行迁移 validate-on-migrate: true # 迁移前校验已有脚本是否被修改
9.6 迁移状态查看
mvn flyway:info# +-----------+---------+---------------------+------+---------------------+---------+# | Category | Version | Description | Type | Installed On | State |# +-----------+---------+---------------------+------+---------------------+---------+# | Versioned | 1 | init schema | SQL | 2025-01-01 10:00:00 | Success |# | Versioned | 2 | add post summary | SQL | 2025-01-02 10:00:00 | Success |# +-----------+---------+---------------------+------+---------------------+---------+
Key Points:
-
• 迁移脚本一旦执行就不能修改——Flyway 用 checksum 校验 -
• 如需修改,新建一个迁移脚本而不是改旧的 -
• flyway_schema_history表记录所有迁移历史
💡 Tip:生产环境用
mvn flyway:migrate手动执行,不要用baseline-on-migrate=true
10. 认证与授权(JWT + Spring Security)
10.1 为什么需要认证与授权?
认证(Authentication)是”你是谁”,授权(Authorization)是”你能做什么”。JWT 是无状态令牌,适合 RESTful API;Spring Security 提供完整的安全框架。
10.2 JWT 工具类
package com.example.blog.util;import io.jsonwebtoken.*;import io.jsonwebtoken.security.Keys;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Component;import javax.crypto.SecretKey;import java.nio.charset.StandardCharsets;import java.util.Date;@Componentpublic class JwtUtil { private final SecretKey key; private final long expiration; public JwtUtil(@Value("${jwt.secret}") String secret, @Value("${jwt.expiration}") long expiration) { this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); this.expiration = expiration; } public String generateToken(String userId) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + expiration); return Jwts.builder() .subject(userId) .issuedAt(now) .expiration(expiryDate) .signWith(key) .compact(); } public String parseToken(String token) { return Jwts.parser() .verifyWith(key) .build() .parseSignedClaims(token) .getPayload() .getSubject(); }}
10.3 JWT 配置
# application.ymljwt: secret: your-256-bit-secret-key-here-must-be-at-least-32-characters expiration: 86400000 # 24 小时(毫秒)
10.4 Spring Security 配置
package com.example.blog.config;import com.example.blog.security.JwtAuthFilter;import lombok.RequiredArgsConstructor;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.security.config.annotation.web.builders.HttpSecurity;import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;import org.springframework.security.config.http.SessionCreationPolicy;import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.security.web.SecurityFilterChain;import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;@Configuration@EnableWebSecurity@RequiredArgsConstructorpublic class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .requestMatchers("/api/hello/**").permitAll() .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }}
10.5 JWT 认证过滤器
package com.example.blog.security;import com.example.blog.util.JwtUtil;import jakarta.servlet.FilterChain;import jakarta.servlet.ServletException;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import lombok.RequiredArgsConstructor;import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;import org.springframework.security.core.context.SecurityContextHolder;import org.springframework.stereotype.Component;import org.springframework.web.filter.OncePerRequestFilter;import java.io.IOException;import java.util.Collections;@Component@RequiredArgsConstructorpublic class JwtAuthFilter extends OncePerRequestFilter { private final JwtUtil jwtUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String authHeader = request.getHeader("Authorization"); if (authHeader != null && authHeader.startsWith("Bearer ")) { try { String userId = jwtUtil.parseToken(authHeader.substring(7)); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userId, null, Collections.emptyList()); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (Exception e) { SecurityContextHolder.clearContext(); } } filterChain.doFilter(request, response); }}
10.6 获取当前用户工具
package com.example.blog.util;import org.springframework.security.core.Authentication;import org.springframework.security.core.context.SecurityContextHolder;public class CurrentUser { public static Long getUserId() { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth != null && auth.getPrincipal() instanceof String) { return Long.parseLong((String) auth.getPrincipal()); } throw new RuntimeException("未登录"); }}
10.7 注册与登录接口
package com.example.blog.controller;import com.example.blog.common.R;import com.example.blog.dto.LoginRequest;import com.example.blog.dto.RegisterRequest;import com.example.blog.service.AuthService;import com.example.blog.vo.LoginVO;import jakarta.validation.Valid;import lombok.RequiredArgsConstructor;import org.springframework.web.bind.annotation.*;@RestController@RequestMapping("/api/auth")@RequiredArgsConstructorpublic class AuthController { private final AuthService authService; @PostMapping("/register") public R<LoginVO> register(@Valid @RequestBody RegisterRequest request) { return R.ok(authService.register(request)); } @PostMapping("/login") public R<LoginVO> login(@Valid @RequestBody LoginRequest request) { return R.ok(authService.login(request)); }}
package com.example.blog.service;import com.example.blog.dto.LoginRequest;import com.example.blog.dto.RegisterRequest;import com.example.blog.entity.User;import com.example.blog.exception.BusinessException;import com.example.blog.mapper.UserMapper;import com.example.blog.util.JwtUtil;import com.example.blog.vo.LoginVO;import lombok.RequiredArgsConstructor;import org.springframework.security.crypto.password.PasswordEncoder;import org.springframework.stereotype.Service;@Service@RequiredArgsConstructorpublic class AuthService { private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; private final JwtUtil jwtUtil; public LoginVO register(RegisterRequest request) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUsername, request.getUsername()); if (userMapper.selectCount(wrapper) > 0) { throw new BusinessException(400, "用户名已存在"); } User user = new User(); user.setUsername(request.getUsername()); user.setPassword(passwordEncoder.encode(request.getPassword())); user.setEmail(request.getEmail()); userMapper.insert(user); String token = jwtUtil.generateToken(String.valueOf(user.getId())); return new LoginVO(token, user.getId(), user.getUsername()); } public LoginVO login(LoginRequest request) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(User::getUsername, request.getUsername()); User user = userMapper.selectOne(wrapper); if (user == null || !passwordEncoder.matches(request.getPassword(), user.getPassword())) { throw new BusinessException(401, "用户名或密码错误"); } String token = jwtUtil.generateToken(String.valueOf(user.getId())); return new LoginVO(token, user.getId(), user.getUsername()); }}
package com.example.blog.dto;import jakarta.validation.constraints.NotBlank;import jakarta.validation.constraints.Size;import lombok.Data;@Datapublic class RegisterRequest { @NotBlank @Size(min = 3, max = 20) private String username; @NotBlank @Size(min = 6, max = 30) private String password; private String email;}@Datapublic class LoginRequest { @NotBlank private String username; @NotBlank private String password;}
package com.example.blog.vo;import lombok.AllArgsConstructor;import lombok.Data;@Data@AllArgsConstructorpublic class LoginVO { private String token; private Long userId; private String username;}
# 注册curl -X POST http://localhost:8080/api/auth/register \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456","email":"admin@test.com"}'# {"code":200,"data":{"token":"eyJ...","userId":1,"username":"admin"}}# 登录curl -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}'# {"code":200,"data":{"token":"eyJ...","userId":1,"username":"admin"}}# 带 Token 访问受保护接口curl http://localhost:8080/api/posts \ -H "Authorization: Bearer eyJ..."
Key Points:
-
• SessionCreationPolicy.STATELESS禁用 session,适合 JWT 无状态认证 -
• BCryptPasswordEncoder自动加盐哈希 -
• JWT Secret 至少 32 字节(256 位)
💡 Tip:JWT 不加密 payload(仅 Base64 编码),不要在 token 中存放密码等敏感信息
11. 文件上传
11.1 为什么需要专门的配置?
Spring Boot 默认最大上传 1MB,生产环境通常需要更大。还需处理文件名冲突、存储路径、静态资源访问等问题。
11.2 application.yml 配置
spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MBfile: upload-dir: ./uploads allowed-extensions: jpg,jpeg,png,gif,pdf,doc,docx
11.3 文件上传接口
package com.example.blog.controller;import com.example.blog.common.R;import com.example.blog.service.FileService;import lombok.RequiredArgsConstructor;import org.springframework.web.bind.annotation.*;import org.springframework.web.multipart.MultipartFile;@RestController@RequestMapping("/api/files")@RequiredArgsConstructorpublic class FileController { private final FileService fileService; @PostMapping("/upload") public R<String> upload(@RequestParam("file") MultipartFile file) { String url = fileService.upload(file); return R.ok(url); } @PostMapping("/uploads") public R<List<String>> uploadBatch(@RequestParam("files") List<MultipartFile> files) { List<String> urls = fileService.uploadBatch(files); return R.ok(urls); }}
11.4 文件服务实现
package com.example.blog.service;import com.example.blog.exception.BusinessException;import org.springframework.beans.factory.annotation.Value;import org.springframework.stereotype.Service;import org.springframework.web.multipart.MultipartFile;import java.io.IOException;import java.nio.file.*;import java.util.ArrayList;import java.util.List;import java.util.Set;import java.util.UUID;@Servicepublic class FileService { private final Path uploadDir; private final Set<String> allowedExtensions; public FileService(@Value("${file.upload-dir}") String uploadDir, @Value("${file.allowed-extensions}") String allowed) { this.uploadDir = Paths.get(uploadDir); this.allowedExtensions = Set.of(allowed.split(",")); try { Files.createDirectories(this.uploadDir); } catch (IOException e) { throw new RuntimeException("无法创建上传目录", e); } } public String upload(MultipartFile file) { if (file.isEmpty()) { throw new BusinessException(400, "文件不能为空"); } String originalName = file.getOriginalFilename(); String extension = getExtension(originalName); if (!allowedExtensions.contains(extension.toLowerCase())) { throw new BusinessException(400, "不支持的文件类型: " + extension); } String storedName = UUID.randomUUID() + "." + extension; Path targetPath = uploadDir.resolve(storedName); try { Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { throw new BusinessException(500, "文件上传失败"); } return "/uploads/" + storedName; } public List<String> uploadBatch(List<MultipartFile> files) { List<String> urls = new ArrayList<>(); for (MultipartFile file : files) { urls.add(upload(file)); } return urls; } private String getExtension(String filename) { if (filename == null) return ""; int dotIndex = filename.lastIndexOf('.'); return dotIndex == -1 ? "" : filename.substring(dotIndex + 1); }}
11.5 静态资源配置
package com.example.blog.config;import org.springframework.beans.factory.annotation.Value;import org.springframework.context.annotation.Configuration;import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;@Configurationpublic class StaticResourceConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/uploads/**") .addResourceLocations("file:" + uploadDir + "/"); }}
curl -X POST http://localhost:8080/api/files/upload \ -H "Authorization: Bearer eyJ..." \ -F "file=@/path/to/photo.jpg"# {"code":200,"data":"/uploads/a1b2c3d4.jpg"}curl -X POST http://localhost:8080/api/files/uploads \ -H "Authorization: Bearer eyJ..." \ -F "files=@photo1.jpg" \ -F "files=@photo2.png"# {"code":200,"data":["/uploads/xxx.jpg","/uploads/yyy.png"]}
Key Points:
-
• 用 UUID 重命名避免文件名冲突 -
• 校验文件扩展名防止恶意上传 -
• 配置静态资源映射使上传文件可直接访问
💡 Tip:生产环境应使用 OSS(阿里云/腾讯云)而非本地存储,否则多实例部署时文件不共享
12. 异步任务
12.1 为什么需要异步任务?
发邮件、生成报表、数据同步等耗时操作不能阻塞 HTTP 请求。异步任务让主线程快速响应,耗时操作后台执行。
12.2 启用异步
package com.example.blog;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.scheduling.annotation.EnableAsync;@SpringBootApplication@EnableAsyncpublic class BlogApplication { public static void main(String[] args) { SpringApplication.run(BlogApplication.class, args); }}
12.3 异步方法
package com.example.blog.service;import lombok.extern.slf4j.Slf4j;import org.springframework.scheduling.annotation.Async;import org.springframework.stereotype.Service;import java.util.concurrent.CompletableFuture;@Slf4j@Servicepublic class EmailService { @Async public void sendWelcomeEmail(String email, String username) { log.info("开始发送欢迎邮件: {}", email); try { Thread.sleep(2000); // 模拟邮件发送 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } log.info("欢迎邮件发送成功: {}", email); } @Async public CompletableFuture<String> generateReport(Long userId) { log.info("开始生成报表: userId={}", userId); try { Thread.sleep(5000); // 模拟耗时操作 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } String reportUrl = "/reports/" + userId + ".pdf"; log.info("报表生成完成: {}", reportUrl); return CompletableFuture.completedFuture(reportUrl); }}
12.4 调用异步方法
@RestController@RequestMapping("/api/users")@RequiredArgsConstructorpublic class UserController { private final EmailService emailService; @PostMapping("/register") public R<Void> register(@Valid @RequestBody RegisterRequest request) { // 同步:保存用户 User user = userService.register(request); // 异步:发邮件(不阻塞响应) emailService.sendWelcomeEmail(user.getEmail(), user.getUsername()); return R.ok(); } @GetMapping("/{id}/report") public R<String> generateReport(@PathVariable Long id) { CompletableFuture<String> future = emailService.generateReport(id); // 可以继续处理其他逻辑 // String url = future.get(); // 需要结果时阻塞等待 return R.ok("报表生成中,稍后查看"); }}
12.5 线程池配置
package com.example.blog.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;import java.util.concurrent.Executor;@Configurationpublic class AsyncConfig { @Bean("taskExecutor") public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(5); executor.setMaxPoolSize(10); executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-"); executor.setRejectedExecutionHandler( new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }}
Key Points:
-
• @Async方法必须 public,且不能同类内部调用(代理不生效) -
• CompletableFuture用于需要返回结果的异步任务 -
• CallerRunsPolicy在队列满时由调用线程执行,避免丢失任务
💡 Tip:异步方法抛出的异常不会被调用方捕获,需在方法内部 try-catch 或配置
AsyncUncaughtExceptionHandler
13. CORS 与跨域
13.1 什么是跨域?
浏览器同源策略:协议、域名、端口任一不同即为跨域。前端http://localhost:3000调后端http://localhost:8080就是跨域。
13.2 Spring Boot CORS 配置
package com.example.blog.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.cors.CorsConfiguration;import org.springframework.web.cors.UrlBasedCorsConfigurationSource;import org.springframework.web.filter.CorsFilter;import java.util.List;@Configurationpublic class CorsConfig { @Bean public CorsFilter corsFilter() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOriginPatterns(List.of("*")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", config); return new CorsFilter(source); }}
13.3 注解方式(单接口)
@RestController@RequestMapping("/api/public")public class PublicController { @CrossOrigin(origins = "http://localhost:3000") @GetMapping("/posts") public R<List<PostVO>> publicPosts() { return R.ok(postService.listPublic()); }}
13.4 CORS 头说明
|
|
|
|---|---|
Access-Control-Allow-Origin |
|
Access-Control-Allow-Methods |
|
Access-Control-Allow-Headers |
|
Access-Control-Allow-Credentials |
|
Access-Control-Max-Age |
|
Key Points:
-
• setAllowCredentials(true)时不能用*作为 origin,必须用allowedOriginPatterns -
• OPTIONS 预检请求由浏览器自动发送,服务端需正确响应
💡 Tip:开发阶段可以
setAllowedOriginPatterns(List.of("*")),生产环境必须限制为具体域名
14. 日志配置
14.1 为什么需要合理的日志?
没有日志排查问题等于盲人摸象。日志分级别、格式化、归档,是生产排障的第一手段。
14.2 日志级别
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
14.3 application.yml 配置
logging: level: root: INFO com.example.blog: DEBUG com.example.blog.mapper: WARN pattern: console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" file: name: logs/app.log max-size: 10MB max-history: 30
14.4 logback-spring.xml(高级配置)
<?xml version="1.0" encoding="UTF-8"?><configuration> <!-- 控制台输出 --> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> <charset>UTF-8</charset> </encoder> </appender> <!-- 文件输出(按日期滚动) --> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <!-- 错误日志单独输出 --> <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/error.log</file> <filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <level>ERROR</level> </filter> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/error.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>90</maxHistory> </rollingPolicy> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="FILE"/> <appender-ref ref="ERROR_FILE"/> </root></configuration>
14.5 在代码中使用日志
package com.example.blog.service;import lombok.extern.slf4j.Slf4j;import org.springframework.stereotype.Service;@Slf4j@Servicepublic class PostService { public PostVO create(CreatePostRequest request) { log.info("创建文章: title={}", request.getTitle()); log.debug("文章详情: {}", request); try { Post post = new Post(); post.setTitle(request.getTitle()); postMapper.insert(post); log.info("文章创建成功: id={}", post.getId()); return toVO(post); } catch (Exception e) { log.error("文章创建失败: title={}", request.getTitle(), e); throw new BusinessException("文章创建失败"); } }}
Key Points:
-
• 使用 @Slf4j(Lombok)自动生成 Logger -
• 参数化日志 log.info("msg={}", param)避免字符串拼接 -
• ERROR 日志必须记录堆栈: log.error("msg", exception) -
• 不要在循环中打印 INFO 日志
💡 Tip:使用 MDC(Mapped Diagnostic Context)在日志中自动携带 traceId,方便串联一次请求的所有日志
15. WebSocket
15.1 为什么需要 WebSocket?
HTTP 是请求-响应模式,服务器无法主动推送。WebSocket 建立全双工通道,适合实时通知、聊天、协作编辑。
15.2 WebSocket 配置
package com.example.blog.config;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;import org.springframework.web.socket.server.standard.ServerEndpointExporter;@Configurationpublic class WebSocketConfig { @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); }}
15.3 WebSocket 服务端点
package com.example.blog.websocket;import jakarta.websocket.*;import jakarta.websocket.server.PathParam;import jakarta.websocket.server.ServerEndpoint;import lombok.extern.slf4j.Slf4j;import org.springframework.stereotype.Component;import java.util.concurrent.ConcurrentHashMap;@Slf4j@Component@ServerEndpoint("/ws/chat/{userId}")public class ChatEndpoint { private static final ConcurrentHashMap<String, Session> ONLINE_USERS = new ConcurrentHashMap<>(); @OnOpen public void onOpen(Session session, @PathParam("userId") String userId) { ONLINE_USERS.put(userId, session); log.info("用户上线: {}", userId); broadcast("系统", "用户 " + userId + " 加入了聊天"); } @OnMessage public void onMessage(String message, @PathParam("userId") String userId) { log.info("收到消息: userId={}, msg={}", userId, message); broadcast(userId, message); } @OnClose public void onClose(@PathParam("userId") String userId) { ONLINE_USERS.remove(userId); log.info("用户下线: {}", userId); broadcast("系统", "用户 " + userId + " 离开了聊天"); } @OnError public void onError(Throwable error) { log.error("WebSocket 错误", error); } private void broadcast(String sender, String message) { String formatted = String.format("{\"sender\":\"%s\",\"message\":\"%s\"}", sender, message); ONLINE_USERS.forEach((userId, session) -> { if (session.isOpen()) { session.getAsyncRemote().sendText(formatted); } }); }}
15.4 集成 JWT 认证
package com.example.blog.websocket;import com.example.blog.util.JwtUtil;import jakarta.websocket.HandshakeResponse;import jakarta.websocket.server.HandshakeRequest;import jakarta.websocket.server.ServerEndpointConfig;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.ApplicationContext;import org.springframework.stereotype.Component;import java.util.List;@Componentpublic class JwtWebSocketConfigurator extends ServerEndpointConfig.Configurator { private static ApplicationContext context; @Autowired public void setContext(ApplicationContext context) { JwtWebSocketConfigurator.context = context; } @Override public void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) { List<String> authHeaders = request.getHeaders().get("Authorization"); if (authHeaders != null && !authHeaders.isEmpty()) { String token = authHeaders.get(0).replace("Bearer ", ""); try { JwtUtil jwtUtil = context.getBean(JwtUtil.class); String userId = jwtUtil.parseToken(token); config.getUserProperties().put("userId", userId); } catch (Exception e) { // token 无效 } } }}
15.5 前端连接示例
const ws = new WebSocket("ws://localhost:8080/ws/chat/123");// 如需认证:添加 token 作为查询参数或通过子协议ws.onopen = () => console.log("已连接");ws.onmessage = (event) => { const data = JSON.parse(event.data); console.log("收到:", data.sender, data.message);};ws.onclose = () => console.log("已断开");ws.send("Hello, everyone!");
Key Points:
-
• @ServerEndpoint每个连接创建一个实例,成员变量是线程安全的 -
• 静态 Map 在所有实例间共享,适合管理在线用户 -
• session.getAsyncRemote()异步发送,不阻塞
💡 Tip:Spring Boot 内嵌 Tomcat 默认支持 WebSocket,无需额外依赖。但 Nginx 反向代理需额外配置
proxy_set_header Upgrade $http_upgrade
16. 测试
16.1 为什么需要测试?
没有测试的代码就是定时炸弹——每次改动都可能引入 bug,手动回归费时费力。
16.2 单元测试(Service 层)
package com.example.blog.service;import com.example.blog.dto.CreatePostRequest;import com.example.blog.entity.Post;import com.example.blog.exception.BusinessException;import com.example.blog.mapper.PostMapper;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.extension.ExtendWith;import org.mockito.InjectMocks;import org.mockito.Mock;import org.mockito.junit.jupiter.MockitoExtension;import static org.junit.jupiter.api.Assertions.*;import static org.mockito.ArgumentMatchers.any;import static org.mockito.Mockito.*;@ExtendWith(MockitoExtension.class)class PostServiceTest { @Mock private PostMapper postMapper; @InjectMocks private PostService postService; private CreatePostRequest request; @BeforeEach void setUp() { request = new CreatePostRequest(); request.setTitle("测试标题"); request.setContent("测试内容"); } @Test void shouldCreatePost() { when(postMapper.insert(any(Post.class))).thenReturn(1); PostVO result = postService.create(request); assertNotNull(result); assertEquals("测试标题", result.getTitle()); verify(postMapper, times(1)).insert(any(Post.class)); } @Test void shouldThrowWhenPostNotFound() { when(postMapper.selectById(999L)).thenReturn(null); BusinessException ex = assertThrows(BusinessException.class, () -> postService.getById(999L)); assertTrue(ex.getMessage().contains("不存在")); } @Test void shouldDeletePost() { Post post = new Post(); post.setId(1L); post.setAuthorId(100L); when(postMapper.selectById(1L)).thenReturn(post); when(postMapper.deleteById(1L)).thenReturn(1); postService.deleteById(1L, 100L); verify(postMapper).deleteById(1L); } @Test void shouldForbidDeleteByOtherUser() { Post post = new Post(); post.setId(1L); post.setAuthorId(100L); when(postMapper.selectById(1L)).thenReturn(post); assertThrows(BusinessException.class, () -> postService.deleteById(1L, 200L)); verify(postMapper, never()).deleteById(anyLong()); }}
16.3 集成测试(Controller 层)
package com.example.blog.controller;import com.example.blog.dto.LoginRequest;import com.fasterxml.jackson.databind.ObjectMapper;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;import org.springframework.boot.test.context.SpringBootTest;import org.springframework.http.MediaType;import org.springframework.test.web.servlet.MockMvc;import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;@SpringBootTest@AutoConfigureMockMvcclass AuthControllerTest { @Autowired private MockMvc mockMvc; @Autowired private ObjectMapper objectMapper; @Test void shouldLoginSuccessfully() throws Exception { LoginRequest request = new LoginRequest(); request.setUsername("admin"); request.setPassword("123456"); mockMvc.perform(post("/api/auth/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.data.token").exists()); } @Test void shouldRejectUnauthorizedRequest() throws Exception { mockMvc.perform(get("/api/posts")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(401)); }}
16.4 Repository 层测试
package com.example.blog.mapper;import com.example.blog.entity.User;import org.junit.jupiter.api.Test;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;import org.springframework.boot.test.context.SpringBootTest;import static org.junit.jupiter.api.Assertions.*;@SpringBootTest@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY)class UserMapperTest { @Autowired private UserMapper userMapper; @Test void shouldInsertAndFindUser() { User user = new User(); user.setUsername("testuser"); user.setPassword("encoded"); userMapper.insert(user); assertNotNull(user.getId()); User found = userMapper.selectById(user.getId()); assertEquals("testuser", found.getUsername()); }}
16.5 测试用 application.yml
# src/test/resources/application.ymlspring: datasource: url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1 username: sa password: driver-class-name: org.h2.Driver flyway: enabled: false sql: init: mode: always schema-locations: classpath:schema.sqljwt: secret: test-secret-key-32-characters-long expiration: 3600000mybatis-plus: configuration: map-underscore-to-camel-case: true
Key Points:
-
• @ExtendWith(MockitoExtension.class)用于纯单元测试 -
• @SpringBootTest加载完整上下文,用于集成测试 -
• H2 内存数据库替代 MySQL 做测试,快速且隔离 -
• verify(postMapper, never()).deleteById(...)验证方法未被调用
💡 Tip:测试命名用
shouldXxxWhenYyy模式,清晰表达预期行为
17. 部署
17.1 为什么关注部署?
代码写完只是开始,能稳定运行才是交付。Docker 统一环境,Docker Compose 编排多服务,CI/CD 自动化交付。
17.2 打包可执行 JAR
mvn clean package -DskipTests# 产物:target/blog-api-0.0.1-SNAPSHOT.jar# 运行java -jar target/blog-api-0.0.1-SNAPSHOT.jar \ --spring.profiles.active=prod \ --server.port=9090
17.3 多环境配置
# application-dev.ymlspring: datasource: url: jdbc:mysql://localhost:3306/blog username: root password: root123logging: level: com.example.blog: DEBUG---# application-prod.ymlspring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} username: ${DB_USERNAME} password: ${DB_PASSWORD}logging: level: com.example.blog: INFO
# 激活指定环境java -jar app.jar --spring.profiles.active=prod# 或export SPRING_PROFILES_ACTIVE=prod && java -jar app.jar
17.4 Dockerfile
FROM eclipse-temurin:17-jre-alpineWORKDIR /appCOPY target/blog-api-*.jar app.jarRUN addgroup -S appgroup && adduser -S appuser -G appgroupUSER appuserEXPOSE 8080ENTRYPOINT ["java", "-jar", "app.jar"]
docker build -t blog-api:latest .docker run -d -p 8080:8080 \ -e SPRING_PROFILES_ACTIVE=prod \ -e DB_HOST=mysql \ -e DB_PORT=3306 \ -e DB_NAME=blog \ -e DB_USERNAME=root \ -e DB_PASSWORD=secret \ --name blog-api \ blog-api:latest
17.5 Docker Compose
version: '3.8'services: mysql: image: mysql:8.0 container_name: blog-mysql environment: MYSQL_ROOT_PASSWORD: root123 MYSQL_DATABASE: blog ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 app: build: . container_name: blog-api ports: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql DB_PORT: 3306 DB_NAME: blog DB_USERNAME: root DB_PASSWORD: root123 depends_on: mysql: condition: service_healthyvolumes: mysql-data:
docker-compose up -ddocker-compose logs -f appdocker-compose down
17.6 优雅关闭
server: shutdown: gracefulspring: lifecycle: timeout-per-shutdown-phase: 30s
// 或代码方式@SpringBootApplicationpublic class BlogApplication { public static void main(String[] args) { SpringApplication app = new SpringApplication(BlogApplication.class); app.setRegisterShutdownHook(false); app.run(args); } @PreDestroy public void shutdown() { log.info("应用正在关闭,清理资源..."); }}
17.7 健康检查
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId></dependency>
management: endpoints: web: exposure: include: health,info,metrics endpoint: health: show-details: when-authorized
curl http://localhost:8080/actuator/health# {"status":"UP"}
17.8 GitHub Actions CI/CD
# .github/workflows/ci.ymlname: CI/CDon: push: branches: [main] pull_request: branches: [main]jobs: build: runs-on: ubuntu-latest services: mysql: image: mysql:8.0 env: MYSQL_ROOT_PASSWORD: test123 MYSQL_DATABASE: blog_test ports: - 3306:3306 steps: - uses: actions/checkout@v4 - name: Set up JDK 17 uses: actions/setup-java@v4 with: java-version: '17' distribution: 'temurin' cache: maven - name: Build with Maven run: mvn clean package - name: Run tests run: mvn test - name: Build Docker image run: docker build -t blog-api:${{ github.sha }} . - name: Push to Registry run: | docker tag blog-api:${{ github.sha }} registry.example.com/blog-api:${{ github.sha }} docker push registry.example.com/blog-api:${{ github.sha }}
Key Points:
-
• spring.profiles.active切换环境配置 -
• depends_on: condition: service_healthy等待 MySQL 就绪后再启动应用 -
• Actuator 健康检查是 K8s/Docker 探活的基础 -
• CI/CD 中使用 Maven cache 加速构建
💡 Tip:生产环境用 K8s 或 Docker Swarm 做滚动更新,配合健康检查实现零停机部署
18. 最佳实践与项目结构
18.1 推荐项目结构
blog-api/├── src/│ ├── main/│ │ ├── java/com/example/blog/│ │ │ ├── BlogApplication.java # 启动类│ │ │ ├── common/ # 通用类│ │ │ │ ├── R.java # 统一响应│ │ │ │ └── PageResult.java # 分页结果│ │ │ ├── config/ # 配置类│ │ │ │ ├── SecurityConfig.java│ │ │ │ ├── WebConfig.java│ │ │ │ ├── CorsConfig.java│ │ │ │ ├── MybatisPlusConfig.java│ │ │ │ └── AsyncConfig.java│ │ │ ├── controller/ # 控制器│ │ │ │ ├── AuthController.java│ │ │ │ ├── PostController.java│ │ │ │ └── FileController.java│ │ │ ├── service/ # 服务层│ │ │ │ ├── AuthService.java│ │ │ │ ├── PostService.java│ │ │ │ └── FileService.java│ │ │ ├── mapper/ # Mapper 接口│ │ │ │ ├── PostMapper.java│ │ │ │ └── UserMapper.java│ │ │ ├── entity/ # 实体类│ │ │ │ ├── Post.java│ │ │ │ └── User.java│ │ │ ├── dto/ # 请求 DTO│ │ │ │ ├── CreatePostRequest.java│ │ │ │ ├── RegisterRequest.java│ │ │ │ └── LoginRequest.java│ │ │ ├── vo/ # 响应 VO│ │ │ │ ├── PostVO.java│ │ │ │ └── LoginVO.java│ │ │ ├── security/ # 安全相关│ │ │ │ └── JwtAuthFilter.java│ │ │ ├── interceptor/ # 拦截器│ │ │ │ └── AuthInterceptor.java│ │ │ ├── exception/ # 异常│ │ │ │ ├── BusinessException.java│ │ │ │ └── GlobalExceptionHandler.java│ │ │ ├── handler/ # 处理器│ │ │ │ └── AutoFillHandler.java│ │ │ ├── util/ # 工具类│ │ │ │ ├── JwtUtil.java│ │ │ │ └── CurrentUser.java│ │ │ └── validation/ # 自定义校验│ │ │ ├── EnumValue.java│ │ │ └── EnumValueValidator.java│ │ └── resources/│ │ ├── application.yml│ │ ├── application-dev.yml│ │ ├── application-prod.yml│ │ ├── logback-spring.xml│ │ └── db/migration/│ │ ├── V1__init_schema.sql│ │ └── V2__add_post_summary.sql│ └── test/│ └── java/com/example/blog/│ ├── service/PostServiceTest.java│ └── controller/AuthControllerTest.java├── Dockerfile├── docker-compose.yml└── pom.xml
18.2 编码原则
|
|
|
|---|---|
|
|
final+@RequiredArgsConstructor |
|
|
|
|
|
R<T> |
|
|
BusinessException,不用RuntimeException |
|
|
@Valid
|
|
|
@Slf4j
|
|
|
@Transactional
|
|
|
|
18.3 常见反模式
// ❌ Controller 中包含业务逻辑@PostMappingpublic R<Void> create(@RequestBody CreatePostRequest req) { if (req.getTitle().length() < 2) { // 业务逻辑应该在 Service return R.fail("标题太短"); } postMapper.insert(req); // Controller 直接调 Mapper return R.ok();}// ✅ Controller 只做参数接收和路由@PostMappingpublic R<PostVO> create(@Valid @RequestBody CreatePostRequest req) { return R.ok(postService.create(req));}// ❌ 返回实体,泄漏数据库结构@GetMapping("/{id}")public Post getById(@PathVariable Long id) { // 返回 Entity return postMapper.selectById(id);}// ✅ 返回 VO@GetMapping("/{id}")public R<PostVO> getById(@PathVariable Long id) { return R.ok(postService.getById(id));}
18.4 性能优化清单
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
EXPLAIN分析 |
|
|
|
|
|
|
|
|
|
💡 Tip:用
p6spy或 MyBatis-Plus 的 SQL 日志,开发时能看到每条 SQL 的执行耗时
19. 常见错误排查
19.1 启动失败
|
|
|
|
|---|---|---|
Failed to configure a DataSource |
|
application.yml中spring.datasource配置 |
Port 8080 already in use |
|
lsof -i :8080
server.port |
Cannot determine embedded database |
|
spring.datasource配置或移除 JPA 依赖 |
BeanDefinitionOverrideException |
|
@Primary |
19.2 404 错误
// 最常见原因:Controller 不在扫描路径下// 主类: com.example.blog.BlogApplication// ✅ Controller: com.example.blog.controller.HelloController// ❌ Controller: com.example.other.HelloController// 解决:确保 Controller 在主类的同级或子级包下
19.3 依赖注入失败
|
|
|
|
|---|---|---|
NoSuchBeanDefinitionException |
|
@Component/@Service/@Repository |
No qualifying bean of type |
|
@Primary或@Qualifier |
|
|
|
@Lazy延迟注入(不推荐) |
19.4 MyBatis-Plus 常见问题
// 问题:insert 后 id 为 null// 原因:主键策略不是 AUTO// 解决:检查实体类 @TableId(type = IdType.AUTO) 和数据库自增// 问题:字段值为 null 不更新// 原因:MyBatis-Plus 默认不更新 null 字段// 解决:在字段上加 @TableField(updateStrategy = FieldStrategy.IGNORED)// 或用 update(null, wrapper) 全量更新// 问题:逻辑删除后还能查到// 原因:逻辑删除字段名不匹配// 解决:检查 @TableLogic 注解字段和 yml 中 logic-delete-field 一致
19.5 Spring Security 常见问题
// 问题:所有接口 401// 原因:SecurityConfig 中 .anyRequest().authenticated()// 解决:放开登录注册接口 .requestMatchers("/api/auth/**").permitAll()// 问题:登录成功但获取不到当前用户// 原因:JwtAuthFilter 未正确设置 SecurityContext// 解决:检查 filter 中 SecurityContextHolder.getContext().setAuthentication()// 问题:CORS 预检请求被拦截// 原因:OPTIONS 请求没有放行// 解决:在 Filter 或 Interceptor 中判断 OPTIONS 直接放行
19.6 事务不回滚
// 问题:抛异常但数据还是写入了// 原因:@Transactional 默认只回滚 RuntimeException 和 Error// ❌ 默认不回滚 checked exception@Transactionalpublic void doSomething() throws Exception { jdbcTemplate.update("INSERT ..."); throw new Exception("出错"); // 不会回滚!}// ✅ 指定回滚异常类型@Transactional(rollbackFor = Exception.class)public void doSomething() throws Exception { jdbcTemplate.update("INSERT ..."); throw new Exception("出错"); // 会回滚}
19.7 内存溢出
# 排查jps -l # 找到 Java 进程 PIDjmap -heap <pid> # 查看堆内存使用jstack <pid> > dump.txt # 导出线程栈# 解决:调整 JVM 参数java -Xms512m -Xmx1024m -jar app.jar
💡 Tip:生产环境加
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/自动 dump
20. 参考链接
官方文档
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
推荐书籍
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
常用 Maven 插件
|
|
|
|---|---|
maven-compiler-plugin |
|
spring-boot-maven-plugin |
|
flyway-maven-plugin |
|
maven-surefire-plugin |
|
maven-failsafe-plugin |
|
社区资源
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
学习建议:建议按顺序学习每一章,每学完一章动手写代码验证。遇到问题先查第 19 章常见错误排查。
夜雨聆风