2.1 RESTful 风格
RESTful 是一种软件架构风格,它采用了以上提到的要素来构建网络应用程序
-
设计要访问的资源【名词】,用统一的 URI 表示
-
选择资源的展现的方式:一般是 json 格式,也可以是其它格式
-
用 HTTP Method 【动词】来转换资源状态
例如有一本《神雕侠侣》这个资源,它有唯一 id=10 ,这样设计
获取资源,Accept 一般用来指示我想要的格式
GET /api/books/10 HTTP/1.1
Accept: application/json;charset=utf-8
服务器返回,Content-Type 一般用来指示我提供的格式
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
{
"id":10,
"title":"神雕侠侣",
"author":"金庸"
}
如果想换一种格式,注意资源 URI 不用变!
GET /api/books/10 HTTP/1.1
Accept: application/xml;charset=utf-8
服务器返回
HTTP/1.1 200 OK
Content-Type: application/xml;charset=utf-8<book>
<id>10</id>
<title>10</title>
<author>金庸</author>
</book>
更新资源,注意资源 URI 不用变!
PUT /api/books/10 HTTP/1.1
Content-Type: application/json;charset=utf-8{
"title":"神雕侠侣",
"author":"查良镛"
}
删除资源,注意资源 URI 不用变!
DELETE /api/books/10 HTTP/1.1
新增资源,可以这么干:如果希望服务器生成 id,则用 post,否则用 put
POST /api/books HTTP/1.1
Content-Type: application/json;charset=utf-8
{
"title":"侠客行",
"author":"金庸"
}
注意
RESTful 是一种风格,并没有什么规范强制你【必须】怎么做,所以常常可以看到一些与 RESTful 理念不符的实例,因开发者而不同,此为正常现象
2.2 开发 RESTful 应用
⭐️1) 设计统一 URI
要实现【设计要访问的资源【名词】,用统一的 URI 表示】这一特性,我们发现 RESTful 风格中,唯一标识 id,并不是像之前一样从请求参数(即 ? 后)传递过来,而是此 id 就是路径的组成部分,因此需要方便的办法获取路径中的参数。
Spring 提供了 @PathVariable 来解析资源路径中的参数信息
例1:当路径参数名与请求参数名一致时
@RestController
public class Controller01 {
private static final Logger log = LoggerFactory.getLogger(Controller01.class);
@RequestMapping("/test1/{id}")
public String test1(@PathVariable int id) {
log.debug("编号:{}", id);
return "test";
}
}
其中 {参数名}
代表路径中变化的部分,比如这里的 {id}
就代表设置了一个 id 参数,可以用来接收
-
/test1/100
-
/test1/101
等路径中的 100、101 这些编号值,SpringMVC 会检查标注了 @PathVariable 的方法参数,将这些值传递给同名参数
测试
服务器控制台输出
[DEBUG] 08:36:43.731 [http-bio-8080-exec-8] c.i.c.c.Controller01 - 编号:100
注意 RESTful 的路径参数作用与普通
?参数名=参数值
没有两样,都是用来获取请求中的信息。区别仅在于风格格式不同,一种是将信息包含在路径里,一种是将信息跟在 ? 号后
例2:当路径参数名与请求参数名不一致时
@RequestMapping("/test2/{sid}")
public String test2(@PathVariable("sid") int id) {
log.debug("编号:{}", id);
return "test";
}
测试
服务器输出
[DEBUG] 08:37:47.302 [http-bio-8080-exec-1] c.i.c.c.Controller01 - 编号:200
例3:路径参数可以有多个
@RequestMapping("/test3/{id}/{name}")
public String test3(@PathVariable int id, @PathVariable String name) {
log.debug("编号:{} 用户名:{}", id, name);
return "test";
}
测试
服务器输出
[DEBUG] 08:38:33.937 [http-bio-8080-exec-1] c.i.c.c.Controller01 - 编号:300 用户名:zhangsan
注意 当参数比较多时,仍然建议把信息放在请求体中而不是路径里,路径里一般只放关键信息
2) 区分请求方法
要实现【用 HTTP Method 【动词】来转换资源状态】就需要更方便的办法按 请求方法(GET POST 等)来区分请求,访问资源时,增删改查,路径都是同一个(增可能会有不同),这时就需要对路径加以区分,否则就会有歧义:
@RestController
public class Controller02 {
private static final Logger log = LoggerFactory.getLogger(Controller02.class);
@RequestMapping("/users/{id}")
public void get(@PathVariable Integer id) {
log.debug("get...");
}
@RequestMapping("/users/{id}") // 当然新增一般不需指定 id
public void post(@PathVariable Integer id) {
log.debug("post...");
}
}
启动时会报错
Caused by: java.lang.IllegalStateException: Ambiguous mapping. Cannot map 'controller02' method
com.itheima.controller.case05.Controller02#post(Integer)
to { /user/{id}}: There is already 'controller02' bean method com.itheima.controller.case05.Controller02#get(Integer) mapped.
显然必须加以区分,怎么区分呢?可以通过 Request 的不同 Method 来区分。其实想想也是正常,RESTful 不就是建议用 Request Method 来区分对资源的增删改查嘛
代码改成下面这样就可以了
@RestController
public class Controller02 {
private static final Logger log = LoggerFactory.getLogger(Controller02.class);
@RequestMapping(value = "/users/{id}", method = RequestMethod.GET)
public void get(@PathVariable Integer id) {
log.debug("get...");
}
@RequestMapping(value = "/users/{id}", method = RequestMethod.POST)
public void post(@PathVariable Integer id) {
log.debug("post...");
}
}
但显然有些繁琐,这里 SpringMVC 为我们提供了几个简化(衍生)注解,相信一看就明白
@RestController
public class Controller02 {
private static final Logger log = LoggerFactory.getLogger(Controller02.class);
@GetMapping("/users/{id}")
public void get(@PathVariable Integer id) {
log.debug("get...");
}
@PostMapping("/users/{id}")
public void post(@PathVariable Integer id) {
log.debug("post...");
}
@DeleteMapping("/users/{id}")
public void delete(@PathVariable Integer id) {
log.debug("delete...");
}
@PutMapping("/users/{id}")
public void put(@PathVariable Integer id) {
log.debug("put...");
}
}
顺便把 @DeleteMapping 和 @PutMapping 也加上了
不过又有了新的问题:浏览器表单可以发送 POST、GET 请求,但它不支持 PUT、DELETE 请求,虽然有方法可以让表单通过 POST 模拟后两种,但属于偏门知识,这里就不讲了,正规的、常见方法有三种:
-
ajax 可以支持各种请求的发送,但属于前台知识,不是重点
-
Spring 提供了 RestTemplate 发送各种请求,底层就是用 java 代码发送 http 请求
-
第三方工具发送请求,底层也都是 http
-
图形界面的有 postman
-
命令行界面的有 curl
-
3) @RequestBody
客户端的参数比较多,比较复杂时,常会在请求体中传递过来 json 字符串,而 controller 这边可以用 @RequestBody 将此 json 字符串转换为 java 对象
客户端数据
{"name":"张三", "age":18}
根据参数设计一个用来接收的 java 类
static class Student {
String name;
Integer age;
// 省略 get set 方法
}
方法参数用 @RequestBody 标注
@RestController
public class Controller03 {
@PostMapping("/students")
public void post(@RequestBody Student student) {
log.debug("{}", student);
}
// ...
}
如果客户端数据更复杂一些,一样没问题
[
{"name":"张三", "age":18},
{"name":"李四", "age":20}
]
控制器方法
@RestController
public class Controller03 {
@PutMapping("/students")
public void put(@RequestBody List<Student> students) {
for (Student student : students) {
log.debug("{}", student);
}
}
// ...
}
4) 请求参数校验
请求参数接收完,还有一步重要操作,那就是校验。数据不校验,就可能导致非法、残缺数据入库,严重还可能引起系统漏洞
步骤
-
添加依赖 - 代码片段1
-
对象属性上添加校验规则 - 代码片段2
-
控制器方法改动 - 代码片段3
代码片段1(pom.xml)
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
代码片段2
static class Student {
@NotEmpty
String name;
@Min(18)
Integer age;
// ...
}
校验规则有不同注解进行控制,代码中的
-
@NotEmpty 表示该参数不能为
null
,也不能为''
-
@Min 用来验证参数是整数,并且最小值不能小于 18
代码片段3
@RestController
public class ValidateController {
private static final Logger log = LoggerFactory.getLogger(ValidateController.class);
@PostMapping("/validate")
public void validate(@Valid @RequestBody Student student, BindingResult result) {
log.debug(student.toString());
if (result.hasErrors()) {
for (FieldError error : result.getFieldErrors()) {
log.debug("{} 验证错误: {}", error.getField(), error.getDefaultMessage());
}
}
}
}
说明
-
@Valid 用来标注是哪个对象需要验证
-
BindingResult result 用来保存验证结果,获取结果方法见以上代码
-
BindingResult result 与被 @Valid 对象必须紧邻
结果1 - 当姓名与年龄都不正确时
2021-11-02 09:03:12.147 DEBUG 30652 --- [nio-8080-exec-2] com.itheima.demo3.Controller06 : Student{name='', age=11}
2021-11-02 09:03:12.147 DEBUG 30652 --- [nio-8080-exec-2] com.itheima.demo3.Controller06 : age 验证错误: 最小不能小于18
2021-11-02 09:03:12.147 DEBUG 30652 --- [nio-8080-exec-2] com.itheima.demo3.Controller06 : name 验证错误: 不能为空
结果2 - 当年龄正确,姓名为空时
2021-11-02 09:03:43.525 DEBUG 30652 --- [nio-8080-exec-3] com.itheima.demo3.Controller06 : Student{name='', age=18}
2021-11-02 09:03:43.525 DEBUG 30652 --- [nio-8080-exec-3] com.itheima.demo3.Controller06 : name 验证错误: 不能为空
5) @ResponseBody
虽然前面讲到了 RESTful 软件架构风格中,资源可以有多种表现形式,但实际用的最多的还是 json,之前见过的 @ResponseBody 的作用将控制器方法的返回值做相应的转换,再写入响应:
-
如果方法返回的是字符串,这时结果仍被视为普通字符串,即 text/plain
-
如果方法返回的是 Java Bean、List、Map 等,这时结果会转换为 json 字符串,即 application/json
-
方法可以声明为 void,表示响应体没有内容
下面的例子都用到同一个 User 类
public class User {
private Integer id;
private String username;
private String password;
private Date birthday;
// ...
}
例1 - 转换 java bean
@RequestMapping("/json/c1")
@ResponseBody
public User c1() {
return new User(1001, "张三", "123", new Date());
}
访问后返回响应
{
"id": 1001,
"username": "张三",
"password": "123",
"birthday": 1611019823007
}
例2 - Jackson 注解
注意到
-
日期被转换为毫秒值
-
当然这个值用起来也挺方便的,只是可读性差,可以用 @JsonFormat 注解来格式化
-
-
密码属性被转换成了 json
-
如果转换时,希望某一属性被忽略,可以使用 @JsonIgnore 注解
-
public class User {
private Integer id;
private String username;
@JsonIgnore
private String password;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date birthday;
// ...
}
访问后响应
HTTP/1.1 200 OK
{
"id": 1001,
"username": "张三",
"birthday": "2021-01-19 09:41:33"
}
注意
-
这几个注解 jackson 类库提供的,如果你将来用其他 json 实现,需要根据实际情况修改
-
@JsonFormat 注解也可以用以下全局配置,更为方便
-
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
-
spring.jackson.time-zone=GMT+8:00
-
例3 - 忽略 null 值
@RequestMapping("/json/c2")
@ResponseBody
public User c2() {
return new User(null, "张三", null, null);
}
访问后响应
HTTP/1.1 200 OK
{
"id":null,
"username":"张三",
"birthday":null
}
有时候希望 null 值的属性不要参与转换 json,可以这么做
@JsonInclude(JsonInclude.Include.NON_NULL)
public class User {
// ...
}
或者通过配置
spring.jackson.default-property-inclusion=non_null
结果变成
HTTP/1.1 200 OK
{
"username":"张三"
}
例4 - 转换 List
@RequestMapping("/json/c4")
@ResponseBody
public List<String> c4() {
return Arrays.asList("张三", "李四");
}
访问后响应
HTTP/1.1 200 OK
[
"张三",
"李四"
]
例5 - 转换 Map
@RequestMapping("/json/c5")
@ResponseBody
public Map<String, String> c5() {
Map<String, String> map = new HashMap<>();
map.put("beijing", "北京");
map.put("shanghai", "上海");
map.put("shenzhen", "深圳");
return map;
}
访问后响应
HTTP/1.1 200 OK
{
"shanghai": "上海",
"shenzhen": "深圳",
"beijing": "北京"
}
可以看到它的转换结果与 java bean 是一样的,但 Map 有缺点:
-
Map 类型是弱类型,将来接参数时,key还好说都是 String,但 value 转换时不能控制该转换为何种类型
-
Map 上无法方便配合上述注解,对转换 json 时进行控制
6) 响应码与响应头
问题
@RestController
public class Controller06 {
private static final Logger log = LoggerFactory.getLogger(Controller06.class);
@RequestMapping("/rs/c1")
public User c1() {
int i = 1 / 0;
return new User(1001, "张三", "123", new Date());
}
@ExceptionHandler
public Map<String, String> ex(ArithmeticException e) {
Map<String, String> map = new HashMap<>();
map.put("error", e.getMessage());
return map;
}
}
访问后响应
HTTP/1.1 200 OK
{
"error": "/ by zero"
}
虽然也可以,但缺点是,必须解析响应体后才知道出现了错误
@ResponseStatus
可以用 @ResponseStatus 来指定控制器方法或异常处理方法的响应头
修改代码为
@RestController
public class Controller06 {
// ...
@ExceptionHandler
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Map<String, String> ex(ArithmeticException e) {
Map<String, String> map = new HashMap<>();
map.put("error", e.getMessage());
return map;
}
}
访问后响应
HTTP/1.1 500 Internal Server Error
{
"error": "/ by zero"
}
其实在 RESTful 架构风格中,建议响应状态码应当更精确,例如
响应状态码 | 用法 |
---|---|
200 OK | 查询、更新、删除成功时 |
201 Created | 新增成功时,一般响应中应含有新资源的 URI |
202 Accepted | 请求成功接收,会在未来处理(异步处理) |
204 No Content | 成功但没有响应体时 |
400 Bad Request | 请求有误(例如参数不正确) |
401 Unauthorized | 请求身份验证失败 |
403 Forbidden | 请求身份验证成功,但没有权限 |
404 Not Found | 请求资源不存在 |
405 Method Not Allowed | 请求 Method 不被支持 |
415 Unsupported Media Type | 请求资源的表现形式不支持 |
429 Too Many Requests | 请求次数超过限额 |
500 Internal Server Error | 服务器内部错误,服务仍可用 |
503 Service Unavailable | 服务器已不可用 |
这时候 @ResponseStatus 就可以派上用场了
响应码中还有一个比较重要的 304 这个讲到缓存时再说
ResponseEntity
如果希望控制响应的各个部分:响应码、响应头、响应体,还可以使用 ResponseEntity 类型作为控制器方法的返回值
例如:
@RequestMapping("/rs/c2")
public ResponseEntity<User> c2() {
return ResponseEntity.ok()
.header("My-Header", "yeah")
.body(new User(1001, "张三", "123", new Date()));
}
访问后响应
HTTP/1.1 200 OK
My-Header: yeah
{
"id": 1001,
"username": "张三",
"birthday": "2021-01-19 11:05:38"
}
7) 统一响应格式
-
响应状态码的不足:上一节说到,可以用状态码较为精确地描述此操作是成功失败、是客户端的问题、还是服务端的问题,但状态码毕竟有限,是最为通用的描述,如果牵扯业务,就有些不够用了
-
返回的响应体中,可能会表示正常的数据,也有可能包含错误提示,若不统一,就增加了前端解析成本
因此一般应用开发时,会定义一个 AppResult 统一响应格式
AppResult
@JsonInclude(JsonInclude.Include.NON_NULL)
public class AppResult {
// 应用状态码,对响应状态码算是补充和扩展
private Integer code;
// 应用出错描述
private String msg;
// 应用响应数据
private Object data;
public AppResult(Integer code, String msg, Object data) {
this.code = code;
this.msg = msg;
this.data = data;
}
// ...
}
对应的 json 数据如下
HTTP/1.1 响应状态码
{
"code": "应用状态码",
"msg": "应用出错描述",
"data": "应用响应数据"
}
应用状态码都是应用内自定义的,以豆瓣 api 的为例,根据豆瓣 api 中通用错误码来看,它的这些错误码有两层分类:大类关联响应状态码
400 - 客户端校验错误
401 - 客户端认证失败
403 - 客户端权限错误
404 - 资源不存在
而每大类下又分了多个小类,小类关联应用状态码,例如
400
999 unknown_v2_error
1002 missing_args 缺失参数
1003 image_too_large 图片太大
1004 has_ban_word 有违禁词
1005 input_too_short
1006 target_not_fount
1008 image_unknow
1009 image_wrong_format
1012 title_missing
1013 desc_missing
403
1001 need_permission 需要权限
1007 need_captcha 需要验证码
1010 image_wrong_ck
1011 image_ck_expired 图片过期
本章注解
Web 注解
注解名称 | 位置 | 注解作用 | 备注 |
---|---|---|---|
@RequestMapping | 方法 | 映射路径 | |
@RequestMapping | 类 | 为映射路径加统一前缀 | |
@ResponseBody | 方法 | 该控制器方法的返回值即为响应体内容 | |
@ResponseBody | 类 | 影响该类的所有控制器方法 | |
@RestController | 类 | 组合注解 @Controller + @ResponseBody | |
@RequestParam | 参数 | 主要设置请求参数默认值 | |
@RequestHeader | 参数 | 获取请求头,头名称不区分大小写 | |
@CookieValue | 参数 | 获取 cookie 值 | |
@ExceptionHandler | 方法 | 处理控制器异常 | |
@RestControllerAdvice | 类 | 该类用来全局处理控制器异常,@ControllerAdvice + @ResponseBody | |
@ResponseStatus | 方法 | 控制返回响应的状态码 |
JSON 注解
注解名称 | 位置 | 注解作用 | 备注 |
---|---|---|---|
@JsonIgnore | 成员变量 | 转 json 时忽略此成员变量 | |
@JsonFormat | 成员变量 | 转 json 时控制日期格式和时区 | 有等价配置 |
@JsonInclude | 类 | 控制该类取值 null 的成员变量不参与转换 | 有等价配置 |
校验注解
注解名称 | 位置 | 注解作用 | 备注 |
---|---|---|---|
@Valid | 方法参数 | 该参数需要校验 | |
@NotEmpty | 成员变量 | 字符串不能为 null、空、集合不能为空 | |
@Min | 成员变量 | 数字不能小于某个值 | |
@Max | 成员变量 | 数字不能大于某个值 | 扩 |
@NotNull | 成员变量 | 不能为 null | 扩 |
@NotBlank | 成员变量 | 字符串不能为 null、空、不能全为空白字符 | 扩 |
@DecimalMin | 成员变量 | 与 @Min 类似,但可以校验字符串 | 扩 |
@DecimalMax | 成员变量 | 与 @Max 类似,但可以校验字符串 | 扩 |
@Digits | 成员变量 | 校验数字的整数、小数位数 | 扩 |
@Size | 成员变量 | 字符串长度、集合大小 | 扩 |
@Past | 成员变量 | 必须是过去的时间 | 扩 |
@Future | 成员变量 | 必须是未来的时间 | 扩 |
@Pattern | 成员变量 | 必须符合正则表达式 | 扩 |
成员变量 | 必须符合 Email 格式 | 扩 |