目录
用户模块主要包含两大部分:
1. 用户注册
2. 用户登录
我们首先来看用户注册
用户注册
注册时序图
我们来理解一下注册过程:
1. 用户输入用户名和密码后,点击提交按钮,发送注册请求给服务器
2. 服务器校验用户信息是否正确,对用户密码进行加密,再将用户信息保存到数据库
3.保存成功后,服务器返回注册成功响应
约定前后端交互接口
[请求] POST /register
{
"name": "zhangsan",
"password": "123456"
}
[响应]
{
"code": 200,
"data": {
"userId": 1
},
"errorMessage": ""
}
后端实现
controller 层接口设计
controller 接口主要完成的功能是:
1. 打印接收到参数
2. 调用 service 层方法进行业务逻辑处理
3. 构造响应并返回
首先定义接收到的参数类型和返回的响应类型:
接收到的参数:
@Data
public class UserRegisterParam implements Serializable {
/**
* 用户名
*/
private String name;
/**
* 用户密码
*/
private String password;
}
返回的响应结果:
@Data
public class UserRegisterResult implements Serializable {
/**
* 注册用户 id
*/
private Long userId;
}
controller 层接口:
@RestController
@Slf4j
public class UserController {
@Autowired
private UserService userService;
@RequestMapping("/register")
public CommonResult<UserRegisterResult> register(@RequestBody UserRegisterParam param) {
// 日志打印
log.info("register 接收到参数 UserRegisterParam: {}",
JacksonUtil.writeValueAsString(param));
// 业务逻辑处理
UserRegisterResultDTO userRegisterResultDTO = userService.register(param);
// 构造响应并返回
return CommonResult.success(
convertToUserRegisterResult(userRegisterResultDTO));
}
}
由于是从请求体中读取数据,因此 UserRegisterParam 需要添加注解 @RequestBody
@RequestBody 注解会将 HTTP 请求体中的 JSON 数据自动转化为指定的 java 对象
在调用 service 层接口进行逻辑处理后,为什么不直接返回 UserRegisterResult 类型结果,而是要定义一个 DTO 类型,然后再进行转化?
这是因为在 数据分层 的设计中,传递数据的结构和方式非常重要,能有效地提高系统的可维护性、扩展性和性能
常见的数据传递设计就包括数据在各个层次之间的传递方式以及使用的对象类型,为了在不同层之间有效地传递数据,开发者通常会采用不同的对象来封装数据
通常,在开发中,系统会划分为多个层次(如:表现层、业务逻辑层、持久化层等),每一层的职责明确,数据在不同层之间的传递有明确的设计标准
系统分层通常包括以下几个主要层次:
controller 层:处理请求,调用业务逻辑,数据封装和返回等
service 层:处理核心业务逻辑
dao 层:处理与数据库的交互
数据传递对象:
DTO(Data Transfer Object):数据访问对象,通常用于服务层(service层)与控制层(controller层)之间的数据传输
VO(Value Object):值对象,通常用于表示某个数据的抽象,在系统中用于传递只读数据,常用于控制层(controller层)中展示数据
DO(Domain Object):领域对象,表示领域模型中的对象,包含业务逻辑层和规则,与数据库中的数据对应,用于持久层(dao层)和业务层(service层)之间的数据传输
UserRegisterResultDTO:
@Data
public class UserRegisterResultDTO implements Serializable {
/**
* 用户 id
*/
private Long userId;
}
最后,来实现转化方法 convertToUserRegisterResult:
/**
* 将 UserRegisterResultDTO 转化为 UserRegisterResult
* @param userRegisterResultDTO
* @return
*/
private UserRegisterResult convertToUserRegisterResult(UserRegisterResultDTO userRegisterResultDTO) {
// 参数校验
if (null == userRegisterResultDTO) {
throw new ControllerException(ControllerErrorCodeConstants.REGISTER_ERROR);
}
// 构造 UserRegisterResult
UserRegisterResult result = new UserRegisterResult();
result.setUserId(userRegisterResultDTO.getUserId());
// 返回
return result;
}
添加 controller 层错误码:
public interface ControllerErrorCodeConstants {
// ---------------------- 用户模块错误码 ----------------------
ErrorCode REGISTER_ERROR = new ErrorCode(100, "注册失败");
}
service 层接口设计
在 service 层,进行接口分离设计
什么是接口分离设计?有什么好处?
Service 层接口分离设计(也称为接口隔离或服务接口分离)是指在设计 Service 层时,将接口和实现分离,确保接口与实现之间的解耦,提升系统的可维护性、扩展性和灵活性。
这种设计模式通常遵循 接口隔离原则,即客户不应该被强迫依赖于它们不需要使用的方法。在分层架构中,Service 层的接口设计尤为重要,因为它是系统业务逻辑的核心。
解耦合:接口与实现分离后,业务逻辑的变化不会直接影响到客户端,客户端只依赖接口而非具体实现
支持多态:接口允许通过共同的接口来引用不同的实现,使得代码更加灵活和通用
扩展性:当需要新增业务逻辑时,可以新增接口并实现,而无需修改现有的接口
清晰的职责划分:通过将接口与实现分离,使得每个模块的职责更加清晰,符合单一职责原则
定义业务接口:
@Service
public interface UserService {
/**
* 用户注册业务逻辑处理
* @param param
* @return
*/
UserRegisterResultDTO register(UserRegisterParam param);
}
实现业务接口:
@Service
@Slf4j
public class UserServiceImpl implements UserService {
/**
* 用户注册
* @param param
* @return
*/
@Override
public UserRegisterResultDTO register(UserRegisterParam param) {
return null;
}
}
register 需要完成的功能:
1. 参数校验
2. 将注册数据入库
3. 构造响应并返回
我们首先来看参数校验
首先,需要确保参数不能为空,这部分校验可以使用 SpringBoot 中集成的 Validation 来实现
Validation 通过一组注解来对对象进行约束和校验,常见的验证注解有:
@NotNull、@NullBack、@NotEmpty、@Min、@Max 等
@NotNull:验证字段是否为 null
@NullBack:验证字段是否为 null 或 空字符串(对字符串字段有效)
@NotEmpty:验证字段是否为 null 或 空(对字符串或集合有效)
引入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
在接口入参上添加 @Validated 注解:
在 UserRegisterParam 中对成员进行校验:
@Data
public class UserRegisterParam implements Serializable {
/**
* 用户名
*/
@NotBlank(message = "用名不能为空")
private String name;
/**
* 用户密码
*/
@NotBlank(message = "用户密码不能为空")
private String password;
}
此外,还需要对密码进行校验,实现 checkPassword() 方法对密码长度进行校验:
/**
* 密码校验
* @param password
* @return
*/
private boolean checkPassword(String password) {
if (!StringUtils.hasText(password)) {
return false;
}
// 使用正则表达式校验密码长度为 6 - 12 位(不限制字符类型)
String regex = "^.{6,12}$";
return Pattern.matches(regex, password);
}
业务功能实现:
@Service
@Slf4j
public class UserServiceImpl implements UserService {
@Autowired
private UserMapper userMapper;
/**
* 用户注册
* @param param
* @return
*/
@Override
public UserRegisterResultDTO register(UserRegisterParam param) {
// 参数校验
if (!checkPassword(param.getPassword())) {
throw new ServiceException(ServiceErrorCodeConstants.PASSWORD_CHECK_ERROR);
}
// 数据入库
UserDO userDO = new UserDO();
userDO.setUserName(param.getName());
userDO.setPassword(SecurityUtil.encipherPassword(param.getPassword()));
userMapper.insert(userDO);
// 构造响应并返回
UserRegisterResultDTO registerResultDTO = new UserRegisterResultDTO();
registerResultDTO.setUserId(userDO.getId());
return registerResultDTO;
}
/**
* 密码校验
* @param password
* @return
*/
private boolean checkPassword(String password) {
if (!StringUtils.hasText(password)) {
return false;
}
// 使用正则表达式校验密码长度为 6 - 12 位(不限制字符类型)
String regex = "^.{6,12}$";
return Pattern.matches(regex, password);
}
}
添加错误码:
public interface ServiceErrorCodeConstants {
// ---------------------- 用户模块错误码 ----------------------
ErrorCode PASSWORD_CHECK_ERROR = new ErrorCode(100, "密码校验失败");
}
定义 UserDO:
@Data
public class UserDO implements Serializable {
/**
* 用户id (主键)
*/
private Long id;
/**
* 创建时间
*/
private Date gmtCreate;
/**
* 修改时间
*/
private Date gmtModified;
/**
* 用户名
*/
private String userName;
/**
* 用户密码
*/
private String password;
/**
* 分数
*/
private BigDecimal score;
/**
* 比赛总场数
*/
private Long totalCount;
/**
* 获胜场数
*/
private Long winCount;
}
其中的成员与表中字段相对应
dao 层接口设计
使用 MyBatis 来完成与数据库的交互:
@Mapper
public interface UserMapper {
@Insert("insert into user (user_name, password, score, total_count, win_count)values (#{userName}, #{password}, 1000, 0, 0)")
@Options(useGeneratedKeys = true, keyProperty = "id", keyColumn = "id")
int insert(UserDO userDO);
}
全局异常处理
在 Spring Boot 中可以使用 @RestControllerAdvice 注解,定义全局异常处理类,从而针对所有异常类型先进行通用处理后,再对特定异常类型进行不同的处理操作
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 捕获 service 层抛出的异常
* @param e
* @return
*/
@ExceptionHandler(value = ServerException.class)
public CommonResult<?> serverException(ServiceException e) {
// 打印错误日志
log.info("ServiceException: ", e);
// 构造异常情况下的返回结果
return CommonResult.fail(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(), e.getMessage());
}
/**
* 捕获 controller 层抛出的异常
* @param e
* @return
*/
@ExceptionHandler(value = ControllerException.class)
public CommonResult<?> controllerException(ControllerException e) {
// 打印错误日志
log.info("ControllerException: ", e);
// 构造异常情况下的返回结果
return CommonResult.fail(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(), e.getMessage());
}
/**
* 捕获未知异常
* @param e
* @return
*/
@ExceptionHandler(value = Exception.class)
public CommonResult<?> Exception(Exception e) {
// 打印错误日志
log.info("ControllerException: ", e);
// 构造异常情况下的返回结果
return CommonResult.fail(GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR.getCode(), e.getMessage());
}
}
使用 @ExceptionHandler 注解定义方法来处理某种特定类型的异常,当异常被抛出时,Spring 会查找对应的处理方法来处理该异常
接口测试
使用 postman 对接口进行测试:
符合预期结果,异常情况就不再一一验证了,我们继续实现前端代码
前端实现
我们先实现相关 html 代码
register.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>注册</title>
</head>
<body>
<div class="nav">
五子棋
</div>
<div class="register-container">
<div class="register-dialog">
<h3>注册</h3>
<div class="row">
<span>用户名</span>
<input type="text" id = "name">
</div>
<div class="row">
<span>密码</span>
<input type="password" id="password">
</div>
<div class="row">
<button id = "submit">注册</button>
</div>
</div>
</div>
</body>
</html>
我们继续实现 css 代码
css
导航栏、背景图片等样式多个页面都是相同的,因此我们定义公共样式:
common.css
/* 公共样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
background-image: url(../img/background.jpg);
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.nav {
height: 50px;
background-color: #efece6;
color: #9abcda;
line-height: 50px;
padding-left: 20px;
}
.container {
width: 100%;
height: calc(100% - 50px);
display: flex;
align-items: center;
justify-content: center;
}
register.css
.register-container {
height: calc(100% - 50px);
display: flex;
justify-content: center;
align-items: center;
}
.register-dialog {
width: 400px;
height: 300px;
background-color: #e8e8e0;
border-radius: 20px;
}
/* 标题 */
.register-dialog h3 {
text-align: center;
padding: 35px 0;
}
/* 行 */
.register-dialog .row {
width: 100%;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.register-dialog .row span {
width: 100px;
font-weight: 700;
}
#name, #password {
width: 200px;
height: 40px;
font-size: 20px;
line-height: 40px;
padding-left: 10px;
border: none;
outline: none;
border-radius: 10px;
}
#submit {
width: 300px;
height: 50px;
background-color: #98b8d0;
color: #e8e8e0;
font-size: 20px;
border: none;
outline: none;
border-radius: 10px;
margin-top: 30px;
}
#submit:active {
background-color: #f8f5d4;
}
在 register.html 中引入相关样式:
最后使用 js 来实现前后端交互
js
使用 ajax 完成页面与服务器之间的交互:
引入 jquery:
cdn.bootcdn.net/ajax/libs/jquery/3.7.1/jquery.min.js
将链接中的内容复制到项目中的 jquery.min.js 文件中:
引入,并实现注册数据的提交:
<script src="js/jquery.min.js"></script>
<script>
let btn = document.querySelector('#submit');
btn.onclick = function() {
$.ajax({
url: "/register",
type: "POST",
contentType: 'application/json',
data: JSON.stringify({
name: $("#name").val(),
password: $("#password").val(),
}),
success: function(result) {
if(result.code == 200) {
// 注册成功,跳转至登录页面
location.assign("login.html");
}else {
alert(result.errorMessage);
}
}
});
}
</script>
背景图片和图标等样式可自行更改
注册模块测试
运行程序,访问 http://127.0.0.1:8080/register.html
页面正确显示
输入用户名和密码,点击注册:
跳转至登录页面:
至此,用户注册模块就基本实现完毕了,我们继续实现用户登录模块
用户登录
登录时序图
我们来理解一下登录过程:
1. 用户输入用户名和密码后,点击提交按钮,发送登录请求给服务器
2. 服务器校验登录用户信息完整,并从数据库中查询用户信息
3. 校验用户输入的密码是否正确
4. 将用户相关信息存储到 session 中
5. 构造登录成功响应并返回
约定前后端交互接口
[请求] POST /login
{
"name": "zhangsan",
"password": "123456"
}
[响应]
{
"code": 200,
"data": {
"userId": 1
},
"errorMessage": ""
}
后端实现
controller 层接口设计
controller 接口主要完成的功能是:
1. 打印接收到参数
2. 调用 service 层方法进行业务逻辑处理
3. 构造响应并返回
首先定义接收到的参数类型和返回的响应类型:
定义接收到的参数类型,并对其进行校验:
@Data
public class UserLoginParam implements Serializable {
/**
* 用户名
*/
@NotBlank(message = "用名不能为空")
private String name;
/**
* 用户密码
*/
@NotBlank(message = "用户密码不能为空")
private String password;
}
响应类型:
@Data
public class UserLoginResult implements Serializable {
/**
* 登录用户 id
*/
private Long userId;
}
在 UserController 中添加 login:
@RequestMapping("/login")
public CommonResult<UserLoginResult> login(@Validated @RequestBody UserLoginParam param, HttpSession session) {
// 日志打印
log.info("login 接收到参数 UserLoginParam: {}",
JacksonUtil.writeValueAsString(param));
// 业务逻辑处理
UserLoginDTO userLoginDTO = userService.login(param, session);
// 构造响应并返回
return CommonResult.success(convertToUserLoginResult(userLoginDTO));
}
定义 UserLoginDTO:
@Data
public class UserLoginDTO implements Serializable {
/**
* 用户 id
*/
private Long userId;
}
将 UserLoginDTO 转化为 UserLoginResult:
private UserLoginResult convertToUserLoginResult(UserLoginDTO userLoginDTO) {
// 参数校验
if (null == userLoginDTO) {
throw new ControllerException(ControllerErrorCodeConstants.LOGIN_ERROR);
}
// 构造 UserLoginResult
UserLoginResult userLoginResult = new UserLoginResult();
userLoginResult.setUserId(userLoginDTO.getUserId());
// 返回
return userLoginResult;
}
添加 controller 层错误码:
public interface ControllerErrorCodeConstants {
// ---------------------- 用户模块错误码 ----------------------
ErrorCode REGISTER_ERROR = new ErrorCode(100, "注册失败");
ErrorCode LOGIN_ERROR = new ErrorCode(101, "登录失败");
}
service 层接口设计
定义业务接口:
实现业务接口:
login 需要完成的功能:
1. 参数校验
2. 获取用户信息
3. 校验密码是否正确
4. 存储用户信息到 session 中
5. 构造响应并返回
从数据库中获取到的用户信息(UserDO )并不需要都存储到 session 中(如创建时间、修改时间),因此我们创建 UserInfo 用于 session 存储:
@Data
public class UserInfo implements Serializable {
/**
* 用户 id
*/
private Long userId;
/**
* 用户名
*/
private String userName;
/**
* 天梯分数
*/
private Long score;
/**
* 总场数
*/
private Long totalCount;
/**
* 获胜场次
*/
private Long winCount;
}
login 实现:
@Override
public UserLoginDTO login(UserLoginParam param, HttpSession session) {
// 参数校验
checkPassword(param.getPassword());
// 获取用户信息
UserDO userDO = userMapper.selectByUserName(param.getName());
// 用户信息是否存在
if (null == userDO) {
throw new ServiceException(ServiceErrorCodeConstants.USER_INFO_IS_EMPTY);
}
// 校验密码是否正确
if (!SecurityUtil.verifyPassword(param.getPassword(),
userDO.getPassword())) {
throw new ServiceException(ServiceErrorCodeConstants.PASSWORD_CHECK_ERROR);
}
// 将用户信息 存储到 session 中
UserInfo userInfo = new UserInfo();
userInfo.setUserId(userDO.getId());
userInfo.setUserName(userDO.getUserName());
userInfo.setScore(userDO.getScore());
userInfo.setTotalCount(userDO.getTotalCount());
userInfo.setWinCount(userDO.getWinCount());
session.setAttribute(USER_INFO, userInfo);
// 构造响应并返回
UserLoginDTO userLoginDTO = new UserLoginDTO();
userLoginDTO.setUserId(userDO.getId());
return userLoginDTO;
}
dao 层接口设计
通过用户名查询用户信息:
@Select("select * from user where user_name = #{userName}")
UserDO selectByUserName(@Param("userName") String userName);
接口测试
使用 postman 进行测试:
符合预期结果,我们继续实现前端页面
前端实现
login.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录</title>
<link rel="stylesheet" href="css/common.css">
<link rel="stylesheet" href="css//login.css">
</head>
<body>
<div class="nav">
五子棋
</div>
<div class="login-container">
<!-- 登录页面对话框 -->
<div class="login-dialog">
<h3>登录</h3>
<div class="row">
<span>用户名</span>
<input type="text" id="name">
</div>
<div class="row">
<span>密码</span>
<input type="password" id="password">
</div>
<div class="register">
没有账号?<a href="register.html">点击注册</a>
</div>
<div class="row">
<button id="submit">登录</button>
</div>
</div>
</div>
</body>
</html>
login.css
.login-container {
height: calc(100% - 50px);
display: flex;
justify-content: center;
align-items: center;
}
.login-dialog {
width: 400px;
height: 300px;
background-color: #e8e8e0;
border-radius: 20px;
}
/* 标题 */
.login-dialog h3 {
text-align: center;
padding: 35px 0;
}
/* 行 */
.login-dialog .row {
width: 100%;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.login-dialog .row span {
width: 100px;
font-weight: 700;
}
#name, #password {
width: 200px;
height: 40px;
font-size: 20px;
line-height: 40px;
padding-left: 10px;
border: none;
outline: none;
border-radius: 10px;
}
#submit {
width: 300px;
height: 50px;
background-color: #98b8d0;
color: #e8e8e0;
font-size: 20px;
border: none;
outline: none;
border-radius: 10px;
margin-top: 30px;
}
#submit:active {
background-color: #f8f5d4;
}
/* 点击注册 */
.login-dialog .register {
text-align: right;
margin-right: 20px;
font-size: 13px;
}
最后使用 js 来实现前后端交互
js
实现登录数据的提交:
<script src="/js/jquery.min.js"></script>
<script>
let submitButton = document.querySelector('#submit'); // 提交按钮
// 添加点击事件
submitButton.onclick = function() {
$.ajax({
url: '/login',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
name: $("#name").val(),
password: $("#password").val(),
}),
success: function(result) {
console.log(result);
if(result.code == 200) {
// 保存 token 信息
localStorage.setItem("user_token", result.data.token);
// 登录成功,跳转至游戏大厅
location.assign('/game_hall.html');
}else {
alert(result.errorMessage);
}
}, error: function() {
alert("登录失败!");
}
})
}
</script>
登录模块测试
运行程序,访问 http://127.0.0.1:8080/login.html:
页面正确显示,输入 用户名 和 密码,进行登录:
页面跳转成功:
至此,用户模块就基本实现完成了