尚硅谷尚庭公寓-03
ThreadLocal
介绍
ThreadLocal是JDK提供的一个线程内部的数据存储工具类,它提供了一些方法,用于在当前线程中存储数据,并且每个线程都可以独立地访问自己的数据。
实践
理论上我们可以在Controller方法中,使用
@RequestHeader
获取JWT,然后在进行解析,如下1
2
3
4
5
6
7
8
public Result<SystemUserInfoVo> info( { String token)
Claims claims = JwtUtil.parseToken(token);
Long userId = claims.get("userId", Long.class);
SystemUserInfoVo userInfo = service.getLoginUserInfo(userId);
return Result.ok(userInfo);
}上述代码的逻辑没有任何问题,但是这样做,JWT会被重复解析两次(一次在拦截器中,一次在该方法中)。为避免重复解析,通常会在拦截器将Token解析完毕后,将结果保存至ThreadLocal中,这样一来,我们便可以在整个请求的处理流程中进行访问了。
在common模块中创建
com.atguigu.lease.common.login.LoginUserHolder
工具类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15public class LoginUserHolder {
public static ThreadLocal<LoginUser> threadLocal = new ThreadLocal<>();
public static void setLoginUser(LoginUser loginUser) {
threadLocal.set(loginUser);
}
public static LoginUser getLoginUser() {
return threadLocal.get();
}
public static void clear() {
threadLocal.remove();
}
}同时在common模块中创建
com.atguigu.lease.common.login.LoginUser
类1
2
3
4
5
6
7
public class LoginUser {
private Long userId;
private String username;
}修改
AuthenticationInterceptor
拦截器1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class AuthenticationInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("access-token");
Claims claims = JwtUtil.parseToken(token);
Long userId = claims.get("userId", Long.class);
String username = claims.get("username", String.class);
LoginUserHolder.setLoginUser(new LoginUser(userId, username));
return true;
}
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
LoginUserHolder.clear();
}
}编写Controller层逻辑
在
LoginController
中增加如下内容1
2
3
4
5
6
public Result<SystemUserInfoVo> info() {
SystemUserInfoVo userInfo = service.getLoginUserInfo(LoginUserHolder.getLoginUser().getUserId());
return Result.ok(userInfo);
}编写Service层逻辑
在
LoginService
中增加如下内容1
2
3
4
5
6
7
8
public SystemUserInfoVo getLoginUserInfo(Long userId) {
SystemUser systemUser = systemUserMapper.selectById(userId);
SystemUserInfoVo systemUserInfoVo = new SystemUserInfoVo();
systemUserInfoVo.setName(systemUser.getName());
systemUserInfoVo.setAvatarUrl(systemUser.getAvatarUrl());
return systemUserInfoVo;
}
xml文件<
和>
的转义
由于xml文件中的<
和>
是特殊符号,需要转义处理。
原符号 | 转义符号 |
---|---|
< |
< |
> |
> |
Mybatis-Plus分页插件注意事项
使用Mybatis-Plus的分页插件进行分页查询时,如果结果需要使用<collection>
进行映射,只能使用嵌套查询(Nested Select for Collection),而不能使用嵌套结果映射(Nested Results for Collection)。
嵌套查询和嵌套结果映射是Collection映射的两种方式,下面通过一个案例进行介绍
例如有room_info
和graph_info
两张表,其关系为一对多,如下
现需要查询房间列表及其图片信息,期望返回的结果如下
1 | [ |
为得到上述结果,可使用以下两种方式
嵌套结果映射
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17<select id="selectRoomPage" resultMap="RoomPageMap">
select ri.id room_id,
ri.number,
ri.rent,
gi.id graph_id,
gi.url,
gi.room_id
from room_info ri
left join graph_info gi on ri.id=gi.room_id
</select>
<resultMap id="RoomPageMap" type="RoomInfoVo" autoMapping="true">
<id column="room_id" property="id"/>
<collection property="graphInfoList" ofType="GraphInfo" autoMapping="true">
<id column="graph_id" property="id"/>
</collection>
</resultMap>这种方式的执行原理如下图所示
嵌套查询
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19<select id="selectRoomPage" resultMap="RoomPageMap">
select id,
number,
rent
from room_info
</select>
<resultMap id="RoomPageMap" type="RoomInfoVo" autoMapping="true">
<id column="id" property="id"/>
<collection property="graphInfoList" ofType="GraphInfo" select="selectGraphByRoomId" column="id"/>
</resultMap>
<select id="selectGraphByRoomId" resultType="GraphInfo">
select id,
url,
room_id
from graph_info
where room_id = #{id}
</select>这种方法使用两个独立的查询语句来获取一对多关系的数据。首先,Mybatis会执行主查询来获取
room_info
列表,然后对于每个room_info
,Mybatis都会执行一次子查询来获取其对应的graph_info
。若现在使用MybatisPlus的分页插件进行分页查询,假如查询的内容是第1页,每页2条记录,则上述两种方式的查询结果分别是
嵌套结果映射
嵌套查询
显然嵌套结果映射的分页逻辑是存在问题的。
异步操作
保存浏览历史的动作不应影响前端获取房间详情信息,故此处采取异步操作。Spring Boot提供了
@Async
注解来完成异步操作,具体使用方式为:启用Spring Boot异步操作支持
在 Spring Boot 主应用程序类上添加@EnableAsync
注解,如下1
2
3
4
5
6
7
public class AppWebApplication {
public static void main(String[] args) {
SpringApplication.run(AppWebApplication.class);
}
}在要进行异步处理的方法上添加
@Async
注解,如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public void saveHistory(Long userId, Long roomId) {
LambdaQueryWrapper<BrowsingHistory> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(BrowsingHistory::getUserId, userId);
queryWrapper.eq(BrowsingHistory::getRoomId, roomId);
BrowsingHistory browsingHistory = browsingHistoryMapper.selectOne(queryWrapper);
if (browsingHistory != null) {
browsingHistory.setBrowseTime(new Date());
browsingHistoryMapper.updateById(browsingHistory);
} else {
BrowsingHistory newBrowsingHistory = new BrowsingHistory();
newBrowsingHistory.setUserId(userId);
newBrowsingHistory.setRoomId(roomId);
newBrowsingHistory.setBrowseTime(new Date());
browsingHistoryMapper.insert(newBrowsingHistory);
}
}
缓存优化
概述
缓存优化是一个性价比很高的优化手段,多数情况下,缓存优化可以通过一些简单的操作,换来性能的大幅提升。缓存优化的核心思想就是将一些原本保存在磁盘(例如MySQL)中的、经常访问并且查询开销比较大的数据,临时保存到内存(例如Redis)中。后序再访问相同数据时,就可直接从内存中获取结果,而无需再访问磁盘,由于内存的读写速度远高于磁盘,因此就能极大的提高程序的性能。
在使用缓存优化时,有一个问题不得不提,那就是数据库和缓存数据的一致性,当数据库中的数据发生变化时,缓存中的数据也要同步更新,否则就会出现数据不一致的问题,解决该问题的方案有如下几个
- 数据发生变化时,更新数据库的同时也更新缓存
- 数据发生变化时,更新数据库的同时删除缓存
在了解了缓存优化的核心思想后,我们以移动端中的根据ID获取房间详情
接口为例,进行缓存优化。该接口涉及多表查询,查询时会多次访问数据库,查询代价较高,故可采取缓存优化,加快查询速度。
编写缓存逻辑
1.自定义RedisTemplate
本项目使用Reids保存缓存数据,因此我们需要使用RedisTemplate进行读写操作。前文提到过,Spring-data-redis
提供了StringRedisTemplate
和RedisTemplate<Object,Object>
两个实例,但是两个实例均不满足我们当前的需求,所以我们需要自定义RedisTemplate。
在common模块中创建com.atguigu.lease.common.redis.RedisConfiguration
类,内容如下
1 |
|
2.编写缓存逻辑
修改web-app模块中的com.atguigu.lease.web.app.service.impl.RoomInfoServiceImpl
中的getDetailById
方法,如下
1 |
|
3.编写删除缓存逻辑
为保证缓存数据的一致性,在房间信息发生变化时,需要删除相关缓存。
修改web-admin模块中的com.atguigu.lease.web.admin.service.impl.RoomInfoServiceImpl
中的saveOrUpdateRoom
方法,如下
1 |
|
修改web-admin模块中的com.atguigu.lease.web.admin.service.impl.RoomInfoServiceImpl
中的removeRoomById
方法,如下
1 |
|
压力测试
可使用Postman或者Apifox等工具对根据ID获取房间详情
这个接口进行压力测试,下图是增加缓存前后的测试报告
阿里云短信
获取短信验证码
该接口需向登录手机号码发送短信验证码,各大云服务厂商都提供短信服务,本项目使用阿里云完成短信验证码功能,下面介绍具体配置。
配置短信服务
配置所需依赖
如需调用阿里云的短信服务,需使用其提供的SDK,具体可参考官方文档。
在common模块的pom.xml文件中增加如下内容
1
2
3
4<dependency>
<groupId>com.aliyun</groupId>
<artifactId>dysmsapi20170525</artifactId>
</dependency>配置发送短信客户端
在
application.yml
中增加如下内容1
2
3
4
5aliyun:
sms:
access-key-id: <access-key-id>
access-key-secret: <access-key-secret>
endpoint: dysmsapi.aliyuncs.com注意:
上述
access-key-id
、access-key-secret
需根据实际情况进行修改。在common模块中创建
com.atguigu.lease.common.sms.AliyunSMSProperties
类,内容如下1
2
3
4
5
6
7
8
9
10
public class AliyunSMSProperties {
private String accessKeyId;
private String accessKeySecret;
private String endpoint;
}在common模块中创建
com.atguigu.lease.common.sms.AliyunSmsConfiguration
类,内容如下1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class AliyunSMSConfiguration {
private AliyunSMSProperties properties;
public Client smsClient() {
Config config = new Config();
config.setAccessKeyId(properties.getAccessKeyId());
config.setAccessKeySecret(properties.getAccessKeySecret());
config.setEndpoint(properties.getEndpoint());
try {
return new Client(config);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
配置Redis连接参数
1
2
3
4
5
6spring:
data:
redis:
host: 192.168.10.101
port: 6379
database: 0编写Controller层逻辑
在
LoginController
中增加如下内容1
2
3
4
5
6
public Result getCode( { String phone)
service.getSMSCode(phone);
return Result.ok();
}编写Service层逻辑
编写发送短信逻辑
在
SmsService
中增加如下内容1
void sendCode(String phone, String verifyCode);
在
SmsServiceImpl
中增加如下内容1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void sendCode(String phone, String code) {
SendSmsRequest smsRequest = new SendSmsRequest();
smsRequest.setPhoneNumbers(phone);
smsRequest.setSignName("阿里云短信测试");
smsRequest.setTemplateCode("SMS_154950909");
smsRequest.setTemplateParam("{\"code\":\"" + code + "\"}");
try {
client.sendSms(smsRequest);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
编写生成随机验证码逻辑
在common模块中创建
com.atguigu.lease.common.utils.VerifyCodeUtil
类,内容如下1
2
3
4
5
6
7
8
9
10public class VerifyCodeUtil {
public static String getVerifyCode(int length) {
StringBuilder builder = new StringBuilder();
Random random = new Random();
for (int i = 0; i < length; i++) {
builder.append(random.nextInt(10));
}
return builder.toString();
}
}编写获取短信验证码逻辑
在
LoginServcie
中增加如下内容1
void getSMSCode(String phone);
在
LoginServiceImpl
中增加如下内容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
public void getSMSCode(String phone) {
//1. 检查手机号码是否为空
if (!StringUtils.hasText(phone)) {
throw new LeaseException(ResultCodeEnum.APP_LOGIN_PHONE_EMPTY);
}
//2. 检查Redis中是否已经存在该手机号码的key
String key = RedisConstant.APP_LOGIN_PREFIX + phone;
boolean hasKey = redisTemplate.hasKey(key);
if (hasKey) {
//若存在,则检查其存在的时间
Long expire = redisTemplate.getExpire(key, TimeUnit.SECONDS);
if (RedisConstant.APP_LOGIN_CODE_TTL_SEC - expire < RedisConstant.APP_LOGIN_CODE_RESEND_TIME_SEC) {
//若存在时间不足一分钟,响应发送过于频繁
throw new LeaseException(ResultCodeEnum.APP_SEND_SMS_TOO_OFTEN);
}
}
//3.发送短信,并将验证码存入Redis
String verifyCode = VerifyCodeUtil.getVerifyCode(6);
smsService.sendCode(phone, verifyCode);
redisTemplate.opsForValue().set(key, verifyCode, RedisConstant.APP_LOGIN_CODE_TTL_SEC, TimeUnit.SECONDS);
}注意:
需要注意防止频繁发送短信。
部署
部署后端项目
打包
使用IDEA的maven插件对项目进行打包,完成后,在web-admin和web-app模块的target
目录下找到web-admin-1.0-SNAPSHOT.jar
和web-app-1.0-SNAPSHOT.jar
。
安装JDK
根据前文的部署方案,需要在server01
部署后端服务,因此需要在server01
中安装JDK,本项目采用JDK17。
- 获取JDK安装包
将资料中提前下载好的JDK上传到server01
,也在服务器执行以下命令可直接下载。
1 | wget https://download.oracle.com/java/17/archive/jdk-17.0.8_linux-x64_bin.tar.gz |
- 解压JDK安装包
执行以下命令将jdk解压到/opt
目录
1 | tar -zxvf jdk-17.0.8_linux-x64_bin.tar.gz -C /opt |
- 测试JDK安装效果
执行以下命令,观察输出是否正常
1 | /opt/jdk-17.0.8/bin/java -version |
部署
上传jar包
将后端项目的两个jar包上传到
server01
服务器的/opt/lease
目录下,若目录不存在,自行创建即可。集成Systemd
为方便项目的启动、停止或者重启,我们同样使用Systemd来管理后端服务的进程。
移动端集成Systemd
创建
lease-app.service
文件1
vim /etc/systemd/system/lease-app.service
内容如下
1
2
3
4
5
6
7
8
9
10
11[Unit]
Description=lease-app
After=syslog.target
[Service]
User=root
ExecStart=/opt/jdk-17.0.8/bin/java -jar /opt/lease/web-app-1.0-SNAPSHOT.jar 1>/opt/lease/app.log 2>&1
SuccessExitStatus=143
[Install]
WantedBy=multi-user.target后台管理系统集成Systemd
创建
lease-admin.service
文件1
vim /etc/systemd/system/lease-admin.service
内容如下
1
2
3
4
5
6
7
8
9
10
11[Unit]
Description=lease-admin
After=syslog.target
[Service]
User=root
ExecStart=/opt/jdk-17.0.8/bin/java -jar /opt/lease/web-admin-1.0-SNAPSHOT.jar 1>/opt/lease/admin.log 2>&1
SuccessExitStatus=143
[Install]
WantedBy=multi-user.target
启动项目
执行以下命令启动两个后端项目。
1
2systemctl start lease-app
systemctl start lease-admin
部署前端项目
Nginx配置概述
移动端和后台管理系统的前端项目均部署在server02
的Nginx中,Nginx的配置思路如下图所示
移动端
打包
明确前端请求的后端接口地址
打包之前需要明确前端请求的后台接口地址,根据前文的部署规划,前端请求后台接口时走的是Ngxin反向代理,也就是请求的地址为
http://81.68.xxx.xxx:xxx
。所以我们需要修改
.env.production
文件中VITE_APP_BASE_URL
环境变量的值,修改结果如下1
VITE_APP_BASE_URL='http://81.68.xxx.xxx:xxx'
构建项目
在项目的根目录执行以下命令
1
npm run build
查看打包结果
观察项目的根目录是否出现
dist
目录
部署
上传dist文件
将
rentHouseH5
项目编译得到dist
文件上传至server02
服务器的/usr/share/nginx/html/app
目录下。最终的目录结构为
1
2
3
4
5
6
7
8/usr
└── share
└── nginx
└── html
└── app
├── static
└── index.html
└── ...编辑Nginx配置文件
创建
/etc/nginx/conf.d/app.conf
文件1
vim /etc/nginx/conf.d/app.conf
内容如下
1
2
3
4
5
6
7
8
9
10
11
12server {
listen xxxx;
server_name 81.68.xxx.xxx;
location / {
root /usr/share/nginx/html/app;
index index.html;
}
location /app {
proxy_pass http://81.68.xxx.xxx:xxxx;
}
}重新加载Nginx配置文件
执行以下命令重新加载配置文件
1
systemctl reload nginx
访问项目
后台管理系统
打包
明确前端请求的后端接口地址
后台管理系统的前端请求后端接口时,同样会走Nginx反向代理,故其请求的接口地址为
http://81.68.xxx.xxx:xxxx
。确保rentHouseAdmin项目中的
.env.production
文件中的VITE_APP_BASE_URL
环境变量配置为如下内容1
VITE_APP_BASE_URL='http://81.68.xxx.xxx:xxxx'
打包
在项目根目录执行以下命令
1
npm run build
查看打包结果
观察项目的根目录是否出现
dist
目录
部署
上传dist文件
将
rentHouseAdmin
项目编译得到dist
文件上传至server02
服务器的/usr/share/nginx/html/admin
目录下。最终的目录结构为
1
2
3
4
5
6
7
8/usr
└── share
└── nginx
└── html
└── admin
├── assets
└── index.html
└── ...编辑Nginx配置文件
创建
/etc/nginx/conf.d/admin.conf
文件1
vim /etc/nginx/conf.d/admin.conf
内容如下
1
2
3
4
5
6
7
8
9
10
11
12server {
listen xxxx;
server_name 81.68.xxx.xxx;
location / {
root /usr/share/nginx/html/admin;
index index.html;
}
location /admin {
proxy_pass http://81.68.xx.xxxx:xxxx;
}
}重新加载Nginx配置文件
执行以下命令重新加载配置文件
1
systemctl reload nginx
访问项目