加密算法、编码方式
加密保证数据不会被窃取或者修改
1、可逆和不可逆加密
区分在于加密后的结果是否可以还原
可逆加密:安全传输数据时使用(如jwt中的数据)
AES:流加密 DES:块加密
RSA HS256
不可逆加密:同一个文件或内容每次加密的结果一样
如:保存用户密码
验证文件的完整性(安装客户端时 访问后端服务器该客户端唯一的MD5值 ,再到本地对客户端计算生成MD5值 如果一致 则认为客户端没有被修改 可以安装)
网盘秒传(本地文件生成MD5值 网盘服务端缓存中查询MD5值是否存在,如果存在直接引用之前的文件 )
2、对称和非对称加密
都是可逆加密,区分点在于加密和解密使用的秘钥是否相同
对称:加密和解密秘钥相同
AES DES HS256
非对称:加密和解密秘钥不同 安全
RSA: 私钥(加密) 公钥(解密) ,可以理解为字符串
RSA算法是一种非对称的加密算法(即:加密、解密的密钥不同) ,通常是生成两把密钥,分别是私钥和公钥,其中私钥保密,公钥对外公开。
RSA加解密过程:使用公钥将数据加密,并通过私钥对加密信息进行解密。针对我们遇到的问题,公钥放在前端对用户名密码进行加密,私钥放在服务端对前端提交的加密数据进行解密,然后在做登陆的业务操作
3、字符集
计算机底层并没有文本文件、图片文件之分,它只是记录着每个文件的二进制序列。
字符集:包含着字符和二进制序列之间的对应关系,一个字符对应一个二进制序列。
乱码:编码、解码使用的字符集不一致导致
Windows中文本文件的默认字符集是GBK。
MySQL在8.0版本之前,默认字符集为latin1,8.0版本默认字符集为utf8mb4
ISO8859-1 字符集,也就是 Latin-1,是西欧常用字符,包括德法两国的字母。 ISO8859-2 字符集,也称为 Latin-2,收集了东欧字符。 ISO8859-3 字符集,也称为 Latin-3,收集了南欧字符。
字符集(Character Set):是指多个字符的集合。不同的字符集包含的字符个数不一样、包含的字符不一样、对字符的编码方式也不一样。如:ASCII字符集只包含了128字符,这个字符集收录的主要字符是英文字母、阿拉伯字母和一些简单的控制字符
4、字符编码规则
字符编码是指一种映射规则,根据这个规则来将字符映射到相应的码点(数值)上面
ASCII字符集
ASCII编码一共规定了128个字符的编码规则,这128个字符形成的集合就叫做ASCII字符集
Unicode字符集
Unicode字符集是一个很大的字符集合,现在的规模可以容纳100多万个符号。
这个字符集只规定了这个字符集中每个字符对应的码值是多少,但是这个字符集并没有规定具体的编码规则,具体的编码规则有UTF系列的编码规则实现。
UTF-8编码
UTF-8就是在互联网上使用最广的一种Unicode的实现方式。UTF-8编码是Unicode的实现方式之一。
UTF-8最大的一个特点,就是它是一种变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度
5、百分号编码
URL 只能使用 ASCII 字符集来通过因特网进行发送。由于 URL 常常会包含 ASCII 集合之外的字符,URL 必须转换为有效的 ASCII 格式。
转化格式为:URL 编码使用 "%" 其后跟随两位的十六进制数来替换非 ASCII 字符
浏览器提交数据时默认会使用UrlEcode对内容进行编码,tomcat服务器默认会使用UrlDecode对内容进行解码,来解决中文乱码问题
测试一:
UrlEncode编码,对acsII码表中的大部分内容不会处理
//测试UrlEncode编码: //1、编码:对acs码表中的大部分内容不会处理 String s ="abc123456."; String encode = URLEncoder.encode(s, "UTF-8"); System.out.println("编码后: "+encode); //2、解码 String decode = URLDecoder.decode(encode, "UTF-8"); System.out.println("解码后: "+decode);
测试二:使用UrlEcode解决中文乱码
//测试UrlEncode编码: //1、编码:UTF-8的中文会转为三个%组成的字符串 String s ="http://localhost:8080?keyword=华为手机"; String encode = URLEncoder.encode(s, "UTF-8"); System.out.println("编码后: "+encode); //2、解码 String decode = URLDecoder.decode(encode, "UTF-8"); System.out.println("解码后: "+decode);
6、Base64编码
jwt: jwt将载荷头 签名 使用Base64的算法处理后得到一个可读的字符串
base64:提供了64个字符
a~z A~Z 0~9 / + = 保留字符 不解析
以后需要base64处理的字符串,每个字符只要可以转为64个字符的某一个就没有乱码
base64将数据转为字节数组,每3个字节重新分为一组,如果一组不足三个使用=补齐
再将三个字节拆分为4个字节,并在高位补充0//1 byte = 8bit [ 1010 0101 ] [ 1110 0101 ] [ 0010 0101 ] 拆分后 [ 00 1010 01 ] [ 00 01 1110 ] [ 00 0101 00 ] [00 10 0101]
处理后每个字节最大是00 1111 11 取值范围一共有64种可能,每一个值对应base64提供的码表中的一个字符 a~z A~Z 0~9 / +
String s = "金三银四?";
byte[] encode = Base64.getEncoder().encode(s.getBytes("UTF-8"));
String s1 = new String(encode);
System.out.println("base64编码后:"+s1);
byte[] decode = Base64.getDecoder().decode(s1);
String s2 = new String(decode);
System.out.println("解码后: "+s2);
如果把字符串改为:
String s = "金三银四a";
如果把字符串改为:
String s = "金三银四ab";
7、加盐Salt加密
在原始密码密文的基础之上,再加入一个随机字符串,从而达到让用户的密码更复杂的效果。这个随机字符串,便是盐。
//3、密码加密
//生成盐
String salt = UUID.randomUUID().toString().substring(0,8);
//MD5加密相同的字符串加密后的结果一样 ,所以md5会结合加盐保证安全
String endcodePwd = DigestUtils.md5Hex(DigestUtils.md5Hex(password)+salt);
userEntity.setSalt(salt);
userEntity.setPassword(endcodePwd);
//初始化用户的默认值
byte[] encode = Base64.getEncoder().encode(("谷粉" + UUID.randomUUID().toString().substring(0, 8)).getBytes());
userEntity.setNickname(new String(encode));
userEntity.setCreateTime(new Date());
userEntity.setIntegration(0);
userEntity.setGrowth(0);
userEntity.setLevelId(1L);
userEntity.setStatus(1);
//4、保存到数据库
this.save(userEntity);
注意:salt我们也存储了
多系统-单点登录
一处登录,处处登录。比如登录微博,则新浪旗下的其他产品也处于登录状态了
单点登录 (SSO) 是一种身份验证功能,允许用户使用一组登录凭据访问多个应用程序。
企业通常利用 SSO 来更加简单便捷地访问各种网络、内部和云应用,从而获得更好的用户体验。
1、Cookie作用域
domain:作用域名
domain参数 | atguigu.com | sso.atguigu.com | order.atguigu.com |
---|---|---|---|
atguigu.com | √ | √ | √ |
sso.atguigu.com | × | √ | × |
order.atguigu.com | × | × | √ |
domain有两点要注意:
1. domain参数可以设置父域名以及自身,但不能设置其它域名,包括子域名,否则cookie不起作用。
2. cookie的作用域是domain本身以及domain下的所有子域名。
cookie的路径(Path):
默认设置/标识项目根路径,访问项目任何位置都会携带
cookie.setDomain("atguigu.com");//设置cookie的作用域名 省略默认当前域名
cookie.setPath("/hello"); //设置cookie作用的路径 省略 默认/
2、有状态登录
用户登录后,我们把登录者的信息保存在服务端session中,并且给用户一个cookie值,记录对应的session。然后下次请求,用户携带cookie值来,我们就能识别到对应session,从而找到用户的信息。
缺点是什么?
-
服务端保存大量数据,增加服务端压力
-
服务端保存用户状态,无法进行水平扩展
-
客户端请求依赖服务端,多次请求必须访问同一台服务器
即使使用redis保存用户的信息,也会损耗服务器资源。
3、无状态登录(推荐)
服务端不保存任何客户端请求者信息,客户端的每次请求必须具备自描述信息,通过这些信息识别客户端身份
带来的好处是什么呢?
-
客户端请求不依赖服务端的信息,任何多次请求不需要必须访问到同一台服务
-
服务端的集群和状态对客户端透明
-
服务端可以任意的迁移和伸缩
-
减小服务端存储压力
无状态登录的流程:
-
当客户端第一次请求服务时,服务端对用户进行信息认证(登录)
-
认证通过,将用户信息进行加密形成token,返回给客户端,作为登录凭证
-
以后每次请求,客户端都携带认证的token
-
服务的对token进行解密,判断是否有效。
整个登录过程中,最关键的点是什么?
token是识别客户端身份的唯一标示,如果加密不够严密,被人伪造那就完蛋了。
采用何种方式加密才是安全可靠的呢?
我们将采用
JWT + RSA非对称加密
JWT实现无状态登录
JWT,全称是Json Web Token, 是JSON风格轻量级的授权和身份认证规范,可实现无状态、分布式的Web应用授权;官网:JSON Web Tokens - jwt.io
1、jwt基础使用
依赖
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.7.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>2.0.6.RELEASE</version>
</dependency>
头 Header: 加密算法(arg)+类型(typ)
有效载荷 Payload:
jwt的载荷:过期时间、主题...
自定义载荷:我们自定义的数据(用户id、头像地址、昵称)
签名 Signature:
使用加密算法和密钥(字符串) 基于载荷的数据生成的加密字符串,用于验证整个数据完整和可靠性JWT的本质就是一个字符串,它是将用户信息保存到一个Json字符串中,然后进行编码后得到一个
JWT token
,并且这个JWT token
带有签名信息,接收后可以校验是否被篡改
生成jwt令牌,即base64编码后的字符串
@SpringBootTest
public class JWTtest {
@Test
public void contextLoads() {
//构建jwt 字符串
String jwt = Jwts.builder()
//请求头 加密算法和令牌类型
.setHeaderParam("alg", "HS256")
.setHeaderParam("typ", "jwt")
//载荷
.setSubject("GMALL-TOKEN") //主题
.setExpiration(new Date(System.currentTimeMillis() + 60 * 1000 * 60)) //什么时候过期
.claim("nickname", "xiaoyumao")
.claim("id", "1001")
.claim("avatar", "yy.jpg")
//签名
.signWith(SignatureAlgorithm.HS256, "123456")
.compact();
System.out.println(jwt);
}
}
eyJhbGciOiJIUzI1NiIsInR5cCI6Imp3dCJ9.eyJzdWIiOiJHTUFMTC1UT0tFTiIsImV4cCI6MTY3NDIwODcyMiwibmlja25hbWUiOiJ4aWFveXVtYW8iLCJpZCI6IjEwMDEiLCJhdmF0YXIiOiJ5eS5qcGcifQ.5WFPxldISkQ8RxGBGFGTtrdjpzXLe6TdHKkc6h3aROo
解析jwt字符串
@Test
public void test(){
String s = "";//s是刚刚生成的jwt字符串
Jwt jwt = Jwts.parser().setSigningKey("123456")
.parse(s);
DefaultClaims body = (DefaultClaims) jwt.getBody();
System.out.println(body.get("id"));
System.out.println(body.get("nickname"));
System.out.println(body.get("avatar"));
}
后端将来只要验证jwt有效载荷数据使用秘钥再次加密生成的签名和jwt的签名 就能判断数据是否被篡改;就算签名验证成功了,再验证过期时间 如果过期了也报错
2、JWT工具类生成token
1.构建公钥、私钥文件,这个secret你跟别人越不一样,就越安全
String publicKeyFile = "E:\\rsa.pub";
String privateKeyFile = "E:\\rsa.pri";
String secret = "`qa*97()'!dasfa213";
@Test
void contextLoads() throws Exception {
//构建公钥私钥文件
RsaUtils.generateKey(publicKeyFile,privateKeyFile , secret);
}
2.加载公钥私钥文件生成对象
PublicKey publicKey;
PrivateKey privateKey;
int expire = 60;
//加载公钥私钥文件生成对象
@Test
void test1() throws Exception {
publicKey = RsaUtils.getPublicKey(publicKeyFile);
privateKey = RsaUtils.getPrivateKey(privateKeyFile);
}
3.使用私钥对象生成jwt字符串,map是载荷
//生成jwt
@Test
void generateJwt() throws Exception {
Map<String, Object> map = new HashMap<>();
map.put("userId","1001");
map.put("username","xiaoyumao");
map.put("avatar","mengmeng.jpg");
//使用私钥对象生成jw map是载荷
String token = JwtUtils.generateToken(map, privateKey, expire);
System.out.println(token);
}
4.使用公钥对象解析jwt字符串
@Test
void parseJwt() throws Exception {
String jwt = "eyJhbGciOiJ...";//上一步生成的jwt字符串,很长很长,这里简写
Map<String, Object> map = JwtUtils.getInfoFromToken(jwt, publicKey);
System.out.println(map);
}
公钥或者私钥任何一个不存在,都要重新构建密钥对
3、单点登录第一块内容(生成token)
JWT签发的token中包含了用户的身份信息,并且客户端每次请求都会携带,这样服务的就无需保存用户信息,甚至无需去数据库查询,完全符合了Rest的无状态规范。
代码实现如下:
//远程查询用户数据 (包含验证密码) ResponseVo responseVo = umsClient.query(loginName, password); if(responseVo.getCode()!=0){ return null; } //构建jwt token Object obj = responseVo.getData(); ObjectMapper mapper = new ObjectMapper(); UserEntity userEntity = mapper.convertValue(obj, UserEntity.class); Map<String, Object> map = new HashMap<>(); map.put("userId" , userEntity.getId()); map.put("username" , userEntity.getUsername()); map.put("ip" , IpUtils.getIpAddressAtService(request)); //使用秘钥签名:防止数据被篡改 //map中可以设置本次登录的客户端ip地址: try { //使用私钥生成token String token = JwtUtils.generateToken(map, jwtProperties.getPrivateKey(), jwtProperties.getExpire() * 24 * 7); //将token设置到cookie中交给客户端: response //把token设置到cookie中 CookieUtils.setCookie(request,response,"GMALL-TOKEN",token , jwtProperties.getExpire() * 24 * 7); //交给前端回显登录信息的cookie CookieUtils.setCookie(request,response,"unick",userEntity.getNickname() , jwtProperties.getExpire() * 24 * 7); return token; return "1"; } catch (Exception e) { e.printStackTrace(); } return "0";
GateWay自定义局部过滤器(登录验证)
网关过滤器分为 全局过滤器和局部过滤器,本次使用的是自定义局部过滤器,验证登录状态。
理由如下:很多接口都需要用户登录以后才能访问,比如“加入购物车”,所以选择在网关服务做登录校验
1、自定义局部过滤器工厂
如下,只是雏形。。在apply方法中还有一大堆业务代码没写呢。也可以看成是一个模板,没啥可变性。。。
@Component
public class AuthGatewayFilterFactory extends AbstractGatewayFilterFactory<AuthGatewayFilterFactory.PathConfig> {
/**
* 一定要重写构造方法
* 告诉父类,这里使用PathConfig对象接收配置内容
*/
public AuthGatewayFilterFactory() {
super(PathConfig.class);
}
@Override
public GatewayFilter apply(PathConfig config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
//获取请求路径
System.out.println(request.getURI());
System.out.println(request.getURI().getPath());
System.out.println("我是局部过滤器!!!" + config);
return chain.filter(exchange);
};
}
@Override
public String name() {
return "auth";
}
@Override
public List<String> shortcutFieldOrder() {
//通过一个集合字段读取所有的路径
return Arrays.asList("authPaths");
}
@Override
public ShortcutType shortcutType() {
//纯字符串列表,多个字符串使用逗号分割
return ShortcutType.GATHER_LIST;
}
/**
* 自定义静态内部类PathConfig,接收路由配置的参数列表
*/
@Data
public static class PathConfig{
//authPaths代表需要登录验证的路径列表
private List<String> authPaths;
}
}
2、配置文件的filters配置参数
网关怎么判断请求是否需要过滤
方式1:在需要验证的路径中添加一层特殊的路径( /cart/auth/xxx )
方式2:给路由配置的过滤器设置参数列表:告诉过滤器哪些路径需要过滤请求
像下面一样指定`拦截路径`,并在过滤器中获取`拦截路径`,再去判断当前路径是否需要拦截【不要觉得我用词用错了,就是拦截而不是过滤,拦截的话更好理解,过滤的话有歧义】
测试一下:http://sso.gmall.com/login
那如果测试地址换成这样呢 :http://sso.gmall.com/abc
还是会走我们的过滤器,这并没有毛病,并不是说配置文件中写了 - auth=/login,/Login.html,就只能是这俩个请求才能走我们的过滤器,
它俩写在那的作用就是为了让过滤器知道什么样的请求需要在过滤器中做登录验证,参数什么作用完全由我们代码说了算
3、单点登录第二块(统一校验)
1.先判断该请求是否需要做登录验证(有些接口需要登录后才能访问),不需要的话直接放行。
2.需要做登录验证的时候,取出名为"GMALL-TOKEN"的cookie,没有的话则重定向让其去登录。有则是代表当前用户处于登录状态,继续校验token有效期、ip等,然后放行。
3.为了方便起见,顺便把token中存储的userId取出来放到了请求头中,方便以后拿。
@Override
public GatewayFilter apply(PathConfig config) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
//获取请求路径
System.out.println(request.getURI());
System.out.println(request.getURI().getPath());
String requestPath = request.getURI().getPath();
System.out.println("config.authPaths:"+config.authPaths);
boolean allMatch = config.authPaths.stream().allMatch(path -> path.indexOf(requestPath) == -1);
if (allMatch) {
//无需验证:直接放行
return chain.filter(exchange);
}
//需要解析的token字符串
String token;
//2.存在GMALL-TOKEN的coookie
if (!CollectionUtils.isEmpty(request.getCookies()) && request.getCookies().containsKey("GMALL-TOKEN")) {
MultiValueMap<String, HttpCookie> cookies = request.getCookies();
// token = cookies.get("GMALL-TOKEN").toString();
token = cookies.getFirst("GMALL-TOKEN").getValue();
} else {
//获取名为“GMALL-TOKEN”的cookie 失败,跳转到登录页面
//响应一个重定向的报文 让浏览器访问登录页面
response.setStatusCode(HttpStatus.SEE_OTHER);//设置重定向状态码
response.getHeaders().set(HttpHeaders.LOCATION, "http://sso.gmall.com/toLogin.html?returnUrl=" + request.getURI());
return response.setComplete();//完成响应报文的封装 直接响应
}
//3.有的话解析token中的userId设置到请求头中
PublicKey publicKey = null;
try {
publicKey = RsaUtils.getPublicKey("E:\\rsa.pub");
Map<String, Object> map = JwtUtils.getInfoFromToken(token, publicKey);
//验证ip
String ip =(String) map.get("ip");
if(StringUtils.isEmpty(ip)){
if (!IpUtils.getIpAddressAtGateway(request).equals(ip)) {
//ip不一致
throw new RuntimeException("ip不一致-存在token伪造问题");
}
}
//把userId放到请求头中,就不用以后再解析一遍了
Integer userId =(Integer) map.get("userId");
request.mutate().header("userId",userId+"").build();//重新构建请求报文对象
exchange.mutate().request(request).build();//重新构建交换机对象
//登录验证完成,放行
return chain.filter(exchange);
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("登录校验失败");
}
};
}
4、token解决CSRF攻击
CSRF (Cross Site Request Forgery)一般被翻译为跨站请求伪造。那么什么是跨站请求伪造呢?
说简单一点用”你的身份"去发送一些恶意请求。
对服务器来说这个请求是完全合法的,但是却完成了攻击者所期望的一个操作,比如以你的名义发送邮件、发消息,盗取你的账号,添加系统管理员,甚至于购买商品、虚拟货币转账等
CSRF 攻击之所以能够成功,是因为黑客可以完全伪造用户的请求,该请求中所有的用户验证信息都是存在于 cookie 中,因此黑客可以在不知道这些验证信息的情况下直接利用用户自己的 cookie 来通过安全验证
在我们登录成功获得token之后,一般会选择存放在cookie中。然后我们在前端通过某些方式会给每个发到后端的请求加上这个token,这样就不会出现CSRF漏洞的问题。
因为,即便有个你点击了非法链接发送了请求到服务端,这个非法请求是不会携带token的,所以这个请求将是非法的。
Spring Task异步调用
在一些场景下,我们会希望异步调用,比如添加商品到购物车,修改购物项的数量、删除购物项等等。。
同步调用redis,异步调用mysql
从数据选型而言,我们把购物车数据放在redis中,使用hash结构来存储,把数据异步存储到mysql中是为了数据安全备份。
考虑到异步调用的可靠性,我们一般会考虑引入分布式消息队列,例如说 RabbitMQ、RocketMQ、Kafka 等等。但是在一些时候,我们并不需要这么高的可靠性,可以使用进程内的队列或者线程池。
使用SpringTask实现异步调用需要注意
- 配置线程池控制线程及阻塞队列的大小。
- JVM进程的正常优雅关闭,保证异步任务都被执行完成。
- 编写异步异常处理器(实现AsyncUncaughtExceptionHandler接口),记录异常日志,进行监控告警。
1、Spring Task入门案例使用
1.引入依赖
因为 Spring Task 是 Spring Framework 的模块,在我们引入 spring-boot-web 依赖后,无需特别引入它。
2.启动类上加@EnableAsync
在springboot工程的启动类上添加@EnableAsync开启spring-task的异步功能
3.@Async标记异步调用方法
在CartAsyncService中编写俩个测试方法executor1和executor2
@Async public String executor1() { try { System.out.println("executor1方法开始执行"); TimeUnit.SECONDS.sleep(4); System.out.println("executor1方法结束执行。。。"); } catch (InterruptedException e) { e.printStackTrace(); } return "executor1"; } @Async public String executor2() { try { System.out.println("executor2方法开始执行"); TimeUnit.SECONDS.sleep(5); System.out.println("executor2方法结束执行。。。"); } catch (InterruptedException e) { e.printStackTrace(); } return "executor2"; }
注意,这2个方法executor1睡眠4s,executor2睡眠5秒,
@GetMapping("test") @ResponseBody public String test(){ long start = System.currentTimeMillis(); System.out.println("test方法开始执行!"); this.cartAsyncService.executor1(); this.cartAsyncService.executor2(); System.out.println("test方法结束执行!!!,耗时:" + (System.currentTimeMillis() - start)+"毫秒"); return "hello cart!"; }
但是测试发现响应浏览器仅需要6毫秒,这便是异步的威力了
那如果不加@Async的话,浏览器会像下面这样需要等待9s才能响应
2、回调处理结果和异常
SpringTask允许使用异步回调的方式,根据不同的响应结果做出不同的处理。SpringTask提供了ListenableFuture对象来实现自定义回调
把方法的返回值:Future ---> ListenableFuture。并添加异常情况下的返回值
@Async public ListenableFuture<String> executor1() { try { System.out.println("executor1方法开始执行"); TimeUnit.SECONDS.sleep(4); System.out.println("executor1方法结束执行。。。"); return AsyncResult.forValue("executor1"); // 正常响应 } catch (InterruptedException e) { e.printStackTrace(); return AsyncResult.forExecutionException(e); // 异常响应 } } @Async public ListenableFuture<String> executor2() { try { System.out.println("executor2方法开始执行"); TimeUnit.SECONDS.sleep(5); System.out.println("executor2方法结束执行。。。"); int i = 1 / 0; // 制造异常 return AsyncResult.forValue("executor2"); // 正常响应 } catch (InterruptedException e) { e.printStackTrace(); return AsyncResult.forExecutionException(e); // 异常响应 } }
public static <V> ListenableFuture<V> forValue(V value) { return new AsyncResult(value, (Throwable)null); }
修改controller中,使用回调处理
如果是正常的结果,调用 SuccessCallback 的回调。
如果是异常的结果,调用 FailureCallback 的回调。
@GetMapping("test") @ResponseBody public String test() throws ExecutionException, InterruptedException { long now = System.currentTimeMillis(); System.out.println("controller.test方法开始执行!"); this.cartAsyncService.executor1().addCallback(result -> {//异步任务成功的回调 System.out.println("executor1的正常执行结果:" + result); }, ex -> {//异步任务失败的回调 System.out.println("executor1执行出错:" + ex.getMessage()); }); this.cartAsyncService.executor2().addCallback(result -> {//异步任务成功的回调 System.out.println("executor2的正常执行结果:" + result); }, ex -> {//异步任务失败的回调 System.out.println("executor2执行出错:" + ex.getMessage()); }); System.out.println("controller.test方法结束执行!!!,耗时:" + (System.currentTimeMillis() - now)+"毫秒"); return "hello cart!"; }
重新发起请求测试,控制台打印如下
3、Async提供的全局异常处理器
返回值为ListenableFuture的异步方法可以使用异步回调处理异常结果,那么返回值为普通类型的异步方法出现异常该如何处理呢?
springTask提供了AsyncUncaughtExceptionHandler 接口,达到对异步调用异常的统一处理。
注意:AsyncUncaughtExceptionHandler 只能拦截返回类型非 Future 的异步调用方法。
返回类型为 Future 的异步调用方法,请使用异步回调来处理。
实现步骤
自定义全局异常处理器,实现AsyncUncaughtExceptionHandler 接口
/* 自定义异常处理器: 只处理@Async标注方法的异常 */ @Component @Slf4j public class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler { @Override public void handleUncaughtException(Throwable throwable, Method method, Object... objects) { //打印日志 log.error("方法:{} 执行出现异常:{} ,参数列表:{}", method.getName() , throwable.getMessage() , objects); } }
再注册,添加配置类(@Configuration)实现AsyncConfigurer异步配置接口
@Configuration public class AsyncConfig implements AsyncConfigurer { // @Override 给异步任务提供自定义线程池 // public Executor getAsyncExecutor() { // return null; // } @Autowired AsyncExceptionHandler asyncExceptionHandler; //给异步任务提供异常处理器,出现异常时有该处理器处理 @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return asyncExceptionHandler; } }
改造CartService的executor2方法
4、线程池配置
execution
Spring 执行器配置,对应 TaskExecutionProperties 配置类。对于 Spring 异步任务,会使用该执行器。
@ConfigurationProperties("spring.task.execution") public class TaskExecutionProperties { }
spring:
task:
execution:
thread-name-prefix: task- # 线程池的线程名的前缀。默认为 task- ,建议根据自己应用来设置
pool: # 线程池相关
queue-capacity: 30 # 任务队列长度,用来缓冲执行任务的队列的大小,默认Integer.MAX_VALUE
keep-alive: 60S # 空闲线程的存活时间,默认为 60 秒
core-size: 8 # 核心线程数,线程池创建时候初始化的线程数 默认8
max-size: 100 # 最大线程数,默认 maxSize = Integer.MAX_VALUE;
allow-core-thread-timeout: true # 是否允许核心线程超时,即开启线程池的动态增长和缩小。默认为 true
shutdown:
await-termination: true # 是否等待正在执行还未完成的任务执行后再关闭线程 默认false,建议设置为 true
await-termination-period: 30 # 等待未完成任务执行的时间,单位为秒。默认为 0
使用 Spring Task 的异步任务,一定要注意三个点:
-
配置线程池控制线程及阻塞队列的大小。
-
JVM 应用的正常优雅关闭,保证异步任务都被执行完成。
-
编写异步异常处理器(实现AsyncUncaughtExceptionHandler接口),记录异常日志,进行监控告警。
购物车
主要就是下面4处功能
- 校验用户登录状态
- 添加商品到购物车
- 登录状态下查询购物车时合并购物车
- 同步更新购物车价格
1、购物车技术选型
由于购物车是一个读多写多的场景,为了应对高并发场景,所有购物车采用的存储方案也和其他功能,有所差别。
1. redis(登录/未登录):性能高,代价高,不利于数据分析
2. mysql(登录/未登录):性能低,成本低,利于数据分析
随着数据价值的提升,企业越来越重视用户数据的收集;我们采用组合方案:redis + mysql
不管是否登录都把数据保存到mysql,mysql负责存储用户所有的数据 (异步存储) 起到一个数据采集的功能;并引入redis,redis负责存储用户实时添加的购物车数据
- 查询时,从redis查询提高查询速度
- 写入时,采用双写模式
综上所述,我们的购物车结构是一个双层Map:Map<String,Map<String,String>>
-
第一层Map,Key是用户id
-
第二层Map,Key是购物车中商品id,值是购物车数据
redis 采用的hash数据类型来存储,格式:Map(userid,map(skuId,cartItemJson))
购物车,使用redis中的hash结构存储 rediskey field value cart:userId/userKey 代表一个用户的购物车 skuId: 代表唯一的一个商品的购物项 value: 购物项的json
==========
设计购物车表结构
数据库设计的三大范式
- 第一范式: 字段原子性 : 字段不可分割 一个字段只能表示一个含义
- 第二范式: 主键唯一性 : 保证该条记录唯一性
- 第三范式: 字段之间不允许出现信息的传递性 关联关系
根据原型图,设计表
userID,skuID ,images,titile,count,price,checked,store,createtime,updatetime,extra
2、拦截器-校验用户状态
登录校验:购物车的处理方式与用户的登录状态有关,因此需要对用户状态进行校验;而登录状态的校验如果在每个方法中进行校验,会造成代码的冗余,不利于维护。 故项目中我们使用拦截器统一处理
@Component
public class UserInfoInterCeptor implements HandlerInterceptor {
private static ThreadLocal<UserInfo> LOCAL = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
System.out.println("MyInterceptor拦截器的preHandle方法执行....");
UserInfo userInfo = new UserInfo();
//不管是未登录还是已登录,都会创建一个userKey,设置给cookie
String userKey = CookieUtils.getCookieValue(request, "userKey");
if (StringUtils.isEmpty(userKey)) {
// 模拟一个userKey,在未登录时也可以加入商品到购物车 如果userKey是本次创建的 设置为cookie
userKey = UUID.randomUUID().toString().replace("-", "");
CookieUtils.setCookie(request, response, "userKey", userKey, 60 * 60 * 24 * 30);
}
userInfo.setUserKey(userKey);
//拿到userId,如果未登录拿不到
String userId = request.getHeader("userId");
if (!StringUtils.isEmpty(userId)) {
//在网关的登录校验中,就已经把userId放入到请求头中了
userInfo.setUserId(Long.parseLong(userId));
} else {
//针对那些不做登录校验的接口的请求,他们只能自己解析拿到userId
String token = CookieUtils.getCookieValue(request, "GMALL-TOKEN");
if(!StringUtils.isEmpty(token)){
PublicKey publicKey = RsaUtils.getPublicKey("E:\\rsa.pub");
Map<String, Object> map = JwtUtils.getInfoFromToken(token, publicKey);
//验证ip
userId =map.get("userId").toString();
userInfo.setUserId(Long.parseLong(userId));
}
//但是还是有可能是拿不到,未登录的情况下userId是没有值的
}
LOCAL.set(userInfo);
return true;//返回是否放行
}
//在视图渲染完成之后执行,经常在完成方法中释放资源
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
LOCAL.remove();
}
//获取userId的值
public static String getUserId() {
return LOCAL.get().getUserId() != null ? LOCAL.get().getUserId() + "" : LOCAL.get().getUserKey();
}
public static UserInfo getUserInfo() {
UserInfo userInfo = LOCAL.get();
return userInfo;
}
}
注册拦截器
@Configuration
public class MvcInterceptorConfig implements WebMvcConfigurer {
@Autowired
UserInfoInterceptor userInfoInterceptor;
@Autowired
AuthJwtProperties jwtProperties;
//注册自定义拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userInfoInterceptor)
.addPathPatterns("/**");
}
}
3、添加购物车
我们的购物车结构是一个双层Map:Map<String,Map<String,String>> 第一层Map,Key是用户id 第二层Map,Key是购物车中商品id,值是购物车数据
/**
* 添加商品到购物车
*
* @param skuId
* @param count
*/
@Override
public void add2Cart(String skuId, Integer count) {
//获取到UserId 有userId使用userId,没有使用userKey
String userId = UserInfoInterCeptor.getUserId();//从拦截器中获取可能是UserId也可能是Userkey
//去redis中查询是否是第一次添加该商品
String cartKey = "cart:" + userId;
BoundHashOperations boundHashOperations = redisTemplate.boundHashOps(cartKey);
if (boundHashOperations.hasKey(skuId)) {
//有代表该购物项中已经存在,是做数量的修改操作
Cart cart = (Cart) boundHashOperations.get(skuId);
cart.setCount(cart.getCount() + count);
boundHashOperations.put(skuId, cart);
//mysql数据库异步更新
cartAsyncService.updateCartInfo(userId,skuId,cart);
} else {
//无代表购物项不存在,做购物项的添加
Cart cart = new Cart();
cart.setSkuId(Long.parseLong(skuId));
cart.setCount(count);
cart.setUserId(userId);
cart.setCheck(true);//默认给true
//远程服务调用
ResponseVo<SkuEntity> skuEntityResponseVo = pmsClient.querySkuById(Long.parseLong(skuId));
ResponseVo<List<SkuAttrValueEntity>> listResponseVo2 = pmsClient.querySearchAttrValueBySkuId(Long.parseLong(skuId));
ResponseVo<List<WareSkuEntity>> listResponseVo = wmsClient.queryWareSkuBySkuId(Long.parseLong(skuId));
ResponseVo<List<ItemSaleVo>> listResponseVo1 = smsClient.querySalesBySkuId(skuId);
SkuEntity skuEntity = skuEntityResponseVo.getData();
List<SkuAttrValueEntity> attrValueEntityList = listResponseVo2.getData();
List<WareSkuEntity> wareSkuEntities = listResponseVo.getData();
List<ItemSaleVo> itemSaleVoList = listResponseVo1.getData();
if (skuEntity != null) {
cart.setDefaultImage(skuEntity.getDefaultImage());
cart.setTitle(skuEntity.getTitle());
cart.setPrice(skuEntity.getPrice());
cart.setCurrentPrice(skuEntity.getPrice());
//设置实时价格
String priceKey="cart:price:"+skuId;
redisTemplate.opsForValue().set(priceKey,skuEntity.getPrice().toString());
}
if (!CollectionUtils.isEmpty(wareSkuEntities)) {
boolean b = wareSkuEntities.stream().anyMatch(wareSkuEntity -> {
//任意有一个仓库能满足货物数量判定为有货
return wareSkuEntity.getStock() - wareSkuEntity.getStockLocked() - count >= 0;
});
cart.setStore(b);
}
cart.setSaleAttrs(JSON.toJSONString(attrValueEntityList));
cart.setSales(JSON.toJSONString(itemSaleVoList));
boundHashOperations.put(skuId, cart);
//mysql数据库异步添加
cartAsyncService.saveCartInfo(userId,cart);
}
}
4、查询(合并)购物车
查询购物车
-
先根据userKey查询未购物车中记录(redis)
-
判断是否登录,未登录直接返回
-
已登录,合并购物车中的记录并删除未登录状态的购物车(redis + mysql)
-
查询购物车记录(redis)
@Override
public List<Cart> queryCarts() {
//1.未登录情况下,直接返回未登录的购物车信息
UserInfo userInfo = UserInfoInterCeptor.getUserInfo();
if (userInfo.getUserId() == null) {
//未登录状态
BoundHashOperations ops = redisTemplate.boundHashOps("cart:" + userInfo.getUserKey());
List<Cart> values = ops.values();
//设置所有未登录购物项的实时价格
values.stream().forEach(cart -> {
if (redisTemplate.hasKey("cart:price:"+cart.getSkuId())) {
//有的话,则更新价格
String newPrice = (String) redisTemplate.opsForValue().get("cart:price:" + cart.getSkuId());
cart.setCurrentPrice(new BigDecimal(newPrice));
ops.put(cart.getSkuId()+"",cart);
}
});
return ops.values();
}
//2.登录情况下,需要合并购物车后返回
//1.未登录时 添加购物车 后台分配一个userkey,使用这个userkey在redis中存储一波购物车数据
//2.做登录,登录后cookie中还存在userkey,所以UserINfo中还是原来的userkey,userId也会设置进去
BoundHashOperations opsUnLogin = redisTemplate.boundHashOps("cart:" + userInfo.getUserKey());
List<Cart> unLoginCarts = opsUnLogin.values();
BoundHashOperations opsLogin = redisTemplate.boundHashOps("cart:" + userInfo.getUserId());
//合并购物车
unLoginCarts.stream().forEach(cart -> {
if (opsLogin.hasKey(cart.getSkuId()+"")) {
//该购物项已经存在于登录的购物车中
Cart cart1 = (Cart) opsLogin.get(cart.getSkuId()+"");
cart1.setCount(cart.getCount() + cart1.getCount());
opsLogin.put(cart.getSkuId()+"", cart1);
//异步更新msql数据库
cartAsyncService.updateCartInfo(userInfo.getUserId()+"",cart.getSkuId()+"",cart1);
} else {
//购物项不存在于登录的购物车中,需要新增
cart.setUserId(userInfo.getUserId()+"");
opsLogin.put(cart.getSkuId()+"", cart);
//异步添加到mysql数据库
cartAsyncService.saveCartInfo(userInfo.getUserId()+"",cart);
}
});
//删除未登录的购物车
redisTemplate.delete("cart:" + userInfo.getUserKey());
//异步删除mysql数据库中数据
cartAsyncService.deleteCarts(unLoginCarts);
//设置所有已登录购物项的实时价格
List<Cart> values = opsLogin.values();
values.stream().forEach(cart -> {
if (redisTemplate.hasKey("cart:price:"+cart.getSkuId())) {
//有的话,则更新价格
String newPrice = (String) redisTemplate.opsForValue().get("cart:price:" + cart.getSkuId());
cart.setCurrentPrice(new BigDecimal(newPrice));
opsLogin.put(cart.getSkuId()+"",cart);
}
});
//返回合并后的购物车
return opsLogin.values();
}
5、购物车价格同步
商品加入购物车之后,商品的价格可能会被修改,会导致redis中购物车记录的价格和数据库中的价格不一致。需要进行同步,甚至是比价:
解决方案:
-
每次查询购物车从数据库查询当前价格(需要远程调用,影响系统并发能力)
-
商品修改后发送消息给购物车同步价格(推荐)
pms-service微服务价格修改后,mq发送消息给购物车cart-service,购物车获取消息后,怎么进行价格的同步?
因此,在添加购物车时需要:
redis中单独维护一个商品的价格,数据结构:{skuId: price}
redis中应该保存两份数据,一份购物车记录数据,一份sku最新价格数据