想要通过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>