想要通过Spring返回JSON字符串作为HTTP的响应体,一个比较简便的方法是使用Spring特殊的REST控制器。
REST服务在HTTP中的使用常见于如下:
HTTP请求类型 | CRUD操作对应 |
---|---|
POST | Create |
GET | Retrieve单个或者对象结果集合 |
PUT | 更新一个存在的对象 |
DELETE | 删除一个已经存在的对象 |
在开始学习REST之前,由于从此之后就是前后端分离了,我们不再返回具体的页面(或者很少返回),所以实际上我们还需要编写前端页面,比如使用Vue等前端框架通过AJAX来发起请求。
不过这里我们只是后端,所以除了浏览器之外,还有一些第三方工具可以用来进行HTTP尤其是REST的测试。
这里涉及的一些Web基础知识就不再多说了,请求行,请求头,请求体,HTTP状态码,MIME等,这里介绍一个开发调试工具Postman
Postman使用
Postman的地址是https://www.getpostman.com/,下载Windows版本然后安装,用Google账户登录之后就可以进入界面了,使用起来也很方便,输入网址就可以方便的看到HTTP的具体信息。
可以通过其访问http://jsonplaceholder.typicode.com/,这是一个用于JSON开发和测试的网站,访问http://jsonplaceholder.typicode.com/users可以看到头部信息和返回的JSON字符串。
现在由于我们没有前端框架用于发送请求,所以就先用Postman当成我们的客户端。
简单的Rest控制器
实际上提供支持的是Spring Web MVC,通过一个注解@RestController
来实现,这个注解继承自@Controller
,所以也是一个Bean。被注解的控制器类用来处理REST请求和响应,会自动在POJO和JSON之间进行转换,只需要将Jackson包在classpath下或者通过Maven配置了Jackson依赖。
使用Maven配置的增删改查项目,配置好Jackson依赖,来添加新的控制器
package cc.conyli.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/test") public class DemoRestController { @GetMapping("/hello") public String sayHello() { return "第一个Spring REST响应"; } }
然后访问链接,可以发现@RestController
修饰的类的方法,并没有被视图解析器解析成jsp路径,而是直接返回了字符串。通过Postman查看可以发现编码不是UTF-8,可以在XML文件中如下配置:
<mvc:annotation-driven> <mvc:message-converters register-defaults="true"> <bean class="org.springframework.http.converter.StringHttpMessageConverter"> <constructor-arg value="UTF-8" /> </bean> </mvc:message-converters> </mvc:annotation-driven>
既然可以使用REST用来直接返回字符串了,剩下就是如何返回JSON字符串了。
返回JSON字符串
这一次我们不再返回简单的字符串,而是要返回一个POJO对象对应的JSON字符串。
其实没有想象的复杂,只需要创建这个POJO对象,然后让控制器返回这个对象就可以了,之后看看到底是什么样的响应体。由于我们已经有了Customer类,就使用这个类,来返回一个Customer集合。
创建返回一个Customer集合对象的REST控制器:
package cc.conyli.controller; import cc.conyli.entity.Customer; import cc.conyli.service.CustomerService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.List; @RestController @RequestMapping("/api") public class CustomerRestController { private CustomerService customerService; @Autowired public CustomerRestController(CustomerService customerService) { this.customerService = customerService; } @GetMapping("/customers") public List<Customer> getCustomers() { return customerService.getCustomers(); } }
注入Bean之类的就不再多说了,因为我们有编写好的业务层和DAO层,可以直接使用。在没有引入REST控制器之前,控制器方法返回了一个Student集合对象,不符合要求。但是现在加上了注解之后,在Postman里访问,可以发现返回了一个由列表转换而成的JSON字符串,这就是Rest控制器的威力。
能返回客户列表了,如何返回单个客户呢,一般采用/api/customers/1
的方式作为REST风格的地址,很显然,就是要获得最后一个数字,用于查询对应的客户。
这里我们使用@PathVariable
注解来获取URL中可变的部分(path variable 路径变量)。这个变量也可以用在普通的非RestController上,这样就像Django一样有匹配。
给REST控制器添加一个新方法:
@GetMapping("/customers/{customerId}") public Customer getCustomer(@PathVariable int customerId) { List<Customer> customers = customerService.getCustomers(); return customers.get(customerId - 1); }
这里现在URL中标出会变化的部分并且给一个名称,然后在参数中绑定该变量为int类型,像极了刚学Spring MVC时候的@ModelAttribute
绑定。
这里我们使用了用户列表,然后通过索引-1去获取,这其实不是太好,应该通过已经编写好的业务层和DAO层按照ID或者对象的方式。不过这里是为了之后的异常处理。
如果直接调用编写好的方法,在id获取不到的时候,返回的就是空页面,观感不好。
尝试在Postman里访问,发现可以正常获取,但是当id超过列表索引的时候,会看到500错误:
Request processing failed; nested exception is java.lang.IndexOutOfBoundsException: Index: 4, Size: 4
很显然这是数组越界,一般如果出现这种运行时错误,应该向用户告知不存在,而不能简单的抛出错误给浏览器。
异常处理
异常处理的逻辑是:
- 编写自定义的错误类和POJO对象
- 添加
@ExceptionHandler
来编写一个处理错误的方法 - 返回POJO对象转换而成的错误信息
由于我们依然要返回一个JSON作为错误信息,所以需要先编写一个类用于转换成JSON:
package cc.conyli.errorhandler; public class CustomerErrorResponse { private int status; private String message; private long timeStamp; public CustomerErrorResponse(int status, String message, long timeStamp) { this.status = status; this.message = message; this.timeStamp = timeStamp; } public CustomerErrorResponse() { } public int getStatus() { return status; } public void setStatus(int status) { this.status = status; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public long getTimeStamp() { return timeStamp; } public void setTimeStamp(long timeStamp) { this.timeStamp = timeStamp; } @Override public String toString() { return "CustomerErrorResponse{" + "status=" + status + ", message='" + message + '\'' + ", timeStamp=" + timeStamp + '}'; } }
然后编写一个自定义的错误类,也很简单:
package cc.conyli.errorhandler; public class CustomerNotfoundError extends RuntimeException { public CustomerNotfoundError(String message) { super(message); } }
之后来编写错误处理方法,先看几个理论要点:
- 错误处理方法用
@ExceptionHandler
注解 - 错误处理方法要求固定返回
ResponseEntity<T>
对象,其中的泛型T是我们刚才编写那个转换成JSON的类。ResponseEntity是一个包装了Http响应的类,这个类可以去控制状态码,响应头和体等具体内容,非常方便。 - 错误处理方法的名称可以是任意名称,但是参数必须是要处理的异常类型,也就是尝试catch的异常类型
来在控制器内编写错误处理方法:
@ExceptionHandler public ResponseEntity<CustomerErrorResponse> handleCustomerNotfoundError(CustomerNotfoundError customerNotfoundError) { CustomerErrorResponse customerErrorResponse = new CustomerErrorResponse(); customerErrorResponse.setStatus(HttpStatus.NOT_FOUND.value()); customerErrorResponse.setMessage(customerNotfoundError.getMessage()); customerErrorResponse.setTimeStamp(System.currentTimeMillis()); return new ResponseEntity<>(customerErrorResponse, HttpStatus.NOT_FOUND); }
这个方法名称可以任意起,传入的参数是我们编写的继承自运行时异常的异常类。而返回的ResponseEntity来自于导入类,其中的泛型是我们编写的转换成JSON的错误信息类。
在方法内部,设置了这个JSON的各个参数,其中的错误信息来自于异常类的.getMessage()方法,还使用了Spring的工具类的状态码信息和值。
最后返回ResponseEntity对象,构建参数是我们的异常信息类和状态码对象,这里的异常信息类就是响应体,而后边的状态码对象,就是让响应的状态码变为404。
然后需要修改刚才的控制器方法,在id不符合要求的时候抛出错误。
@GetMapping("/customers/{customerId}") public Customer getCustomer(@PathVariable int customerId) { Customer customer = customerService.getCustomer(customerId); if (customer == null) { throw new CustomerNotfoundError("Customer with id " + customerId + " NOT FOUND!"); } return customer; }
理论上讲此时如果查询超过范围的id,取不到对象,就会返回JSON字符串,其中的内容就是刚才的类转换而成的错误信息。
试着访问一下:http://localhost:8080/api/customers/10
,得到响应:
{ "status": 404, "message": "Customer with id 14 NOT FOUND!", "timeStamp": 1553500503641 }
成功的出现了错误对象,这里如果尝试输入一个较长的数字,会发现依然有提示,这是因为错误不再是我们自定义的错误,而是转换INT的时候出现的错误,因此可以再添加一个直接抓任何的Exception的方法:
@ExceptionHandler public ResponseEntity<CustomerErrorResponse> handleNormalException(Exception ex) { CustomerErrorResponse customerErrorResponse = new CustomerErrorResponse(); customerErrorResponse.setStatus(HttpStatus.BAD_REQUEST.value()); customerErrorResponse.setMessage(ex.getMessage()); customerErrorResponse.setTimeStamp(System.currentTimeMillis()); return new ResponseEntity<>(customerErrorResponse, HttpStatus.BAD_REQUEST); }
错误会优先匹配子类,然后是父类,所以如果错误是找不到对象的错误,那就会显示JSON格式的404错误,如果是其他错误,就是JSON格式的400错误,尝试访问http://localhost:8080/api/customers/1000000000000000
,会得到如下:
{ "status": 400, "message": "Failed to convert value of type 'java.lang.String' to required type 'int'; nested exception is java.lang.NumberFormatException: For input string: \"1000000000000000\"", "timeStamp": 1553501045234 }
这样就完成了错误处理,如果我们的前端有处理错误JSON字符串的JS程序,就可以将错误信息显示在页面上。
后记:这里最好将Jackson的版本升级到2.9.8或者之后,Github提示低于这个版本的Jackson有安全风险,可以使用如下方式配置:
<dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>[2.9.8,)</version> </dependency>