前言
在完成了第一周的 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对象的id是null,它会执行 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 个。如果直接把实体返回给前端,多余字段就会泄露。更严重的是,如果实体里有 password或 internalCode这样的敏感字段,直接暴露会带来安全隐患。
使用 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>、BookResponse或 Void |
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 请求配置,确保字段名正确
九、总结
第二周的学习让我完成了:
-
✅ 理解三层架构(Controller → Service → Repository)
-
✅ 掌握 @Service、@Repository、@Autowired 注解
-
✅ 引入 DTO 隔离实体和接口
-
✅ 实现统一返回格式 Result<T>
-
✅ 完成完整的 CRUD 接口
通过这两周的学习,我已经能够独立开发带有数据库的后端接口,并遵循标准的项目结构。下一周将深入学习异常处理、参数校验、JWT 鉴权等内容。