乐于分享
好东西不私藏

Spring Boot 3 完整学习文档

Spring Boot 3 完整学习文档

目标:学完本教程能独立开发完整的后端项目

适用版本:Java 17 · Spring Boot 3.4+ · MyBatis-Plus 3.5+ · MySQL 8.0


目录

  1. 1.环境与工具链
  2. 2.Spring Boot 基础
  3. 3.Bean Validation 数据验证
  4. 4.请求与响应
  5. 5.依赖注入
  6. 6.异常处理
  7. 7.拦截器与过滤器
  8. 8.MyBatis-Plus 数据库
  9. 9.Flyway 数据库迁移
  10. 10.认证与授权(JWT + Spring Security)
  11. 11.文件上传
  12. 12.异步任务
  13. 13.CORS 与跨域
  14. 14.日志配置
  15. 15.WebSocket
  16. 16.测试
  17. 17.部署
  18. 18.最佳实践与项目结构
  19. 19.常见错误排查
  20. 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>
Spring Boot 的父 POM,统一版本管理
<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
Web 框架(内嵌 Tomcat)
spring-boot-starter-validation
Bean Validation 参数校验
spring-boot-starter-security
认证与授权
mybatis-plus-spring-boot3-starter
MyBatis-Plus ORM
mysql-connector-j
MySQL JDBC 驱动
flyway-core

+flyway-mysql
数据库迁移
lombok
减少样板代码
jjwt-api/impl/jackson
JWT 令牌生成与解析
spring-boot-starter-websocket
WebSocket 支持
spring-boot-starter-test
测试框架(JUnit 5 + Mockito)
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
映射 HTTP 请求路径
@GetMapping

/@PostMapping/@PutMapping/@DeleteMapping
快捷请求映射
@PathVariable
绑定 URL 路径参数
@RequestParam
绑定 URL 查询参数
@RequestBody
绑定 JSON 请求体

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
不能为 null
@NotBlank
不能为 null 且 trim 后长度 > 0
@NotEmpty
不能为 null 且 size > 0
@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; }
⭐⭐⭐ 推荐
Setter 注入
@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
配置类中声明 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 拦截器

特性
Filter(过滤器)
Interceptor(拦截器)
归属
Servlet 规范
Spring 框架
能访问 Spring Bean
❌ 需额外处理
✅ 可以直接注入
能获取 Controller 方法
✅ HandlerMethod
执行顺序
Filter → Interceptor → Controller
适用场景
编码设置、XSS 过滤
登录检查、权限校验、日志

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)
按 ID 删除(逻辑删除则 UPDATE)
deleteBatchIds(ids)
批量删除
updateById(entity)
按 ID 更新
selectById(id)
按 ID 查询
selectBatchIds(ids)
批量 ID 查询
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 头说明

Header
说明
Access-Control-Allow-Origin
允许的来源域名
Access-Control-Allow-Methods
允许的 HTTP 方法
Access-Control-Allow-Headers
允许的请求头
Access-Control-Allow-Credentials
是否允许携带 Cookie
Access-Control-Max-Age
预检请求缓存时间(秒)

Key Points:

  • setAllowCredentials(true)时不能用*作为 origin,必须用allowedOriginPatterns
  • • OPTIONS 预检请求由浏览器自动发送,服务端需正确响应

💡 Tip:开发阶段可以setAllowedOriginPatterns(List.of("*")),生产环境必须限制为具体域名


14. 日志配置

14.1 为什么需要合理的日志?

没有日志排查问题等于盲人摸象。日志分级别、格式化、归档,是生产排障的第一手段。

14.2 日志级别

级别
用途
TRACE
最详细,通常不用
DEBUG
调试信息,开发环境启用
INFO
关键业务节点(推荐生产默认级别)
WARN
警告,不影响功能但需要关注
ERROR
错误,需要立即处理

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
DTO/VO 分离
输入输出模型分开,不暴露实体
统一响应
所有接口返回R<T>
业务异常
BusinessException,不用RuntimeException
参数校验
@Valid

+ Bean Validation
日志规范
@Slf4j

+ 参数化日志
事务边界
@Transactional

放在 Service 方法上
密码安全
BCrypt 加密,不存储明文

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 性能优化清单

优化项
方案
N+1 查询
批量查询或 JOIN
大分页
游标分页替代 OFFSET
热点数据
Redis 缓存
慢 SQL
索引优化 +EXPLAIN分析
大事务
拆分事务,缩小范围
循环调用
批量操作 API
连接池
HikariCP 合理配置 max/min

💡 Tip:p6spy或 MyBatis-Plus 的 SQL 日志,开发时能看到每条 SQL 的执行耗时


19. 常见错误排查

19.1 启动失败

现象
原因
解决
Failed to configure a DataSource
缺少数据源配置或驱动
检查application.ymlspring.datasource配置
Port 8080 already in use
端口被占用
lsof -i :8080

查进程,或改server.port
Cannot determine embedded database
有 JPA 依赖但没有数据源
添加spring.datasource配置或移除 JPA 依赖
BeanDefinitionOverrideException
Bean 重复定义
检查是否多个类实现了同一接口且未指定@Primary

19.2 404 错误

// 最常见原因:Controller 不在扫描路径下// 主类: com.example.blog.BlogApplication// ✅ Controller: com.example.blog.controller.HelloController// ❌ Controller: com.example.other.HelloController// 解决:确保 Controller 在主类的同级或子级包下

19.3 依赖注入失败

现象
原因
解决
NoSuchBeanDefinitionException
Bean 未注册
添加@Component/@Service/@Repository
No qualifying bean of type
多个实现类冲突
@Primary@Qualifier
循环依赖
A → B → A
重构代码,或用@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. 参考链接

官方文档

资源
链接
Spring Boot 官方文档
https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/
Spring Security 文档
https://docs.spring.io/spring-security/reference/
MyBatis-Plus 文档
https://baomidou.com/
Flyway 文档
https://flywaydb.org/documentation/
Lombok 文档
https://projectlombok.org/features/
JJWT GitHub
https://github.com/jwtk/jjwt
Spring Initializr
https://start.spring.io/

推荐书籍

书名
说明
《Spring 实战》第 6 版
Spring 全家桶入门首选
《高性能 MySQL》第 4 版
数据库优化必读
《Effective Java》第 3 版
Java 编码最佳实践

常用 Maven 插件

插件
用途
maven-compiler-plugin
编译
spring-boot-maven-plugin
打包可执行 JAR
flyway-maven-plugin
数据库迁移
maven-surefire-plugin
运行单元测试
maven-failsafe-plugin
运行集成测试

社区资源

资源
链接
Spring 中文社区
https://springdoc.cn/
MyBatis-Plus 示例
https://github.com/baomidou/mybatis-plus-samples
Spring Boot 常见问题
https://github.com/spring-projects/spring-boot/wiki

学习建议:建议按顺序学习每一章,每学完一章动手写代码验证。遇到问题先查第 19 章常见错误排查。