这一章下了源码包一看,已经是写好的整个Cloud项目,还不知道怎么用,另外前端用的是Angular取数,虽然能够看懂Angular做了什么,但是毕竟还是不懂,比较费劲。
痛定思痛,主要还是之前Django 开发的时候没有好好的接触前后端分离的项目,这次立刻去找了Vue的视频,也要重新学起Vue来了。
于是这一章自己再弄一个简单的系统,把SIA5里第六章的技术都实验一遍。
数据库设计
这次简单一些,先弄一个外键一对多的例子,一个student表,一个course表,学生外键关联到course表。
SQL如下:
SET FOREIGN_KEY_CHECKS=0;
-- ----------------------------
-- Table structure for course
-- ----------------------------
DROP TABLE IF EXISTS `course`;
CREATE TABLE `course` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`course_name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4;
-- ----------------------------
-- Records of course
-- ----------------------------
INSERT INTO `course` VALUES ('1', 'Java Programming');
INSERT INTO `course` VALUES ('2', 'Discrete mathematics');
INSERT INTO `course` VALUES ('3', 'Software engineering');
INSERT INTO `course` VALUES ('4', 'Design Pattern');
-- ----------------------------
-- Table structure for student
-- ----------------------------
DROP TABLE IF EXISTS `student`;
CREATE TABLE `student` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`first_name` varchar(255) NOT NULL,
`last_name` varchar(255) NOT NULL,
`create_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`course_id` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `student_course_fk` (`course_id`),
CONSTRAINT `student_course_fk` FOREIGN KEY (`course_id`) REFERENCES `course` (`id`) ON DELETE NO ACTION ON UPDATE NO ACTION
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4;
-- ----------------------------
-- Records of student
-- ----------------------------
INSERT INTO `student` VALUES ('1', 'Angelo', 'Gladstone', '2019-04-09 10:42:29', '1');
INSERT INTO `student` VALUES ('2', 'Ronald', 'Constance', '2019-04-09 10:43:21', '1');
INSERT INTO `student` VALUES ('3', 'Sabina', 'Wood', '2019-04-09 10:43:19', '2');
INSERT INTO `student` VALUES ('4', 'Rachel', 'Isaac', '2019-04-09 10:43:16', '2');
INSERT INTO `student` VALUES ('5', 'Veronica', 'Katrine', '2019-04-09 10:43:38', '2');
INSERT INTO `student` VALUES ('6', 'Wordsworth', 'Clement', '2019-04-09 10:43:51', '2');
INSERT INTO `student` VALUES ('7', 'Paula', 'Aled', '2019-04-09 10:44:01', '3');
INSERT INTO `student` VALUES ('8', 'Diana', 'Hughes', '2019-04-09 10:44:12', '3');
INSERT INTO `student` VALUES ('9', 'Maurice', 'Eveline', '2019-04-09 10:44:24', '4');
INSERT INTO `student` VALUES ('10', 'Dominic', 'Toynbee', '2019-04-09 10:44:33', '4');
INSERT INTO `student` VALUES ('11', 'Aries', 'Browning', '2019-04-09 10:44:44', '1');
INSERT INTO `student` VALUES ('12', 'Gary', 'Ward', '2019-04-09 10:44:55', '2');
INSERT INTO `student` VALUES ('13', 'Lindsay', 'Newton', '2019-04-09 10:45:03', '2');
INSERT INTO `student` VALUES ('14', 'Leo', 'Hansen', '2019-04-09 10:45:13', '3');
INSERT INTO `student` VALUES ('15', 'Ingrid', 'Julia', '2019-04-09 10:45:22', '4');
配置数据库和Entity设计
用Spring Initializr创建项目,依赖先选Web,Thymeleaf,JPA,lombok和mysql驱动,安全先不选了。
这里还是继续沿用原来的配置:
spring.datasource.url=jdbc:mysql://localhost:3306/sia5?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=UTF-8
spring.datasource.username=springstudent
spring.datasource.password=springstudent
spring.thymeleaf.cache=false
spring.thymeleaf.encoding=UTF-8
之后就是写对应的entity类:
package cc.conyli.restlearn.entity;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Data
@Entity
@NoArgsConstructor(force = true)
@Table(name = "course")
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private final int id;
private final String courseName;
@OneToMany(fetch = FetchType.LAZY, mappedBy = "courseId")
private final List<Student> students = new ArrayList<>();
void add(Student student) {
this.students.add(student);
}
}
package cc.conyli.restlearn.entity;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.*;
@Data
@Entity
@Table(name = "student")
@NoArgsConstructor(force = true)
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private final int id;
private final String firstName;
private final String lastName;
//这里先不设置外键,否则JSON化之后会来回引用,无尽循环
private final int courseId;
}
RestController-Retrieve功能
先写一个简单的REST控制器,用于取所有课程和学生,单个课程和学生
package cc.conyli.restlearn.controller;
import cc.conyli.restlearn.entity.Course;
import cc.conyli.restlearn.entity.Student;
import cc.conyli.restlearn.repository.CourseRepo;
import cc.conyli.restlearn.repository.StudentRepo;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.Optional;
@org.springframework.web.bind.annotation.RestController
@RequestMapping(path = "/api", produces = "application/json")
@CrossOrigin("*")
public class RestController {
private StudentRepo studentRepo;
private CourseRepo courseRepo;
public RestController(StudentRepo studentRepo, CourseRepo courseRepo) {
this.courseRepo = courseRepo;
this.studentRepo = studentRepo;
}
@GetMapping("/students")
public Iterable<Student> showStudentList() {
return studentRepo.findAll();
}
@GetMapping("/courses")
public Iterable<Course> showCourseList() {
return courseRepo.findAll();
}
@GetMapping("/student/{id}")
public Student getStudent(@PathVariable int id) {
Optional<Student> student = studentRepo.findById(id);
return student.orElse(null);
}
@GetMapping("/course/{id}")
public Course getCourse(@PathVariable int id) {
Optional<Course> course = courseRepo.findById(id);
return course.orElse(null);
}
}
@RestController
注解已经知道了,这个是将返回的内容转换成字符串后直接写到请求体中,不通过视图解析器去找视图。这个注解其实可以拆解成@Controller
和@ResponseBody
两个注解,但还是用一个语义更加明确。
@RequestMapping
也更进了一步,带上了路径参数和后边的produces参数,如此设置就让这个控制器仅接受请求头里Accept包含application/json的请求,针对这个新建了一个项目,试验成功。
然后是一个允许跨域的@CrossOrigin("*")
,设置为*表示任何跨域来的请求都可以处理。一般AJAX设置为不跨域,外加自己的方法不对外提供跨域服务,就很安全了。
这里注意,没有返回字符串,直接返回List,这就是借助了Jackson自动转换成Json。
这个控制器里需要改进的是后边两个返回单个对象的方法。首先这里是Java8的特性,就是写成函数式的方法,使用orElse
来判断,如果不为空就返回结果,为空就返回null。
此时去实验,发现一个问题是,虽然没找到结果,但是依然返回响应体为空的200响应。但实际上没有找到对象,应该返回一个404错误,此时可以返回ResponseEntity
对象,可以设置响应。
将两个方法修改如下:
@GetMapping("/student/{id}")
public ResponseEntity<Student> getStudent(@PathVariable int id) {
Optional<Student> student = studentRepo.findById(id);
if (student.isPresent()) {
return new ResponseEntity<>(student.get(), HttpStatus.OK);
}else {
return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
}
}
@GetMapping("/course/{id}")
public ResponseEntity<Course> getCourse(@PathVariable int id) {
Optional<Course> course = courseRepo.findById(id);
if (course.isPresent()) {
return new ResponseEntity<>(course.get(), HttpStatus.OK);
}else {
return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
}
}
这样这两个方法在找不到的时候,就会返回404错误,ResponseEntity
非常好用。
RestController-Create功能
添加功能已经知道了,就是给指定的路径接受POST方法,然后绑定传入的JSON对象即可。
@PostMapping(path = "/students", consumes = "application/json")
@ResponseStatus(HttpStatus.CREATED)
public Student addStudent(@RequestBody Student student) {
log.info(student.toString());
return studentRepo.save(student);
}
@PostMapping(path = "/courses", consumes = "application/json")
@ResponseStatus(HttpStatus.CREATED)
public Course addStudent(@RequestBody Course course) {
log.info(course.toString());
return courseRepo.save(course);
}
这里的关键是@RequestBody
绑定Taco对象,现在学过好多玩意了,比如@RequestParam
绑定URL参数,@PathVariable
绑定路径变量,@ModelAttribute绑定各种数据对象。
consumes="application/json"是针对数据input来说的,只能接受Content-type=application/json的数据,和produce略有区别。
@ResponseStatus(HttpStatus.CREATED)
用于具体控制响应代码,如果不特殊设置,只要没有异常,都会返回200,但对于这个功能,CREATED=201更加精确,浏览器端也可以知道,不仅访问成功,对象也成功建立了。
启动项目可以实验,如果以其他形式Post过去,都不行,必须以JSON格式才可以。
RestController-Update功能
Update用使用@PutMapping
和@PatchMapping
。PUT一般用于整个替换成新的,而PATCH一般用于部分更新。举个例子:
@PutMapping(value = "/student/{id}", consumes = "application/json")
public ResponseEntity<Student> replaceStudent(@PathVariable("id") int id, @RequestBody Student student) {
Optional<Student> targetStudent = studentRepo.findById(id);
if (targetStudent.isPresent()) {
Student theStudent = targetStudent.get();
theStudent.setFirstName(student.getFirstName());
theStudent.setLastName(student.getLastName());
theStudent.setCourseId(student.getCourseId());
return new ResponseEntity<>(studentRepo.save(theStudent), HttpStatus.ACCEPTED);
}else {
return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
}
}
这个PUT方法要求传入一个完整的Student对应的JSON数据,否则就会因为设置成null而写入失败,数据传输的量比较大。而如果用PATCH更新部分,就好很多了:
@PatchMapping(path = "/student/{id}", consumes = "application/json")
public ResponseEntity<Student> patchStudent(@PathVariable("id") int id, @RequestBody Student student) {
Optional<Student> targetStudent = studentRepo.findById(id);
if (targetStudent.isPresent()) {
Student theStudent = targetStudent.get();
if (student.getFirstName() != null) {
theStudent.setFirstName(student.getFirstName());
}
if (student.getLastName() != null) {
theStudent.setLastName(student.getLastName());
}
if (student.getCourseId() != null) {
theStudent.setCourseId(student.getCourseId());
}
return new ResponseEntity<>(studentRepo.save(theStudent), HttpStatus.ACCEPTED);
} else {
return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
}
}
这样通过判断哪些字段不为空,可以只更新有值的字段,注意这里的int是不能直接与null进行比较,所以这里需要修改Entity类中的外键为Integer类型。
注意,PUT和PATCH仅仅是语义,实际代码怎么写,和语义是没有关系的。在实际写项目的时候,要规定好自己的各个URL和接受的请求方法才行。
RestController-Delete功能
Delete功能一般无需返回任何结果,哪怕找不到请求对象,因为请求对象不存在本身就说明删除的目的达到了。
SIA5里是直接调用Repo的删除方法,然后去抓异常,都返回204响应。这里修改了一下代码,如果找不到还是返回一个404吧,在前端还能区分出来具体结果。
@DeleteMapping(path = "/student/{id}", consumes = "application/json")
public ResponseEntity<Student> removeStudent(@PathVariable("id") int id) {
Optional<Student> targetStudent = studentRepo.findById(id);
if (targetStudent.isPresent()) {
studentRepo.delete(targetStudent.get());
return new ResponseEntity<>(null, HttpStatus.NO_CONTENT);
}else {
return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
}
}
HATEOAS
这个之前已经了解过,就是自解释的一套REST API的返回结果。要手工控制,需要添加如下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
在开始之前要了解HATEOAS的两个概念,有两个对象:Resource表示单个对象,Resources表对多个对象。可以将其理解为两个包装类,将查询结果包装到其中之后,就成了符合HATEOAS要求的JSON字符串。
在之前我们的RestController里的这个方法:
@GetMapping("/students")
public Iterable<Student> showStudentList() {
return studentRepo.findAll();
}
现在对其改造,让其返回HATEOAS标准的JSON。
@GetMapping("/students")
public Resources<Resource<Student>> showStudentList() {
PageRequest page = PageRequest.of(0, 12, Sort.by("id").descending());
List<Student> students = (List<Student>)studentRepo.findAll();
Resources<Resource<Student>> studentsHATEOAS = Resources.wrap(students);
studentsHATEOAS.add(new Link("localhost:8080/students","students"));
return studentsHATEOAS;
}
仔细看这个方法,Resource就相当于一个包裹具体对象的类,Resources就相当于一个Resource集合类,很像泛型。要使用Resources类的.wrap()方法,传入一个Iterable对象,就可以包装了。
在包装之后,可以给这个对象设置使用.add()设置其在JSON字符串中的链接和键名。
如此设置之后,再访问students地址,就得到:
{
"_embedded": {
"studentList": [
{
"id": 1,
"firstName": "Angelo",
"lastName": "Gladstone",
"courseId": 1
},
{
"id": 2,
"firstName": "Ronald",
"lastName": "Constance",
"courseId": 1
},
{
"id": 3,
"firstName": "Sabina",
"lastName": "Wood",
"courseId": 2
},
......
]
},
"_links": {
"students": {
"href": "localhost:8080/students"
}
}
}
相比原来的一个数组形式的JSON,现在的JSON被分为两部分,一部分是_embedded -- studentList,其中的内容就是原来的数组形式的JSON,还一部分是_links -- students,表示上一部分的数据的来源。
其中第二部分的键名就是Link()对象的第二个参数,而链接就是第一个参数。
这里还需要再进行一些改进,由于Link()构造器中的URL是写死的,实际上我们希望这个链接能够从其控制器匹配的路径中自动获取。
这里就需要引入一个自动组装URL的类叫做ControllerLinkBuilder
,用这个类来替代Link中写死的字符串。控制器方法可以修改成:
studentsHATEOAS.add(ControllerLinkBuilder.linkTo(RestController.class).slash("students").withRel("students"));
这里就使用了这个类动态构建字符串,后边的slash函数显然是加上一个/students
路径,withRel是键名。当然,.slash()也是写死的。
最好的方法是再传入方法名称,这样就彻底写活了,无论方法上URL如何变化,都会自动生成URL。
@GetMapping("/students")
public Resources<Resource<Student>> showStudentList() {
List<Student> students = (List<Student>)studentRepo.findAll();
Resources<Resource<Student>> studentsHATEOAS = Resources.wrap(students);
studentsHATEOAS.add(ControllerLinkBuilder.linkTo(ControllerLinkBuilder.methodOn(RestController.class).showStudentList()).withRel("students"));
return studentsHATEOAS;
这样就彻底写活了。
给每个对象添加链接
我们给列表添加了链接,现在返回的JSON中,已经有了整个列表对应的链接,但是每个对象还没有链接。
固然可以迭代每个Resource对象来添加链接,但这样的做法并不好。在之前是直接使用Resources.wrap(list)
来生成了一个JSON,现在我们需要使用一个新的组装类,来将每一个Student对象包装成一个Resource<Student>。
这种方法需要两个类,一个是从一个Student对象中的数据生成一个ResourceSupport的子类,相当于一个数据对象;然后还需要一个组装类,把一个个ResourceSupport对象组装成一个Resources对象。
先来看数据对象类,这个类需要继承org.springframework.hateoas
包中的ResourceSupport
类,将Student对象的属性搬到上边去:
package cc.conyli.restlearn.api;
import cc.conyli.restlearn.entity.Student;
import lombok.Getter;
import org.springframework.hateoas.ResourceSupport;
public class StudentResource extends ResourceSupport {
@Getter
private String firstName;
@Getter
private String lastName;
@Getter
private Integer courseId;
public StudentResource(Student student) {
this.firstName = student.getFirstName();
this.lastName = student.getLastName();
this.courseId = student.getCourseId();
}
}
这个类很像一个数据类(domain类,之前也叫做entity类),但是没有必要给出id了,因为在访问的URL中已经提供了id。如果不对外提供服务的话也可以暴露id给前端页面。
在日常开发中,可能有的开发者会直接把Entity直接继承ResourceSupport类,差不多,
然后是组装类,这个类需要继承org.springframework.hateoas.mvc.ResourceAssemblerSupport
:
package cc.conyli.restlearn.api;
import cc.conyli.restlearn.controller.RestController;
import cc.conyli.restlearn.entity.Student;
import org.springframework.hateoas.mvc.ResourceAssemblerSupport;
public class StudentResourceAssembler extends ResourceAssemblerSupport<Student, StudentResource> {
public StudentResourceAssembler() {
super(RestController.class, StudentResource.class);
}
@Override
protected StudentResource instantiateResource(Student entity) {
return new StudentResource(entity);
}
@Override
public StudentResource toResource(Student student) {
return createResourceWithId(student.getId(), student);
}
}
ResourceAssemblerSupport
的泛型是原始的数据类和包装后的类。这个类还有一个父类,默认的无参构造器传入控制器类和包装后的类,用于确定JSON里链接的属性。
instantiateResource(Student entity)
方法是将一个Student转换成StudentResource类的方法,如果StudentResource有默认构造器,这个方法可以不用写。由于我们的StudentResource类构造器是需要传入一个Student对象的,因此这里还需要手工重写这个方法。
toResource()
是ResourceAssemblerSupport类唯一一个强行要求重写的方法,这个方法是从student对象创建对应的Resource的时候,自动将其的id作为作为链接加上去。
最后修改控制器,来使用新类组装JSON,不使用Resources.wrap:
@GetMapping("/students")
public Resources<StudentResource> showStudentList() {
List<Student> students = (List<Student>)studentRepo.findAll();
List<StudentResource> studentResources = new StudentResourceAssembler().toResources(students);
Resources<StudentResource> studentsHATEOAS = new Resources<>(studentResources);
studentsHATEOAS.add(ControllerLinkBuilder.linkTo(ControllerLinkBuilder.methodOn(RestController.class).showStudentList()).withRel("students"));
return studentsHATEOAS;
}
可以看到,其实就是用自己写的StudentResource代替了原来的Resource<Student>;把List<Student>
变成List<StudentResource>
,然后直接用Resources<>(studentResources)构造器方法创建一个新的Resources对象。
再把方法返回的泛型修改一下就行了。
在实际测试的时候,发现只返回了http://127.0.0.1:8080/api/1
这样的链接,这是因为传入的类的匹配只有/api,需要重构一下控制器类,分成两个Rest控制器。另一个CourseRestController的代码就不放了。
这里是完整的StudentRestController:
package cc.conyli.restlearn.controller;
import cc.conyli.restlearn.api.StudentResource;
import cc.conyli.restlearn.api.StudentResourceAssembler;
import cc.conyli.restlearn.entity.Student;
import cc.conyli.restlearn.repository.StudentRepo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.hateoas.Resources;
import org.springframework.hateoas.mvc.ControllerLinkBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Optional;
@Slf4j
@org.springframework.web.bind.annotation.RestController
@RequestMapping(path = "/api/students", produces = "application/json")
@CrossOrigin("*")
public class StudentRestController {
private StudentRepo studentRepo;
@Autowired
public StudentRestController(StudentRepo studentRepo) {
this.studentRepo = studentRepo;
}
@GetMapping
public Resources<StudentResource> showStudentList() {
List<Student> students = (List<Student>)studentRepo.findAll();
List<StudentResource> studentResources = new StudentResourceAssembler().toResources(students);
Resources<StudentResource> studentsHATEOAS = new Resources<>(studentResources);
studentsHATEOAS.add(ControllerLinkBuilder.linkTo(ControllerLinkBuilder.methodOn(StudentRestController.class).showStudentList()).withRel("students"));
return studentsHATEOAS;
}
@GetMapping("/{id}")
public ResponseEntity<Student> getStudent(@PathVariable("id") int id) {
Optional<Student> student = studentRepo.findById(id);
if (student.isPresent()) {
return new ResponseEntity<>(student.get(), HttpStatus.OK);
} else {
return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
}
}
@PostMapping(consumes = "application/json")
@ResponseStatus(HttpStatus.CREATED)
public Student addStudent(@RequestBody Student student) {
log.info(student.toString());
return studentRepo.save(student);
}
@PutMapping(path = "/{id}", consumes = "application/json")
public ResponseEntity<Student> replaceStudent(@PathVariable("id") int id, @RequestBody Student student) {
Optional<Student> targetStudent = studentRepo.findById(id);
if (targetStudent.isPresent()) {
Student theStudent = targetStudent.get();
theStudent.setFirstName(student.getFirstName());
theStudent.setLastName(student.getLastName());
theStudent.setCourseId(student.getCourseId());
return new ResponseEntity<>(studentRepo.save(theStudent), HttpStatus.CREATED);
} else {
return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
}
}
@PatchMapping(path = "/{id}", consumes = "application/json")
public ResponseEntity<Student> patchStudent(@PathVariable("id") int id, @RequestBody Student student) {
Optional<Student> targetStudent = studentRepo.findById(id);
if (targetStudent.isPresent()) {
Student theStudent = targetStudent.get();
if (student.getFirstName() != null) {
theStudent.setFirstName(student.getFirstName());
}
if (student.getLastName() != null) {
theStudent.setLastName(student.getLastName());
}
if (student.getCourseId() != null) {
theStudent.setCourseId(student.getCourseId());
}
return new ResponseEntity<>(studentRepo.save(theStudent), HttpStatus.CREATED);
} else {
return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
}
}
@DeleteMapping(path = "/{id}", consumes = "application/json")
public ResponseEntity<Student> removeStudent(@PathVariable("id") int id) {
Optional<Student> targetStudent = studentRepo.findById(id);
if (targetStudent.isPresent()) {
studentRepo.delete(targetStudent.get());
return new ResponseEntity<>(null, HttpStatus.NO_CONTENT);
}else {
return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
}
}
}
注意其中的红色部分要修改成当前的类,然后还要把StudentResourceAssembler修改如下:
package cc.conyli.restlearn.api;
import cc.conyli.restlearn.controller.StudentRestController;
import cc.conyli.restlearn.entity.Student;
import org.springframework.hateoas.mvc.ResourceAssemblerSupport;
public class StudentResourceAssembler extends ResourceAssemblerSupport<Student, StudentResource> {
public StudentResourceAssembler() {
super(StudentRestController.class, StudentResource.class);
}
@Override
protected StudentResource instantiateResource(Student entity) {
return new StudentResource(entity);
}
@Override
public StudentResource toResource(Student student) {
return createResourceWithId(student.getId(), student);
}
}
传入了正确的类之后,再获取JSON字符串,就可以显示出正确的结果了。现在还有一个问题,就是Student类全是基本类型和字符串,而Course类中有Student类的对象,也需要制作两个类。
由于外键字段是一个列表,因此不能简单的在CourseResource类中使用原来的List<Student>,而是要使用已经编写好的StudentResource对象(可以理解为最终渲染好的一段JSON)
package cc.conyli.restlearn.api;
import cc.conyli.restlearn.entity.Course;
import lombok.Getter;
import org.springframework.hateoas.ResourceSupport;
import java.util.ArrayList;
import java.util.List;
public class CourseResource extends ResourceSupport {
private final StudentResourceAssembler studentResourceAssembler = new StudentResourceAssembler();
@Getter
private String courseName;
@Getter
private List<StudentResource> students = new ArrayList<>();
public CourseResource(Course course) {
this.courseName = course.getCourseName();
this.students = studentResourceAssembler.toResources(course.getStudents());
}
}
实际上这里的List是一个Resoure对象的List了,这部分调用了之前编写的studentResourceAssembler来转换学生列表,就和单独查询学生结果列表一样。
其他就是还要编写一个CourseResourceAssembler用于生成最终的类:
package cc.conyli.restlearn.api;
import cc.conyli.restlearn.controller.CourseRestController;
import cc.conyli.restlearn.controller.StudentRestController;
import cc.conyli.restlearn.entity.Course;
import cc.conyli.restlearn.entity.Student;
import org.springframework.hateoas.mvc.ResourceAssemblerSupport;
public class CourseResourceAssembler extends ResourceAssemblerSupport<Course, CourseResource> {
public CourseResourceAssembler() {
super(CourseRestController.class, CourseResource.class);
}
@Override
protected CourseResource instantiateResource(Course entity) {
return new CourseResource(entity);
}
@Override
public CourseResource toResource(Course course) {
return createResourceWithId(course.getId(), course);
}
}
此时访问http://127.0.0.1:8080/api/courses
,得到的JSON字符串如下:
{
"_embedded": {
"courseResourceList": [
{
"courseName": "Java Programming",
"students": [
{
"firstName": "Angelo",
"lastName": "Gladstone",
"courseId": 1,
"_links": {
"self": {
"href": "http://127.0.0.1:8080/api/students/1"
}
}
},
{
"firstName": "Ronald",
"lastName": "Constance",
"courseId": 1,
"_links": {
"self": {
"href": "http://127.0.0.1:8080/api/students/2"
}
}
},
{
"firstName": "Aries",
"lastName": "Browning",
"courseId": 1,
"_links": {
"self": {
"href": "http://127.0.0.1:8080/api/students/11"
}
}
},
{
"firstName": "Angelo",
"lastName": "Gladstone",
"courseId": 1,
"_links": {
"self": {
"href": "http://127.0.0.1:8080/api/students/16"
}
}
}
],
......
在每个课程的内部,带上了选上该课的学生的JSON。
可见就是两个包裹类,如果是单独的数据对象,就直接包裹,如果有连接到其他数据对象的,可以直接显示外键的数值,也可以像第二个例子一样,用包装后的列表对象作为JSON显示,这样就可以通过一个数据同时获得相关的数据。
自定义名称
查看JSON可以发现其中:
{
"_embedded": {
"courseResourceList": [
{
这个红色部分的名称,是从List<Resources<XXX>>
创建Resources对象的时候生成的,可以通过@Relation
注解自定义:
@Relation(value = "course", collectionRelation = "courses")
public class CourseResource extends ResourceSupport {
private final StudentResourceAssembler studentResourceAssembler = new StudentResourceAssembler();
@Getter
private String courseName;
@Getter
private List<StudentResource> students = new ArrayList<>();
public CourseResource(Course course) {
this.courseName = course.getCourseName();
this.students = studentResourceAssembler.toResources(course.getStudents());
}
}
这么定义的话,表示如果一个List<CourseResource>被用作Resources对象的一部分时,键名叫做courses。一个单独的Course对象则被叫做course。当然,返回单个对象我们这里没有编写。
使用Spring Data Rest全自动生成HATEOAS REST API
感觉配置比较麻烦,还需要自定义包裹类一层一层包裹? Spring Data Rest可以根据已经实例化的Repo自动生成对应的链接。
只需要添加如下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-rest</artifactId>
</dependency>
之后可以将所有的控制器都删除,不删除也可以。重新启动项目。
访问如下地址http://localhost:8080/students可以获得全部学生列表,然后里边还生成了单个学生地址。
访问http://localhost:8080/courses可以获得课程地址,每个课程还带了例如http://localhost:8080/courses/1/students的查看每个课程里的学生的地址,非常方便。
不只是能访问,还可以向/students
地址POST新的对象,或者向students/id
来PATCH和DELETE对象。
spring.data.rest.base-path=/api
就是用来设置API前缀。
这里还有一点要注意的就是,Spring Data Rest会自动根据Entity类的对象类名进行复数化。这里的courses和students都是普通复数,如果遇到一些自动复数有问题,可以在Entity类之前加上注解来指定路径和名称:
@RestResource(rel="studentlists", path="stu")
这个表示路径会变成localhost:8080/api/stu,显示出的JSON中的列表键名会变成:
{
"_embedded" : {
"studentsss" : [ {
"firstName" : "Angelo",
"lastName" : "Gladstone",
"courseId" : 1,
分页排序查询
关于分页排序查询,这里发现Spring Data Rest的查询参数不生效,经过试验,发现关键在于Repo类继承了哪个类。
如果继承的是CrudRepository,则没有查询,因为这个类不支持查询。继承PagingAndSortingRepository或者JPARepository都可以。
剩下就比较简单了,之前也学习过,直接采用URL请求参数查询:http://localhost:8080/api/students?size=10&page=1&sort=id,desc
。
这里还有一部分内容是让自己定义的Rest端点加入的Spring Data Rest的返回结果中,这一部分暂时先过去。