RESTful

REST

一. 简介

REST,即REST风格架构,全称为Resource Representational State Transfer - 资源表现层状态转移,了解REST要先理解其中三个名词:资源表现层状态转换

  • 资源(Resource),可以是系统用户,角色,菜单,也可以是文本,图片等文件,是一个具体存在的对象。每个资源对应一个URI,URI也叫端点。
  • 表现层(Representational),获取资源后的表现形式,如JSON,XML等。
  • 状态转换(State Transfer),资源可以经历创建,访问,修改,删除的过程,HTTP是一个无状态的协议,资源的状态变化只能在服务器端,不过HTTP有一系列动作可以进行转换。

它不能算是HTTP那种协议或规则,而是一种风格,主要用于微服务间的交互。

每个资源都对应一个网址,URL中每级路径都是名词代表着一个资源,而不包含动作或服务。如获取id为1的用户:xxxx/user/1。

1.1 特点

REST架构的特点

  • 服务器有一系列资源,每个资源对应一个URI
  • 客户端和服务器可以相互传递资源,资源以表现层展示
  • 客户端通过HTTP的一系列动作对资源进行操作,实现资源状态转换
  • 用 HTTP Status Code传递Server的状态信息。比如最常用的 200 表示成功,500 表示Server内部错误等

网络是怎样连接的 (一) Web浏览器有介绍HTTP协议的一系列动作。

1.2 为什么使用RESTful风格?

在传统的网站开发中,网页代码是前后端杂糅的,如PHP和JSP等,但近些年平台的不断增加,client越来越多,如何兼容或者说提供统一的接口给不同的平台提供服务,而RESTful风格很适合这种场景。

1.3 RESTful与常用URL的异同

  • RESTful风格对应常用的服务请求URL:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    常规:     PUT users?userName=user_name&note=note
    RESTful: PUT users/{userName}/{note}

    常规: /getProducts
    常规: /listOrders
    常规: /retrieveClientByOrder?orderId=1
    RESTful: GET /products //不用动词,推荐复数
    RESTful: POST /products
    RESTful: GET /products/4
    RESTful: PATCH/PUT /products/4
  • RESTful风格保证HEAD和GET动作是数据安全的,不会导致资源状态的变化。

    1
    GET /deleteProduct?id=1 //禁止此类请求
  • RESTful风格警惕返回结果的大小,需要及时进行分页(pagination)或者加入限制(limit)。HTTP协议支持分页(Pagination)操作,在Header中使用 Link 即可。

  • RESTful风格使用正确的HTTP Status Code表示访问状态

  • RESTful风格要求返回的信息中要是让人容易理解的文本,而不是code信息。

  • 安全方面:https,加上一个key做一次hash放在最后即可。可以考虑OAuth2。

Server统一提供一套RESTful API,各个平台各自调用接口,每个平台都有自己合适的框架来方便我们开发。如Spring完全支持RESTful,Web端可以用重量级的AngularJS,也可以用轻量级 Backbone + jQuery。Android端有RetroFit,Volley等,IOS端有RestKit等。

二. Spring MVC整合REST

Spring MVC 从设计基础上就支持REST风格,如最初的**@RequestMapping**设计URI。

  • @GetMapping: 对应HTTP的GET请求,获取资源
  • @PostMapping: 对应HTTP的POST请求,创建资源
  • @PutMapping: 对应HTTP的PUT请求,提交所有资源属性以修改资源
  • @PatchMapping: 对应HTTP的PATCH请求,提交资源部分修改的属性
  • @DeleteMapping: 对应HTTP的DELETE请求,删除服务器端的资源

Spring使用:

  • 通过注解**@PathVariable**将URI地址的参数获取。
  • 通过注解**@RequestMapping@GetMapping**把URI定位到映射的控制器方法。
  • 对于复杂的参数,通过**@RequestBody**将JSON数据集转换为Java对象。
  • 通过@RestController注解使整个控制器都默认返回JSON数据集。

三. 简单实现RESTful风格的Server+Client

通过一个简单的demo来学习SpringBoot使用RESTful风格。可以参考官网学习:Spring指南-rest-service

3.1 POM.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-rest</artifactId>
<version>2.0.0.RELEASE</version>
</dependency>
<dependency>
<groupId>com.microsoft.sqlserver</groupId>
<artifactId>mssql-jdbc</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

3.2 application.properties

1
2
3
4
5
6
7
8
9
10
11
12
#database
spring.datasource.driver-class-name=com.microsoft.sqlserver.jdbc.SQLServerDriver
spring.datasource.url=jdbc:sqlserver://localhost;DatabaseName=Test
spring.datasource.username=sa
spring.datasource.password=123456

#控制台显示真实SQL
spring.jpa.show-sql = true
#hibernate实体类自动维护数据库表结构,update:启动时根据实体类生成表,类和表的更改会同步,validate:启动时验证实体类和表是否一致
spring.jpa.hibernate.ddl-auto=update
#rest访问路径
spring.data.rest.base-path= /api

3.3 启动类

1
2
3
4
5
6
@SpringBootApplication
public class TestprojectApplication{
public static void main(String[] args) {
SpringApplication.run(TestprojectApplication.class, args);
}
}

3.4 实体类

1
2
3
4
5
6
7
8
9
10
11
12
13
@Data
@Getter
@Setter
@Entity
public class Person {
@Id
@GeneratedValue(generator = "idSequence", strategy = GenerationType.SEQUENCE)
@SequenceGenerator(name = "idSequence", sequenceName = "SEQ_TEST", allocationSize=1)
private Long id;
private String name;
private String sex;
private String note;
}

3.5 Repository

通过 @RepositoryRestResource 创建RESTful端点,通过 @RestResource 可以直接通过URL调用函数。

1
2
3
4
5
6
@RepositoryRestResource(collectionResourceRel = "people",path = "people")//REST默认为名词+s:persons
public interface PersonRepository extends JpaRepository<Person,Long> {

@RestResource(path = "nameStartsWith", rel = "nameStartsWith")
Person findByNameStartsWith(@Param("name")String name);
}

3.6 运行

启动项目,访问根目录,在浏览器输入: http://localhost:8080/

1
2
3
4
5
6
7
8
9
10
11
{ 
"_links" : {
"people" : {
"href" : "http://localhost:8080/people{?page,size,sort}",
"templated" : true
},
"profile" : {
"href" : "http://localhost:8080/profile"
}
}
}

获取用户,输入: http://localhost:8080/people

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{ 
"_embedded" : {
"people" : [ ]
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/people{?page,size,sort}",
"templated" : true
},
"profile" : {
"href" : "http://localhost:8080/profile/people"
},
"search" : {
"href" : "http://localhost:8080/people/search"
}
},
"page" : {
"size" : 20,
"totalElements" : 0,
"totalPages" : 0,
"number" : 0
}
}

表示数据库内没有数据,随便在数据表添加一些数据,再次执行结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
{ 
"_embedded" : {
"people" : [
{
"name" : "阿大",
"sex" : "男",
"note" : "合肥",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/1"
},
"person" : {
"href" : "http://localhost:8080/people/1"
}
}
},
{
"name" : "xx",
"sex" : "男",
"note" : "北京",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/2"
},
"person" : {
"href" : "http://localhost:8080/people/2"
}
}
},
{
"name" : "yy",
"sex" : "男",
"note" : "上海",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/3"
},
"person" : {
"href" : "http://localhost:8080/people/3"
}
}
}, {
"name" : "zz",
"sex" : "男",
"note" : "南京",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/4"
},
"person" : {
"href" : "http://localhost:8080/people/4"
}
}
},
{
"name" : "aa",
"sex" : "女",
"note" : "武汉",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/5"
},
"person" : {
"href" : "http://localhost:8080/people/5"
}
}
},
{
"name" : "bb",
"sex" : "女",
"note" : "合肥",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/6"
},
"person" : {
"href" : "http://localhost:8080/people/6"
}
}
}
]
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/people{?page,size,sort}",
"templated" : true
},
"profile" : {
"href" : "http://localhost:8080/profile/people"
},
"search" : {
"href" : "http://localhost:8080/people/search"
}
},
"page" : {
"size" : 20,
"totalElements" : 6,
"totalPages" : 1,
"number" : 0
}
}

模糊查询,输入:http://localhost:8080/people/search/nameStartsWith?name=a

1
2
3
4
5
6
7
8
9
10
11
12
13
{ 
"name" : "aa",
"sex" : "女",
"note" : "武汉",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/5"
},
"person" : {
"href" : "http://localhost:8080/people/5"
}
}
}

返回消息中提示我们此资源的RESTful风格链接,输入:http://localhost:8080/people/5

1
2
3
4
5
6
7
8
9
10
11
12
13
{ 
"name" : "aa",
"sex" : "女",
"note" : "武汉",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/5"
},
"person" : {
"href" : "http://localhost:8080/people/5"
}
}
}

前面提示我们可以在请求中携带分页和排序的参数,输入:http://localhost:8080/people?page=1&size=2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
{ 
"_embedded" : {
"people" : [
{
"name" : "yy",
"sex" : "男",
"note" : "上海",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/3"
},
"person" : {
"href" : "http://localhost:8080/people/3"
}
}
},
{
"name" : "zz",
"sex" : "男",
"note" : "南京",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/4"
},
"person" : {
"href" : "http://localhost:8080/people/4"
}
}
}
]
},
"_links" : {
"first" : {
"href" : "http://localhost:8080/people?page=0&size=2"
},
"prev" : {
"href" : "http://localhost:8080/people?page=0&size=2"
},
"self" : {
"href" : "http://localhost:8080/people{&sort}",
"templated" : true
},
"next" : {
"href" : "http://localhost:8080/people?page=2&size=2"
},
"last" : {
"href" : "http://localhost:8080/people?page=2&size=2"
},
"profile" : {
"href" : "http://localhost:8080/profile/people"
},
"search" : {
"href" : "http://localhost:8080/people/search"
}
},
"page" : {
"size" : 2,
"totalElements" : 6,
"totalPages" : 3,
"number" : 1
}
}

再试一下排序,输入:http://localhost:8080/people?sort=sex,note

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
{ 
"_embedded" : {
"people" : [
{
"name" : "xx",
"sex" : "男",
"note" : "北京",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/2"
},
"person" : {
"href" : "http://localhost:8080/people/2"
}
}
},
{
"name" : "阿大",
"sex" : "男",
"note" : "合肥",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/1"
},
"person" : {
"href" : "http://localhost:8080/people/1"
}
}
},
{
"name" : "zz",
"sex" : "男",
"note" : "南京",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/4"
},
"person" : {
"href" : "http://localhost:8080/people/4"
}
}
},
{
"name" : "yy",
"sex" : "男",
"note" : "上海",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/3"
},
"person" : {
"href" : "http://localhost:8080/people/3"
}
}
},
{
"name" : "bb",
"sex" : "女",
"note" : "合肥",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/6"
},
"person" : {
"href" : "http://localhost:8080/people/6"
}
}
},
{
"name" : "aa",
"sex" : "女",
"note" : "武汉",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/5"
},
"person" : {
"href" : "http://localhost:8080/people/5"
}
}
}
]
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/people"
},
"profile" : {
"href" : "http://localhost:8080/profile/people"
},
"search" : {
"href" : "http://localhost:8080/people/search"
}
},
"page" : {
"size" : 20,
"totalElements" : 6,
"totalPages" : 1,
"number" : 0
}
}

除了简单的查询,新增和修改操作呢?可以通过Postman工具,发送一个POST请求:{“name” : “小明” , “sex” : “男” , “note” : “杭州”}

1
2
3
4
5
6
7
8
9
10
11
12
13
{ 
"name" : "小明",
"sex" : "男",
"note" : "杭州",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/7"
},
"person" : {
"href" : "http://localhost:8080/people/7"
}
}
}

用PUT方式对数据进行修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
{ 
"name" : "小明",
"sex" : "男",
"note" : "深圳",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/7"
},
"person" : {
"href" : "http://localhost:8080/people/7"
}
}
}
//此时表格数据如下
{
"_embedded" : {
"people" : [
{
"name" : "阿大",
"sex" : "男",
"note" : "合肥",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/1"
},
"person" : {
"href" : "http://localhost:8080/people/1"
}
}
},
{
"name" : "xx",
"sex" : "男",
"note" : "北京",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/2"
},
"person" : {
"href" : "http://localhost:8080/people/2"
}
}
},
{
"name" : "yy",
"sex" : "男",
"note" : "上海",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/3"
},
"person" : {
"href" : "http://localhost:8080/people/3"
}
}
},
{
"name" : "zz",
"sex" : "男",
"note" : "南京",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/4"
},
"person" : {
"href" : "http://localhost:8080/people/4"
}
}
},
{
"name" : "aa",
"sex" : "女",
"note" : "武汉",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/5"
},
"person" : {
"href" : "http://localhost:8080/people/5"
}
}
},
{
"name" : "bb",
"sex" : "女",
"note" : "合肥",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/6"
},
"person" : {
"href" : "http://localhost:8080/people/6"
}
}
},
{
"name" : "小明",
"sex" : "男",
"note" : "深圳",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/7"
},
"person" : {
"href" : "http://localhost:8080/people/7"
}
}
}
]
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/people{?page,size,sort}",
"templated" : true
},
"profile" : {
"href" : "http://localhost:8080/profile/people"
},
"search" : {
"href" : "http://localhost:8080/people/search"
}
},
"page" : {
"size" : 20,
"totalElements" : 7,
"totalPages" : 1,
"number" : 0
}
}

用DELETE方式对数据进行删除:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
//此时表格数据如下
{
"_embedded" : {
"people" : [
{
"name" : "阿大",
"sex" : "男",
"note" : "合肥",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/1"
},
"person" : {
"href" : "http://localhost:8080/people/1"
}
}
},
{
"name" : "xx",
"sex" : "男",
"note" : "北京",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/2"
},
"person" : {
"href" : "http://localhost:8080/people/2"
}
}
},
{
"name" : "yy",
"sex" : "男",
"note" : "上海",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/3"
},
"person" : {
"href" : "http://localhost:8080/people/3"
}
}
},
{
"name" : "zz",
"sex" : "男",
"note" : "南京",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/4"
},
"person" : {
"href" : "http://localhost:8080/people/4"
}
}
},
{
"name" : "aa",
"sex" : "女",
"note" : "武汉",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/5"
},
"person" : {
"href" : "http://localhost:8080/people/5"
}
}
},
{
"name" : "小明",
"sex" : "男",
"note" : "深圳",
"_links" : {
"self" : {
"href" : "http://localhost:8080/people/7"
},
"person" : {
"href" : "http://localhost:8080/people/7"
}
}
}
]
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/people{?page,size,sort}",
"templated" : true
},
"profile" : {
"href" : "http://localhost:8080/profile/people"
},
"search" : {
"href" : "http://localhost:8080/people/search"
}
},
"page" : {
"size" : 20,
"totalElements" : 6,
"totalPages" : 1,
"number" : 0
}
}

http://localhost:8080/people

上述这样的路径有些难以管理,可以自定义路径吗?在application.properties中添加如下配置,此时访问路径为:http://localhost:8080/api/people

1
2
# rest访问路径
spring.data.rest.base-path= /api

四. RESTful API的异常处理

使用了RESTful风格后,后端无需再考虑页面渲染问题,但同时也带来了一个新的问题,就是发生异常时后端也只能提供一个响应。

4.1 设计异常信息格式

异常信息需要哪些返回呢?可以参考一下一些互联网大厂的格式设计。

  • Github (use http status)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    {
    "message": "Validation Failed",
    "errors": [
    {
    "resource": "Issue",
    "field": "title",
    "code": "missing_field"
    }
    ]
    }
  • Google (use http status)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    {
    "error": {
    "errors": [
    {
    "domain": "global",
    "reason": "insufficientFilePermissions",
    "message": "The user does not have sufficient permissions for file {fileId}."
    }
    ],
    "code": 403,
    "message": "The user does not have sufficient permissions for file {fileId}."
    }
    }
  • Facebook (use http status)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    {
    "error": {
    "message": "Message describing the error",
    "type": "OAuthException",
    "code": 190,
    "error_subcode": 460,
    "error_user_title": "A title",
    "error_user_msg": "A message",
    "fbtrace_id": "EJplcsCHuLu"
    }
    }
  • Twitter (use http status)

    1
    2
    3
    4
    5
    6
    7
    8
    {
    "errors": [
    {
    "message": "Sorry, that page does not exist",
    "code": 34
    }
    ]
    }
  • Twilio (use http status)

    1
    2
    3
    4
    5
    6
    {
    "code": 21211,
    "message": "The 'To' number 5551234567 is not a valid phone number.",
    "more_info": "https://www.twilio.com/docs/errors/21211",
    "status": 400
    }

总结一下它们的相同点:

  • 都使用了Http状态码,有些还会返回业务错误码。
  • 都提供了异常信息,有些为开发者提供了文档。

4.2 常用的Http状态码

分类 状态码 产生原因 说明
正确返回 200 OK 表示已成功接受客户的请求,相比204多包含一个响应主体。
正确返回 201 Created 在集合中创建资源时,返回此状态码,可以通过响应主体返回新创建的资源,若不能在返回响应之前创建资源,可以用202来代替。
客户端异常 400 Bad Request 由于包含语法错误,当前请求无法被服务器理解。除非进行修改,否则客户端不应该重复提交这个请求。通常在请求参数不合法或格式错误的时候可以返回这个状态码。
客户端异常 401 Unauthorized 当前请求需要用户验证。通常在没有登录的状态下访问一些受保护的 API 时会用到这个状态码。
客户端异常 403 Forbidden 服务器已经理解请求,但是拒绝执行它。与401响应不同的是,身份验证并不能提供任何帮助。通常在没有权限操作资源时(如修改/删除一个不属于该用户的资源时)会用到这个状态码。
客户端异常 404 Not Found 请求失败,请求所希望得到的资源未被在服务器上发现。通常在找不到资源时返回这个状态码。
服务端异常 500 Internal Server Error 通用REST API错误响应,表示非客户错误,所以接收此响应时客户可以再次请求并期待获取正确的返回。
服务端异常 503 Service Unavailable 服务器可能因为请求过载或系统维护而无法处理该请求,通常情况下表示当前是一个临时状态。

4.3 业务错误码

很多时候,我们根据业务类型来自定义错误码。 这些业务错误码与 Http 状态码并不重叠,这时候我们可以返回业务错误码,用来提示用户/开发者错误类型。

我们可以自定义一个Bean来作为异常返回格式,通过枚举类等方式来享元管理异常分类,通过统一异常处理(例如Spring提供了注解@ControllerAdvice)来捕获各个异常并指定相应的Http状态码。


参考:

🔗 《Spring Boot 实战》

🔗 REST 架构该怎么生动地理解?

🔗 Building a RESTful Web Service

🔗 Spring Boot之@RepositoryRestResource注解入门使用教程

🔗 Restful API 中的错误处理

🔗 HTTP Status Codes

🔗 List of HTTP status codes