I wrote this for the m5-201 course, which aims at preparing new programmers for real full-stack development and tech team leader roles.
后端开发规范
在开始着手做以下内容之前,请务必先把产品的逻辑梳理得井井有条。该用到 UML
、该写文档的地方,绝对不要节省笔墨和时间,哪怕为此稍微耽误了开发进度。也不能因为使用了 敏捷开发
,而忽略明确需求的重要性。
在反复确认准备工作已经完成之后,我们进入开发流程。本篇文档着重描述基于 SpringBoot
的后端开发规范。
为了方便沟通,我们以一个申研信息分享论坛的项目为例,按照后端开发的时间顺序,逐一梳理开发过程中需要注意的规范。
数据库的设计
需求明确之后,首先要进行开发的是数据库。在分析完申研信息分享论坛的项目需求之后,我们大概可以明确,数据库要储存哪些信息。我们例举其中比较重要的几个数据表:
offer 表
:记录所有的 offer,信息包括来自哪所学校、收到的时间、有无奖学金等等。thread 表
:记录论坛里所有的帖子,信息包括发布者、发布时间、帖子内容等等。user 表
:记录所有的用户信息,信息包括用户名、密码的hash
、昵称等等。
设计数据库时,可以尽量参照阿里数据库设计规范。可以先通读一遍,有个大致印象;在设计完数据库之后再一一对照纠错。
接口规范
在不那么复杂的项目中,接口与数据库的表一般是高度对应的。除了一些后台使用的,或为了表示多对多关系的表之外,一般每个表都会对应一系列的 CRUD
操作 —— Create (创建)
, Read (读取)
, Update (更新)
和 Delete (删除)
。上面提到的 thread
表(记录所有帖子信息的)就是如此。对于一个论坛来说,我们需要创建新帖子、读取一系列/某个帖子、更新一个帖子、删除一个帖子。对于 offer
和 user
,我们也要进行一样的操作。
每一个接口,对应一个 url
以及一些参数。在设计的时候,我们希望遵循时下流行的 RESTful API 规范。以 thread
相关接口的设计为例,假定我们的服务器 ip
地址为 10.20.30.40
:
- 创建一个新帖子:用
POST
请求https://10.20.30.40/threads
- 读取所有帖子:用
GET
请求https://10.20.30.40/threads
- 读取一个
id
为1115201
的帖子:用GET
请求https://10.20.30.40/threads/1115201
- 更新一个
id
为1115201
的帖子:用PUT
或PATCH
请求https://10.20.30.40/threads/1115201
- 删除一个
id
为1115201
的帖子:用DELETE
请求https://10.20.30.40/threads/1115201
- 列出一个
id
为1115201
的帖子的所有回复:用GET
请求https://10.20.30.40/threads/1115201/posts
- 删除一个
id
为1115201
的帖子中,id
为1005101
的回复:用DELETE
请求https://10.20.30.40/threads/1115201/posts/1005101
可以看出,光从我们请求的 url
,并不能看出我们进行的是 CRUD
中的哪种操作(前两条功能完全不同的请求甚至有一模一样的 url
)。我们是通过请求不同的请求方法,达到不同的目标的。换句话说,请求的 url
,只提供操作对象的信息,而不定义操作的种类。
使用 SpringBoot
代码实现出来,应该是下面这样(注意其中函数的返回类型均为 CommonResult
,这我们会在 数据的包装
中讲到):
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
// 第一个接口,创建一个帖子
@PostMapping("/threads")
public CommonResult<String> createThread(@RequestBody Thread thread) {...}
// 第二个接口,读取所有帖子
@GetMapping("/threads")
public CommonResult<List<Thread>> getThreads() {...}
// 第三个接口,读取指定 id 的帖子
@GetMapping("/threads/{id}")
public CommonResult<Thread> getThread(@PathVariable BigInteger id) {...}
// 第四个接口 (只写了 PUT),更新一个帖子
@PutMapping("/threads/{id}")
public CommonResult<String> updateThread(@PathVariable BigInteger id, @RequestBody Thread thread) {...}
// 第五个接口,删除指定 id 的帖子
@DeleteMapping("/threads/{id}")
public CommonResult<String> deleteThread(@PathVariable BigInteger id) {...}
// 第六个接口,列出指定 id 的帖子的所有回复
@GetMapping("/threads/{threadId}/posts")
public CommonResult<List<Post>> getThreadPosts(@PathVariable BigInteger threadId) {...}
// 第七个接口,删除指定 id 的帖子下的指定 id 的回复
@DeleteMapping("/threads/{threadId}/posts/{postId}")
public CommonResult<String> deleteThreadPost(@PathVariable BigInteger threadId, @PathVariable BigInteger postId) {...}
数据的包装
以 user
这个表为例。我们的数据库中,可能记录了这些数据:
id
: 用户id
user_name
: 用户名user_psw_hash
: 用户密码的hash
display_name
: 用户昵称user_level
: 用户等级user_exp
: 用户经验值
对应地,我们会创建一个 User
类,对应这个表:
1
2
3
4
5
6
7
8
9
public class User {
private BigInteger id;
private String userName;
private String userPswHash;
private String displayName;
private int userLevel;
private int userExp;
// Getters and setters...
}
在前端,当用户 A
点开 用户 B
(id
为 5201) 的个人主页,需要向后端索要用户 B
的信息。按照 RESTful
的接口设计风格,需向 https://10.20.30.40/users/5201
发送一条GET
请求。然后,我们在 Mybatis
的 Mapper
里写这样的查询函数:
1
2
3
4
public interface UserMapper {
@Select("SELECT * FROM user WHERE id = #{userId}")
User findById(@Param("userId") String userId);
}
然后,在 UserController
中这样处理请求:
1
2
3
4
5
6
7
/********************
* 这样写其实不可取!!!!
********************/
@GetMapping("/users/{id}")
public User getUser(@PathVariable BigInteger id) {
return userMapper.findById(id);
}
这样,在收到用户请求时,相当于运行了下面的查询,然后把结果返回给前端。
1
SELECT * FROM user WHERE id = 5201
但是,这样会有问题:用户 A
在前端会接收到 用户 B
在数据库中的全部信息,包括 user_name
和 user_psw_hash
。这两个信息只是后台登录用的,根本就不应该让用户看到,不然会造成严重的安全问题。我们只能展示前端需要的信息。
所以,我们得新创建一个类 UserVO
,这是专门面对用户的 User
类,包含的信息都是用户可以看的:
1
2
3
4
5
6
public class UserVO {
private String displayName;
private String userLevel;
private String userExp;
// Getters and Setters...
}
VO
即 View Object
,是专门返还给前端用的。UserVO
和 User
比起来,少了 userName
和 userPswHash
。这些是用户登录的时候用的东西,不能给其它用户看。
于是,我们可以在 UserController
里这样处理请求:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@GetMapping("/users/{id}")
public User getUser(@PathVariable BigInteger id) {
// 先去数据库找 user
User user = userMapper.findById(id);
// 创建 userVO,把 user 中不敏感的数据拿出来,给到 userVO
UserVO userVO = new userVO();
userVO.setDisplayName(user.getDisplayName());
userVO.setUserLevel(user.getUserLevel());
userVO.setUserExp(user.getUserExp());
// 把不包含敏感信息的 userVO 返回给用户
return userVO;
}
但是,这样仍然不够完美。我们并没有考虑到 Error
的可能性(比如查询的用户不存在)。并且,我们回复的消息过于 干货
了:除了数据内容之外,啥也没有。这对前端来说非常不友好。我们建议大家使用 CommonResult
(点击这里下载它的代码) 对返回值进行封装,这样任何的返回消息,都会是以下格式:
1
2
3
4
5
{
"code": 200, // 200 是成功,其它可能的有 500、404、401 等等。
"message": "some text here", // 除了数据之外,有哪些信息要展示给用户的(尤其是在出错的情况下)。
"data": ... // 这才是数据。如果出错的话,可以设置成 null,然后在 message 里给出错误原因。
}
于是,上面的代码变成:
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
@RestController
public class UserController {
@Resource // 告诉 SpringBoot,如何初始化这个变量,这样就不用在 constructor 里去初始化了。
private UserMapper userMapper;
@GetMapping("/users/{id}")
public CommonResult<UserVO> getUser(@PathVariable BigInteger id) {
// 先去数据库找 user,跟之前一样,但是考虑数据库错误的情况
User user;
try {
user = userMapper.findById(id);
} catch (Exception e) {
return CommonResult.failed("数据库错误。");
}
// 如果 user 找不到的话:
if (!user) {
return CommonResult.failed("操作失败,用户未找到。");
}
// 这跟之前一样
UserVO userVO = new userVO();
userVO.setDisplayName(user.getDisplayName());
userVO.setUserLevel(user.getUserLevel());
userVO.setUserExp(user.getUserExp());
// 返回封装好的 result
return CommonResult.success(userVO);
}
}
这样是不是相当优雅?其实还是不够。因为我们把过多的功能放在了 Controller
类里,这很不好,如果接口很多,会导致这个类肥硕无比。并且,这样做的复用性极差:因为我们在 Controller
里定义的函数,返回的都是封装好的 CommonResult
,是专门对外的,内部重复利用 Controller
里面的函数,还要把返回值的封装拆开,这非常蠢;另外,Controller
里函数的内容,都是完整的请求处理逻辑,而不是像积木那样很独立、很割裂的功能模块,本来就很难有可以重复利用的地方。
如何进一步优化呢?这是我们下一节要讲的内容。
处理流程
仍然以获取用户信息的请求为例(向 https://10.20.30.40/users/5201
发送 GET
请求)。目前为止,我们处理用户请求的逻辑是这样:
UserController
的user/{id}
这个url
上接到请求。UserController
调用对应的函数getUser()
。getUser
使用userMapper
查找数据库。getUser
创建UserVO
对象,把可以给用户看的属性从User
对象里提取出来,塞到UserVO
对象里。UserController
把UserVO
塞到CommonResult
里,返回给用户。
之前提到,getUser
这个函数做的事情太多了,也不能被重复利用。我们需要再设计一个类 UserService
,里面写一些可以被重复利用的逻辑(就像为自己打造各种形状的小积木),这样 UserController
就不需要写很多代码,只需要把想用的积木从 UserService
里拿出来,拼一下就可以了。
具体地,对于 UserController
,我们写一个 UserService
,像下面这样:
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
@Service
public class UserService throws Exception{
@Resource
private UserMapper userMapper;
public User getUserById(BigInteger id) {
User user;
try {
user = userMapper.findById(id);
} catch (Exception e) {
throw new Exception("Database error", e);
}
// 如果 user 找不到的话:
if (user == null) {
throw new NotFoundException("User not found");
}
return user;
}
public static UserVO UserToUserVO(User user) {
UserVO userVO = new userVO();
userVO.setDisplayName(user.getDisplayName());
userVO.setUserLevel(user.getUserLevel());
userVO.setUserExp(user.getUserExp());
return userVO
}
}
我们注意到,把 User
转成 UserVO
这样的操作会经常用到,所以我们写一个函数,方便复用。
这样写好之后,我们的 UserController
就变成:
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
@RestController
public class UserController {
// 用 UserService 去替换之前的 UserMapper
// 这样 UserController 就没有权限去直接操控数据库了,一切处理都需要通过 UserService 完成
@Resource
private UserService userService;
@GetMapping("/users/{id}")
public CommonResult<UserVO> getUser(@PathVariable BigInteger id) {
// 先去数据库找 user,跟之前一样,但是考虑数据库错误的情况
// 这跟之前一样
try {
User user = userService.getUserById(id);
} catch (NotFoundException e) {
return CommonResult.failed(e.getMessage());
} catch (Exception e) {
e.printStackTrace(); // 这个错误信息对 debug 非常有用,要 print 出来
return CommonResult.failed(e.getMessage());
}
UserVO userVO = UserService.UserToUserVO(user);
// 返回封装好的 result
return CommonResult.success(userVO);
}
}
这样处理之后,上述的流程就变成:
- (不变)
UserController
的user/{id}
这个url
上接到请求。 - (不变)
UserController
调用对应的函数getUser()
。 - (新)
UserController
里的getUser()
调用userService.getUserById()
函数。 - (新) 在
UserService
的getUserById
函数中,使用userMapper
查找数据库,返回User
对象。 - (新) 在
UserService
的getUserById
函数中创建UserVO
对象,把可以给用户看的属性从User
对象里提取出来,塞到UserVO
对象里。 - (新)
UserService
把UserVO
返回给UserController
。 - (不变)
UserController
把UserVO
塞到CommonResult
里,返回给用户。
注意,这里 UserService
考虑了各种错误情况,throw
对应的 Exception
,然后 UserController
根据 Exception
的类型,返回用 CommonResult
封装的错误信息。
可是,我们发现 UserService
和 UserController
里充满了 try...catch
。写其它接口的时候,可能会发现,大部分的 try...catch
都是相同内容的重复。在 SpringBoot
里,我们可以使用全局异常处理
来简化。
全局异常处理
我们可以创建一个类 GlobalExceptionHandler
,打上 SpringBoot
的特定标签,让它处理整个程序任何地方的 Exception
,像这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@ControllerAdvice
public class GlobalExceptionHandler {
// @ExceptionHandler 告诉 SpringBoot,所有的没有 catch 的 `NotFoundException` 全部送给这个函数处理
// @RespondBody 告诉 SpringBoot 把返回的对象变成 JSON(我们的 @RestController 标签其实就包含了 @ResponseBody)
@ExceptionHandler(value=NotFoundException.class)
@ResponseBody
public CommonResult<String> notFoundExceptionHandler(NotFoundException e) {
return CommonResult.failed(e.getMessage());
}
// 处理剩下的所有 Exception
@ExceptionHandler(value = Exception.class)
@ResponseBody
public CommonResult<String> exceptionHandler(Exception e) {
e.printStackTrace();
return CommonResult.failed(e.getMessage());
}
}
然后,Service
和 Controller
就变得简单了,不需要处理 Exception
,只需要在函数头部声明 throws Exception
就行。先看 UserController
中的请求处理:
1
2
3
4
5
6
@GetMapping("/users/{id}")
public CommonResult<UserVO> getUser(@PathVariable BigInteger id) throws Exception {
User user = userService.getUserById(id);
UserVO userVO = UserService.UserToUserVO(user);
return CommonResult.success(userVO);
然后是 ProductService
里的对应函数:
1
2
3
4
5
6
7
8
public User getUserById(BigInteger id) {
User user = userMapper.findById(id);
if (user == null) {
throw new NotFoundException("User not found");
}
return user;
}
于是,异常处理的篇幅被大大缩减了。优雅。
接口文档
前端开发人员需要使用接口从后端调取数据。为了节省沟通成本,后端开发人员需要把接口以文档的形式详细列出,包括请求的 url
、请求的参数、可能的返回值,以及其它可能需要的信息。
我们当然可以用 word
手写这样一份文档,但是维护起来可能比较麻烦(而且用微信传来传去的非常不优雅)。推荐使用 Springfox
去做这件事。只要进行一些配置,就可以自动生成非常漂亮的接口文档。
具体的使用方法,官方文档讲得非常详细了。但是暂时不建议通读,因为不需要每个人全盘掌握。可以在开发团队里面找个人专门负责这一块,然后阅读文档,进行配置。
如果只是想使用基础功能的话,只需要在 pom.xml
里面加入这个依赖:
1
2
3
4
5
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
然后在应用程序入口 class
(就是那个有个 main
函数,里面有一句 SpringApplication.run()
的) 上方加入 @EnableSwagger2
即可,做完之后,看起来是这样:
1
2
3
4
5
6
7
@SpringBootApplication
@EnableSwagger2
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
之后,你可以从 base_url/swagger-ui/index.html
去访问你的接口文档。你也可以自己配置这个路径。
注释及其它
对于一些不是很明了的逻辑,我们要写注释,确保不看代码也能知道下面做的事情是什么。
同时,推荐对每个 class
以及下面的每个 method
都按照 Javadoc
的规范去书写注释,这样就可以自动生成文档。具体教程参考这里,或者直接搜索 Javadoc
。
另外,我们放出阿里开发规范手册供大家参考。可以尽量去学习,然后制定自己认为最优秀的开发规范。