Spring Boot Data JPA : Repository、Service 和 Controller 层(一)万字长文+代码全解

1 API设计

1. 多级API目录设计方案不符合业界标准

之前那种方案(每个班级对应单独一张表)

  • 学校(school):一级目录(如 /school),下面包含两个二级目录:class1class2

  • 班级(class):二级目录,表示具体的班级,数据来源可能是一个班级表。

  • 学生(student):对应数据表,字段包括 idname。这里假设每个班级对应一个学生表(例如:student1student2),实际访问时可以通过 URL 中的班级标识来区分。

  • Class Metadata:除了学校下的班级目录,还存在一张“班级元数据”表,字段包括 idnumber

这种设计被认为是不合理的。原因在于:当班级数量增多时,需要维护的表也会随之增多,管理复杂;跨班级查询时需要拼接不同表的逻辑,灵活性较差;

业界推荐的设计:只使用两张表来存储班级和学生数据:一张表存班级信息(例如 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)

返回的资源

GET /school/classes/

SELECT * FROM school_classes;

所有班级

GET /school/classes/class1

SELECT * FROM school_classes WHERE class_id = 'class1';

class1 的信息

GET /school/classes/class1/students/

SELECT * FROM students WHERE class_id = 'class1';

class1 班的所有学生

GET /school/classes/class1/students/1

SELECT * FROM students WHERE class_id = 'class1' AND student_id = 1;

class1 班 ID=1 的学生

如果你坚持使用“每个班级一张表”的方式(不推荐,但可以实现),那 REST API 仍然可以保持统一的结构:

  1. /school/classes/class1 → 查询 school_classes 这张表中的 class1 记录

  2. /school/classes/class1/students/ → 查询 class1 这张表中的所有学生

  3. /school/classes/class1/students/1 → 查询 class1 这张表中 ID=1 的学生

查询 SQL 可能会变成这样:

SELECT * FROM class1 WHERE student_id = 1;

 但问题是,每增加一个班级,就需要新建一张表,这是不符合数据库设计最佳实践的

为什么?查询不同班级的学生变得复杂,每个查询都需要拼接表名:如果班级很多,数据库表数量会无限增长,管理变得困难。REST API 设计和数据库紧耦合,增加新班级就需要修改代码,不利于扩展。

所以,最佳数据库设计是“所有班级共用 school_classesstudents 两张表”,而不是“每个班级一张表”每个学生记录用 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}删除学生

Repository

Spring Boot 的标准分层架构中:

Controller  ——  负责协议转换,接收 HTTP 请求,返回 HTTP 响应
Service      ——  负责业务逻辑,调用 Repository 进行数据处理
Repository   ——  负责数据存取,封装数据库操作(等同于 DAO 层)

 Repository 层的作用等同于 DAO(Data Access Object)层,但 Spring Data JPA 采用 Repository 命名,负责数据库的增删改查(CRUD):

  • 传统 JDBCMyBatis 里,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 - 查询

还支持逻辑操作符

Spring Data JPA 中文文档

方法名

自动生成的 SQL

findByStudentName(String name)

SELECT * FROM students WHERE student_name = ?

findByStudentNameAndClassId(String name, String classId)

SELECT * FROM students WHERE student_name = ? AND class_id = ?

findByStudentNameOrStudentMark(String name, Integer mark)

SELECT * FROM students WHERE student_name = ? OR student_mark = ?

findByStudentMarkGreaterThan(Integer mark)

SELECT * FROM students WHERE student_mark > ?

findByStudentMarkBetween(Integer min, Integer max)

SELECT * FROM students WHERE student_mark BETWEEN ? AND ?

findByStudentNameLike(String name)

SELECT * FROM students WHERE student_name LIKE ?

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 是哪里来的?
  • 在以下代码中,existingOptional 中包含的那条学生记录(类型为 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) 保存更新后的学生记录。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值