1 API设计
1. 多级API目录设计方案不符合业界标准
之前那种方案(每个班级对应单独一张表):
-
学校(school):一级目录(如
/school
),下面包含两个二级目录:class1
和class2
。 -
班级(class):二级目录,表示具体的班级,数据来源可能是一个班级表。
-
学生(student):对应数据表,字段包括
id
和name
。这里假设每个班级对应一个学生表(例如:student1
和student2
),实际访问时可以通过 URL 中的班级标识来区分。 -
Class Metadata:除了学校下的班级目录,还存在一张“班级元数据”表,字段包括
id
和number
。
这种设计被认为是不合理的。原因在于:当班级数量增多时,需要维护的表也会随之增多,管理复杂;跨班级查询时需要拼接不同表的逻辑,灵活性较差;
业界推荐的设计:只使用两张表来存储班级和学生数据:一张表存班级信息(例如 school_classes
),一张表存学生信息(例如 students
),其中用 class_id
作为外键。而不是将每个班级的数据拆成多张表。
具体解释:
你认为:
/school/classes/
似乎是两级目录,为什么/school/classes/class1
就变成school_classes
这张表了?
原因:REST API 的资源层级并不直接等于数据库表。/school/classes/class1
代表的是“class1
这个具体的班级”,它应该存储在 school_classes
这张表中,而不是单独的一张 class1
表。
数据库表结构示例
方式 1:标准化设计(推荐)
班级元数据存储在 school_classes
表
CREATE TABLE school_classes (
class_id VARCHAR(50) PRIMARY KEY,
class_name VARCHAR(100),
teacher VARCHAR(100)
);
学生数据存储在 students
表
CREATE TABLE students (
student_id INT PRIMARY KEY,
student_name VARCHAR(100),
student_mark INT,
class_id VARCHAR(50),
FOREIGN KEY (class_id) REFERENCES school_classes(class_id)
);
REST API 映射
API 端点 | 数据库查询(SQL) | 返回的资源 |
---|---|---|
|
| 所有班级 |
|
| class1 的信息 |
|
| class1 班的所有学生 |
|
| class1 班 ID=1 的学生 |
如果你坚持使用“每个班级一张表”的方式(不推荐,但可以实现),那 REST API 仍然可以保持统一的结构:
-
/school/classes/class1
→ 查询school_classes
这张表中的class1
记录 -
/school/classes/class1/students/
→ 查询class1
这张表中的所有学生 -
/school/classes/class1/students/1
→ 查询class1
这张表中 ID=1 的学生
查询 SQL 可能会变成这样:
SELECT * FROM class1 WHERE student_id = 1;
但问题是,每增加一个班级,就需要新建一张表,这是不符合数据库设计最佳实践的。
为什么?查询不同班级的学生变得复杂,每个查询都需要拼接表名:如果班级很多,数据库表数量会无限增长,管理变得困难。REST API 设计和数据库紧耦合,增加新班级就需要修改代码,不利于扩展。
所以,最佳数据库设计是“所有班级共用 school_classes
和 students
两张表”,而不是“每个班级一张表”,每个学生记录用 class_id
作为外键来区分班级,
SELECT * FROM students WHERE class_id = 'class1' AND student_id = 1;
2. API 路径分隔符不对应目录 / 数据库结构!
不是说路径分隔符就真的是子目录!!URL 中的路径分隔符用来表达资源之间的层级和关联关系。在这里:/school/classes
表示“班级集合”这一资源,/school/classes/{classId}
表示具体某个班级的资源。
与数据库表结构无直接对应:虽然从数据库角度看,班级信息可能全部存储在一张表(例如 school_classes
),但在 API 设计上,我们把它抽象成一个“目录”结构。也就是说,URL 中的分隔符并不代表物理上存在对应的目录或表,而是逻辑上表达资源之间的包含关系。
3. API端点与API:一个RESTful API包含多个端点,每个端点对应不同的功能调用。一个API端点 = HTTP方法 + URL,如POST /users
RestController与URL:一个@RestController = @RequestMapping("
路径"),可能包含多个URL。
- 例子:如果多个 URL 处理的是相同的资源(如
/school/class1/students
和/school/class2/students
),可以合并到一个 Controller。
不同Controller之间的区分:
- 根据类级别的
@RequestMapping
定义的基础路径:比如一个Controller用@RequestMapping("/user")
,另一个用@RequestMapping("/order")
方法级别的路径及HTTP方法:
- 同一个Controller中可以有多个方法,每个方法定义了不同的子路径和HTTP方法,所以最终端点是
HTTP方法 + 基础路径 + 子路径
的组合。绝大多数情况下,请求的字段(例如请求体或查询参数)不是用来区分Controller的。
RequestMapping
(以及@GetMapping
、@PostMapping
等)中定义的路径都是从根路径(服务器的根 URL)开始的,可以是多级的。
- 例如:
@RequestMapping("/school/class")
表示映射的是/school/class
下的请求。然后在各个方法上再用@GetMapping
最终设计 REST API :
资源 | HTTP 方法 | URL 结构 | 说明 |
---|---|---|---|
获取所有班级 | GET | /school/classes | 获取所有班级(元数据) |
获取某个班级信息 | GET | /school/classes/{classId} | 获取单个班级信息 |
获取某班学生列表 | GET | /school/classes/{classId}/students | 获取某个班的所有学生 |
获取某个学生信息 | GET | /school/classes/{classId}/students/{studentId} | 获取某个学生的信息 |
添加学生 | POST | /school/classes/{classId}/students | 在某班级添加学生 |
更新学生信息 | PUT | /school/classes/{classId}/students/{studentId} | 更新学生信息 |
删除学生 | DELETE | /school/classes/{classId}/students/{studentId} | 删除学生 |
2 Repository
层
在 Spring Boot 的标准分层架构中:
Controller —— 负责协议转换,接收 HTTP 请求,返回 HTTP 响应
Service —— 负责业务逻辑,调用 Repository 进行数据处理
Repository —— 负责数据存取,封装数据库操作(等同于 DAO 层)
Repository 层的作用:等同于 DAO(Data Access Object)层,但 Spring Data JPA 采用 Repository
命名,负责数据库的增删改查(CRUD):
- 在 传统 JDBC 或 MyBatis 里,DAO 层通常手写 SQL 语句:
public class UserDao { private JdbcTemplate jdbcTemplate; public User getUserById(int id) { String sql = "SELECT * FROM users WHERE id = ?"; return jdbcTemplate.queryForObject(sql, new BeanPropertyRowMapper<>(User.class), id); } }
- 在 Spring Data JPA 里,
Repository
层通常使用JpaRepository
自动提供 CRUD:@Repository public interface UserRepository extends JpaRepository<User, Integer> { User findById(int id); }
1. 数据库实体类
SchoolClass.java
import javax.persistence.*;
@Entity
@Table(name = "school_classes")
public class SchoolClass {
@Id
@Column(name = "class_id")
private String classId; // 如 "class1", "class2"
@Column(name = "class_name")
private String className;
@Column(name = "teacher")
private String teacher;
// getter 和 setter
public String getClassId() {
return classId;
}
public void setClassId(String classId) {
this.classId = classId;
}
public String getClassName() {
return className;
}
public void setClassName(String className) {
this.className = className;
}
public String getTeacher() {
return teacher;
}
public void setTeacher(String teacher) {
this.teacher = teacher;
}
}
Student.java
@Entity
@Table(name = "students")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "student_id")
private Long studentId;
@Column(name = "student_name")
private String studentName;
@Column(name = "student_mark")
private Integer studentMark;
@ManyToOne
@JoinColumn(name = "class_id", referencedColumnName = "class_id")
private SchoolClass schoolClass;
// getter 和 setter
}
1 @GeneratedValue(strategy = GenerationType.IDENTITY)
是什么?
@GeneratedValue(strategy = GenerationType.IDENTITY)
用于让数据库自动生成主键值,通常用于 @Id
字段。因为 students
表中的 student_id
需要是唯一的,并且每次新增学生时要自动递增,所以 @GeneratedValue(strategy = GenerationType.IDENTITY)
很有用。
为什么 SchoolClass
没有 @GeneratedValue
?
因为 class_id
是 字符串(如 "class1"
、"class2"
),不适合让数据库自动生成。班级 ID 需要手动指定,而不是让数据库自动递增。
2 class_id
是如何建立外键关系的?用 @ManyToOne
+ @JoinColumn
:
@ManyToOne
说明 一个班级有多个学生(多对一关系)。@JoinColumn(name = "class_id", referencedColumnName = "class_id")
告诉 JPA class_id
关联 SchoolClass
表的 class_id
字段。这样 JPA 会自动生成外键约束,确保 class_id
只能是 school_classes
表中已有的值。
这样数据库会强制 class_id
必须在 school_classes
表里存在,否则插入失败。
3. Repository 层
使用 Spring Data JPA 提供 Repository 接口。
SchoolClassRepository.java
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface SchoolClassRepository extends JpaRepository<SchoolClass, String> {
// 这里主键为 classId (String 类型)
}
StudentRepository.java
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {
// 根据班级 ID 查询所有学生
List<Student> findByClassId(String classId);
}
#### findBy
Spring Data JPA 支持的方法前缀:
-
findBy
- 按某个字段查找countBy
- 统计记录数deleteBy
- 删除existsBy
- 判断是否存在getBy
- 获取readBy
- 读取queryBy
- 查询
还支持逻辑操作符:
方法名 | 自动生成的 SQL |
---|---|
|
|
|
|
|
|
|
|
|
|
|
|
List<Student> findByClassId(String classId); // 查询某班的所有学生
long countByClassId(String classId); // 统计某班学生数量
boolean existsByStudentName(String studentName); // 判断某个学生是否存在
void deleteByClassId(String classId); // 删除某个班的所有学生
findBy
是如何自动识别字段的?
findByXXX
中的 XXX
必须和 @Entity
里的字段名匹配,不能随便写。Spring Data JPA 不会 根据方法的参数名来决定查询的字段,而是根据方法名中的 XXX
解析字段。
1. findByXXX
依赖方法名,而不是参数名
正确的例子:ClassId
必须匹配 Student
实体类的 classId
,与参数名无关。
List<Student> findByClassId(String classId);
错误的例子:
List<Student> findByAbc(String studentName);
2. 按实体类的字段名匹配,而不是数据库字段名!
假设 Student
类:
@Entity
@Table(name = "students")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long studentId;
@Column(name = "student_name")
private String studentName;
@Column(name = "class_id")
private String classId;
}
正确的 findBy
方法:
List<Student> findByStudentName(String name); // ✅ 正确
List<Student> findByClassId(String id); // ✅ 正确
错误的 findBy
方法:
List<Student> findByAbc(String studentName); // ❌ 错误,没有 `abc` 这个字段!
List<Student> findByClassID(String classId); // ❌ 错误,JPA 识别大小写敏感,`ClassID` ≠ `classId`
如果 Spring Data JPA 解析不了的复杂查询,就要用 @Query
手写 SQL。
Service 层
负责业务逻辑的实现,它不仅仅是简单地调用 DAO 层,而是可以组合多个 DAO 操作、进行业务校验、事务管理等。举例来说,创建一个学生记录时,可能需要先验证班级是否存在,再调用 DAO 层插入学生记录.
#### 必须放在 Service 层的情况
1. 跨多个数据源或多个表的事务协调
假设一个业务操作需要同时修改订单、库存、记录日志等,这些操作可能分别涉及不同的 Repository 或甚至外部系统。
- 数据库内:比如同时更新订单表和库存表,这两个操作必须保证在同一个事务中同时成功或失败。
- 数据库外:如在订单生成后,还需要调用外部服务(比如第三方支付、邮件通知、缓存更新等),这些操作与数据库本身无关。
代码示例:
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private InventoryRepository inventoryRepository;
@Autowired
private NotificationService notificationService; // 外部邮件或短信通知
/**
* 创建订单时:
* 1. 保存订单数据;
* 2. 更新库存信息;
* 3. 发送通知。
* 这些操作必须在一个事务中执行,并且其中有部分操作与数据库无关。
*/
@Transactional
public Order createOrder(Order order) {
// 1. 保存订单
Order savedOrder = orderRepository.save(order);
// 2. 更新库存(涉及另一个表的数据操作)
int updated = inventoryRepository.decreaseStock(order.getProductId(), order.getQuantity());
if (updated == 0) {
throw new RuntimeException("库存不足");
}
// 3. 调用外部服务发送通知(非数据库操作)
notificationService.sendOrderConfirmation(savedOrder);
return savedOrder;
}
}
2 数据校验与业务规则判断
- 有些业务规则不能简单地用 SQL 查询来实现。例如:“如果订单金额超过某个阈值,则自动打折”。“检查用户历史订单,判断是否允许提交新订单”。“根据多个条件(数据库数据、外部数据、缓存状态等)做出决策”
这些规则往往需要调用多个 Repository,并对返回的数据进行聚合、判断,然后再决定后续操作。
而 Repository 的职责仅仅是数据查询和持久化,并不适合承担业务决策。
代码示例:
@Service
public class DiscountService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private CustomerRepository customerRepository;
/**
* 根据用户历史订单、会员等级和当前订单金额计算优惠
*/
public BigDecimal calculateDiscount(Long customerId, BigDecimal orderAmount) {
// 查询用户历史订单总数
int orderCount = orderRepository.countByCustomerId(customerId);
// 查询用户会员等级
String membershipLevel = customerRepository.findMembershipLevel(customerId);
BigDecimal discount = BigDecimal.ZERO;
if (orderCount > 10 && "VIP".equals(membershipLevel)) {
discount = orderAmount.multiply(new BigDecimal("0.1")); // 10% 优惠
} else if (orderAmount.compareTo(new BigDecimal("1000")) > 0) {
discount = orderAmount.multiply(new BigDecimal("0.05")); // 5% 优惠
}
// 此处逻辑较复杂,无法直接用 SQL 写成单个 @Query 实现
return discount;
}
}
3 调用外部服务或执行非数据库操作
例如用户注册成功后,不仅要写入数据库,还要调用第三方 API、发送邮件、写日志或更新缓存等。这些操作与数据库无关,因此必须放在 Service 层
#### “可以放在 Repository,但推荐放在 Service 层里”的情况
其实很多简单的查询操作(如 findById、findByClassId)直接放在 Repository 中是没问题的,但即便如此,出于解耦和扩展的考虑,通常建议 Controller 调用 Service 层,再由 Service 调用 Repository。理由:
-
Controller 只处理 HTTP 请求与响应;Repository 只处理数据访问;而 Service 则承载业务规则。这样以后如果需要在数据访问前后加入额外逻辑(例如日志、缓存、数据转换、异常处理等),只需要修改 Service 层,而 Controller 和 Repository 不受影响。
-
将业务逻辑放在 Service 层可以使得单元测试更加方便,不需要依赖 Web 层和复杂的数据库查询。
这样以后若业务需求变化(例如增加数据校验、缓存处理、调用其他服务),只需在 Service 层扩展,而不必调整 Controller 和 Repository 的接口。
3. Service 层
一个用于班级操作,一个用于学生操作。
SchoolClassService.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
public class SchoolClassService {
@Autowired
private SchoolClassRepository schoolClassRepository;
public List<SchoolClass> getAllClasses() {
return schoolClassRepository.findAll();
}
public Optional<SchoolClass> getClassById(String classId) {
return schoolClassRepository.findById(classId);
}
@Transactional
public SchoolClass createClass(SchoolClass schoolClass) {
return schoolClassRepository.save(schoolClass);
}
@Transactional
public SchoolClass updateClass(String classId, SchoolClass updatedClass) {
return schoolClassRepository.findById(classId)
.map(existing -> {
existing.setClassName(updatedClass.getClassName());
existing.setTeacher(updatedClass.getTeacher());
return schoolClassRepository.save(existing);
}).orElse(null);
}
@Transactional
public void deleteClass(String classId) {
schoolClassRepository.deleteById(classId);
}
}
StudentService.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
public class StudentService {
@Autowired
private StudentRepository studentRepository;
// 获取某个班级下所有学生
public List<Student> getStudentsByClass(String classId) {
return studentRepository.findByClassId(classId);
}
// 获取指定学生
public Optional<Student> getStudent(String classId, Long studentId) {
// 可做额外校验,确保查询的学生属于该班级
return studentRepository.findById(studentId)
.filter(student -> student.getClassId().equals(classId));
}
@Transactional
public Student createStudent(String classId, Student student) {
// 设置外键
student.setClassId(classId);
return studentRepository.save(student);
}
@Transactional
public Student updateStudent(String classId, Long studentId, Student updatedStudent) {
return studentRepository.findById(studentId)
.filter(student -> student.getClassId().equals(classId))
.map(existing -> {
existing.setStudentName(updatedStudent.getStudentName());
existing.setStudentMark(updatedStudent.getStudentMark());
return studentRepository.save(existing);
}).orElse(null);
}
@Transactional
public void deleteStudent(String classId, Long studentId) {
studentRepository.findById(studentId)
.filter(student -> student.getClassId().equals(classId))
.ifPresent(student -> studentRepository.delete(student));
}
}
3.1 @Autowired
@Autowired
是 Spring 提供的依赖注入注解,用来自动装配 Bean。它告诉 Spring 容器“请把你管理的某个对象自动注入到这里”,从而不用手动 new 对象或通过工厂方法获取对象。当你需要在一个组件(比如 Service、Controller、Repository 等)中使用其他组件时,就可以在字段、构造函数或 setter 方法上加上 @Autowired
。
例如:DAO 层(Repository)被注入到 Service 层。Service 层 被注入到 Controller 层。
-
通俗例子:
假设你在建一个咖啡店应用:CoffeeMaker
是制作咖啡的机器(可以看作 Service)。WaterSupplier
是提供水的组件(可以看作 Repository/DAO)。你希望
CoffeeMaker
在制作咖啡时自动获得一个WaterSupplier
对象,那么可以这么写:@Service public class CoffeeMaker { @Autowired private WaterSupplier waterSupplier; // 自动注入 WaterSupplier 对象 public void makeCoffee() { // 使用 waterSupplier 获取水,制作咖啡 } }
同理,Controller 通常注入 Service 层来完成业务操作:
@RestController public class CoffeeController { @Autowired private CoffeeMaker coffeeMaker; // 自动注入 CoffeeMaker @GetMapping("/makeCoffee") public String makeCoffee() { coffeeMaker.makeCoffee(); return "Coffee is ready!"; } }
3.2. 关于
Optional<Student>
-
Optional
是 Java 8 引入的一个容器类型,用来表示一个值可能存在也可能不存在。可以避免直接返回null
,从而减少空指针异常(NullPointerException)的风险。Optional<Student>
表示查询结果可能存在一个Student
对象,也可能不存在。如果存在,就包装在Optional
中;如果不存在,则返回Optional.empty()
。 -
Optional<Student> optStudent = studentRepository.findById(studentId); // 使用 filter 进行额外校验,再使用 map 对结果做处理,最后通过 orElse(null) 返回值或 null
3.3 @Transactional
的作用
@Transactional
用于声明方法或者类中的一系列数据库操作在一个事务中完成。一系列数据库操作要么全部成功,要么全部回滚,不会出现部分操作成功部分失败的情况。当一个业务方法中涉及多个操作,希望它们作为一个整体执行(例如,更新学生记录的同时可能还需要记录日志),那么就需要使用 @Transactional
。如果方法执行过程中出现异常,事务会回滚,所有数据库操作恢复到方法调用之前的状态。
想象一个银行转账操作,转出账户扣款和转入账户加款必须同时成功。如果在加款时出错,整个转账操作就应该取消(回滚扣款),这就需要事务支持。
3.4 existing
是哪里来的?
- 在以下代码中,
existing
是Optional
中包含的那条学生记录(类型为Student
),经过 filter 条件校验后进入 map 方法的参数:return studentRepository.findById(studentId) .filter(student -> student.getClassId().equals(classId)) .map(existing -> { existing.setStudentName(updatedStudent.getStudentName()); existing.setStudentMark(updatedStudent.getStudentMark()); return studentRepository.save(existing); }).orElse(null);
studentRepository.findById(studentId)
返回一个Optional<Student>
。- 接下来使用
.filter(student -> student.getClassId().equals(classId))
过滤出班级 ID 符合要求的Student
对象。 - 然后调用
.map(existing -> { ... })
:
这里existing
就是经过前面步骤过滤后传递下来的那个Student
对象。
换句话说,existing
表示当前查找到且满足classId
条件的学生记录。 - 在 lambda 表达式内部:对
existing
进行属性更新(设置学生姓名和成绩)。最后调用studentRepository.save(existing)
保存更新后的学生记录。