美文网首页Spring Boot
Spring Boot 之六:创建 REST 服务

Spring Boot 之六:创建 REST 服务

作者: 小胡_鸭 | 来源:发表于2023-04-24 20:36 被阅读0次

    1、关于什么是 Rest API

      首先介绍API的概念,Application Programming Interface(应用程序接口)是它的全称。简单的理解就是,API是一个接口。那么它是一个怎样的接口呢,现在我们常将它看成一个 HTTP 接口即 HTTP API。

      因为 HTTP 是一个比较通用的协议,所以不管是前后端交互,公司内部不同群组应用之间的交互,还是不同公司组织结构间的系统联动通信,通常都是通过调用 HTTP 接口来实现。比如简书这个web应用要允许用户查看(query)、创建(create)、编辑更新(update)、删除(delete)文章,就可以通过创建对应的 HTTP API 来实现:

    http://www.jianshu.com/query_article?id=xxx
    http://www.jianshu.com/create_article...
    http://www.jianshu.com/update_article?...
    http://www.jianshu.com/delete_article?...

      这4个API分别实现了对简书文章的增删查改,可以调用相应的接口来实现相关的功能。但是有个不方便的地方,这种 API 写法有个缺点,就是没有一个统一的风格,比如查询文章也可以写成:

    http://www.jianshu.com/view_article/xxx

      这样会造成接口调用方必须详细了解 API 才知道接口如何运作调用。

      而 REST 可以将我们从上述的困惑中解救出来。

    什么是REST?

    有了上面的介绍,你可能也大概有了直观的了解,说白了,REST是一种风格!

    REST的作用是将我们上面提到的查看(query)、创建(create)、编辑更新(update)、删除(delete)直接映射到HTTP 中已实现的GET,POST,PUT和DELETE方法。

    这四种方法是比较常用的,HTTP总共包含八种方法:

    GET
    POST
    PUT
    DELETE
    OPTIONS
    HEAD
    TRACE
    CONNECT

    当我们在浏览器点点点的时候我们通常只用到了GET方法,当我们提交表单,例如注册用户的时候我们就用到了POST方法...

    介绍到这里,我们重新将上面的四个接口改写成REST风格:

    查看文章:

    GET http://www.jianshu.com/article/xxx

    新增文章:

    POST http://www.jianshu.com/article
    Data: content=xxx,author=yyy

    修改文章:

    PUT http://www.jianshu.com/article
    Data: id=xxx,content=zzz

    删除文章:

    DELETE http://www.jianshu.com/article
    Data: id=xxx

      改动之后API变得统一了,我们只需要改变请求方式就可以完成相关的操作,这样大大简化了我们接口的理解难度,变得易于调用。

    这就是REST风格的意义!

    HTTP状态码

    REST的另一重要部分就是为既定好请求的类型来响应正确的状态码。如果你对HTTP状态码陌生,以下是一个简易总结。当你请求HTTP时,服务器会响应一个状态码来判断你的请求是否成功,然后客户端应如何继续。以下是四种不同层次的状态码:

    • 2xx = Success(成功)
    • 3xx = Redirect(重定向)
    • 4xx = User error(客户端错误)
    • 5xx = Server error(服务器端错误)

    我们常见的是200(请求成功)、404(未找到)、401(未授权)、500(服务器错误)...

    比如查询数据,查不到数据应该返回一个 404 的状态码而不是 200,更加贴近语义。

    API格式响应

    上面介绍了REST API的写法,响应状态码,剩下就是请求的数据格式以及响应的数据格式。说的通俗点就是,我们用什么格式的参数去请求接口并且我们能得到什么格式的响应结果。

    我这里只介绍一种用的最多的格式——JSON格式

    目前json已经发展成了一种最常用的数据格式,由于其轻量、易读的优点。

    所以我们经常会看到一个请求的header信息中有这样的参数:

    Accept:application/json

    这个参数的意思就是接收来自后端的json格式的信息。


    2、Spring 解析请求参数

      对不同的方式请求传递进来的参数,需要使用不同的解析方法。

    (1)请求 URL 参数

      注意:只支持 GET 请求,POST 会报错 403。

      就是参数需要从请求 URL 中解析出来,首先要 @GetMapping 注解里进行参数占位,然后方法参数使用 @PathVariable 注解解析获取参数。

      比如:/param/1,获取参数1

    @Slf4j
    @RestController
    @RequestMapping("/param")
    public class ParseRequestParamDemoController {
    
        @GetMapping("/{id}")
        public ResponseEntity<Map> getPathParam(@PathVariable("id") Long id) {
            log.info("id = " + id);     
            Map<String, Object> map = new HashMap<>();
            map.put("id", id);
            return new ResponseEntity<>(map, HttpStatus.OK);    
        }
      
    }
    

      测试效果:

      如果要多个路径参数,比如:/param/zhangsan/20

    @GetMapping("/{username}/{age}")
    public ResponseEntity<Map> getPathParam2(@PathVariable("username") String user, @PathVariable("age") Integer age) {
        log.info("username = " + user + ", age = " + age);
        Map<String, Object> map = new HashMap<>();
        map.put("username", user);
        map.put("age", age);
        return new ResponseEntity<>(map, HttpStatus.OK);                
    }
    

      测试效果:



    (2)GET 请求参数

      第一种类型是(1)中的路径参数,第二种是形如 /param?username=xxx&age=10 的请求参数。

    方式1:方法参数名跟提交参数一致

    @GetMapping
    public ResponseEntity<Map> getRequestParam(Long id) {
        log.info("id = " + id);     
        Map<String, Object> map = new HashMap<>();
        map.put("id", id);
        return new ResponseEntity<>(map, HttpStatus.OK);
    }
    

    测试:

    方式2:通过 HttpServletRequest 接收,POST、GET 方式都可以

    @GetMapping
    public ResponseEntity<Map> getRequestParam2(HttpServletRequest request) {
        Long id = Long.valueOf(request.getParameter("id"));
        log.info("id = " + id);     
        Map<String, Object> map = new HashMap<>();
        map.put("id", id);
        return new ResponseEntity<>(map, HttpStatus.OK);
    }
    

    方式3:使用 @RequestParam 注解,POST、GET方式都可以

      当方法参数名跟上送参数不一致时,需要使用 @RequestParam 注解,但是使用该注解的参数若没上送会报错 400。

    @GetMapping
    public ResponseEntity<Map> getRequestParam3(@RequestParam("id") Long ids) {
        log.info("id = " + ids);        
        Map<String, Object> map = new HashMap<>();
        map.put("id", ids);
        return new ResponseEntity<>(map, HttpStatus.OK);
    }   
    

    方式4:通过 bean 接收参数

      比如用户登录上送用户名和密码 login?username=xxx&password=yyy,先创建一个对应的 Java Bean:

    public class UserLoginForm {
      
        private String username;
        private String password;
        public String getUsername() {
            return username;
        }
        public void setUsername(String username) {
            this.username = username;
        }
        public String getPassword() {
            return password;
        }
        public void setPassword(String password) {
            this.password = password;
        }
        
        @Override
        public String toString() {
            return "UserLoginForm [username=" + username + ", password=" + password + "]";
        }    
    }
    

      在 Controller 中使用该类:

    @GetMapping("/login")
    public ResponseEntity<UserLoginForm> login(UserLoginForm loginForm) {
        log.info("loginForm = " + loginForm);
        return new ResponseEntity<>(loginForm, HttpStatus.OK);
    }
    

    测试:



    (3)POST 请求参数

      POST 请求通常是上送表单数据,或者Content-Type: 为 application/x-www-form-urlencoded编码的内容,也可以是直接请求 json 数据。

    方式1:通过 HttpServletRequest 接收,POST、GET 方式都可以

      不支持 Content-Type 为 非 form-data 和 x-www-form-urlencoded 类型的请求,比如 application/json,会报错 500。

    @PostMapping("/login")
    public ResponseEntity<Map> login(HttpServletRequest request) {
        Long id = Long.valueOf(request.getParameter("id"));
        log.info("id = " + id);     
        Map<String, Object> map = new HashMap<>();
        map.put("id", id);
        return new ResponseEntity<>(map, HttpStatus.OK);
    }   
    

    测试:




    方式2:使用 @RequestParam 注解,POST、GET方式都可以

    @PostMapping("/login")
    public ResponseEntity<Map> login2(@RequestParam("id") Long ids) {
        log.info("id = " + ids);        
        Map<String, Object> map = new HashMap<>();
        map.put("id", ids);
        return new ResponseEntity<>(map, HttpStatus.OK);
    }   
    

      不支持 Content-Type 为 非 form-data 和 x-www-form-urlencoded 类型的请求,比如 application/json,会报错 400 bad request



    方式3:直接使用 bean 接收参数

    @PostMapping("/login")
    public ResponseEntity<UserLoginForm> login3(UserLoginForm loginForm) {
        log.info("loginForm = " + loginForm);
        return new ResponseEntity<>(loginForm, HttpStatus.OK);
    }
    


    方式4:使用 @RequestBody 注解

      @RequestBody 注解常用来处理 content-type 不是默认的 application/x-www-form-urlcoded 编码的内容,比如说:application/json 或者是 application/xml 等。一般情况下来说常用其来处理 application/json 类型。

    @PostMapping("/login")
    public ResponseEntity<UserLoginForm> login4(@RequestBody UserLoginForm loginForm) {
        log.info("loginForm = " + loginForm);
        return new ResponseEntity<>(loginForm, HttpStatus.OK);
    }   
    

      使用 JSON 请求参数体

      使用 form-data 或 x-www-form-urlcoded 请求参数会报错 415 Unsupported Media Type




    2、创建 Restful 控制器

    (1)检索数据

      定义一个控制器,用来获取最近创建的 Taco,并且只获取最新的 12 个。

      为了使得 TacoRepository 具备分页获取并排序的能力,需要创建一个继承 JPA PagingAndSortingRepository 的 TacoRepository,代码如下:

    package tacos.data;
    
    import org.springframework.data.repository.PagingAndSortingRepository;
    
    import tacos.Taco;
    
    /**
     * 继承了PagingAndSortingRepository就具备了分页获取并排序的能力
     */
    public interface TacoRepository extends PagingAndSortingRepository<Taco, Long> {
    
    }
    

      PagingAndSortingRepository 实际上依然是 CrudRepository,所以 TacoRepository 依然是一个 CrudRepository。

      控制器取名为 TacoApiController,代码如下:

    package tacos.web.api;
    
    import java.util.Optional;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.domain.PageRequest;
    import org.springframework.data.domain.Sort;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.ResponseEntity;
    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.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.ResponseStatus;
    import org.springframework.web.bind.annotation.RestController;
    
    import lombok.extern.slf4j.Slf4j;
    import tacos.data.TacoRepository;
    import tacos.Taco;
    
    @Slf4j
    @RestController                         
    @RequestMapping(path = "/design",       
                   produces = "application/json")   
    @CrossOrigin(origins = "*") 
    public class TacoApiController {
        
        private TacoRepository tacoRepo;
        
        @Autowired
        public TacoApiController(TacoRepository tacoRepo) {     
            this.tacoRepo = tacoRepo;
        }
        
        // 获取最近设计的所有taco
        @GetMapping("/recent")
        public Iterable<Taco> recentTacos() {
            PageRequest page = PageRequest.of(0, 12, Sort.by("createdAt").descending());
            return tacoRepo.findAll(page).getContent();
        }
    }
    
    • @RestController:和使用 @Controller 的区别是,该注解会告诉 Spring,控制器中所有处理器方法的返回值都要直接写入响应体中,而不是将值放到模型中并传递给一个视图以便于进行渲染。

      如果使用的是 @Controller,则必须为每个处理器方法再添加 @ResponseBody注解才能达到一样的效果。

    • @RequestMappingpath 属性表示要响应哪个路径的请求,produces 属性表示控制器中的所有处理器方法返回的报文信息头是 "application/json",如果要限制只处理 Accept 头信息包含 "application/json" 的请求,添加 consumes = "application/json" 即可。

    • @CrossOrigin:配置 origins = "*" 表示允许来自任何域的客户端消费该 API。

    • PageRequestrecentTacos() 方法中先构造了一个 PageRequest 对象,指明想要第一页的 12 条结果,并且要按照 Taco 创建时间降序排列,然后该对象被传递给 Repository 的 findAll() 方法,获得的分页结果内容会返回到客户端。

    测试效果如下:

      查看响应报文的 Headers,可以看到 Content-Type 是 application/json

      下面为控制器编写新的方法,我们希望通过请求 "/design/{id}" 的 GET 请求获取某条数据,新增控制器响应处理方法如下:

    @GetMapping("/{id}")
    public Taco tacoById(@PathVariable("id") Long id) {
        Optional<Taco> optTaco = tacoRepo.findById(id);
        if (optTaco.isPresent()) {
            return optTaco.get();
        }
        return null;
    }
    

      路径的 "{id}" 部分是占位符,请求中的实际值将会传递给 id 参数,它通过 @PathVariable 注解和 {id} 占位符进行匹配。Repository 的 findById() 方法返回一个 Optional 类型的对象,如果查到了数据 isPresent() 方法会返回 true,并且可以通过 get() 方法获得查询到的 Taco 对象,否则返回 null。

      但是这里有个问题,如果查询的数据不存在,会返回 null,客户端将收到空的响应体和 200(OK)的 HTTP 状态码。客户端实际上收到一个无法使用的响应,但是状态码却提示一切正常。

      有一种更好的方式是在响应中使用 HTTP 404(NOT FOUND)状态,代码如下:

    @GetMapping("/{id}")
    public ResponseEntity<Taco> tacoById2(@PathVariable("id") Long id) {
        Optional<Taco> optTaco = tacoRepo.findById(id);
        if (optTaco.isPresent()) {
            log.info("Query taco succ: " + optTaco.get());
            return new ResponseEntity<Taco>(optTaco.get(), HttpStatus.OK);          
        }
        return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
    }   
    

      这里返回的是一个 ResponseEntity 的对象,查到数据会将其放入 Entity 对象中,并且会带有 OK 的 HTTP 状态;如果查不到数据,则包装一个 null 并带有 NOT FOUND 的 HTTP 状态。

      同样测试请求获取不存在的数据,返回响应状态码变了。



    (2)解析 URL 和请求参数

    方式2:通过 HttpServletRequest 接收,POST、GET 方式都可以

    @GetMapping("/taco")
    public ResponseEntity<Taco> tacoById4(HttpServletRequest request) {
        Long id = Long.valueOf(request.getParameter("id"));
        Optional<Taco> optTaco = tacoRepo.findById(id);
        if (optTaco.isPresent()) {
            log.info("Query taco succ: " + optTaco.get());
            return new ResponseEntity<Taco>(optTaco.get(), HttpStatus.OK);          
        }
        return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
    }   
    

      验证下接收 POST 参数,修改代码为:

    @PostMapping("/taco")
    public ResponseEntity<Taco> tacoById4(HttpServletRequest request) {
        System.out.println(request.getParameter("id"));
        Long id = Long.valueOf(request.getParameter("id"));
        Optional<Taco> optTaco = tacoRepo.findById(id);
        if (optTaco.isPresent()) {
            log.info("Query taco succ: " + optTaco.get());
            return new ResponseEntity<Taco>(optTaco.get(), HttpStatus.OK);          
        }
        return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
    }
    

      使用以下配置发起请求

      服务端正常解析接收数据



    方式3:使用 @RequestParam 注解,POST、GET方式都可以

      当方法参数名跟上送参数不一致时,需要使用 @RequestParam 注解,但是使用该注解的参数若没上送会报错 400。

    @GetMapping("/taco")
    public ResponseEntity<Taco> tacoById5(@RequestParam("id") Long ids) {       
        Optional<Taco> optTaco = tacoRepo.findById(ids);
        if (optTaco.isPresent()) {
            log.info("Query taco succ: " + optTaco.get());
            return new ResponseEntity<Taco>(optTaco.get(), HttpStatus.OK);          
        }
        return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
    }
    

      使用 POST 方式表单提交,和Content-Type: 为 application/x-www-form-urlencoded编码的内容,@RequestParam 能正常解析,修改注解为:

    @PostMapping("/taco")
    

      服务端正常解析接收数据



    (3)发送数据到服务器

      在 taco 页面填写表单并发送数据到服务器,代码如下:

    @PostMapping(consumes="application/json")
    @ResponseStatus(HttpStatus.CREATED)
    public Taco postTaco(@RequestBody Taco taco) {
        return tacoRepo.save(taco);
    }
    

      postTaco() 方法使用 @PostMapping 注解表示只处理 POST 请求,这里没有指定 path 属性,所以默认按控制器类级别 @RequestMapping 注解设置的 path 为准。

      这里设置了 consumes 属性,表示只处理报文头 Content-Type: application/json 的请求,对应的 produces 属性用于指定请求输出。

      方法头参数带有 @RequestBody 注解,表示请求应该被转换为一个 Taco 对象并绑定到该参数上。该注解表示要将请求体中的 JSON 绑定到对象,而不是请求参数(表单参数、请求参数)绑定到 Taco 对象上。

      方法还使用了 @ResponseStatus(HttpStatus.CREATED) 注解,正常情况下所有响应的 HTTP 状态码都是200(OK),表示请求是成功的。尽管我们都希望得到 HTTP 200,但是有些时候他的描述性不足。在 POST 请求新增数据的情况下,201(CREATED)的 HTTP 状态更具有描述性,它会告诉客户端,请求不仅成功了,还创建了一个资源。在适当的地方使用该注解将最具描述性和最精确的 HTTP 状态码传递给客户端。

    (4)更新数据

      POST 请求通常用来创建资源,而 PUT、PATCH 请求通常用来更新资源。

      尽管 PUT 经常被用来更新资源,但它在语义上其实是 GET 的对立面。GET 请求用来从服务端往客户端传输数据,而 PUT 请求则是从客户端往服务端发送数据。

      从这个意义上将,PUT 真正的目的是执行大规模的替换(replacement)操作,而不是更新操作;PATCH 的目的是对资源数据打补丁或局部更新

      比如要更新某个订单的信息,使用 PUT 请求处理,使用 @PutMapping 注解:

    @PutMapping(path="/{orderId}", consumes="application/json")
    public Order putOrder(@RequestBody Order order) {       
        return repo.save(order);        
    }   
    

    测试:

      如果说 PUT 请求所做的是对资源数据的大规模替换,那么 PATCH 请求就是处理局部更新,使用 @PatchMapping 注解:

    @PatchMapping(path = "/{orderId}", consumes = "application/json")
    public Order patchOrder(@PathVariable("orderId") Long orderId, 
                            @RequestBody Order patch) {
    
        Order order = repo.findById(orderId).get();
        if (patch.getDeliveryName() != null) {
            order.setDeliveryName(patch.getDeliveryName());
        }
        if (patch.getDeliveryStreet() != null) {
            order.setDeliveryStreet(patch.getDeliveryStreet());
        }
        if (patch.getDeliveryCity() != null) {
            order.setDeliveryCity(patch.getDeliveryCity());
        }
        if (patch.getDeliveryState() != null) {
            order.setDeliveryState(patch.getDeliveryState());
        }
        if (patch.getDeliveryZip() != null) {
            order.setDeliveryZip(patch.getDeliveryState());
        }
        if (patch.getCcNumber() != null) {
            order.setCcNumber(patch.getCcNumber());
        }
        if (patch.getCcExpiration() != null) {
            order.setCcExpiration(patch.getCcExpiration());
        }
        if (patch.getCcCvv() != null) {
            order.setCcCvv(patch.getCcCvv());
        }
        return repo.save(order);
    }
    

      在语义上 PATCH 表示局部更新,但是实际处理还是要依赖业务代码。

    测试:

    (5)删除数据

      使用 DELETE 请求语义上表示删除数据,配套的注解是 @DeleteMapping ,示例代码如下:

    @DeleteMapping("/{orderId}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void deleteOrder(@PathVariable("orderId") Long orderId) {
        try {
            repo.deleteById(orderId);
        } catch (EmptyResultDataAccessException e) {
            log.error("delete order(" + orderId + ") exception", e);
        }
    }   
    

      首先会从 URL 中接收到订单 id,然后直接根据id删除数据库中的记录,若数据库中不存在该记录会抛出 EmptyResultDataAccessException 异常,跟删除成功的结果是一样的,不管如何最终都是数据资源不存在,所以使用 @ResponseStatus(HttpStatus.NO_CONTENT) 表示资源不存在。

    测试:


      如果程序逻辑处理的不同分支要使用不同的响应码,那方法返回值应该是一个 ResponseEntity 对象。

    3、Spring HATEOAS

      HATEOAS 的全名是超媒体作为应用状态引擎(Hypermedia as the Engine of Application State),是一种创建自描述 API 的方式。API 返回的资源中会包含相关资源的链接,客户端只需了解最小的 API URL 信息就能导航整个 API。这种方式能够掌握 API 所提供的资源之间的关系,客户端能够基于 API 的 URL 中所发现的关系对它们进行遍历。

      比如,查询用户最近创建的 taco 列表,使用常规 API 开发的方式,在控制器中创建一个方法:

    @GetMapping("/recent")
    public List<Taco> getRecentTacos() {
        PageRequest page = PageRequest.of(0, 12, Sort.by("createdAt").descending());
        List<Taco> tacos = tacoRepo.findAll(page).getContent();
        return tacos;
    }   
    

      访问 /taco/recent ,效果如下:

    [
        {
            "id": 2,
            "name": "kk's taco",
            "createdAt": "2023-03-10T03:40:04.000+00:00"
        },
        {
            "id": 1,
            "name": "huyihao's taco",
            "createdAt": "2023-03-09T12:24:20.000+00:00"
        }
    ]
    

      如果要访问某个 taco 的详情,则在点击对应 taco 时必须编写对应的 JavaScript 代码将 id 属性拼接到 /taco URL 上,再发起请求,比如 /taco/2 。但是如果 API 发现了变化,硬编码的信息都要跟着修改,而使用 HATEOAS API,返回的响应报文如下:

    {
        "_embedded": {
            "tacoModels": [
                {
                    "taco": {
                        "name": "kk's taco",
                        "createdAt": "2023-03-10T03:40:04.000+00:00"
                    },
                    "_links": {
                        "self": {
                            "href": "http://localhost:8080/taco/2"
                        }
                    }
                },
                {
                    "taco": {
                        "name": "huyihao's taco",
                        "createdAt": "2023-03-09T12:24:20.000+00:00"
                    },
                    "_links": {
                        "self": {
                            "href": "http://localhost:8080/taco/1"
                        }
                    }
                }
            ]
        }
    }
    

      这样无需硬编码访问 URL,即使 API 发生变化也不需要修改 JavaScript 代码。

      Spring HATEOAS 项目是一个 API 库,我们可以使用它轻松创建遵循 HATEOAS(超文本作为应用程序状态引擎)原则的 REST 表示。

      一般来说,该原则意味着 API 应通过返回有关后续潜在步骤的相关信息以及每个响应来指导客户端完成应用程序。将客户端和服务器解耦,理论上允许 API 在不破坏客户端的情况下更改其 URI 方案。

    (1)准备

      引入依赖,使用 Spring Boot 时引入对应的 starter 即可(需要指定 spring-boot-starter-parent 为父工程):

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-hateoas</artifactId>
    </dependency>
    

    (2)添加 HATEOAS 支持

      在Spring HATEOAS项目中,我们既不需要查找Servlet上下文,也不需要将路径变量连接到基本URI。

      相反,Spring HATEOAS 提供了三个用于创建 URI 的抽象 – RepresentationModel、Link 和 WebMvcLinkBuilder。我们可以使用这些来创建元数据并将其与资源表示相关联。

    Ⅰ、向资源添加超媒体支持

      Spring Hateoas 提供了一个名为 RepresentationModel 的基类,以便在创建资源表示时继承:

    package tacos.web.api;
    
    import org.springframework.hateoas.RepresentationModel;
    import com.fasterxml.jackson.annotation.JsonProperty;
    import tacos.Taco;
    
    public class TacoModel extends RepresentationModel<TacoModel> {
    
        private final Taco taco;
    
        /**
         * 参数加上@JsonProperty("content")会有以下效果:
         * 
         *   "content" : {
         *      "name" : "huyihao's taco",
         *      "createdAt" : "2023-03-09T12:24:20.000+00:00"
         *   }
         *
         * 默认效果等同于@JsonProperty("taco")
         *   "taco" : {
         *      "name" : "huyihao's taco",
         *      "createdAt" : "2023-03-09T12:24:20.000+00:00"
         *   }     
         */
        public TacoModel(Taco taco) {       
            this.taco = taco;
        }
    
        public Taco getTaco() {
            return taco;
        }
        
    }
    

      客户资源从 RepresentationModel 类扩展为继承 add() 方法。因此,一旦我们创建了一个链接,我们就可以轻松地将该值设置为资源表示形式,而无需向其添加任何新字段。

    Ⅱ、创建链接

      Spring HATEOAS 提供了一个链接对象来存储元数据(资源的位置或 URI)。

      首先,我们将手动创建一个简单的链接:

    Link link = new Link("http://localhost:8080/taco/1");
    tacoModel.add(link);    // 控制器方法直接返回 TacoModel 对象
    

      Link 对象遵循 Atom 链接语法,由标识与资源关系的 relhref 属性(即实际链接本身)组成。

      下面是客户资源现在包含新链接的外观:

    {
        "taco": {
            "name": "kk's taco",
            "createdAt": "2023-03-10T03:40:04.000+00:00"
        },
        "_links": {
            "self": {
                "href": "http://localhost:8080/taco/1"
            }
        }
    }
    

      与响应关联的 URI 被限定为链接。自我关系的语义很清楚——它只是可以访问资源的规范位置。

    Ⅲ、创建更好的链接

      该库提供的另一个非常重要的抽象是WebMvcLinkBuilder - 它通过避免硬编码链接来简化URI的构建。

      以下代码片段演示如何使用 WebMvcLinkBuilder 类构建客户自链接:

    Link link = WebMvcLinkBuilder.linkTo(RecentTacosController.class)
                               .slash(taco.getId())
                               .withRel("self");
    

    一起来看看:

    • linkTo() 方法检查控制器类并获取其根映射
    • slash() 方法将 tacoId 值添加为链接的路径变量
    • 最后,withSelfMethod() 将关系限定为自链接

    代码如下:

    @GetMapping("/{tacoId}")
    public TacoModel getTacoById(@PathVariable Long tacoId) {
        Optional<Taco> taco = tacoRepo.findById(tacoId);
        TacoModel tacoModel = new TacoModel(taco.get());
        Link link = WebMvcLinkBuilder.linkTo(RecentTacosController.class)
                                     .slash(tacoModel.getTaco().getId())
                                     .withRel("self");
        tacoModel.add(link);
        return tacoModel;
    }
    

    测试效果:

      linkTo(RecentTacosController.class) 的效果体现在,href 超链接的前缀是 http://localhost:8080/taco.slash(tacoModel.getTaco().getId()) 的效果体现在超链接的后缀是 1.withRel("self") 的效果体现在表示超链接的 json 的属性是 self

      如果想要去除多余的 taco 属性,希望返回的报文是下面这样的:

    {
        "id": 1,
        "name": "huyihao's taco",
        "createdAt": "2023-03-09T12:24:20.000+00:00",
        "_links": {
            "self": {
                "href": "http://localhost:8080/taco/1"
            }
        }
    }
    

      首先要重新定义一个继承 RepresentationModel 的 Taco 类:

    package tacos.web.api;
    
    import java.util.Date;
    
    import javax.validation.constraints.NotNull;
    import javax.validation.constraints.Size;
    
    import org.springframework.hateoas.RepresentationModel;
    
    import lombok.Data;
    
    @Data
    public class Taco extends RepresentationModel<Taco> {
        
          private Long id;   
          private String name;   
          private Date createdAt;
          
          public Taco(tacos.Taco taco) {
              this.id = taco.getId();
              this.name = taco.getName();
              this.createdAt = taco.getCreatedAt();
          }
    }
    

      控制器定义一个映射路径不冲突的新方法:

    @GetMapping("/id/{tacoId}")
    public tacos.web.api.Taco getTacoById2(@PathVariable Long tacoId) {
        Optional<Taco> taco = tacoRepo.findById(tacoId);
        tacos.web.api.Taco tacoModel = new tacos.web.api.Taco(taco.get());
        Link link = WebMvcLinkBuilder.linkTo(RecentTacosController.class)
                                     .slash(tacoModel.getId())
                                     .withRel("self");
        tacoModel.add(link);
        return tacoModel;
    }
    

      测试:



    Ⅳ、获取资源列表

      现在我们希望创建一个返回资源列表的 HATEOAS 的 API,返回的报文中带有每个 taco 的访问链接,形式如下所示:

    {
        "_embedded": {
            "tacoes": [
                {
                    "id": 2,
                    "name": "kk's taco",
                    "createdAt": "2023-03-10T03:40:04.000+00:00",
                    "_links": {
                        "self": {
                            "href": "http://localhost:8080/taco/2"
                        }
                    }
                },
                {
                    "id": 1,
                    "name": "huyihao's taco",
                    "createdAt": "2023-03-09T12:24:20.000+00:00",
                    "_links": {
                        "self": {
                            "href": "http://localhost:8080/taco/1"
                        }
                    }
                }
            ]
        }
    }
    

      新增控制器方法:

    @GetMapping("/recent2")
    public CollectionModel<tacos.web.api.Taco> getRecentTacos2() {
        PageRequest page = PageRequest.of(0, 12, Sort.by("createdAt").descending());
        List<Taco> tacos = tacoRepo.findAll(page).getContent();
        List<tacos.web.api.Taco> tacosList = new ArrayList<>();
        for (Taco taco : tacos) {
            Link link = WebMvcLinkBuilder.linkTo(RecentTacosController.class)
                                         .slash(taco.getId())
                                         .withRel("self");
            tacos.web.api.Taco apiTaco = new tacos.web.api.Taco(taco);
            apiTaco.add(link);
            tacosList.add(apiTaco);
        }
        return CollectionModel.of(tacosList);
    }
    

      测试:

    (3)嵌套的关系

      每个 taco 都有对应的配料信息,我们希望创建一个查询 taco 的 HATEOAS API,返回配料的信息和访问配料的连接,报文如下:

    {
        "id": 1,
        "name": "huyihao's taco",
        "createdAt": "2023-03-09T12:24:20.000+00:00",
        "_links": {
            "allIngredients": [
                {
                    "href": "http://localhost:8080/ingredients/COTO"
                },
                {
                    "href": "http://localhost:8080/ingredients/FLTO"
                }
            ],
            "self": {
                "href": "http://localhost:8080/taco/1"
            }
        }
    }
    

      控制器新增方法:

    @GetMapping("/{tacoId}/ingredient")
    public tacos.web.api.Taco getTacoIngredientById(@PathVariable Long tacoId) {
        Optional<Taco> taco = tacoRepo.findById(tacoId);
        tacos.web.api.Taco tacoModel = new tacos.web.api.Taco(taco.get());      
        Link link = WebMvcLinkBuilder.linkTo(RecentTacosController.class)
                                     .slash(tacoModel.getId())
                                     .withRel("self");
        for (Ingredient ingredient : taco.get().getIngredients()) {
            Link ingredientLink = WebMvcLinkBuilder.linkTo(IngredientApiController.class)
                                                   .slash(ingredient.getId())
                                                   .withRel("allIngredients");
            tacoModel.add(ingredientLink);
        }
        
        tacoModel.add(link);
        return tacoModel;
    }
    

      测试:


    4、启用后端数据服务

    (1)自动创建的 REST API

      Spring Data 有一种特殊的魔法,它能够基于我们定义的接口自动创建 repository 实现,还能帮助我们定义应用的 API

      Spring Data REST 是 Spring Data 家族中的另一个成员,它会为 Spring Data 创建的 repository 自动生成 REST API。只需将 Spring Data REST 添加到构建文件中,就能得到一套 API,操作和定义 repository 接口是一致的。

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-rest</artifactId>
    </dependency>
    

      添加完依赖后,不需要做什么其他事情,应用的自动配置功能会为 Spring Data 创建的所有 repository 自动创建 RESTAPI。

      为了统一自动生成的 API 的路径,也为了避免与所编写的控制器发生冲突,在配置文件中添加以下配置:

    spring.data.rest.base-path=/api
    

      访问 http://localhost/api 即可获取所有可访问的 API 的端点链接:

      访问 ingredient 端点

      API 端点中,每个实体的后缀都是单词复数,如果是元音字母,复数为es,比如 taco 本来的链接前缀是 http://localhost:8080/api/tacoes ,如果想统一复数为s,则为其添加类注解:

    ...
    @RestResource(rel = "tacos", path = "tacos")
    ...
    public class Taco {
    
    }
    

      @RestResource 注解能够为实体提供任何我们想要的关系名和路径。在本例中,我们将它们都设置成 "tacos"。

    (2)分页和排序

      为了在查询 taco 时有分页和排序的能力,定义 TacoRepository 时继承了 PagingAndSortingRepository ,如下:

    package tacos.data;
    
    import org.springframework.data.repository.PagingAndSortingRepository;
    import tacos.Taco;
    
    /**
     * 继承了PagingAndSortingRepository就具备了分页获取并排序的能力
     */
    public interface TacoRepository extends PagingAndSortingRepository<Taco, Long> {
    
    }
    

      所以自动生成的 REST API 如下:

    http://localhost:8080/api/tacos{?page,size,sort}
    

      size 表示每页数据的数量,page 表示数据页序号(从0开始),sort 表示数据按照什么排序。

      如果想获取第一页 taco,包含5个条目,可以 GET 请求:

    http://localhost:8080/api/tacos?size=5
    

      如果想获取第二页的 taco,可以 GET 请求:

    http://localhost:8080/api/tacos?size=5&page=1
    

      HATEOAS 返回的报文中分为3部分:

    • 第一部分 "_embedded" 是被查询的数据本身。
    • 第二部分 "_links" 返回第一页、下一页、上一页和最后一页的链接。
    • 第三部分 "page" 返回数据总数、总数据页数等信息。

      如果要对查询出来的数据进行排序,比如按照创建日期字段降序,则请求的 URL :

    http://localhost:8080/api/tacos?size=2&sort=createdAt,desc
    

      在比如根据名字进行升序,则请求的 URL 为:

    http://localhost:8080/api/tacos?size=2&sort=name,acs
    


    (3)添加自定义的端点

      有时候自动生成的 Spring Data REST API 不能满足需求,需要自行定义对应的控制器和方法,为了保持跟 Spring Data REST 生成端点的 API 保持一致,可以在控制器上的映射路径注解加上 /api ,如:

    @RequestMapping("/api/taco")
    

      但是这样硬编码了,如果 spring.data.rest.base-path 参数的设置有变化,就意味着该控制器硬编码要重新修改以保持一致,更好的方案是使用 @RepositoryRestController 注解,所有映射将会具有和 spring.data.rest.base-path 属性一样的前缀。

    注意:使用了 @RepositoryRestController 注解,就不能在控制器上使用 @RequestMapping 注解

      比如新增一个自定义端点,根据 id 查询 taco:

    @RepositoryRestController
    public class RecentTacosController {
        // other code
        @Value("${spring.data.rest.base-path}")
        private String apiBasePath;
        
        @GetMapping(path = "/tacos/{tacoId}", produces = "application/hal+json")
        public ResponseEntity<tacos.web.api.Taco> getTacoById3(@PathVariable Long tacoId) {
            Optional<Taco> taco = tacoRepo.findById(tacoId);
            tacos.web.api.Taco tacoModel = new tacos.web.api.Taco(taco.get());
            Link link = WebMvcLinkBuilder.linkTo(RecentTacosController.class)                                    
                                         .slash(apiBasePath + "/tacos/" + tacoId)
                                         .withRel("self");
            tacoModel.add(link);
            return new ResponseEntity<>(tacoModel, HttpStatus.OK);
        }
    }
    

      测试:

      生成链接时,除了可以使用 slash() 指定,也可以调用其他控制器方法生成。

    @GetMapping(path = "/tacos/recent", produces = "application/hal+json")
    public ResponseEntity<CollectionModel<tacos.web.api.Taco>> getRecentTacos3() {
        PageRequest page = PageRequest.of(0, 12, Sort.by("createdAt").descending());
        List<Taco> tacos = tacoRepo.findAll(page).getContent();
        List<tacos.web.api.Taco> tacosList = new ArrayList<>();
        for (Taco taco : tacos) {
    //          Link link = WebMvcLinkBuilder.linkTo(RecentTacosController.class)
    //                                       .slash(taco.getId())
    //                                       .withRel("self");
            Link link = WebMvcLinkBuilder.linkTo(WebMvcLinkBuilder.methodOn(RecentTacosController.class).getTacoById3(taco.getId())).withRel("self");           
            tacos.web.api.Taco apiTaco = new tacos.web.api.Taco(taco);
            apiTaco.add(link);
            tacosList.add(apiTaco);
        }
        return new ResponseEntity<>(CollectionModel.of(tacosList), HttpStatus.OK);
    }
    

      测试:

      这里还有点小问题囧。。。明明调用 getTacoById3() 返回的超链接是有带 api 前缀的,但是在 中通过 *methodOn() 反射调用得到的链接反而没有,知道的大佬指导一下o(╥﹏╥)o。

    相关文章

      网友评论

        本文标题:Spring Boot 之六:创建 REST 服务

        本文链接:https://www.haomeiwen.com/subject/ufoujdtx.html