这一章下了源码包一看,已经是写好的整个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的返回结果中,这一部分暂时先过去。