文章目录
- 开发模式和环境搭建
- 开发模式
- 环境搭建
- 1. 用户注册
- 1.1 注册接口基本代码编写
- 1.2 注册接口参数校验
- 2. 用户登录
- 2.1 登录接口基本代码编写
- 2.2 登录认证
- 2.2.1 登录认证引入
- 2.2.2 JWT 简介
- 2.2.3 登录功能集成 JWT
- 2.2.4 拦截器
- 3. 获取用户详细信息
- 3.1 获取用户详细信息基本代码编写
- 3.2 ThreadLocal 优化
- 4. 更新用户基本信息
- 4.1 更新用户基本信息基本代码编写
- 4.2 更新用户基本信息参数校验
- 5. 更新用户头像
- 5.1 更新用户头像基本代码编写
- 5.2 url 参数校验
- 6. 更新用户密码
开发模式和环境搭建
开发模式
前后端分离开发中,前端向后端发出请求,后台处理完请求后给出响应数据。此时会出现一个问题:前端写代码时,怎么知道后端有哪些接口?后端写代码时,怎么知道前端需要什么样的数据呢?
这时就需要一套约束标准——接口文档。接口文档对每个接口的访问路径、请求方式、请求参数、响应数据进行了明确的说明。
前后端程序员参考同一份接口文档进行开发,项目就能无缝衔接了。
环境搭建
- 执行 big_event.sql 脚本,准备数据库表
- 创建 springboot 工程,引入对应的依赖(web、mybatis、mysql 驱动)
- 在配置文件 application.yml 中引入 mybatis 的配置信息(将来连接的数据库在哪?用户名和密码是什么?)
- 创建包结构,并准备实体类
(1) 创建表
-- 创建数据库
create database big_event;-- 使用数据库
use big_event;-- 用户表
create table user (id int unsigned primary key auto_increment comment 'ID',username varchar(20) not null unique comment '用户名',password varchar(32) comment '密码',nickname varchar(10) default '' comment '昵称',email varchar(128) default '' comment '邮箱',user_pic varchar(128) default '' comment '头像',create_time datetime not null comment '创建时间',update_time datetime not null comment '修改时间'
) comment '用户表';-- 分类表
create table category(id int unsigned primary key auto_increment comment 'ID',category_name varchar(32) not null comment '分类名称',category_alias varchar(32) not null comment '分类别名',create_user int unsigned not null comment '创建人ID',create_time datetime not null comment '创建时间',update_time datetime not null comment '修改时间',constraint fk_category_user foreign key (create_user) references user(id) -- 外键约束
);-- 文章表
create table article(id int unsigned primary key auto_increment comment 'ID',title varchar(30) not null comment '文章标题',content varchar(10000) not null comment '文章内容',cover_img varchar(128) not null comment '文章封面',state varchar(3) default '草稿' comment '文章状态: 只能是[已发布] 或者 [草稿]',category_id int unsigned comment '文章分类ID',create_user int unsigned not null comment '创建人ID',create_time datetime not null comment '创建时间',update_time datetime not null comment '修改时间',constraint fk_article_category foreign key (category_id) references category(id),-- 外键约束constraint fk_article_user foreign key (create_user) references user(id) -- 外键约束
)
(2) 创建springboot工程(这次采用手动创建的方式)
创建好的 maven 工程缺少 resources 目录:
于是创建 resources 目录:
工程需要在 resources 目录下提供一个核心配置文件,即 yml 配置文件
至此,boot 工程创建完毕。下面引入所需的依赖:
(3) 在配置文件 application.yml 中引入 mybatis 的配置信息
spring:datasource:driver-class-name: com.mysql.cj.jdbc.Driver# 3306是mysql默认端口,big_event是数据库名称url: jdbc:mysql://localhost:3306/big_eventusername: rootpassword: 123456
(4) 创建包结构,并准备实体类
① 创建包结构:controller、service(包括impl)、mapper、pojo、util
② 准备实体类
import java.time.LocalDateTime;public class User {private Integer id;//主键IDprivate String username;//用户名private String password;//密码private String nickname;//昵称private String email;//邮箱private String userPic;//用户头像地址private LocalDateTime createTime;//创建时间private LocalDateTime updateTime;//更新时间
}
import java.time.LocalDateTime;public class Article {private Integer id;//主键IDprivate String title;//文章标题private String content;//文章内容private String coverImg;//封面图像private String state;//发布状态 已发布|草稿private Integer categoryId;//文章分类idprivate Integer createUser;//创建人IDprivate LocalDateTime createTime;//创建时间private LocalDateTime updateTime;//更新时间
}
import java.time.LocalDateTime;public class Category {private Integer id;//主键IDprivate String categoryName;//分类名称private String categoryAlias;//分类别名private Integer createUser;//创建人IDprivate LocalDateTime createTime;//创建时间private LocalDateTime updateTime;//更新时间
}
③ boot 工程启动类
SpringBoot 的启动类名称一般是工程名+Application,所以先来为 App 重命名一下:
现在 BigEventApplication.java 内容是这样:
改造后的 BigEventApplication.java:
在用户模块,总共有 6 个接口需要开发:
- 注册
- 登录
- 获取用户详细信息
- 更新用户基本信息
- 更新用户头像
- 更新用户密码
开发流程:
1. 用户注册
接口文档:
1.1 注册接口基本代码编写
需求:输入用户名、密码,点击注册。
首先来看一下数据库表字段和实体类属性,两者是一一对应的。Java 的属性习惯用驼峰命名法,数据库表字段习惯用下划线命名法。另外可以观察到,用户头像(userPic / user_pic)的数据类型是字符串,这是因为头像的图片会存放在三方服务器上,这里的变量只是存放一个服务器上的访问地址。
可以看到,实体类中并没有写 getter、setter、toString 等方法,这是因为可以通过 lombok 工具在编译阶段自动生成 getter、setter、toString 等方法。
使用 lombok 需要在 pom.xml 中引入依赖,并在实体类上添加注解。
<!--lombok依赖-->
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId>
</dependency>
添加注解后,重新编译:
从 target 中找到编译过的 User 类,可以发现,其中已经有 getter、setter、toString 等方法。
响应数据一般由 code、message、data 三个数据组成。于是可以定义 Result 类,该类含 code、message、data 三个成员变量。
当我们给浏览器响应一个 Result 对象时,Spring 会将该 Result 对象转换成 json 字符串,此时的响应数据格式就符合了文档要求。
//统一所有接口的响应结果格式
//lombok 提供了两个注解
@NoArgsConstructor //在编译时生成无参构造方法
@AllArgsConstructor //在编译时生成全参构造方法
@Data
public class Result<T> {private Integer code;//业务状态码 0-成功 1-失败private String message;//提示信息// data成员变量的类型是泛型T,也就是说data可以对应Object、String、Bean对象等等private T data;//响应数据//返回操作成功响应结果(带响应数据)public static <E> Result<E> success(E data) {return new Result<>(0, "操作成功", data);}//返回操作成功响应结果(不带响应数据,如注册、添加文章)public static Result success() {return new Result(0, "操作成功", null);}//返回操作失败响应结果public static Result error(String message) {return new Result(1, message, null);}
}
在上面代码中,使用了 lombok 提供的两个注解:
@NoArgsConstructor
:在编译时生成无参构造方法
@AllArgsConstructor
:在编译时生成全参构造方法
完成了实体类,下面就来分析一下三层架构:
在 Controller 中,要声明 register 方法完成注册功能。方法上添加了 @PostMapping,是因为文档中标明该请求是 post 请求。请求路径是 /register,对比文档少了 /user,是因为 Controller 类上会添加 /user,两者拼接后就是文档中要求的路径了。返回值类型是统一的 Result。在方法内部要首先看用户名是否已被占用,然后注册。当然,这些过程需要调用 Service 来完成。
Service 层要提供对应的两个方法:根据用户名查询用户、注册
相应地,Mapper 层也需要声明两个方法,分别用于执行查询和插入的 Sql
下面开始编写代码,首先创建相关的类和接口:
首先编写 UserController:
findByUserName 和 register 两个方法爆红是因为 UserService 中还未声明这两个方法。将光标放在爆红的方法上,Ctrl + Enter 就能在 UserService 中生成对应的方法。
在 UserController 中,userService 爆红是因为还未向 IOC 容器中注入 UserService 的 bean 对象。
下面编写 UserService 的实现类:
@Service
public class UserServiceImpl implements UserService {@Autowiredprivate UserMapper userMapper;//根据用户名查询用户@Overridepublic User findByUserName(String username) {User user = userMapper.findByUserName(username);return user;}//注册(添加用户)@Overridepublic void register(String username, String password) {//要先将密码通过MD5加密,然后调用Mapper层进行注册String md5String = Md5Util.getMD5String(password);userMapper.add(username, md5String);}
}
UserMapper:
@Mapper
public interface UserMapper {//根据用户名查询用户@Select("select * from user where username = #{username}")User findByUserName(String username);//注册(添加用户)//now()是mysql的函数@Insert("insert into user(username, password, create_time, update_time)" +" values(#{username}, #{password}, now(), now())")void add(String username, String password);
}
postman 测试:
数据库表已写入:
现在数据库中已经有了 wangba 用户,如果再用同样的用户名注册,就会出现以下情况:
1.2 注册接口参数校验
前面开发了注册接口,但忽略了一件事。接口文档中对于 username 和 password 有明确的说明,两者必须是 5~16 位的非空字符。
所以后端的接口必须能保证:如果前端传递的参数不符合规则,是不能完成注册的。因此,后端接口需要对两个参数进行校验。我们首先想到的方式可能是通过 if-else 来判断,如下:
这种方式确实能实现预期功能,但是代码看起来很繁琐。
因此,Spring 提供了一个参数校验框架 Spring Validation,使用预定义的注解来完成参数校验。
使用 Spring Validation 对接口参数的合法性进行校验的流程:
- 引入Spring Validation 起步依赖
- 在参数前面添加
@Pattern
注解,按照正则表达式的要求进行参数校验 - 在 Controller 类上添加
@Validated
注解,使类中方法的参数上的注解能够被扫描到(使@Pattern
生效)
<!--validationy依赖-->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId>
</dependency>
完成以上操作后,发现虽然校验起作用了,但是返回的 json 数据并不符合接口文档要求的 code-message-data
格式:
此时,可以使用全局异常处理器来解决。
全局异常处理器:参数校验失败异常处理
需要定义一个类,并标注 @RestControllerAdvice 注解,表示该类用于处理异常。由于该注解是 @Restxxx,所以该类中所有方法的返回值都会被转换成 json 字符串相应给浏览器。在类中,需要添加一个方法用于处理异常,方法上添加 @ExceptionHandler,Exception.class 表示处理所有异常。注意方法返回值类型是 Result,如此一来,即使出现异常,返回的结果也是满足接口文档要求的。
@RestControllerAdvice
public class GlobalExceptionHandler {@ExceptionHandler(Exception.class)public Result handleException(Exception e){//参数上声明一个变量,一旦出现异常,就接收捕获异常对象//为了方便调试,将异常信息输出到控制台,否则因为异常被捕获了,就不会输出了e.printStackTrace();//Exception对象一般都会封装错误提示信息,使用e.getMessage()来获取//但有些异常并不会封装错误提示信息,所以需要判断一下//借助Spring中String字符串的工具类StringUtils.hasLength(),如果包含错误信息就返回,否则返回“操作失败”return Result.error(StringUtils.hasLength(e.getMessage())? e.getMessage() : "操作失败");}
}
添加异常处理类之后,返回的错误信息就符合接口文档中的 json 格式要求了:
2. 用户登录
接口文档:
2.1 登录接口基本代码编写
需求:输入用户名、密码,登录。
对于登录,仍然是考虑 Controller、Service、Mapper 层。可以发现,Service、Mapper 层需要做的就只是根据用户名查询,而且这部分代码在注册部分已经完成了。
于是只关注 Controller 即可:
postman 测试:
① 用户名不存在
② 用户名密码都正确,登录成功
③ 密码错误:
2.2 登录认证
2.2.1 登录认证引入
假如现在已经有了 ArticalController 接口,该接口中有文章列表查询方法。
@RestController
@RequestMapping("/article")
public class ArticleController {@GetMapping("/list")public Result<String> list(){return Result.success("所有的文章数据...");}
}
正常情况下,如果用户未登录,应该是不能访问到 ArticalController 的文章列表查询方法的。所以,其他接口就应该在提供服务之前对登录状态进行检查,这个检查的过程称为登录认证。当前程序显然不能达到登录认证的效果。
如何实现登录验证呢?需要借助令牌技术。
浏览器访问登录接口时,如果登录成功,就在后台生成令牌,并把该令牌响应给浏览器。浏览器再访问其他接口时,都需要携带该令牌。其他接口如果看到浏览器已携带令牌,且该令牌合法,就会正常提供服务,否则不提供。这个令牌跟皇帝的令牌差不多,起身份识别的作用。
令牌本质是一个字符串,且满足以下要求:
- 承载业务数据,减少后续请求查询数据库的次数:
比如:系统中经常需要知道本次操作是由哪个用户发起的,如果每次都去数据库查询该用户的信息,就会降低系统性能。浏览器每次发起请求都会携带令牌,若能把用户信息封装到令牌中,需要用户数据时就可以从令牌中获取,从而减少数据库查询次数,提高系统性能。 - 防篡改,保证信息的合法性和有效性:
令牌要具备防伪功能,否则会有安全隐患
当前满足令牌要求的规范有很多,在 web 开发中最常用的是 JWT。
2.2.2 JWT 简介
JWT 全称:JSON Web Token,即用于 web 领域的基于 json 格式的令牌(https://jwt.io/)
定义了一种简洁的、自包含的格式,用于通信双方以 json 数据格式安全的传输信息。
上面是一个 JWT 令牌字符串,通过两个 .
将字符串分成了三部分,每个字串对应 token 令牌中的一部分。
第一部分: Head(头),是由一段 json 字符串编码得来,该 json 字符串记录两个信息,alg 是加密算法(防篡改),type 是令牌类型。
第二部分: Payload(有效载荷),是由一段 json 字符串编码得来,该 json 字符串用于存放业务数据,如用户的 id 和 username。
在这两部分中,json 字符串如何才能转换成 token 令牌中展示的这段字符串呢?在 JWT 中会借助 Base64 这种编码方式来完成。把任意数据转换成 64 个字符可打印字符,这些字符的特点就是通用,在任意场景下都能被支持。
为什么要将 json 字符串转换为 64 个可打印字符呢?主要是为了提高 token 的适用性。比如:当 json 中包含中文或空格等字符时,cookie 就不能支持,因此 JWT 中就将 json 字符串通过 Base64 编码转换成 token 中展示的形式。
Base64 仅仅是一种编码方式,并不是加密方式,且这种编码方式是公开的,任何人都能通过 Base64 进行编码和解码。所以在 token 的第二部分(有效载荷)一定不要存放登录密码等私密数据。
第三部分: Signature(数字签名),将第一、二部分借助密钥和加密算法经过加密得来,这里的加密算法就是第一部分(头)中通过 alg 来指定的,密钥可以在程序中单独配置。有了数字签名,就可以防篡改了,确保 token 是安全的。因为即使篡改了前两部分,第三部分也是不能篡改的,因为该部分是加密后的字符串。将来 JWT 在去解析 token 令牌时,会通过解密数字签名来得到第一、二部分,再拿着解密的内容与用户传递的内容进行比对,如果不一样就证明篡改过数据,就不允许访问。
下面在代码中实现:
在用户登录成功后,需要生成 JWT 令牌并响应给浏览器。前面说过:浏览器在访问其他接口时,都需要携带该令牌。其他接口如果看到浏览器已携带令牌,且该令牌合法,就会正常提供服务,否则不提供。所以重点在于如何生成令牌、验证令牌。
在实际开发中,生成令牌、验证令牌的代码不需要记,一般直接调用现成 api。
(1) 导入坐标
<!--SpringBoot整合单元测试的起步依赖-->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId>
</dependency>
(2) 生成、验证 JWT 令牌
先通过测试类编写代码主体,验证代码的功能。
public class JwtTest {//生成JWT令牌@Testpublic void testGen() {Map<String, Object> claims = new HashMap<>();claims.put("id", 1);claims.put("username", "张三");//生成jwt的代码String token = JWT.create().withClaim("user", claims)//添加数据//添加过期时间,登录后一个小时不操作令牌就失效.withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60)).sign(Algorithm.HMAC256("itheima"));//指定算法,配置密钥为itheimaSystem.out.println(token);}//验证JWT令牌@Testvoid testParse() {String token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3MDU2NzQzNDYsInVzZXIiOnsiaWQiOjEsInVzZXJuYW1lIjoi5byg5LiJIn19.lgWdmBGpXd6wNXSIxxUxBgx5BcGEH12f17a1iJ-2AaU";//申请JWT验证器,解密时采用与加密同样的算法和密钥//build方法用于生成验证器JWTVerifier jwtVerifier = JWT.require(Algorithm.HMAC256("itheima")).build();//调用验证器的方法来验证token,生成解析后的JWT对象//如果解析成功,就可以从中得到头部、载荷、签名DecodedJWT decodedJWT = jwtVerifier.verify(token);//getClaims得到所有的载荷Map<String, Claim> claims = decodedJWT.getClaims();//只需要得到键为user的载荷System.out.println(claims.get("user"));}
}
如果解析令牌时抛出异常,可能的原因是:
- 篡改了头部或载荷部分的数据
- 解密与加密的密钥不一致
- token 过期
2.2.3 登录功能集成 JWT
在上节,我们已经能够生成和验证 JWT 令牌,本节将在登录接口中集成 JWT。
登录成功时生成令牌,其他接口在提供服务前验证该令牌,只有令牌合法才提供服务。
当然,第一步仍然是导入坐标:
<!--SpringBoot整合单元测试的起步依赖-->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId>
</dependency>
JWT 生成和验证工具类:
public class JwtUtil {private static final String KEY = "itheima";//接收业务数据,生成token并返回public static String genToken(Map<String, Object> claims) {return JWT.create().withClaim("claims", claims).withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 12)).sign(Algorithm.HMAC256(KEY));}//接收token,验证token,并返回业务数据public static Map<String, Object> parseToken(String token) {return JWT.require(Algorithm.HMAC256(KEY)).build().verify(token).getClaim("claims").asMap();}}
添加了 JWT 令牌生成功能的 UserController:
//@RestController整合了@ResponseBody注解,将后台传到前端的java对象转为json数据
@RestController
@RequestMapping("/user")
@Validated
public class UserController {@Autowiredprivate UserService userService;@PostMapping("/register")//^和$之间是正则表达式的主体,\S表示非空,它前面的\是转义字符,{5,16}表示5~16位public Result register(@Pattern(regexp = "^\\S{5,16}$") String username, @Pattern(regexp = "^\\S{5,16}$") String password){//查询用户User user = userService.findByUserName(username);if(user == null){//没有占用,注册userService.register(username, password);return Result.success();}else {//已占用return Result.error("用户名已被占用");}}@PostMapping("/login")public Result<String> login(@Pattern(regexp = "^\\S{5,16}$") String username, @Pattern(regexp = "^\\S{5,16}$") String password){//根据用户名查询用户User user = userService.findByUserName(username);//判断用户是否存在if (user == null){return Result.error("用户名错误");}//判断密码是否正确,从数据库获取到的user对象的password是密文if (Md5Util.getMD5String(password).equals(user.getPassword())){//登录成功,生成JWT令牌Map<String, Object> claims = new HashMap<>();//令牌携带id和username即可claims.put("id", user.getId());claims.put("username", user.getUsername());//调用方法生成JWT令牌String token = JwtUtil.genToken(claims);return Result.success(token);}//密码错误return Result.error("密码错误");}
}
postman 验证:
在 Article 提供服务之前先验证 token 令牌,这个 token 从哪来呢?之前我们说浏览器访问其他接口时会携带 token,这个 token 是以什么形式携带的呢?是请求头还是请求体?文档中给出了说明:
用户登录成功后,系统会自动下发 JWT 令牌,然后在后续的每次请求中,浏览器将 JWT 令牌包裹在请求头 header 中携带到服务端。JWT 令牌在请求头中的名称为 Authorization。
如果检测到用户未登录,则 http 响应状态码为 401(未授权)。
所以,应该在请求头中去获取浏览器携带的 token。从请求头获取 token 可以直接在参数中声明,并在参数前面添加注解 @RequestHeader(name=请求头名称)
,这样 token 就能获取到了。获取到 token 之后,再利用 JwtUtil 解析 token,如果解析的代码正常执行说明 JWT 验证成功,否则失败。另外,在验证失败时,http 响应状态码是 401。如何设置响应状态码?需要一个 Response 对象,Response 对象也可以在参数中声明,到时候框架在调用方法时会把该对象传入。
@RestController
@RequestMapping("/article")
public class ArticleController {@GetMapping("/list")public Result<String> list(@RequestHeader(name = "Authorization") String token, HttpServletResponse response){try {//如果这行代码正常执行,就解析成功Map<String, Object> claims = JwtUtil.parseToken(token);return Result.success("所有的文章数据...");} catch (Exception e) {//设置http响应状态码为401response.setStatus(401);return Result.error("未登录");}}}
完成了 JWT 验证的代码,下面来测试一下是否能达到预期效果:
未登录时,@RequestHeader(name = "Authorization") String token
参数接收不到请求头,抛出异常,文章列表访问不成功(这段 json 是由前面定义的异常处理器返回的):
如果浏览器要携带请求头,需要写前端代码,如果不写前端代码的话,可以用 postman 辅助验证:
2.2.4 拦截器
至此就完成了登录认证。但是存在一个问题:Contorller 层有 UserController、ArticleController 等很多 Controller,且每个 Contorller 都提供了很多接口,难道要在每个接口中都写同样的代码完成令牌的校验吗,这显然是不合适的。
如果多个接口有同样的任务要完成,可以用拦截器实现:
要实现拦截器首先要定义一个类,去实现 HandlerInterceptor 接口,并重写 preHandle 方法(意为在目标方法执行之前拦截下来):
@Component//将当前拦截器的对象注入IOC容器
public class LoginInterceptor implements HandlerInterceptor {@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {//拦截请求,拦截下来之后进行令牌验证//之前通过参数声明来拿到令牌,这里借助request对象(请求对象)拿到令牌,因为这个对象包含所有的请求数据String token = request.getHeader("Authorization");//解析令牌try {//如果这行代码正常执行,就解析成功Map<String, Object> claims = JwtUtil.parseToken(token);//放行return true;} catch (Exception e) {//解析失败//设置http响应状态码为401response.setStatus(401);//不放行return false;}}
}
还要写一个 web 配置类,它实现 WebMvcConfigurer 接口,在该类重写的 addInterceptors 方法中将刚刚编写的拦截器注册进去就可以了。注意:登录和注册接口不拦截。
@Configuration
public class WebConfig implements WebMvcConfigurer {@Autowiredprivate LoginInterceptor loginInterceptor;@Overridepublic void addInterceptors(InterceptorRegistry registry) {//登录接口和注册接口不拦截registry.addInterceptor(loginInterceptor).excludePathPatterns("/user/login", "/user/register");}
}
现在,我们把 ArticleController 中接口的代码注释掉:
用 postman 测试:
① 没有登录(携带令牌)时,访问文章列表:
② 登录(携带令牌)后,访问文章列表:
3. 获取用户详细信息
3.1 获取用户详细信息基本代码编写
需求:当用户登录成功后,需要跳转到首页,首页顶部展示了用户昵称、头像等信息,像这样的数据都需要访问后台接口来获取。在个人中心中有基本资料、更换头像、重置密码功能,也都需要先查询,再展示。
获取用户详细信息,需要根据已登录的用户名查询用户。
但接口文档中写到,该请求没有请求参数,并没有携带 username。此时,可以从 token 令牌中获取 username,因此可以在方法上声明一个带@RequestHeader(name = "Authorization")
注解的参数,从而可以获取 token,进而解析 token 得到用户名。关于根据用户名查询信息在 Service 和 Mapper 层的实现,前面都已经完成了,此处不再赘述。
代码具体实现
在 UserController 中添加:
@RequestMapping("/userInfo")
public Result<User> userInfo(@RequestHeader(name = "Authorization") String token){Map<String, Object> map = JwtUtil.parseToken(token);String username = (String) map.get("username");User user = userService.findByUserName(username);return Result.success(user);
}
postman 测试
未登录(未携带 token 令牌):
已登录(已携带 token 令牌):
我们用 postman 测试时,每一个请求都要单独在请求头中设置 token 令牌,比较繁琐。实际上可以为一个集合中所有的请求统一添加请求头:
但是,此时还有一个问题:后台在响应获取用户信息的请求时,把用户密码也传过去了,这样是不安全的。
解决方式:在 password 成员变量上添加 @JsonIgnore
注解,这样,SpringMVC 在把当前对象转换成 json 字符串时,就会忽略 password,最终的 json 字符串也就没有 password 这个属性了。
postman 测试发现,后台传回的数据中确实没有 password 了:
另外,可以发现传回的数据中 nickname、email、userPic 为空,这时因为用户注册时并没有填写这些信息,后续用户注册成功后才会完善这些信息。
那么,createTime 和 updateTime 为什么为 null 呢?
原因在于:数据库表中的字段是下划线命名方式,而 User 实体类中的成员变量是驼峰命名方式,二者名称不一致,所以 mybatis 就不知道该如何封装了。
要解决这个问题,需要在配置文件(application.yml)中开启驼峰命名和下划线命名自动转换:
mybatis:configuration:map-underscore-to-camel-case: true#开启驼峰命名和下划线命名自动转换
开启之后,当 mybatis 发现数据库表中是下划线命名时,就会在实体类中去找对应的驼峰命名的属性,然后完成数据封装。此时再次用 postman 测试可以发现,后台已经能返回 createTime 和 updateTime。
3.2 ThreadLocal 优化
在获取用户信息的代码中,为了获取已登录用户的用户名,需要在方法的参数上声明 token,并且在方法体中解析 token 得到用户名。这看似合理,但是拦截器中已经解析过 token 了,重复写相同的代码不是一种好的编程习惯。因此,我们希望在其他地方复用拦截器中解析得到的结果。如何才能做到呢?
此时需要用到 ThreadLocal 来优化代码。
ThreadLocal 的作用:
- 提供线程的局部变量。
- 提供 set() / get() 方法来存取数据: ,使用 ThreadLocal 存储的数据是线程安全的,每个线程之间互不影响。
举个例子,多个线程使用 ThreadLocal 存取用户名:现有蓝色、绿色两个线程,它们都持有 ThreadLocal 的 tl 对象的引用。在两个线程中,都可以使用 set() 方法来存储用户名,蓝色线程存储用户名“萧炎”,绿色线程存储用户名“药尘”。存储完毕后,在蓝色线程中调用 get() 方法获取用户名时,获取到的就是“萧炎”,而不能获取到绿色线程存入的“药尘”;绿色线程中调用 get() 方法时同理。因为 ThreadLocal 会为两个线程分别创建数据存储空间,可以做到线程隔离。
代码验证:
public class ThreadLocalTest {@Testpublic void testThreadLocalSetAndGet(){//提供一个ThreadLocal对象ThreadLocal tl = new ThreadLocal();//开启两个线程//传递两个参数:线程任务和线程名字new Thread(()->{tl.set("萧炎");System.out.println(Thread.currentThread().getName()+": "+tl.get());System.out.println(Thread.currentThread().getName()+": "+tl.get());System.out.println(Thread.currentThread().getName()+": "+tl.get());}, "蓝色").start();new Thread(()->{tl.set("药尘");System.out.println(Thread.currentThread().getName()+": "+tl.get());System.out.println(Thread.currentThread().getName()+": "+tl.get());System.out.println(Thread.currentThread().getName()+": "+tl.get());}, "绿色").start();}
}
输出结果:
蓝色: 萧炎
蓝色: 萧炎
绿色: 药尘
绿色: 药尘
绿色: 药尘
蓝色: 萧炎
现在,ThreadLocal 的作用已经明白了,但是它与当前需求有什么关系呢?
假设现在程序中有 ArticleController、ArticleService、ArticleDao,它们都有 add() 方法,且方法内部都要用到 userId,那么就得在每个方法上声明 userId 完成参数传递,如果其他地方需要 userId,也需要通过传参来完成。
这个操作不难,但是很繁琐。如果既不想在方法上声明,又想使用 userId,怎么办呢?此时就可以用 ThreadLocal 来优化。
可以维护一个全局的 ThreadLocal 对象,用来存储用户名这类数据。有了它,可以在请求到达拦截器之后,调用 tl.set() 方法将 userId 存储到 ThreadLocal 中。接下来,当请求到达 ArticleController、ArticleService、ArticleDao 之后,就可以在它们的 add() 方法中调用 tl.get() 方法从 ThreadLocal 中获取 userId 来使用。
Controller、Service、Dao 在容器中一般都是单例的,获取 id 的时候怎么知道是哪个用户的 id 呢?会不会发生线程安全问题呢?
假如现在有两个用户来访问程序,其 userId 分别是 1 和 2,当请求到达 tomcat 之后,服务器会为每个用户开辟一个线程用来提供服务。在为用户 1 提供服务时,会把拦截器中的 preHandle() 方法加载进黑色线程执行。在执行 tl.set(userId) 时,由于用户 1 携带的 userId=1,所以就将 1 这个 userId 设置进去。拦截器放行之后,会依次加载 ArticleController、ArticleService、ArticleDao 中的 add() 方法进栈继续执行,在执行这些 add() 方法时,获取到的 userId 都是 1;为用户 2 提供服务时同理。这样就做到了线程隔离。
因此可以借助 ThreadLocal 做到两件事:
- 减少参数传递
- 同一个线程执行的代码之间共享数据,拦截器中的数据共享到 Controller、Service、Dao 中
明白了 ThreadLocal 的使用场景,下面借助 ThreadLocal 优化一下代码:
(1) 提供 ThreadLocal 工具类
@SuppressWarnings("all")
public class ThreadLocalUtil {//提供一个常量THREAD_LOCAL,用来维护一个全局唯一的ThreadLocal对象private static final ThreadLocal THREAD_LOCAL = new ThreadLocal();//根据键获取值public static <T> T get(){//调用ThreadLocal对象的get()方法,把得到的数据返回//强制类型转换return (T) THREAD_LOCAL.get();}//存储键值对public static void set(Object value){//调用ThreadLocal的set方法把值存进去THREAD_LOCAL.set(value);}//清除ThreadLocal,防止内存泄漏//ThreadLocal对象是全局唯一的,生命周期特别长,如果里面的数据一直不清除,有可能造成内存泄漏public static void remove(){THREAD_LOCAL.remove();}
}
(2) 在拦截器中,把 token 令牌(含id、username)的解析结果存入 ThreadLocal
(3) UserController 中的方法获取 ThreadLocal 存储的 token 令牌解析结果
前面说过,ThreadLocal 中的数据用完后要清除,应该在哪清除呢?
(4) 在拦截器中重写 afterCompletion() 方法来清除数据
当请求发起后,在拦截器中解析 token 令牌,并将解析结果存入 ThreadLocal;拦截器放行之后,Controller、Service、Dao 中就都能使用到 ThreadLocal 中的数据。那么 ThreadLocal 中的数据什么时候用完呢?当响应完成,即本次请求结束后,该数据就不再使用了。所以就应该在请求结束后将数据移除。
postman 测试成功:
4. 更新用户基本信息
接口文档:
4.1 更新用户基本信息基本代码编写
需求:当用户在个人中心点击“基本信息”时,页面主区域会展示当前用户的详细信息,用户可以修改“用户昵称”、“用户邮箱”,最后提交修改,从而访问后台接口,更新当前用户信息。而且可以看到,登录名称,即“用户名”是不允许修改的。
浏览器在请求体中以 json 格式携带 id、username、nicknime、email 等数据,在后台,会用实体类对象 User 来接收这些数据,为了让框架能够将请求体中的 json 数据自动转换成实体类对象,需要在接收参数之前添加 @RequestBody 注解,方法声明完成后,在方法体内调用 Service 层的方法完成更新就可以了。所以 Service 层将来也要提供更新对应的方法,Mapper 层也要执行对应的 SQL:
UserController 中添加方法:
@PutMapping("/update")
//@RequestBody:将请求体中的json数据自动转换成实体类对象传给参数user
public Result update(@RequestBody User user){userService.update(user);return Result.success();
}
UserService 及其实现类添加:
//UserService.java
void update(User user);//更新用户信息//UserServiceImpl.java
@Override
public void update(User user) {//更新用户信息//获取系统当前时间user.setUpdateTime(LocalDateTime.now());userMapper.update(user);
}
UserMapper 添加:
//更新用户信息
//格式:数据库表字段名=#{实体类属性名}
@Update("update user set nickname=#{nickname},email=#{email},update_time=#{updateTime} where id=#{id}")
void update(User user);
postman测试:
4.2 更新用户基本信息参数校验
在接口的方法上声明了一个实体类参数 user,用它来接收前端传过来的参数 id、username、nicknime、email,但刚才并没有对这些参数进行校验,而接口文档中对 id、nicknime、email 这三个参数是由要求的:id 必须传递,不能为 null;nicknime 除了不为 null,还得是 1~10 位的非空字符;email 除了不为 null,还要满足邮箱格式。而 username 在注册之后就不能修改了,所以此时可以不用关注。
本章节采用 Validation 完成参数校验。
之前注册的时候,已经使用 Validation 进行了请求参数的校验,只不过是通过直接在方法参数上添加 @Pattern 注解的方式。
但现在把请求数据封装在实体类对象 user 中了,像这样的实体参数应该如何完成校验呢?
① 在实体类的成员变量上添加 Validation 提供的注解,对指定的属性值进行参数校验
② 在具体使用该实体参数的地方添加 @Validated
注解,此时实体类属性上的注解才能生效
5. 更新用户头像
接口文档:
5.1 更新用户头像基本代码编写
需求:当用户在个人中心点击了“更换头像”之后,在页面主区域展示出当前用户的头像,用户点击“选择图片”按钮选择本地的一张图片,然后点击“上传头像”按钮,以访问后台接口,完成头像更新。
Controller 层添加更新头像的方法,由于请求方式是 PATCH,所以方法上添加 @PatchMapping
注解,方法的参数列表上声明 avatarUrl 参数来接收头像地址。方法内部调用 Service 层更新头像的方法,Mapper 层编写 SQL 来更新头像(update_time 也要更新一下)。
UserController 中添加更新头像的方法:
@PatchMapping("/updateAvatar")
//@RequestParam: 用于接收url地址传参,标明需要从queryString中获取数据
public Result updateAvatar(@RequestParam String avatarUrl){userService.updateAvatar(avatarUrl);return Result.success();
}
UserService 中添加:
//UserService接口
//更新用户头像
void updateAvatar(String avatarUrl);//UserService实现类
//更新用户头像
@Override
public void updateAvatar(String avatarUrl) {//因为mapper层需要根据id修改用户头像,所以要先从ThreadLocal中把id拿过来Map<String,Object> map = ThreadLocalUtil.get();Integer id = (Integer) map.get("id");userMapper.updateAvatar(avatarUrl, id);
}
UserMapper 中添加:
//更新用户头像
@Update("update user set user_pic=#{avatarUrl}, update_time=now() where id=#{id}")
void updateAvatar(String avatarUrl, Integer id);
postman 测试:
5.2 url 参数校验
在当前更新用户头像代码的基础上,如果传递一个不符合 url 地址格式要求的地址,仍然会操作成功且更新数据库,这是不合理的。因此需要后台要对传递过来的头像地址 avatarUrl 进行参数校验。
如何校验是否为 url 地址呢?
Validation 提供了一个注解 @URL
来完成数据是否是 url 的校验:
postman 测试:
6. 更新用户密码
接口文档:
需求:用户点击个人中心的“重置密码”后,会在主区域展示重置密码的表单,表单填写完成后,可以点击“修改密码”按钮,从而调用后台接口,完成修改密码操作。
之前接收前端 json 数据时采用的是 User 实体对象,当时是因为 json 中的键名刚好与 User 实体类中的属性名相同。但是现在更新用户密码时,json 中的键名可能是 oldPwd、newPwd 等,与实体类的属性不一致,所以需要声明一个 Map 集合来接收前端传过来的 json 数据。到时候,SpringMVC 会自动将 json 数据转换成 Map 集合对象。方法声明好之后,同样需要在方法内部调用 Service 层的方法完成密码更新,Mapper 层也需要执行对应的 SQL,除了要更新 password 字段,还要更新 update_time字段,且同样要通过 id 来更新。
UserController 中添加修改 / 更新密码的方法:
@PatchMapping("/updatePwd")
//@RequestBody:使SpringMVC框架自动读取请求体中的json数据,转换为Map集合对象
public Result updatePwd(@RequestBody Map<String, String> params){//Validation提供的注解并不能满足需求,所以要手动校验参数String oldPwd = params.get("old_pwd");String newPwd = params.get("new_pwd");String rePwd = params.get("re_pwd");//原密码、新密码、确认密码是否都传过来了if (!StringUtils.hasLength(oldPwd) || !StringUtils.hasLength(newPwd) || !StringUtils.hasLength(rePwd)){return Result.error("缺少必要的参数");}//原密码填写是否正确//从ThreadLocalUtil的信息中拿到username,从而查询到数据库中的原密码,与用户输入的原密码进行比对Map<String,Object> map = ThreadLocalUtil.get();String username = (String) map.get("username");User user = userService.findByUserName(username);if (!user.getPassword().equals(Md5Util.getMD5String(oldPwd))){return Result.error("原密码填写不正确");}//新密码与确认密码是否一致if (!newPwd.equals(rePwd)){return Result.error("新密码与确认密码不一致");}//调用Service完成密码更新userService.updatePwd(newPwd);return Result.success();
}
UserService 中添加:
//UserService接口
//更新用户密码
void updatePwd(String newPwd);//UserService实现类
//更新用户密码
@Override
public void updatePwd(String newPwd) {//因为mapper层需要根据id修改用户密码,所以要先从ThreadLocal中把id拿过来Map<String,Object> map = ThreadLocalUtil.get();Integer id = (Integer) map.get("id");//新密码需要先加密再更新到数据库userMapper.updatePwd(Md5Util.getMD5String(newPwd), id);
}
UserMapper 中添加:
//更新用户密码
@Update("update user set password=#{password}, update_time=now() where id=#{id}")
void updatePwd(String password, Integer id);
postman 测试: