万字 + 代码拆解JAVA Spring Data JPA 实现数据库操作(二):Controller 层

接着上篇的Service层。

Controller 层

  • 解析规则
    • 路径变量(@PathVariable):从 URL 中“花括号”指定的部分解析,例如 /school/classes/{classId} 中的 {classId}
    • 查询参数(@RequestParam):从 URL 中问号(?)之后的参数串中提取数据,例如 ?name=张三&name=李四
  • 解析时机
    Spring MVC 在匹配 URL 时,先根据映射规则(例如 /school/classes/{classId}/students)解析出路径变量,然后再从 URL 中问号后面的查询字符串提取请求参数。
  • 混用情况
    路径参数和查询参数的解析是互不干扰的。路径参数只由花括号内指定的部分获取,查询参数只从问号后面的部分读取。例如:
    GET /school/classes/class1/students?name=张三&name=李四
    
    此时:路径变量classId = "class1" 查询参数name 对应的值为 ["张三", "李四"]

@RequestParam 的行为和语法

HTTP 请求的查询参数(Query Parameters) 中提取数据,并自动转换为方法参数的类型。

@GetMapping("/search")
public String search(@RequestParam("keyword") String keyword) {
    return "搜索关键字:" + keyword;
}

当客户端发送请求自动解析 keyword=Java 并赋值给 search 方法的 keyword 参数。

GET /search?keyword=Java

如果请求 URL 包含多个查询参数,例如:

GET /search?keyword=Java&category=Programming&limit=10

则 Controller 方法可以定义多个 @RequestParam 处理这些参数:

@GetMapping("/search")
public String search(
    @RequestParam("keyword") String keyword,
    @RequestParam("category") String category,
    @RequestParam("limit") int limit) {
    return "关键词:" + keyword + ",类别:" + category + ",限制:" + limit;
}

解析结果keyword="Java" category="Programming"limit=10

如果 URL 里没有某个参数

@GetMapping("/search")
public String search(
    @RequestParam(value = "keyword", required = false, defaultValue = "默认") String keyword,
    @RequestParam(value = "category", required = false) String category,
    @RequestParam(value = "limit", required = false, defaultValue = "10") int limit) {
    return "关键词:" + keyword + ",类别:" + category + ",限制:" + limit;
}
  • required = false 让参数变为 可选,否则如果参数缺失,会抛出异常。
  • defaultValue = "默认" 设定默认值,如果参数未提供,就用这个值。

处理多个同名参数:如果前端请求:

GET /search?name=张三&name=李四

可以使用 List<String> 来接收:

@GetMapping("/search")
public String search(@RequestParam("name") List<String> names) {
    return "搜索的姓名:" + names;
}

2 @RequestBody 如何将 JSON 请求体转换为 Java 对象?

@RequestBody 注解:用于将 HTTP 请求体中的 JSON 数据(或其他格式,如 XML)转换为 Java 对象。请求体的 JSON 数据只需包含目标对象中需要的字段。对于多余的字段,如果在 Java 类中没有对应属性,这些字段会被忽略。

  • 客户端发送的 HTTP 请求体可能是如下 JSON 数据:{ "studentName": "张三", "studentMark": 95, "irrelevantField": "无关数据" }
  • Spring Boot 收到请求后:会读取整个请求体的 JSON 字符串。使用 Jackson 将 JSON 转换为 Student 对象:
    • irrelevantField 没有对应的属性,因此会被忽略。
    • 只有 JSON 中与 Student 类中定义的属性匹配(如 studentNamestudentMark)的字段会被赋值;
    • 结果就是得到一个 Student 对象,其属性为 studentName = "张三"studentMark = 95,而其他属性保持默认值。

application/json 是 HTTP 请求头(Request Header)的一部分。它并不在请求体(Request Body)中,而是在请求的 Content-Type 头部。它的作用是告诉服务器:"这个请求的请求体是 JSON 格式的"

假设客户端发送了一个 PUT 请求来更新学生信息:

PUT /school/classes/class1/students/100 HTTP/1.1
Host: example.com
Content-Type: application/json
Authorization: Bearer some-jwt-token

{
    "studentName": "张三",
    "studentMark": 95
}

JSON 请求体的字段必须要和后端的 Java 对象不完全需要匹配,但推荐尽量匹配。

1. Spring Boot 只会映射 JSON 中“能匹配上的字段”。如果 JSON 里有多余的字段(如 irrelevantField),但 Student 类中没有定义这个字段,Spring Boot 不会报错,只是自动忽略它。如果 JSON 里缺少某些字段,Spring Boot 会使用 Java 类的默认值(如 null0)。

假设 Student 类定义如下:

public class Student {
    private String studentName;
    private Integer studentMark;

    // getter 和 setter 方法
}

客户端发送的 JSON:

{
    "studentName": "张三",
    "studentMark": 95,
    "irrelevantField": "无关数据"
}

Spring Boot 解析后,转换的 Student 对象:

Student student = new Student();
student.setStudentName("张三");
student.setStudentMark(95);
// "irrelevantField" 没有 setter,不会影响 student 对象

3. 但如果 JSON 缺少必要字段呢?

{
    "studentName": "张三"
}

此时 studentMark 这个字段会是 null(如果是 Integer 类型)或者 0(如果是 int 类型)。

前端开发必须严格按照后端的字段来写 JSON 吗? 理论上,前端和后端应该约定 API 规范。如果你不希望前端严格按照 Java 字段来写 JSON,你可以使用 @JsonProperty 指定字段的映射关系:

import com.fasterxml.jackson.annotation.JsonProperty;

public class Student {
    @JsonProperty("name") // 前端 JSON 里用 "name",后端用 "studentName"
    private String studentName;

    @JsonProperty("mark") // 让 "mark" 对应 studentMark
    private Integer studentMark;

    // getter 和 setter 方法
}

这样,前端可以发送:

{
    "name": "张三",
    "mark": 95
}

后端仍然能正确解析 studentNamestudentMark

如果字段不匹配,会发生什么?多余字段(如 "irrelevantField": "无关数据")会被忽略。缺少字段(如 studentMark)会使用默认值(如 null字段类型不匹配 可能会导致解析失败:

{
    "studentName": "张三",
    "studentMark": "高分"
}
  • 由于 studentMark 期望的是 Integer,但 JSON 里是 "高分"(字符串),Spring Boot 解析时会报错 Cannot deserialize value of type 'java.lang.Integer' from String "高分"


SchoolClassController.java负责将 URL 解析成资源调用,然后调用 Service 层。示例中因为URL中所有学生都在classes/后面,所以我们只需要一个 Controller 来管理班级及其下的学生。

我们尤其想支持一个查询,就是在班级层级下,通过查询参数传入一个或多个学生姓名来进行过滤查询,假设目标 URL 为GET /school/classes/{classId}/students?name=张三&name=李四

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;

@RestController
@RequestMapping("/school/classes")
public class SchoolClassController {

    @Autowired
    private SchoolClassService schoolClassService;

    @Autowired
    private StudentService studentService;

    // ---------------------------
    // 班级资源的 CRUD 操作
    // ---------------------------

    // 获取所有班级
    @GetMapping
    public List<SchoolClass> getAllClasses() {
        return schoolClassService.getAllClasses();
    }

    // 获取指定班级信息
    @GetMapping("/{classId}")
    public SchoolClass getClassById(@PathVariable String classId) {
        Optional<SchoolClass> clazz = schoolClassService.getClassById(classId);
        return clazz.orElse(null);
    }

    // 创建班级
    @PostMapping
    public SchoolClass createClass(@RequestBody SchoolClass schoolClass) {
        return schoolClassService.createClass(schoolClass);
    }

    // 更新班级
    @PutMapping("/{classId}")
    public SchoolClass updateClass(@PathVariable String classId, @RequestBody SchoolClass schoolClass) {
        return schoolClassService.updateClass(classId, schoolClass);
    }

    // 删除班级
    @DeleteMapping("/{classId}")
    public String deleteClass(@PathVariable String classId) {
        schoolClassService.deleteClass(classId);
        return "删除成功";
    }

    // ---------------------------
    // 学生资源的 CRUD 操作(针对某个班级)
    // ---------------------------

    // 获取某班级下所有学生,支持按姓名过滤
    @GetMapping("/{classId}/students")
    public List<Student> getStudentsByClass(
            @PathVariable("classId") String classId,
            @RequestParam(value = "name", required = false) List<String> names) {
        
        // 如果没有传入 name 参数,则返回该班级下所有学生
        if (names == null || names.isEmpty()) {
            return studentService.getStudentsByClass(classId);
        } else {
            // 如果传入了 name 参数,则按班级及姓名进行查询
            return studentService.findStudentsByClassAndNames(classId, names);
        }
    }

    // 获取某班级下指定学生
    @GetMapping("/{classId}/students/{studentId}")
    public Student getStudent(@PathVariable String classId, @PathVariable Long studentId) {
        Optional<Student> student = studentService.getStudent(classId, studentId);
        return student.orElse(null);
    }
    
    // 创建学生(在某个班级下)
    @PostMapping("/{classId}/students")
    public Student createStudent(@PathVariable String classId, @RequestBody Student student) {
        return studentService.createStudent(classId, student);
    }

    // 更新学生
    @PutMapping("/{classId}/students/{studentId}")
    public Student updateStudent(@PathVariable String classId, @PathVariable Long studentId,
                                 @RequestBody Student student) {
        return studentService.updateStudent(classId, studentId, student);
    }

    // 删除学生
    @DeleteMapping("/{classId}/students/{studentId}")
    public String deleteStudent(@PathVariable String classId, @PathVariable Long studentId) {
        studentService.deleteStudent(classId, studentId);
        return "删除成功";
    }
}

请求 1(没有 name 参数)→ 服务器返回所有学生。

GET /school/students

请求 2(有 name 参数)→ 服务器只返回姓名为 张三 的学生。

GET /school/students?name=张三

请求 3(多个 name 参数)→ 服务器返回 张三李子 两个学生的数据。

GET /school/students?name=张三&name=李子

如果 name 为空,默认返回全部学生

@GetMapping()
public List<Student> getStudentsByName(@RequestParam(value = "name", required = false) List<String> names) {
    return (names == null || names.isEmpty()) ? 
            studentService.getAllStudents() : 
            studentService.findStudentsByNames(names);
}

    为支持通过查询参数传入一个或多个学生姓名来过滤,在 StudentService 中增加相应的方法:

    @Service
    public class StudentService {
    
        @Autowired
        private StudentRepository studentRepository;
    
        // 查询某班级下所有学生
        public List<Student> getStudentsByClass(String classId) {
            return studentRepository.findByClassId(classId);
        }
        
        // 按班级和姓名过滤查询学生(姓名可以是多个)
        public List<Student> findStudentsByClassAndNames(String classId, List<String> names) {
            return studentRepository.findByClassIdAndStudentNameIn(classId, names);
        }
        
        // 其他方法如 getStudent, createStudent, updateStudent, deleteStudent…
    }
    

    在 StudentRepository 中增加一个根据班级 ID 与学生姓名集合查询的方法,可以利用 Spring Data JPA 的findByAndIn完成:

    方法名写成 findByClassIdAndStudentNameIn,可以分解为两个查询条件:

    1. findByClassId:这一部分没有使用 In 修饰符,所以生成的查询条件是 classId = ?,也就是精确匹配。

    2. AndStudentNameIn:这一部分中的 In 关键字告诉 Spring Data JPA,针对 studentName 字段生成的条件为 studentName IN (...),因此对应的参数应当是一个集合(如 List)。

    为什么不是都用 IN:Spring Data JPA 并不是根据参数的位置判断,而是根据方法名称中每个字段后是否存在 In 关键字决定的。第一个参数 classId 对应 classId = ?。第二个参数 List<String> names 对应 studentName IN (?, ?, …)。这种方式让你可以在一个方法中为每个字段值都设置单独的可选范围,非常灵活。如果你希望对某个字段使用 IN 操作,就在方法名中使用 In 关键字。

    举个例子,如果你写了:

    List<Student> findByClassIdAndStudentNameIn(String classId, List<String> names);
    

    Spring Data JPA 解析成的 SQL 大致为:

    SELECT * FROM students 
    WHERE class_id = ? 
      AND student_name IN (?, ?, ...);
    
    @Repository
    public interface StudentRepository extends JpaRepository<Student, Long> {
    
        // 根据班级查询所有学生
        List<Student> findByClassId(String classId);
    
        // 根据班级和学生姓名集合查询(姓名满足其中之一)
        List<Student> findByClassIdAndStudentNameIn(String classId, List<String> names);
    }
    

     Controller 和 Service 的职责划分

    • Controller 只关注如何将 HTTP 请求(URL、请求方法、参数、请求体)转换为业务方法调用,及将 Service 层返回的数据格式化成 HTTP 响应。

    • Service 不关心 URL 的设计,只关注如何根据参数调用 Repository 层完成数据增删改查。
      例如,在 StudentService 中,根据传入的 classId 调用 StudentRepository.findByClassId(classId),这与数据库中所有学生存放在一张表的设计完全对应。

    注意:即使 URL 中有多层目录,如 /school/classes/{classId}/students,这只是在资源层面定义了抽象关系,而数据库设计和业务逻辑完全由 Service 层和 Repository 层实现。

    好处在于:前端和 API 用户不必关心底层数据库如何存储。即使数据库设计发生变化(例如从每个班级一张表调整为统一的 students 表),只需调整 Service 层和 Repository 层,而 Controller 层的 URL 结构和接口保持不变。业务逻辑清晰:Controller 负责协议转换,Service 负责业务,Repository 负责数据存取。


    【关于是否需要新增 Controller 或 QueryService】

    • Controller 层:由于这个查询属于“班级下的学生资源”,建议将此方法写在原有的 Controller 中(例如 SchoolClassController),不必新建一个独立的 Controller。

    • Service 层:如果项目中仅有少量类似查询,直接在 StudentService 中添加相应的方法即可。如果以后查询逻辑较多且复杂,再考虑抽离出一个专门的 QueryService。目前我们保持简单,将查询方法直接放在 StudentService 中。

    • DTO 的问题
      这里因为返回结果直接是 Student 对象,所以不需要额外使用 DTO。如果需要返回更复杂的信息(例如班级名称与学生信息组合),可以考虑引入 DTO 并在 Service 层做转换,但本例中按姓名查询返回 Student 即可。
       

    什么时候需要用到dto?

    VO / DTO / BO / ORM DAO entity DO PO/ POJO(分层领域模型规约)_dto orm-CSDN博客

    • DTO(Data Transfer Object) 是专门用来在不同层(如 Service 层与 Controller 层)之间传递数据的对象。
    • 放置位置建议:在项目中单独建立一个包,例如 com.example.dtocom.yourproject.dto,专门存放各种 DTO 对象

    假设你需要查询“某个老师所带班级下的所有学生及班级名称”,可以把这个跨表查询放进专门支持复杂业务查询的 QueryService 示例。此时 Controller 的设计可以有两种方案:

    单独设计一个 Controller 专门处理查询请求

    @RestController
    @RequestMapping("/school/query")
    public class QueryController {
    
        @Autowired
        private QueryService queryService;
    
        // 根据教师姓名查询学生及班级名称
        @GetMapping("/studentsWithClass")
        public List<StudentWithClassDTO> getStudentsWithClassByTeacher(@RequestParam("teacher") String teacher) {
            return queryService.getStudentsWithClassNameByTeacher(teacher);
        }
    }
    

    客户端调用接口时,可以发送如下请求:

    • GET /school/query/studentsWithClass?teacher=李老师
      
      此时 teacher 参数将由 @RequestParam 提取,并传递给 queryService 进行跨表查询。

    如果认为查询功能不多,可以放在现有 Controller 中。例如,在 SchoolClassControllerStudentController 中新增一个接口。但为了职责清晰,建议把查询功能独立出来。

    示例 DTO 定义(放在单独的包中):

    package com.example.dto;
    
    public class StudentWithClassDTO {
        private Long studentId;
        private String studentName;
        private String className;
    
        public StudentWithClassDTO(Long studentId, String studentName, String className) {
            this.studentId = studentId;
            this.studentName = studentName;
            this.className = className;
        }
    
        // getter 和 setter 方法
        public Long getStudentId() {
            return studentId;
        }
        public void setStudentId(Long studentId) {
            this.studentId = studentId;
        }
        public String getStudentName() {
            return studentName;
        }
        public void setStudentName(String studentName) {
            this.studentName = studentName;
        }
        public String getClassName() {
            return className;
        }
        public void setClassName(String className) {
            this.className = className;
        }
    }
    

    最终 Controller 可能如下:

    @RestController
    @RequestMapping("/school/query")
    public class QueryController {
    
        @Autowired
        private QueryService queryService;
    
        // 根据教师姓名查询学生及对应的班级名称
        @GetMapping("/studentsWithClass")
        public List<StudentWithClassDTO> getStudentsWithClassByTeacher(@RequestParam("teacher") String teacher) {
            return queryService.getStudentsWithClassNameByTeacher(teacher);
        }
    }
    

    跨表查询什么时候放在repository,什么时候放在service?

    跨表查询的场景下,在 Repository 里写查询语句,还是在 Service 层调用多个 Repository 的查询结果再组合,通常取决于以下几个因素:

    1. 是否存在实体关联(外键约束、JPA 映射)

            已建立外键约束并有 JPA 映射:如果两个(或多个)表之间已经建立了外键关系,并且在实体中通过 @ManyToOne@OneToMany 等注解建立了关联,那么可以直接利用 JPQL在 Repository 里写跨表查询。 假设我们在 Student 实体中建立了对 SchoolClass 的关联,在 Repository 中写一个简单的跨表查询方法。Service 层只需要调用这个 Repository 方法:

    @Repository
    public interface StudentRepository extends JpaRepository<Student, Long> {
        // 根据班级 ID 查询所有学生,同时 fetch 班级信息
        @Query("SELECT s FROM Student s JOIN FETCH s.schoolClass WHERE s.schoolClass.classId = :classId")
        List<Student> findStudentsByClassId(@Param("classId") String classId);
    }
    

            如果两个表之间没有外键约束,或者业务上不适合使用 JPA 关联(例如两个表的耦合度比较低,或者需要动态组合多表数据),那么可以有两种选择:

    1. 在 Repository 层使用原生 SQL 或 JPQL 的 join 语句

      @Repository
      public interface StudentRepository extends JpaRepository<Student, Long> {
          @Query(value = "SELECT s.*, c.class_name FROM students s LEFT JOIN school_classes c ON s.class_id = c.class_id WHERE c.teacher = ?1", nativeQuery = true)
          List<Object[]> findStudentsAndClassByTeacher(String teacher);
      }
      

      返回的 Object[] 需要在 Service 层进行 DTO 的封装。StudentWithClassDTO 可以简单定义为:

      public class StudentWithClassDTO {
          private Long studentId;
          private String studentName;
          private String className;
      
          public StudentWithClassDTO(Long studentId, String studentName, String className) {
              this.studentId = studentId;
              this.studentName = studentName;
              this.className = className;
          }
      
          // getter 和 setter
      }
      

    在 Service 层组合多个 Repository 的结果
    如果跨表查询逻辑复杂,涉及多个步骤,比如先根据某个条件查询班级,再根据班级查询学生信息,那么可以在 Service 层分别调用 SchoolClassRepositoryStudentRepository 的方法,然后进行数据组合。这种方式更适用于业务逻辑本身就需要分步查询和组合结果的情况,而不仅仅是单纯的 join 查询。

    @Service
    public class QueryService {
        @Autowired
        private SchoolClassRepository classRepository;
    
        @Autowired
        private StudentRepository studentRepository;
        
        // 查询某个老师所带班级下的所有学生及班级名称
        public List<StudentWithClassDTO> getStudentsWithClassNameByTeacher(String teacher) {
            // 第一步:查询该老师所带的所有班级
            List<SchoolClass> classes = classRepository.findByTeacher(teacher);
            List<StudentWithClassDTO> result = new ArrayList<>();
            
            // 第二步:遍历每个班级查询学生
            for (SchoolClass clazz : classes) {
                List<Student> students = studentRepository.findByClassId(clazz.getClassId());
                for (Student s : students) {
                    // 假设有一个 DTO 类封装需要的数据
                    result.add(new StudentWithClassDTO(s.getStudentId(), s.getStudentName(), clazz.getClassName()));
                }
            }
            return result;
        }
    }
    

    3. 跨 Repository 调用

    有时跨表查询涉及多个 Repository(例如,班级和学生分别由不同的 Repository 管理),可以选择:

    • Repository 内部提供联合查询

    • Service 层调用多个 Repository:在 Service 层分别调用各个 Repository 的方法,并对结果进行业务处理和整合。

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

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

    抵扣说明:

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

    余额充值