《Spring Boot 第二周:Spring Boot CRUD 与三层架构实战》

前言

在完成了第一周的 Spring Boot 入门后,本周我深入学习了标准的三层架构(Controller → Service → Repository),引入了 DTO 数据传输对象,并实现了统一的返回格式。本文将记录这一过程,希望能给同样在学习 Spring Boot 的朋友一些参考。

一、什么是三层架构

三层架构是一种经典的软件架构模式,将应用程序分为三个层次:

复制代码
Controller(接收请求、返回响应)
    ↓ 调用
Service(业务逻辑、事务管理)
    ↓ 调用
Repository(数据访问、ORM映射)
    ↓
Database(MySQL)

每一层的职责:

层次 职责 类比
Controller 接收 HTTP 请求,调用 Service,返回响应 餐厅服务员,负责接待顾客和上菜
Service 处理业务逻辑,调用 Repository 餐厅厨师,负责做菜
Repository 操作数据库(CRUD) 餐厅采购员,负责买菜和储存食材

好处:

  • 代码清晰,易于维护

  • 各层独立,便于测试

  • 修改某一层不影响其他层

二、创建 Repository(数据访问层)

2.1 什么是 Repository

Repository 是数据访问层,负责与数据库交互。在 Spring Boot 中,我们通过继承 JpaRepository接口,自动获得 CRUD 方法,无需手写 SQL。

2.2 代码实现

复制代码
// 	声明这个接口属于 repository包
package com.example.backend.repository;
	
// 引入 Book 实体类
import com.example.backend.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
// 继承 JPA 接口,泛型第一个参数是实体类(Book),第二个是主键类型(Long)
}

自动获得的方法:

  • findAll():查询所有记录

  • findById(Long id):根据 ID 查询

  • save(Book book):新增或更新

  • deleteById(Long id):根据 ID 删除

  • count(): 统计记录总数

  • existsById(Long id):判断记录是否存在

2.3 repository 包的三大作用

作用 说明 前端类比
1. 封装数据库操作 把所有 CRUD(增删改查)代码集中到一处,其他地方调用即可 相当于把 axios.get('/api/books')封装到 bookApi.js
2. 隔离技术细节 Controller 和 Service 不需要知道底层用的是 MySQL、PostgreSQL 还是 MongoDB 就像前端不需要关心后端是 Java 还是 Python
3. 便于替换和维护 如果要换数据库,只需要改 repository 层,上层代码完全不用动 相当于把 API 地址从 http://old.com改成 http://new.com,只改一个文件

2.4 总结

repository包 = 后端的"数据管理员",专门负责跟数据库打交道。Controller 和 Service 通过它来读写数据,但不需要知道数据到底存在哪里、怎么存的。

三、创建 Service(业务逻辑层)

3.1 什么是 Service

Service 是业务逻辑层,负责处理具体的业务规则,如数据校验、默认值设置、事务管理等。

3.2 代码实现

复制代码
package com.example.backend.service;

import com.example.backend.model.Book;
import com.example.backend.repository.BookRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class BookService {

   @Autowired
    private BookRepository bookRepository;

    // 1. 查询所有书籍
    public List<Book> findAll() {
        return bookRepository.findAll();
    }

    // 2. 根据 ID 查询单本详情
    public Optional<Book> findById(Long id) {
        return bookRepository.findById(id);
    }

    // 3. 新增或更新书籍
    public Book save(Book book) {
        return bookRepository.save(book);
    }

    // 4. 根据 ID 删除书籍
    public void deleteById(Long id) {
        bookRepository.deleteById(id);
    }
}

代码解释

这次我们新增了三个方法,它们分别对应着不同的数据库操作:

  • findAll() :调用 bookRepository.findAll(),查询 book表中的所有数据。

  • findById(Long id) :调用 bookRepository.findById(id)注意这里返回的是 Optional<Book>,这是 Java 8 引入的一个安全容器,用来优雅地处理"可能找不到数据"的情况,避免空指针异常。

  • save(Book book) :调用 bookRepository.save(book)。这是一个非常智能的方法:如果传入的 book对象的 idnull,它会执行 INSERT ​ 插入新数据;如果 id存在,它会执行 UPDATE​ 更新旧数据。

  • deleteById(Long id) :调用 bookRepository.deleteById(id),根据主键 ID 删除对应的记录

3.3 依赖注入(Dependency Injection)

用前端知识类比:

复制代码
// 不好的写法:手动创建依赖
class BookService {
    constructor() {
        this.repository = new BookRepository();  // 自己 new
    }
}

// 好的写法:依赖从外部注入
class BookService {
    constructor(repository) {
        this.repository = repository;  // 别人把 repository 传进来
    }
}

@Autowired的作用:

  • 告诉 Spring:"我需要一个 BookRepository 的实例,你帮我创建一个并塞给我。"

  • 你不用自己 new BookRepository(),Spring 会在启动时自动创建好,然后注入进来。

好处:

  • 代码解耦:BookService 不关心 BookRepository 怎么创建

  • 便于测试:可以轻松替换成 Mock 对象

  • 统一管理:Spring 保证整个应用中只有一个实例(单例)

三种注入方式:

  • 字段注入(简单,但不推荐)

  • 构造方法注入(推荐,便于测试)

  • Setter 注入(较少用)

3.4 进一步理解@Service、@Repository、依赖注入

3.4.1. @Service注解

**位置:**​ 用在 Service 类上(业务逻辑层)。

作用:

  • 告诉 Spring:"这是一个业务服务类,请把它纳入容器管理。"

  • Spring 启动时会自动扫描带有 @Service的类,创建其实例(Bean),并管理它的生命周期。

类比: ​ 就像你开了一家餐厅,@Service相当于给厨师贴上"厨师"标签,餐厅管理系统(Spring)就知道这位是负责做菜的,会自动分配任务给他。

3.4.2. @Repository注解

**位置:**​ 用在 Repository 类/接口上(数据访问层)。

作用:

  • 告诉 Spring:"这是一个数据访问组件,负责与数据库打交道。"

  • Spring 会自动处理数据库异常(将 SQL 异常转换为 Spring 的统一异常体系)。

类比:@Repository相当于给采购员贴上"采购员"标签,管理系统知道他是负责买菜的。

3.4.3. @Autowired注解

**位置:**​ 用在字段、构造方法或 Setter 方法上。

作用:

  • 自动注入依赖的对象。Spring 会在容器中查找匹配的 Bean,然后赋值给被注解的字段。

类比: ​ 厨师(Service)需要用到采购员(Repository)买的菜。@Autowired就相当于管理系统自动把采购员分配到厨师手下,厨师不用自己去招聘采购员。

3.4.4. 依赖注入的三种方式
方式 代码示例 推荐度
字段注入(最常用) @Autowired private BookRepository bookRepository; ⭐⭐⭐(简单,但不利于测试)
构造方法注入(推荐) private final BookRepository bookRepository; public BookService(BookRepository bookRepository) { this.bookRepository = bookRepository; } ⭐⭐⭐⭐⭐(推荐,便于测试和不可变性)
Setter 注入 @Autowired public void setBookRepository(BookRepository repo) { this.bookRepository = repo; } ⭐⭐(较少用)

推荐使用构造方法注入,因为:

  • 依赖关系明确,对象创建时必须提供所有依赖。

  • 便于单元测试(可以轻松传入 Mock 对象)。

  • 字段可以用 final修饰,保证不可变性。

3.4.5. 三层架构调用关系图
复制代码
[浏览器]
    ↓ HTTP 请求
[Controller]  ← @RestController
    ↓ 调用
[Service]     ← @Service
    ↓ 调用
[Repository]  ← @Repository
    ↓ SQL
[Database]

每一层的职责:

  • Controller:接收请求、解析参数、调用 Service、返回响应(不写业务逻辑)。

  • Service:处理业务逻辑(校验、计算、事务),调用 Repository。

  • Repository:执行数据库操作(CRUD)。

backend2项目中的对应关系

注解 文件
Controller @RestController BookController.java
Service @Service BookService.java
Repository @Repository BookRepository.java
Model @Entity Book.java

四、创建 Controller(表现层)

4.1 什么是 Controller

Controller 是表现层,负责接收 HTTP 请求、调用 Service、返回响应。

4.2 代码实现

复制代码
@RestController
@RequestMapping("/api")
public class BookController {

    @Autowired
    private BookService bookService;

    @GetMapping("/books")
    public Result<List<BookResponse>> getAllBooks() {
        return Result.success(bookService.findAll());
    }

    @PostMapping("/books")
    public Result<BookResponse> addBook(@RequestBody BookRequest request) {
        return Result.success(bookService.save(request));
    }

    // 其他方法...
}

五、引入 DTO(数据传输对象)

5.1 为什么要用 DTO

直接暴露 Entity 的问题:

假设你的 Book实体类有 10 个字段,但前端只需要 3 个。如果直接把实体返回给前端,多余字段就会泄露。更严重的是,如果实体里有 passwordinternalCode这样的敏感字段,直接暴露会带来安全隐患。

使用 DTO 的好处:

好处 说明
控制暴露字段 只返回前端需要的字段,隐藏敏感信息
请求和响应分离 新增时不需要传 id,但返回时可以带 id
实体变化不影响接口 数据库表结构调整时,只需改 DTO 的转换逻辑,接口契约不变
安全性 不会意外泄露数据库内部字段

前端类比:

  • Entity = 数据库表结构(类似后端数据库表)

  • DTO = API 接口协议(类似前端定义的接口数据类型

5.2 BookRequest(请求对象)

**作用:**​ 接收前端传来的数据,只包含前端需要传递的字段。

复制代码
package com.example.backend.dto;

public class BookRequest {
    private String title;
    private String author;
    private String status;

    // Getter 和 Setter
    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getAuthor() {
        return author;
    }

    public void setAuthor(String author) {
        this.author = author;
    }

    public String getStatus() {
        return status;
    }

    public void setStatus(String status) {
        this.status = status;
    }
}

注意: ​ 请求对象中没有 id字段,因为新增时 ID 由数据库自动生成,前端不需要传。

5.3 BookResponse(响应对象)

**作用:**​ 返回给前端的数据,包含前端需要看到的所有字段。

复制代码
package com.example.backend.dto;

public class BookResponse {
    private Long id;
    private String title;
    private String author;
    private String status;

    // 无参构造(必须)
    public BookResponse() {}

    // 全参构造(方便转换)
    public BookResponse(Long id, String title, String author, String status) {
        this.id = id;
        this.title = title;
        this.author = author;
        this.status = status;
    }

    // Getter 和 Setter
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getAuthor() {
        return author;
    }

    public void setAuthor(String author) {
        this.author = author;
    }

    public String getStatus() {
        return status;
    }

    public void setStatus(String status) {
        this.status = status;
    }
}

然后在BookService,BookController中使用 DTO 具体看源码:backend2

六、统一返回格式

6.1 为什么要统一

如果没有统一格式,前端需要处理多种返回情况:

  • 成功时直接返回数据

  • 失败时返回 null

  • 异常时返回错误页面

6.2 Result 类实现

dto包下创建 Result.java

复制代码
package com.example.backend.dto;

public class Result<T> {
    private int code;
    private String message;
    private T data;

    // 成功(带数据)
    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.code = 200;
        result.message = "success";
        result.data = data;
        return result;
    }

    // 成功(不带数据,用于删除等操作)
    public static <T> Result<T> success() {
        return success(null);
    }

    // 失败
    public static <T> Result<T> error(int code, String message) {
        Result<T> result = new Result<>();
        result.code = code;
        result.message = message;
        return result;
    }

    // Getter 方法
    public int getCode() {
        return code;
    }

    public String getMessage() {
        return message;
    }

    public T getData() {
        return data;
    }
}

代码解释:

部分 说明
Result<T> 泛型类,T表示 data 字段的类型,可以是 List<BookResponse>BookResponseVoid
success(T data) 静态工厂方法,创建一个成功的响应,包含数据
success() 重载方法,创建一个成功的响应,不包含数据(用于删除操作)
error(int code, String message) 静态工厂方法,创建一个失败的响应
getCode()/ getMessage()/ getData() Getter 方法,Spring 自动转 JSON 时需要

然后在BookController中引用

6.3 统一后的返回格式

复制代码
// 成功
{
  "code": 200,
  "message": "success",
  "data": [...]
}

// 失败
{
  "code": 404,
  "message": "书籍不存在",
  "data": null
}

七、测试验证

使用 Postman 测试所有接口:

方法 URL 预期结果
GET /api/books 返回书籍列表
GET /api/books/1 返回单本书籍
POST /api/books 新增书籍
PUT /api/books/1 更新书籍
DELETE /api/books/1 删除书籍

八、遇到的坑与解决

8.1 编译报错:找不到符号

  • 原因:包名或类名拼写错误

  • 解决:检查 import 语句,确保路径正确

8.2 启动报错:数据库连接失败

  • 原因:MySQL 未启动或配置错误

  • 解决:检查 application.yml 中的连接信息

8.3 返回 400 Bad Request

  • 原因:JSON 格式错误或字段类型不匹配

  • 解决:检查 Postman 请求配置,确保字段名正确

九、总结

第二周的学习让我完成了:

  1. ✅ 理解三层架构(Controller → Service → Repository)

  2. ✅ 掌握 @Service、@Repository、@Autowired 注解

  3. ✅ 引入 DTO 隔离实体和接口

  4. ✅ 实现统一返回格式 Result<T>

  5. ✅ 完成完整的 CRUD 接口

通过这两周的学习,我已经能够独立开发带有数据库的后端接口,并遵循标准的项目结构。下一周将深入学习异常处理、参数校验、JWT 鉴权等内容。

源码地址: backend2